Key proof
Someone adds a signing key to their DFOS identity by proving, from the machine that holds it, that they hold it — and then confirming, in their own settings, that it is a key they meant to add. Two 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.
Both are anonymous. There is no Authorization header here and no
credential to attach: the signed envelope is the whole capability, and the
client presenting it has no session by construction — it is in the middle of
acquiring the key that would let it get one.
The user's side of the same ceremony is Sessions & keys.
Presenting does not add the key
This is the shape of the whole surface, so it is worth stating before anything
else. present verifies an envelope, stores it, and reports presented.
Nothing is on the identity chain.
What appends the operation is the person, in their own DFOS settings, after comparing the key's fingerprint against what their terminal printed. A code carried to the wrong terminal therefore adds nothing to anybody's chain — it produces a key the owner does not recognize and declines.
That split is the design: the anonymous leg proves possession, the session-authorized leg supplies consent, and neither one is sufficient alone.
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=K7M2QXPA'
{
"present": "https://api.dfos.com/v1/key-proof/present",
"nonce": "…",
"audience": "api.dfos.com",
"purpose": "did:dfos:key-add",
"adopts": {
"did": "did:dfos:r7z9c4kfhne2t38va6d9kn2ch7f4b6a",
"handle": "bvalosek",
"displayName": "Brandon"
},
"roleSet": "auth,assert",
"prevCID": "…",
"expiresAt": "2026-08-28T17:10:00Z",
"relay": "https://relay.dfos.com"
}
That answer carries everything needed to sign, and everything a tool must
show its human before signing. An unknown code and a lapsed one both answer
404 {"error": "unknown or expired code"}, deliberately identically.
This document sits outside the /v1 prefix — it is discovery, not a
versioned resource — which is why it is not an operation in the
interactive reference.
Render before you sign
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 cannot discharge that:
did:dfos:r7z9c4… is not something a person recognizes, and an unrecognizable
subject is exactly how consent phishing works.
The handle and display name are public identity facts, and the code is already the capability, so naming them costs a code-holder nothing they did not have.
2. Present the envelope
curl -X POST https://api.dfos.com/v1/key-proof/present \
-H 'content-type: application/json' \
-d '{"code":"K7M2QXPA","envelope":"eyJhbGciOiJFZERTQSIsInR5cCI6…","description":"work laptop"}'
{
"status": "presented",
"adopts": {
"did": "did:dfos:r7z9c4kfhne2t38va6d9kn2ch7f4b6a",
"handle": "bvalosek",
"displayName": "Brandon"
},
"expiresAt": "2026-08-28T17:10:00Z"
}
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.
Its payload is byte-compared against the canonical serialization of exactly these seven members, in this order:
| 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 |
The same values in a different order are a different signed object and are
refused. did, roleSet, and prevCID are the position the key is being
added at, all three from the resolution above: an envelope binds one key to one
introduction, on one chain, at one head, and is worthless anywhere else. There
is no such thing as a proof held in reserve.
description is an optional label, at most 200 characters, shown to the owner
when they decide whether to adopt — typically the machine the key lives on. It
is not one of the signed members and nothing about it affects whether the
key is admitted. Omitted, empty, or whitespace-only gets the default label CLI signing key, and the user can rename it afterwards.
3. Poll for the decision
curl 'https://api.dfos.com/v1/key-proof/status?code=K7M2QXPA'
{ "status": "presented", "stale": false }
This is the leg to wait on. The identity's owner has to adopt or reject in their
browser, and until they do the answer is presented.
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 |
Treat rejected as what it is: the person declined a key they did not
recognize, nothing was added, and that is the honest thing to tell your user.
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 without asking anything else. It is
served only while the ceremony's ten minutes are still running; past
expiresAt the answer narrows to the bare status. A CLI polling its own
ceremony has the receipt seconds after presenting, long before that matters.
Watch stale
While presented, stale turns true if another writer moves the identity's
chain head. The stored envelope is bound to the head it was signed against, so
it can no longer be adopted as it stands.
Re-resolve the code, sign a fresh envelope for the same key with the new
prevCID, and present it again. Presenting the same key twice is admitted and
replaces the stored envelope; the owner's approval carries across it, and their
browser retries the adoption on its own. Presenting 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 deliberately not, because a caller varying its
input must not be able to read a ladder position off the status.
Only a bad signature consumes the ceremony. Everything else decidable from the request bytes — the label bound, the envelope cap, the shape gates, the audience, the freshness, and the three positional checks — is refused before anything is spent, so the code stays live and the command can simply be re-run. Unknown, expired, spent, and wrong-nonce ceremonies all answer the same "this ceremony is not open", so there is nothing to learn by varying it.
One key names one identity: a public key another identity chain has already proved is refused at adoption, because otherwise "who signed this" stops having an answer.
These routes are rate limited more tightly than the reads beside them — they are the one place this API does signature verification on caller-supplied bytes before it knows anything about the caller. See Rate limits and caching.
See the interactive reference for the exact request and response schemas, Sessions & keys for what a signing key is and what it can do, and Protocol discovery for resolving the chain the key landed on.