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 — 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:

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

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.

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

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.