technical passkeys, delegation, AT Protocol, threat model

Identity is a passkey

An account is created by WebAuthn registration with residentKey: required and ES256 as the only accepted algorithm, so every account key is a P-256 point. The relying-party id is this service's hostname, taken from the request origin. We store the credential id, the COSE public key, the signature counter and a handle. There is no email, no password and no recovery path yet.

The public key becomes the account's identifier as a did:key: the multicodec prefix for P-256 (0x1200, varint bytes 80 24) followed by the compressed point (33 bytes), base58btc-encoded with a z prefix. Such identifiers start with did:key:zDna. Anyone holding the JWK can recompute the DID; anyone holding the DID and the JWK can check they match. The same function runs in the browser and in Node from /lib/did.js.

Delegation to a device key

WebAuthn signatures are bound to the relying party's origin and each one prompts the user. A site on another origin cannot ask your passkey to sign, and a prompt per upvote is unusable. So the passkey signs a delegation instead.

delegation {v:1, type:"delegation", root, device, devKey:{x,y}, origin, from, until}

The delegation's id is the SHA-256 of its canonical JSON. That id, as bytes, is the WebAuthn challenge; the passkey's assertion over it is the signature on the delegation. Sign-in therefore is the act of delegating, and a returning user sees one prompt. The assertion (authenticatorData, clientDataJSON, signature) is stored with the delegation and appended to the log, so anyone with the root's public key can verify the delegation later, offline. The signature counter is updated and monotonic.

On a page of another origin, the login page runs in a popup on our origin (so the passkey works), receives the page's device DID and public JWK in the query string, and posts the finished session back with postMessage to the origin in the delegation. On our own origin it is a redirect.

Records and the log

A record is signed by the device key (ECDSA P-256, SHA-256, raw r||s) over its canonical JSON; the envelope carries the delegation id. Verification walks the chain: record signature against devKey, delegation assertion against the passkey. Acceptance also checks: the delegation's root is the record's author, the record's time is inside the delegation window, the handshake origin equals the delegation origin, the target is in normal form, the kind's fields are exactly the allowed ones, and a retraction refers to the author's own record on the same target.

Storage is SQLite (node:sqlite, WAL). One table, log, is append-only with a monotonic seq and holds every accepted delegation and record as received. Index tables (records, delegations, accounts, credentials) are derived and rebuildable from it. A partial unique index enforces one live upvote per key per target; retraction flips a flag rather than deleting. GET /log?since= pages the log, which is the whole state a mirror needs, plus the passkey public keys published by /by.

The log is not yet a Merkle structure and there is no signed tree head. That is the Certificate Transparency shape we intend to adopt, so a mirror can prove we did not rewrite history; today a mirror can only detect it by comparison.

What we took from AT Protocol, and what we did not

atprotohere
Label record: src, uri, cid, val, neg, cts, exp, sigRecord: by, target, (hash targets), kind + body, retract, at, delegation until, sig. Same idea, wider targets: labels only address at:// and did:; ours address any URL, DOI, ISBN, handle or hash.
DIDs (did:plc, did:web)did:key for people, derived from the passkey, no directory. did:web is the intended form for sites. Migration to did:plc-style rotatable identifiers is the custody milestone.
PDS: a signed Merkle repo per account, relayed by a firehoseA single append-only log for everyone, one HTTP endpoint to page it. A self-hosted PDS was considered for pass 1 and deferred: it would have meant PLC registration, mail and browser OAuth before anything could be clicked.
Labelers: queryLabels, subscribeLabelsPlanned: emitter scores published in label format so any subscribing client can consume a root's view.
OAuth with DPoP and PARPasskeys plus delegation. Lighter, and it gives per-site keys for free.

Other borrowings: the NIP-73 identifier grammar for targets; EAS-style referencing (ref) for chaining; SIWE's idea that a login is a signed, origin-bound, time-stamped message.

Threat model, as it stands

threattoday
Stolen device key (XSS on the integrating site)Can sign as the user on that origin until the delegation expires (≤90 days) or the root revokes it from /me: a revoke action signed by the passkey, appended to the log; later records under that delegation are refused. Cannot act on other origins.
Stolen delegation without the device keyUseless: the device private key is non-extractable and never transmitted.
Replay of a recordIdempotent: the id is content-derived; a duplicate upvote is a no-op.
Spoofed Origin header from a non-browser clientThe origin check is a browser guarantee, not a cryptographic one. A non-browser attacker still needs the device key. Binding the origin into what the device key signs is a planned tightening.
Sybil keysFree to create. Counts today are raw and say so. Bounded-flow scoring from a root is the answer and is the next milestone after vouches exist.
Whitewashing (burn and return)By design. Newcomers start at zero; the cost is paid in time. See Friedman and Resnick.
Lost passkeyLost account unless a second passkey was added: a new device registers one, and an existing passkey signs add-key to adopt it. Removing one needs a signature from another. Key export is still to come.
Our server lies or rewritesDetectable by any mirror of the log; not yet provable. Signed tree heads are planned.
Reads reveal what you look atTrue for the first-party site's own reads. The planned browser client scores from a mirror or asks by hash prefix.

Root actions

Sign-in is one instance of a general shape: the passkey signs an action by signing a WebAuthn assertion whose challenge is the action's SHA-256. The others are revoke (a delegation), add-key and remove-key (passkeys on the account). Each is appended to the log with its assertion, so a mirror can replay account history. The service offers only the account's own passkeys in the browser prompt, and never the one being removed.

Proof of control and the service key

A claim record says a key controls a handle or URL. The token attest-proof:<claim id> goes where only the controller can put it: the page itself or /.well-known/attest.txt for a website, a TXT record at _attest.<domain>, a public gist, a Bluesky bio. The service fetches, and if it finds the token it signs a verify record with the evidence URL. The service has a key of its own for this (P-256, generated on first start, DID at /service), and its records carry the public key in the envelope instead of a delegation. Any other key may verify a claim by hand and its verification counts as far as that key is trusted; the service's counts as far as the service is.

Canonical JSON

Object keys sorted lexicographically at every level; arrays in given order; undefined members dropped; null kept; no whitespace; strings and numbers as JSON.stringify emits them. It is deliberately smaller than RFC 8785 and identical to it for the shapes we sign.

Source: github.com/orbitalfoundation/attest. Reading behind these choices is in the repository's reference/ and devlog/.