Protocol reference
The exact v1 wire format: identity encoding, canonical bytes, proof envelope, test vectors.
Protocol v1 is stable Ghost IDs plus Ed25519 credentials. The 1 in every
version-carrying string means exactly this suite — algorithm changes are
version changes.
Public key
The canonical public key form is the raw 32-byte Ed25519 key (RFC 8032). On the wire it is base64url without padding. SPKI, JWK, and PEM are never used in the protocol.
Ghost ID and credential ID
ghostId = "ghost_1_" + base32( SHA-256( "ghost-id-v1:" ‖ identityMaterial )[0..20) )
credentialId = "cred_1_" + base32( SHA-256( "ghost-credential-v1:" ‖ publicKeyRaw )[0..20) )
- The Ghost ID is the stable application identity.
- The credential ID identifies the active browser-held public key.
"ghost-id-v1:"and"ghost-credential-v1:"are UTF-8 domain-separation prefixes.- The SHA-256 digest is truncated to 20 bytes (160 bits) — the same fingerprint length git and SSH used for decades.
- base32 is RFC 4648, lowercase, no padding: alphabet
a-z2-7. - Grammar:
^ghost_1_[a-z2-7]{32}$— 40 characters, URL-safe, case-safe. - Credential grammar:
^cred_1_[a-z2-7]{32}$.
Early experimental Ghost IDs were derived directly from the public key. That shape remains valid as a legacy self-certifying ID, but recoverable Ghosts use a stable ID above individual credentials.
Challenge
{
"version": 1,
"nonce": "base64url, 32 random bytes",
"audience": "https://app.example",
"action": "login",
"expiresAt": 1783075200000,
"ghostId": "optional — binds the challenge to one identity"
}expiresAt is Unix epoch milliseconds as a JSON number. It is rendered
as a base-10 ASCII string inside the signed bytes, so no floating-point or
timezone formatting can enter the signature.
Canonical signed bytes
The message that gets signed is the concatenation, in fixed order, of each field prefixed by its byte length as an unsigned 32-bit big-endian integer:
| # | Field |
|---|---|
| 1 | "ghost-proof-v1" (constant context string) |
| 2 | nonce |
| 3 | audience |
| 4 | action |
| 5 | expiresAt as base-10 ASCII |
| 6 | ghostId, or the empty field when absent |
All fields are UTF-8. Length prefixes make field boundaries unforgeable —
no delimiter can be smuggled inside a value — and an absent ghostId
(length 0) can never collide with a present one, because a valid ghost ID
is never empty. There is no JSON canonicalization anywhere in the protocol.
canonicalChallengeBytes() is exported from both entry points so any
implementation can be audited against these bytes.
Proof envelope
{
"version": 1,
"algorithm": "ed25519",
"ghostId": "ghost_1_…",
"credentialId": "cred_1_…",
"publicKey": "base64url, raw 32 bytes",
"challenge": { "… echoed verbatim …": true },
"signature": "base64url, 64 bytes"
}Verifiers MUST recompute the credential ID from publicKey and reject on
mismatch, MUST validate expiry/audience/action/binding, and MUST consume
the nonce at most once. For stable Ghost IDs, verifiers MUST also check
that the credential is active for the Ghost. A valid signature alone
authenticates nothing.
Recovery record
Recovery is opt-in and app-stored:
{
"version": 1,
"method": "recovery-secret",
"ghostId": "ghost_1_…",
"authorityId": "recauth_1_…",
"createdAt": 1783075200000
}The user-held recovery secret derives authorityId. Recovery never imports
the old private key; it creates a fresh non-extractable credential and
attaches it to the same Ghost ID in the application credential registry.
Test vectors
The fixture keypair is RFC 8032 §7.1 TEST 1. For its public key
d75a9801…511a:
legacy ghostId ghost_1_crhgcniramqtgfpib5uiaautocwdigbt
credentialId cred_1_3dveqtnobb556nsxhfyhh6qbn5lhsseb
challenge { version: 1, nonce: "dGVzdC1ub25jZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAw",
audience: "https://ghost.test", action: "login",
expiresAt: 4102444800000 }
canonical 0000000e 67686f73742d70726f6f662d7631 … (see repository)
The complete pinned vectors — canonical bytes and a known-good signature
for the unbound and identity-bound cases — live in
tests/protocol-vectors.ts
and are recomputed by CI in Node, Chromium, Firefox, and WebKit on every
run. An independent implementation that reproduces them speaks protocol v1.