Skip to main content

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.

MemberWhat it is
nonceFrom the resolution
audienceThis API's own host — api.dfos.com
didThe chain the key is being added to
roleSetThe roles it is being added in
prevCIDThe chain head it is being added at
publicKeyMultibaseThe public half of the key
timestampA 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 }
statusWhat it means
pendingNobody has presented an envelope
presentedYours verified; the identity's owner is deciding
adoptedThey adopted it and the key is on the chain — see onAdopted
rejectedThey declined. Nothing was added, and this is not an error
failedAn envelope was refused at the signature; the ceremony is burned
expiredThe 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.