Source: BRCs-250/wallet/0250.md · sha256 81c08d3a565d

BRC-250: Device Login and Recovery

Brandon Cryderman / HandCash (brandongcryderman@gmail.com)

Status: Draft (proposed number, not a BSVA assignment)

Abstract

This standard lets a user sign in to a self-custodial wallet on a new device with their email address, and get the wallet back with that email alone after losing every device and every written backup. Nothing stored off the user's devices can open the wallet by itself: the host validates, relays and keeps one recovery share it cannot use alone.

  1. The new device obtains a login proof: a zero-knowledge proof of the user's email, bound to a key only that device holds and validated by a pinned host. The user can paste it into the wallet from their email or from a browser.
  2. The proof asks the user's existing devices for approval.
  3. An existing device checks the proof itself, has the user type the comparison code shown on the new device, and sends the vault master straight to the new device, encrypted to that device's key.

If none of the user's devices has been online for seven days, the same login proof starts a recovery (§9):

  1. The host waits seven days, alerting every enrolled device and the user's inbox. Any device that appears turns the request back into an approval, and any refusal ends it.
  2. After the wait, the host releases its share, sealed to the new device's key. The user pastes the second share from an email they sent themselves at enrollment, and the new device rebuilds the wallet from the two.

A user who still has their recovery phrase or offline slice restores at once, with no wait.

Overview

  1. The new device creates a one-time login key L and a secret random value, and commits to both.
  2. The user signs in to the host (handcash.io). The host signs an identifier naming the account, L and that commitment. The identifier's hash is the binding.
  3. The user proves their email in zero knowledge, bound to the binding:
    • Any address: send one email to login+<binding>@handcash.io, then paste its source into the wallet (or let the host relay it).
    • Google accounts: sign in with Google, in the wallet or in the browser, then paste the resulting proof into the wallet.
  4. The wallet sends the login proof to the host. The host validates it and alerts the user's signed-in devices.
  5. A signed-in device verifies the proof itself. It then exchanges random values with the new device, and the new device shows an 8-digit comparison code. The user types the code from the new device into the signed-in device, which then sends the vault master, sealed to L.
  6. If no device has been online in the last seven days, the request becomes a recovery. After seven more days with no device appearing and no refusal, the host releases its share sealed to L, and the user pastes the mailbox share (§9).

Transfer rule. A signed-in device sends the vault master only when both of these hold:

Release rule. The host releases its recovery share only to L, only after a seven-day wait with no enrolled device online and no refusal (§9.2).

Party Holds Does Cannot
Mail provider or Google Its signing keys; the user's mail, including the mailbox share Signs the user's mail or ID token Sign in by itself; open the vault without the host share
Host Attester key, account salt, device list, the host share Signs identifiers, validates proofs, relays requests, releases its share after the wait Make an email proof; open the vault alone
Signed-in device The vault master Verifies, approves and sends; or refuses —
New device L Builds the login proof; receives the vault —

Terminology

Term Definition
Vault master The wallet's root secret, for example BRC-157 entropy.
Signed-in device An install holding the vault master and enrolled with the host.
New device The install signing in.
Host The service the user signs in to. It publishes a manifest, signs identifiers, validates and relays.
Login key (L) A secp256k1 key the new device creates for one login.
Identifier The host-signed statement naming the account and L.
Binding The base64url SHA-256 of the identifier preimage, without padding.
Login proof The identifier plus a zero-knowledge email proof bound to it, in a pasteable form.
Comparison code An 8-digit code derived from L, the new device's committed random value and the approving device's random value. Neither the host nor anyone relaying can choose it.
Device authority key A key only signed-in devices can derive. It signs presence, refusals and re-enrollment.
Email commitment A salted Poseidon hash of the email address. The host and devices compare this, never the address.
Recovery shares A 2-of-3 BRC-140 split of the vault entropy: the host share, the mailbox share and the user share. Any two rebuild the vault master; one alone reveals nothing.
Mailbox share The recovery share the user emails to themselves at enrollment. Only their mailbox holds it.
Recovery A login request that no device approves, released by the host after a seven-day wait (§9.2).

