Deus Witness

Quickstart | Concepts | API | Verify | Coverage | Teams | Anchors | Recovery

Recovery: a lost phone without weaker security (0.2.2)

Your passkey lives on a device. Lose the device and, until 0.2.2, you lost the account. 0.2.2 gives three ways back, and none of

them lets anyone sign for you without either a passkey you already hold or other people who know you.

you lostway backwhat vouches for the new key
one device, you still have another passkeyDevices: remove the lost onea signature by a passkey you still hold
every passkey, you have a recovery codeRecovery code -> add a new passkeyonly the witness (said so in the pack)
your team key (organisation with a policy)the other members replace your keytheir signatures (rule recovery, k >= 2)

1. Several passkeys (Devices page)

and signs for your own account.

key** (a wa-sig of type application/dw-key-event+cbor over {principal, op, kid, pub, cred, nonce}). Until that signature arrives

the new key is not active. A cookie alone cannot add a key; the new key cannot vouch for itself (409 KEY_SELF).

once: it cannot sign in (401 CREDENTIAL_REVOKED), step up, approve or sign anything (409 KEY_REVOKED), and the sessions it

opened end.

Your other devices get a push ("a passkey was added to or removed from your account").

2. Recovery codes

keeps only a scrypt hash of each (N = 2^14, r = 8, p = 1, 16-byte salt per code). Making new codes voids the old ones.

Wrong, unknown and used codes get the same answer (401 RECOVERY_INVALID) after the same scrypt work. Limits: 10 attempts per hour

per IP; 10 FAILED attempts per hour per account the code's lookup group reaches (0.2.12, R7-05: then that account's codes wait an hour);

a code no account has (garbage) also counts against one ceiling for the whole server (300 per hour), which real codes never pay - a flood of

garbage from many addresses cannot lock owners out.

just used"), and the browser gets a 15-minute session that can do one thing only: add a new passkey (every other path answers

403 RECOVERY_ONLY). After the new passkey is created the recovery session ends; you sign in with the new passkey, remove the lost one

on Devices, and make new codes.

on the recovery page (POST /api/auth/recovery/passkey/begin {"label", "device": "phone"}). Only a phone key may remove or demote your lost

phone's key, so after losing the phone: run recovery on the new phone, choose "my new phone", sign in with it, remove the lost key.

The declaration is recorded (key_device, by: "recovery-code") and pushed to your devices. Keep the codes on paper: whoever holds a code

can declare a new "phone".

may set 24-72 h: DW_RECOVERY_PHONE_DELAY_H); until then it is a laptop key (it signs you in and confirms ordinary project commands, but does

not remove the lost phone, confirm phone-level actions or vouch for another phone key). The registry row carries not_before; the pack checker

prints keys: <kid> declared the new phone with a recovery code at T; counts as phone from T+24h and fails a row whose wait is shorter

