Challenges and proofs
How possession is proven: one-time challenges, canonical bytes, self-contained proofs.
Ghost proves one sentence:
The requester possesses an active credential for this ghost ID.
It proves it with a challenge–response, never with a static credential.
The challenge
Your server issues a short-lived, single-use challenge:
{
"version": 1,
"nonce": "…32 random bytes, base64url…",
"audience": "https://app.example",
"action": "login",
"expiresAt": 1783075200000
}Every field is load-bearing. The nonce makes the challenge single-use.
audience stops a proof harvested for one site being replayed against
another. action scopes the proof to one intent — a "login" proof can
never authorize "delete-account". expiresAt (epoch milliseconds) bounds
the window. An optional ghostId binds the challenge to one identity, and
only that ghost will sign it.
The canonical bytes
The browser does not sign JSON. It signs a fixed-order, length-prefixed
byte encoding of the challenge fields, with a protocol context string in
front. Deterministic bytes mean no canonicalization rules, no number
formatting surprises, and no way to smuggle content across field
boundaries. The exact layout is in the
protocol reference, and
canonicalChallengeBytes() is exported so you can audit precisely what
gets signed.
The proof
{
"version": 1,
"algorithm": "ed25519",
"ghostId": "ghost_1_…",
"credentialId": "cred_1_…",
"publicKey": "…base64url…",
"challenge": { "…echoed verbatim…": true },
"signature": "…base64url…"
}The proof is self-contained — the public key travels inside it, so your
server can verify the signature bytes directly. For recoverable Ghost IDs,
your server also checks that credentialId is active for ghostId; this
is what lets a Ghost rotate credentials without becoming a new Ghost.