Specification

1. Conventions

Signature Signer Message
identifier attester key the identifier preimage (§5)
presence device authority key brc250-presence\n<deviceId>\n<label>\n<state>\n<at>
pending device authority key brc250-pending\n<at>
refuse device authority key brc250-refuse\n<binding>
sent device authority key brc250-sent\n<binding>
reenroll the account's current device authority key brc250-reenroll\n<binding>
release login key L brc250-release\n<binding>
restore device authority key brc250-restore\n<at>
restored device authority key brc250-restored\n<binding>

The host MUST refuse an at more than 300 seconds from its clock.

2. Host manifest

The host serves https://<host>/.well-known/brc250.json:

{
  "v": 1,
  "attesterKey": "<public key>",
  "loginMailbox": { "localPart": "login", "domain": "handcash.io" },
  "googleClientIds": ["<OAuth client id>"],
  "circuits": {
    "email-zk": { "id": "<hex SHA-256 of the Groth16 verifying key>", "url": "<verifying key>" },
    "oauth-zk": { "id": "<…>", "url": "<…>" }
  }
}

The mail domain MUST accept mail for <localPart>+<anything>@<domain>. Each device fetches the manifest over TLS at enrollment and pins it. A pinned manifest changes only by re-enrollment.

3. Keys

Each install keeps a random deviceId (BRC-249) and a label such as MacBook · Desktop.

4. Enrollment

A device enrolls when the wallet is created or when the user turns on email login:

  1. It picks a random salt, obtains an identifier with purpose enroll, and builds a login proof for it (§6). This proves the address before it is used.
  2. It cuts recovery shares (§9.1).
  3. It sends POST /enroll to the host:
{
  "loginProof": "<brc250:…>",
  "salt": "<hex>",
  "deviceAuthorityKey": "<public key>",
  "device": { "deviceId": "<hex>", "label": "<text>" },
  "hostShare": "<BRC-140 backup share>",
  "reenrollSignature": "<required if the account is already enrolled>"
}

The host verifies the proof and stores accountRef, the email commitment, salt, the device authority key, the device and the host share, replacing any earlier host share. It MUST require the reenroll signature to change an enrolled account, so a host session alone cannot replace the email.

Every signed-in device keeps the address, salt and pinned manifest, so it can verify login proofs without the host.

5. Identifier

The new device creates L. The user signs in to the host either in the wallet, or on the host's login page at https://<host>/login#<L>, which the wallet opens in a browser. The host MUST issue identifiers only for the signed-in account:

{
  "v": 1,
  "purpose": "login" | "enroll",
  "accountRef": "<hex SHA-256 of \"brc250-account\\n\" + host account id>",
  "loginKey": "<L>",
  "commitment": "<hex SHA-256 of \"brc250-commit\\n\" + L + \"\\n\" + rA>",
  "context": "<client type and approximate location, as the host saw them>",
  "refuseHash": "<hex SHA-256 of the refuse code>",
  "iat": 0,
  "exp": 0,
  "nonce": "<16 random bytes, hex>",
  "signature": "<identifier signature>"
}

The preimage is the following lines joined with \n. context has no line breaks and is at most 200 bytes. exp − iat MUST be at most 900 seconds.

brc250-identifier
v1
<purpose>
<accountRef>
<loginKey>
<commitment>
<context>
<refuseHash>
<iat>
<exp>
<nonce>

rA is 32 random bytes in hex. The new device generates it with L and keeps it secret until §8.

For every identifier, the host MUST email the account's address. The email states context, carries a refuse link with the refuse code, and has Reply-To: <localPart>+<binding>@<domain>. It MUST NOT contain a comparison code: the code is only ever shown by the two devices.

6. Login proof

6.1 Format

A login proof is the string brc250: followed by base64url JSON:

