oss.sarwagya.wtf

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.