than 24 h or longer than 72 h (RECOVERY_DELAY). At the declaration every other device of yours gets a push (kind p: "A recovery code was used

to declare a NEW PHONE ... Not you? cancel"); Devices shows the waiting key with "This was not me - cancel". Cancelling needs a step-up with

your phone; if you have no phone key, with any other active key that is not waiting itself (a waiting key never cancels another one).

Cancelling ends the claim for good (the key stays a laptop key until you remove it, its sessions end) and stops every unused recovery code

(whoever used one probably holds the sheet): make new ones. Removing a waiting key follows the same rule. With no other device left the

wait still holds: come back after 24 h. 0.2.16: until then the waiting key makes no new recovery codes and removes no passkey (409

PHONE_PENDING). The "first marking" shortcut (an account that never had a phone may mark a key phone without a phone

signature) is closed while a recovery code was used in the last 24 h or a declared phone waits - otherwise a code would skip the wait.

In a team whose policy has the rule recovery (k >= 2), two other members can end the wait early: any member opens

POST /api/org/phone-lift {"user_id"}, the others sign it on the sign page (typ application/dw-phone-lift+cbor); the person never signs it.

The registry row then says lifted: true, by: "team-quorum" with the quorum envelope. E-mail: the service stores no e-mail addresses, so

the notice goes by push only.

pack checker says exactly that: keys: 2 keys (1 added: 0 by passkey, 1 by recovery code (witness-attested); 1 removed).

3. Organisations: the others replace your key

A team policy names one key per member. A new passkey on your account does not sign for the team until the policy names it.

K must be at least 2 - one person alone never takes over someone else's key - and reachable whichever one member is lost

(otherwise POLICY_MALFORMED).

is a policy version that changes only your key (same roles, label and rules). It needs K signatures of OTHER members; your lost key

never counts (409 RECOVERY_SELF). The sign page tells each signer to confirm with you in person or by phone first.

there is no recovery rule, nobody can change the policy any more: add the rule while you can.

org policy: chain ok (v1..v4)
org recovery: v4 replaced key 3992a358 -> b97f1671 (rule recovery any k=2, 2 signers, the replaced key not among them: yes)
keys: 2 keys (1 added: 0 by passkey, 1 by recovery code (witness-attested); 1 removed), 0/0 signatures by a key active at the time
quorum: 1/1 entries meet policy-at-time
RESULT: VALID

(from my run of server/tests/test_recovery_org.py::test_o1_owner_key_replaced_by_two_others_and_pack_valid, 19.10.2026: the owner of a

3-member organisation lost the phone; Bob and Carol replaced the key.)

4. Mandates signed by the lost key

They stay valid: each signature was good when it was recorded, the agents keep working, and the pack still verifies. The checker's

second pass accepts a signature only from a key that was active at the entry's witnessed time - before its removal, yes; after, INVALID

(keys: FAIL KEY_NOT_ACTIVE@<seq>).

If you do not trust them any more (the phone was stolen unlocked), Devices -> "Revoke all" lists the active mandates that key signed

and revokes them with a current passkey in one ceremony per 20 mandates: one batch envelope wb of typ

application/aar-revocation+cbor whose challenge covers the items and the type, so an approval batch can never be replayed as a

revocation (REVOCATION_SIG), nor the other way round. API: GET /api/mandates/by-key/{kid}, POST /api/mandates/revoke-batch.

5. API (machine form: contract/v02/openapi-0.2.2-recovery.yaml)

Person-level (under /api/auth/, any member including an observer): GET /api/auth/keys, POST /api/auth/keys/add/{begin,finish,confirm},

POST /api/auth/keys/{kid}/remove/begin, POST /api/auth/keys/{kid}/remove, GET|POST /api/auth/recovery-codes,

POST /api/auth/recovery/login, GET /api/auth/recovery/state, POST /api/auth/recovery/passkey/{begin,finish}.

Organisation-level: POST /api/org/key-replace, GET /api/mandates/by-key/{kid}, POST /api/mandates/revoke-batch.

GET /api/me adds signing_keys (the passkeys that may sign in the current account: every active one of a solo account, the member's

key in a team).

6. Honest limits

  1. A recovery code is a key to the account. Whoever holds one (and knows nothing else) can add a passkey, then remove yours and make

new codes. What stands in the way: the push to your devices, the registry row, and in a team the other members. Keep codes offline.

  1. The witness vouches for a code-added key. The pack shows by recovery code (witness-attested); a third party trusts the witness

for that step, as for the time of every entry.

  1. Removal does not reach the lost device. It stops the key here; it does not erase the passkey on the phone, nor at the platform

(iCloud Keychain / Google Password Manager may sync it to the thief's other devices - remove it there too).

  1. Old sessions. Sessions opened before 0.2.2 do not record which passkey opened them; removing any key ends all of those.
  2. Agent API keys are separate. A stolen session could have read nothing secret, but if you fear it was used, recreate the agents' keys.
  3. Checkers. A pack with key changes needs the 0.2.2 checker. A 0.2.1 checker ignores the key rows: where a second passkey signed

it answers INVALID (ledger: invalid MANDATE_KEY@0 in my probe), otherwise it may say VALID without having looked at the key

changes. The Swift checker does

not know key events yet.

  1. Push delivery is best effort (see Approvals): a security notice can arrive late or not at all.

0.2.5: what a recovery-code key can do in a team (R4-09)

A team policy names one key per member. A passkey you added with a recovery code (or on a second device) is not in the policy. In an organisation with a policy that key gets no rights there until the other members swap it in (Team -> "Lost passkey: replace key", rule recovery):

So a stolen paper code of a team owner does not by itself open the team: before the quorum looks at the replacement request, the thief can read nothing protected and change nothing in the team.

Partly covered since 0.2.15: a key added with a code reaches the phone level only after the wait (24-72 h), and the other devices can cancel it. Closed in 0.2.16: during the wait the declared key can neither make new recovery codes nor remove any passkey, phone or laptop (409 PHONE_PENDING until the wait ends); the owner's other keys still can, and can cancel it. It can still open a key-replacement request that looks like an ordinary "lost phone": the team's confirmation by voice or in person (shown on the signing page) is the protection there. The price: a person who lost every device waits 24 h before making new codes.