{
  "identifier": { },
  "method": "email-zk" | "oauth-zk",
  "proof": { },
  "publicSignals": [ ]
}

It is safe to paste and to display. It works only with L, which never leaves the new device, and only to request approval.

Before using a login proof, the new device MUST check that its identifier names the device's own L and commitment, and MUST reject it otherwise. A proof made for someone else's login cannot be slipped in.

The proof is a Groth16 proof under the pinned circuit for its method. Whoever builds it needs the salt. The host returns the salt from POST /login/salt only for a valid login identifier, so addresses cannot be guessed against the commitment.

6.2 email-zk (any address)

The user sends one email from the enrolled address to <localPart>+<binding>@<domain>. They can reply to the host's notice, or use a mailto: that the wallet or the login page opens. Subject and body are ignored.

The wallet gets the signed message either way:

The wallet, or the login page in the browser, then builds the proof. The circuit takes the signed header block and the salt as private inputs. It proves:

  1. A DKIM-Signature with a=rsa-sha256 verifies over the relaxed-canonicalized headers it signs, under a 2048-bit RSA modulus with Poseidon hash dkimKeyHash.
  2. Its d= is dkimDomain, its s= is selector, and its h= includes from and to.
  3. The From address's domain is dkimDomain or a subdomain of it.
  4. The To header contains toAddress.
  5. emailCommitment is the commitment of the From address.

Public signals: dkimKeyHash, pack_9(dkimDomain), pack_3(selector), pack_9(toAddress), emailCommitment.

A verifier MUST check:

6.3 oauth-zk (Google accounts)

The user obtains a Google ID token whose nonce is the binding, for one of the pinned googleClientIds. They can do this either way:

The circuit takes the token and the salt as private inputs. It proves:

  1. The RS256 signature verifies under a modulus with Poseidon hash googleKeyHash.
  2. iss is https://accounts.google.com, email_verified is true, and aud, nonce and exp equal the public signals.
  3. emailCommitment is the commitment of the email claim.

Public signals: googleKeyHash, pack_5(aud), pack_2(nonce), exp, emailCommitment.

A verifier MUST check:

6.4 Verifying a login proof

A verifier (the host, or a signed-in device) MUST also check:

Only email the user sends counts, because the user's provider signs it. Verifiers MUST NOT accept a proof built from an email the host sent to the user, such as a verification code or link. The host signs those itself and keeps a copy, so it could build such a proof without the user. Addresses whose provider does not sign with an aligned 2048-bit RSA key cannot use email-zk. More OpenID Connect issuers MAY be added, each with its own circuit.

7. Presence

While unlocked and online, each signed-in device sends POST /presence to the host, at least daily and at most hourly:

{ "deviceId": "<hex>", "label": "<text>", "state": "active" | "signedOut", "at": 0, "signature": "<presence>" }

at MUST increase per device. The host records lastSeen, and drops the device on signedOut. A device is recent if now − lastSeen < 7 days.

8. Login

Request

The new device sends POST /login/start with { loginProof }. The host:

  1. verifies it (§6, purpose login, now < exp);
  2. opens a request keyed by the binding, replacing any open request for the account. If no enrolled device is recent, the request is a recovery and gets releaseAt, seven days after it opened (§9.2). A request stays open for at most seven days, or a recovery until a day after releaseAt;
  3. sends a BRC-33 message to every enrolled device authority key's messagebox;
  4. answers:
{
  "binding": "<base64url>",
  "deviceAuthorityKey": "<public key>",
  "recentDevices": [{ "label": "<text>", "lastSeen": 0 }],
  "releaseAt": 0
}

releaseAt is present only for a recovery. The new device shows "Approve on (seen

Approve

