technical passkeys, delegation, AT Protocol, threat model

Identity: a passkey and an AT Protocol account

Sign-up does two things. It registers a passkey by WebAuthn with residentKey: required and ES256 as the only accepted algorithm, so the passkey is a P-256 key held by the person's device. Attestation is none and user verification preferred: attest asks the device nothing about itself and accepts any authenticator, whether a phone, a Linux desktop, a password manager or a hardware key. (WebAuthn attestation, the authenticator vouching for its own make, is unrelated to attest's records, which are people vouching for URLs and keys.) And it creates an AT Protocol account on our data server (PDS): a did:plc in the public PLC directory, a repository, and a handle <name>.attest.monster bound to the DID by a DNS TXT record at _atproto.<handle>. The account's password is a random server-side secret nobody sees; the passkey is the person's key. We store the credential id, the passkey's public key, its signature counter, the handle and the DID. There is no email and no password the person uses.

The passkey's public key is also expressed as a did:key (multicodec prefix for P-256, 0x1200, then the 33-byte compressed point, base58btc with a z prefix; these start did:key:zDna). The account's identity is the did:plc; the passkey is what controls the account's delegations. A passkey cannot itself be the PLC rotation key, because AT Protocol rotation keys sign raw bytes and WebAuthn signs a challenge structure, so the rotation key for accounts made here is held by the service and backed up off the server.

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:2, type:"delegation", root, device, devKey:{x,y}, originHash, from, until}
originHash = sha256(canonical({origin, salt}))   // origin and a 16-byte salt travel beside it, never inside it

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.

Agents

An agent is a program acting for a person. It holds its own P-256 key, and the person's passkey signs a permission for it, as it signs a delegation for a browser:

agent {v:1, type:"agent", root, agent (did:key), agentKey:{x,y}, name, purpose,
       permissions:["repo:monster.attest.comment?action=create&action=delete", …], limits:{perDay}, from, until}

Records in repositories

A record is an AT Protocol record in the author's repository, in one of our lexicons, monster.attest.{vote, comment, statement, vouch, claim, verification, bookmark}, published at _lexicon.attest.monster. Each carries the author's inline signature in the shared app.certified.signature.defs#inline shape, as in badge.blue and Hypercerts: the device key signs the 36-byte CIDv1 (DAG-CBOR, SHA-256) of the record with its signatures removed and a $sig object {$type, key, repository} inserted. ECDSA P-256, raw r||s, low-S. Because the repository DID is inside what is signed, a signature cannot be replayed into another repository.

The client builds and signs the record; the service checks the signature against the delegated device key and the repository DID, checks the delegation is live and was issued for the calling origin, validates the record against the lexicon, and writes it into the repository through the PDS. Votes, vouches, claims and bookmarks use a record key derived from the subject, so there is one per author per subject; comments and statements use time-ordered keys. Retraction deletes the record, authorised by a device-key signature over {type:"retract", uri, at}.

The service's index (SQLite) is derived from the repositories. It follows the PDS's subscribeRepos firehose: a record in our collections written by any client is verified against its repository and indexed, an unsigned one is ignored, a deletion retracts, and an account's deletion or deactivation removes its records from all counts. Delegations, revocations and passkey changes, which are not repository records, are kept in the index's own log with their passkey assertions.

Identities proven on Bluesky are linked, and the trust they have already declared on AT Protocol is imported as edges: Tangled vouches and denouncements (sh.tangled.graph.vouch, subject in the record key), Tangled follows and Bluesky follows. Each is kept as its own kind so a scorer can weigh them differently. Imports refresh every six hours.

What we take from AT Protocol, and where we differ

atprotohere
DIDs, PLC directory, handles by DNSUsed as is: every account is a did:plc with a DNS-bound handle.
PDS: a signed Merkle repository per account, relayed by a firehoseUsed as is: we run a PDS; records live in repositories; our index follows the firehose.
Lexicons and lexicon resolutionOur own monster.attest.* lexicons, published for resolution. Records address URLs, DOIs and content hashes, which the shared vocabularies do not yet.
Inline signatures (badge.blue, app.certified)Used as is, and required: every record carries the author's own signature from a key they hold, not only the repository's commit signature.
Labelers: queryLabels, subscribeLabelsPlanned: scores published as labels so any subscribing client can consume a root's view.
OAuth with DPoP and PARUsed to sign in with an existing Bluesky or AT Protocol handle, asking only for repo:monster.attest.*; the passkey and per-site device key still sign every record.
Tangled vouches, Bluesky followsRead in as weighted trust edges for proven identities.

Other borrowings: the NIP-73 identifier grammar for subjects; SIWE's idea that a login is a signed, origin-bound, time-stamped message; Farcaster's per-app signer keys under one root.

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 recordThe signed input includes the repository DID, so a record cannot be replayed into another repository; within one, votes, vouches and claims have subject-derived keys, so a duplicate 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 the signed record 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 planned.
Our server lies or rewritesRepositories are signed Merkle trees relayed to others, so a rewrite is visible; and each record carries its author's own signature, which we cannot forge. The service does hold the repositories' signing and rotation keys for accounts it hosts, which is why the author's inline signature is required.
The service stopsIdentities are in the public PLC directory; the rotation key and repository data are backed up off the server; records are copied by relays. The service can move to another host.
Reads reveal what you look atTrue for a site's own reads through us. The planned browser client scores from a copy of the records 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 kept in the index's log with its assertion, so account history can be replayed and checked. 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 cid> 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 writes a verification record into its own repository (attest.attest.monster, DID at /service), signed inline by its own P-256 key, with the evidence URL. Any other account may verify a claim by writing its own verification; each counts as far as its author is trusted. A verified Bluesky claim also links that identity for the graph read-in above.

Practices borrowed from neighbours, and what is still to adopt

practicesourcehere
Bind the context into the signed payload so a signature cannot be replayed elsewherebadge.blue binds the repository DID into the CID it signsAdopted for the repository: its DID is inside every signature. The site origin is carried by the delegation and enforced by the server, not yet inside the record.
Low-S normalisation of ECDSA signaturesbadge.blue; Bitcoin before itAdopted: inline signatures are low-S, and verification refuses high-S.
Content-addressed ids in a form other systems can referencebadge.blue's CIDv1 over DAG-CBOR; AT Protocol generallyAdopted: records are addressed by their at:// URI and CID like any AT Protocol record.
Third-party attestations as separate proof records the subject's owner may or may not referencebadge.blue remote attestationsDifferent by design: every record here is public in one log regardless of the subject's wishes. The subject's control is the root-as-parameter score, not editing what others said.
A shared vocabulary of attestation records: signatures list, remote signature proofs, badge awards about a DID or a record, follows, evaluations with a bounded numeric score, acknowledgements that accept or reject a linkHypercerts' lexicons and the app.certified.* namespace they sharePartly: the inline signature shape is shared. Our record types are our own lexicons because they address URLs and hashes; proposing that subject shape to the shared vocabulary is on the roadmap.
Access policies over a vouch graph ("only keys my graph vouches for")socialweb.computer's policy engineThe consumer we want for the planned labeler output.
Stackable, subscribable labelsAT Protocol labelersChosen as the publication format for scores, once scoring exists.
Per-app signer keys under one rootFarcasterAdopted as per-site device keys under a passkey.
Signed-once, proven-by-fetch identity claimsKeybase, WebmentionAdopted as proof of control.

Canonical JSON

Used only for delegations, agent permissions, revocations and retraction authorisations, which are not repository records: object keys sorted at every level, arrays in order, undefined dropped, no whitespace. Repository records are hashed as DAG-CBOR, as AT Protocol specifies.

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