Your first identity
Create an identity, sign a challenge, verify the proof.
Three moves: the browser creates (or loads) its identity, your server issues a one-time challenge, the browser answers it with a signature your server verifies.
1. Create the identity
import { createGhost } from "@0xsarwagya/ghost";
const ghost = await createGhost();
ghost.id; // "ghost_1_crhgcniramqtgfpib5uiaautocwdigbt"
ghost.credentialId; // "cred_1_…"
ghost.publicKey; // base64url of the raw Ed25519 public keyThe first call generates a non-extractable keypair and persists it in IndexedDB. Every later call — tomorrow, next month — loads the same key and returns the same Ghost ID. No network, no user gesture, no dashboard.
2. Issue a challenge on your server
import { createChallenge, InMemoryChallengeStore } from "@0xsarwagya/ghost/server";
const store = new InMemoryChallengeStore();
// GET /ghost/challenge
const challenge = createChallenge({
audience: "https://app.example",
action: "login",
});
// → { version: 1, nonce, audience, action, expiresAt }Send it to the browser as JSON. Challenges expire (two minutes by default) and verify exactly once.
3. Sign it in the browser
const proof = await ghost.sign(challenge);
// POST /ghost/verify with the proof4. Verify the proof on your server
import { verifyGhostProof } from "@0xsarwagya/ghost/server";
const result = await verifyGhostProof(proof, {
expectedAudience: "https://app.example",
expectedAction: "login",
challengeStore: store,
});
if (result.ok) {
// result.ghostId is proven: this client holds an active credential.
// Store it next to whatever this ghost owns, or start your session.
}verifyGhostProof never throws for bad proofs — it returns
{ ok: false, code } so a hostile request is a result, not an exception.
The verifier enforces signature, expiry, audience, action, identity
binding, and one-time nonce use; a valid signature alone is never enough.