Key proof
Someone adds a signing key to their DFOS identity by proving, from the machine that holds it, that they hold it — then confirming in their own settings that it is a key they meant to add. Two anonymous routes carry the first half:
POST /v1/key-proof/present— present a signed envelope against an open ceremony.GET /v1/key-proof/status— poll that ceremony while its owner decides.
There is no Authorization header and no credential here; the signed envelope
is the whole capability. The user's side is
Sessions & keys.
Presenting does not add the key. present verifies an envelope, stores it,
and reports presented; nothing reaches the identity chain until the person, in
their own DFOS settings, compares the key's fingerprint against what their
terminal printed and adopts it. A code carried to the wrong terminal produces a
key the owner does not recognize and declines.
1. Resolve the code
You do not construct the present URL. The user carries an eight-character code out of the DFOS app; resolve it against the well-known document on this host:
curl 'https://api.dfos.com/.well-known/dfos-key-proof?code=K7RD4HEA'
{
"present": "https://api.dfos.com/v1/key-proof/present",
"nonce": "…",
"audience": "api.dfos.com",
"purpose": "did:dfos:key-add",
"adopts": {
"did": "did:dfos:z8zt7ecn9h8n782kae3k796crva2c73",
"handle": "bvalosek",
"displayName": "Brandon"
},
"roleSet": "auth,assert",
"prevCID": "…",
"expiresAt": "2026-09-08T18:40:00.000Z",
"relay": "https://relay.dfos.com"
}
An unknown code and a lapsed one both answer
404 {"error": "unknown or expired code"}, identically. This document sits
outside the /v1 prefix — it is discovery, not a versioned resource — so it is
not an operation in the interactive reference.
roleSet varies by ceremony; the value above is an example. It is the
canonical comma-joined set the person chose when minting the code —
auth,assert for an ordinary signing key, controller for a key registered to
carry the identity beyond this platform. Sign the set the resolution handed you
and render it to your human; never hardcode one. It is a signed position member,
so an envelope carrying a different set is refused.
No tool may sign a key-add envelope without showing its human the did, the
handle, and the roleSet it is consenting to. A bare DID is not something a
person recognizes, and an unrecognizable subject is how consent phishing works.
2. Present the envelope
curl -X POST https://api.dfos.com/v1/key-proof/present \
-H 'content-type: application/json' \
-d '{"code":"K7RD4HEA","envelope":"eyJhbGciOiJFZERTQSIsInR5cCI6…","description":"work laptop"}'
{
"status": "presented",
"adopts": {
"did": "did:dfos:z8zt7ecn9h8n782kae3k796crva2c73",
"handle": "bvalosek",
"displayName": "Brandon"
},
"expiresAt": "2026-09-08T18:40:00.000Z"
}
The envelope is a compact JWS signed by the key being added, at most 4096
bytes. Its header carries alg (EdDSA) and typ (did:dfos:key-add) and
nothing else — a kid is refused, because the verifying key comes from the
payload. The payload is byte-compared against the canonical serialization of
exactly these seven members, in this order; a different order is a different
signed object and is refused.
| Member | What it is |
|---|---|
nonce | From the resolution |
audience | This API's own host — api.dfos.com |
did | The chain the key is being added to |
roleSet | The roles it is being added in |
prevCID | The chain head it is being added at |
publicKeyMultibase | The public half of the key |
timestamp | A whole-second UTC instant, within five minutes of now |
did, roleSet, and prevCID come from the resolution and are the position
the key is added at: an envelope binds one key to one chain at one head, and is
worthless anywhere else.
description is an optional label, at most 200 characters, shown to the owner
when they decide. It is not signed and does not affect whether the key is
admitted. Omitted, empty, or whitespace-only gets the default label
CLI signing key, renameable afterwards.
3. Poll for the decision
curl 'https://api.dfos.com/v1/key-proof/status?code=K7RD4HEA'
{ "status": "presented", "stale": false }
status | What it means |
|---|---|
pending | Nobody has presented an envelope |
presented | Yours verified; the identity's owner is deciding |
adopted | They adopted it and the key is on the chain — see onAdopted |
rejected | They declined. Nothing was added, and this is not an error |
failed | An envelope was refused at the signature; the ceremony is burned |
expired | The ten minutes ran out |
On adopted, an onAdopted block carries the did, the chain-local keyId
(the DID URL a verifier sees is <did>#<keyId>), and the chainOpCID of the
operation that added the key — enough to fetch the chain from the relay the
resolution named and file the key locally. It is served only while the
ceremony's ten minutes are still running; past expiresAt the answer narrows to
the bare status.
While presented, stale turns true if another writer moves the identity's
chain head, and the stored envelope can no longer be adopted. Re-resolve the
code, sign a fresh envelope for the same key with the new prevCID, and
present it again: the same key replaces the stored envelope, the owner's
approval carries across it, and their browser retries the adoption. A
different key against a ceremony that already has one is refused.
When it is refused
Every way a request can be wrong answers 400 with the code
E_INVALID_REQUEST — a malformed JWS, a wrong typ, a non-canonical payload, a
foreign audience, a stale timestamp, an envelope bound to a different chain or
role set or chain head, a ceremony that is not open, a signature that does not
verify, a key another identity has already proved. The message is precise enough
to print; the code is not, so varying the request cannot reveal which check
failed. Unknown, expired, spent, and wrong-nonce ceremonies all answer the same
"this ceremony is not open".
Only a bad signature consumes the ceremony. Every other validation error leaves the code live, so the command can be re-run. One key names one identity: a public key another identity chain has already proved is refused at adoption.
POST /v1/key-proof/present is rate limited more tightly than the reads beside
it; GET /v1/key-proof/status shares the ordinary read budget, so polling it
every few seconds while a human decides is fine. See
Rate limits and caching.
See the interactive reference for exact schemas, Sessions & keys for what a signing key can do, and Protocol discovery for resolving the chain the key landed on.