A signed-in device finds the request through the messagebox, or through GET /login/pending sent with a pending signature. The user opens it there.

  1. Verify. The device verifies the login proof itself (§6), against its own pinned manifest, address and salt. It MUST NOT rely on the host's verification.
  2. Challenge. It generates 32 random bytes rB and sends POST /login/challenge with { binding, rB }. It MUST send one challenge per request, and only after step 1.
  3. Reveal. The new device receives rB from GET /login/status, then sends POST /login/reveal with { binding, rA }. It MUST reveal only after receiving rB, and only once.
  4. Check. The signed-in device receives rA and checks it against the identifier's commitment. It MUST refuse on a mismatch.
  5. Compare. Both devices compute the comparison code, uint64(SHA-256("brc250-code\n" + binding + "\n" + rA + "\n" + rB)[0..8]) mod 100000000, as eight digits shown in two groups of four. The new device displays it. The signed-in device shows context and asks the user to type the code shown on the new device. It MUST NOT display the code itself.
  6. Decide. On a wrong code, the device sends refuse. On the right code and confirmation, it sends the vault master to L: a BRC-78 message from its device authority key to L, delivered through L's BRC-33 messagebox at the host. It then sends POST /login/close with { binding, signature } (sent).

The host fixes everything in the identifier, including L and the commitment, before rB exists. The new device fixes rA before it sees rB. So no relay can choose a request whose code matches the one on the user's new device. A substituted request matches with probability 10⁻⁸.

Refuse

POST /login/close with { binding, signature } (refuse) from a device, or { binding, refuseCode } from the inbox link, closes the request. After a device refusal, the host SHOULD refuse new requests for the account for 24 hours.

Complete

The new device polls GET /login/status?binding=, which returns the state (open, waiting, sent, released or refused), rB once present, and releaseAt for a recovery. It reads L's messagebox, and MUST:

  1. decrypt the message, and check that the master derives deviceAuthorityKey, discarding it otherwise;
  2. discard L, enroll its presence, and find its accounts by BRC-208 discovery and open only those none of the user's other devices holds (BRC-249).

Later unlocks are local. Signing out wipes the vault and sends signedOut.

9. Recovery

Two ways back when no signed-in device can approve: with only the email (§9.2), or from the user's own backup (§9.3). Neither gives the host the vault.

9.1 Recovery shares

At enrollment, and again on every re-enrollment, the device splits the vault entropy with toBackupShares(2, 3) (BRC-157, BRC-140):

A device MUST NOT give any party two shares of one split, and MUST NOT send the mailbox or user share to the host. A new split does not revoke an old one, because shares of the same split still combine. On re-enrollment the host MUST delete the old host share, which leaves the old mailbox and user shares one short.

9.2 With only the email

  1. Start. The new device sends a login request (§8). With no recent device, the host answers with releaseAt and the request waits. The host alerts every enrolled device and emails the address, with the refuse link, when the request opens and again a day before releaseAt.
  2. Wait. A device that comes online during the wait finds the request (§8) and approves or refuses it. Any presence from an enrolled device turns the request into an ordinary login, which then completes only by that device's approval. A refusal from a device or from the email link closes it.
  3. Release. At releaseAt, if the request is still open and no enrolled device has sent presence since seven days before it opened, the new device sends POST /login/release with { binding, signature } (release). The host answers with its share as a BRC-78 message to L, and closes the request. It MUST NOT release earlier, or to any key but L.
  4. Rebuild. The user pastes the mailbox share, or the user share. The device combines the two shares and decodes the entropy (BRC-157). It MUST check that the master derives deviceAuthorityKey, and discard both shares and the master otherwise. It then discards L.
  5. Rejoin. The device cuts new recovery shares and re-enrolls (§4) with the reenroll signature, which deletes the released host share. It sends presence, and chooses accounts to hold as at the end of a login (§8).

9.3 From the user's own backup

A user who still has their recovery phrase, or two of their own key slices, restores at once. The backup already proves custody, so there is no wait.

  1. Restore. The device restores the vault master from the phrase or the slices (BRC-157), and derives the device authority key (§3).
  2. Match. It sends POST /restore with { at, signature } (restore). The host answers with the account's email commitment and salt, or 404 if the key was never enrolled. The user types their email address. The device MUST check that the commitment of that address under the returned salt equals the returned commitment, and asks again otherwise. It then keeps the address, the salt and the manifest it pins now. Because the device checks the address the user typed, the host cannot substitute another.
  3. Close. If the device opened a login request or a recovery, it sends POST /login/close with { binding, signature } (restored). The host closes it without the cooldown that follows a refusal.
  4. Rejoin. It sends presence (§7). If the user no longer controls the enrolled address, or the host answered 404, the device enrolls a new address (§4) with the restored key's reenroll signature. Any other signed-in device adopts a replaced address as in step 2, once the user types it there, and refuses login proofs for the new address until then.
  5. Hold. It chooses accounts to hold as at the end of a login (§8).

A recovered or restored device is a signed-in device from then on, and can approve later logins.

10. Host endpoints

Endpoint Caller Authorized by Effect
POST /enroll signed-in device enroll login proof; reenroll signature to change store commitment, salt, device authority key, host share
POST /presence signed-in device presence signature update lastSeen
POST /restore restoring device restore signature return the email commitment and salt
POST /login/salt new device or login page login identifier return the salt
POST /login/start new device login login proof open a request or recovery, alert devices and inbox
POST /login/release new device release signature by L; after releaseAt, no device seen, not refused return the host share sealed to L
GET /login/pending signed-in device pending signature return the open request and, once revealed, rA
POST /login/challenge signed-in device the binding; once per request store rB
POST /login/reveal new device the binding; once per request, after rB store rA
POST /login/close signed-in, restoring device or inbox link sent, refuse or restored signature, or refuse code close the request
GET /login/status new device the binding state, rB and releaseAt

Security considerations

Attacker Outcome
Host or insider Holds one recovery share, which alone reveals nothing. Cannot sign the user's mail or Google tokens, so it cannot make a login proof that devices accept. It can drop or delay requests, not complete them.
A malicious host redirecting the user's own login It could get the user to make a proof for the host's own L', through a forged reply-to address or a doctored login page. The new device rejects a pasted proof that does not name its L. The signed-in device's code is then computed from L'. The host fixed L' before seeing rB, so the code matches the new device's screen with probability 10⁻⁸.
Someone with a login proof (pasted, leaked or phished) Can only cause an approval prompt, and only for the L named in it. The vault is sealed to L.
Phishing the user into producing a proof for an attacker's L Needs the victim's host session. Then needs the victim to type, on their own device, the code shown on the attacker's screen.
User approving a login they did not start Requires the user to type a code read from someone else's screen. Not preventable without a hardware factor. Mitigated by context and the email notice.
Look-alike host Wrong attester key, wrong mail domain, wrong token audience. Devices reject it.
Mail the user sends elsewhere Not a proof: the binding must be in the To address at the pinned host.
Inbox or Google account takeover Produces valid proofs and holds the mailbox share. A login still needs approval on a signed-in device. A recovery waits seven days, and any device that appears, or the refuse link, stops it. With no device left, and the user not reading their inbox, nothing stops it: the email is then the wallet's last key.
Mail provider Holds the mailbox share and could sign mail as the user. It still needs the host share, released only after the seven-day wait.
Host and mail provider together Hold two shares between them and can open the wallet. A user who does not accept this deletes the mailbox share and keeps the user share offline; recovery then needs that share instead.
A host session for the victim's account Cannot change the enrolled email without the reenroll signature.
Email guessing The salt is released only for a signed login identifier.
Stolen signed-in device Already holds the vault, so approving adds nothing, and nothing in this protocol takes the vault back. It can sign for every account; account holding (BRC-249) coordinates honest installs and does not stop it. The owner's only remedy is to move every account's funds to a fresh vault from a device they still hold, before the thief spends them.
Host lying in POST /restore The device checks the returned commitment against the address the user types. Another address, or a wrong salt, fails the check.
Forged DKIM key in DNS DNSSEC, or two agreeing resolvers.
Replay The binding covers the nonce, the account and L, and the identifier expires in 15 minutes.

Rationale

References