BRC-250 Wallet

Device Login and Recovery

This draft lets a user sign in to a self-custodial wallet on a new device with their email address. The host validates and relays. It never holds the vault or a share of it. 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 wa

Brandon Cryderman / HandCash (brandongcryderman@gmail.com) Status: Draft (proposed number, not a BSVA assignment)

How it connects

Relationships

Authoritative content mirror

Full upstream text

View source ↗
Rendered from wallet/0250.md at the indexed upstream commit

Implementation status

HandCash's v1 prototype of this draft, a direct Google OpenID Connect proof against https://brc250-google.bcryderman.workers.dev, is retired. HandCash Desktop no longer ships its enrollment, Google sign-in or Google login approvals, and the host answers 410 (google-only-recovery-retired) to POST /v1/enroll and POST /v1/login/release. The prototype sent the host one share of a 2-of-3 split and emailed the other to the Google account's own inbox, so Google sign-in plus a seven-day wait reached the threshold; §9.1 withdraws that design. The zero-knowledge mail and OAuth proofs below are not implemented.

Abstract

This draft lets a user sign in to a self-custodial wallet on a new device with their email address. The host validates and relays. It never holds the vault or a share of it.

  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 directly to the new device, encrypted to that device's key.

The email alone never recovers the wallet (§9.2). A user with no signed-in device restores from their recovery phrase or slices (§9.3).

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 waits for one. The host never releases anything for it (§9.2).

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

  • the login proof verifies on that device (§6), for the email it enrolled;
  • the user types the comparison code shown on the new device, and it matches the code that device computed (§8).

No release rule. The host holds no recovery share and releases nothing on a login proof (§9.2).

Party Holds Does Cannot
Mail provider or Google Its signing keys; the user's mail Signs the user's mail or ID token Sign in by itself; open the vault
Host Attester key, account salt, device list Signs identifiers, validates proofs, relays requests Make an email proof; open the vault
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.
Restore Rebuilding the vault master on a device from the user's own recovery phrase or slices (§9.3).

Specification

1. Conventions

  • Signature: a 64-byte compact secp256k1 ECDSA signature over SHA-256 of a UTF-8 message, hex.
  • Public key: compressed secp256k1, hex.
  • pack_n(s): the UTF-8 bytes of s, right-padded with zero bytes to 31·n, as n big-endian BN254 field elements.
  • Poseidon: circomlib Poseidon over BN254.
  • Email commitment: Poseidon(pack_9(lowercase(address)), salt), where salt is one field element.
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>
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

  • Device authority key: the BRC-42 child of account 0's identity key for protocol [2, "brc250 device"], key id 1, counterparty self.
  • Login key: fresh for each login, discarded when the login completes or fails.

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 sends POST /enroll to the host:
{
  "loginProof": "<brc250:…>",
  "salt": "<hex>",
  "deviceAuthorityKey": "<public key>",
  "device": { "deviceId": "<hex>", "label": "<text>" },
  "reenrollSignature": "<required if the account is already enrolled>"
}

The host verifies the proof and stores accountRef, the email commitment, salt, the device authority key and the device. It MUST NOT accept a recovery share. It MUST require the reenroll signature to change an enrolled account. A host session alone therefore 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. Only the two devices ever show the code.

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 is useful 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 substituted.

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:

  • Paste: the user copies the message source from their Sent folder ("Show original" or "View source") into the wallet.
  • Relay: the host's inbound mail keeps the raw message until exp, and the wallet or login page fetches it with the host session.

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:

  • the proof, under the pinned email-zk circuit;
  • that the key at <selector>._domainkey.<dkimDomain> hashes to dkimKeyHash, fetched over DNSSEC or from two independent DNS-over-HTTPS resolvers that agree;
  • that toAddress is <localPart>+<binding>@<domain> from the pinned manifest;
  • that emailCommitment is the enrolled commitment.

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:

  • In the wallet: the wallet runs the sign-in itself, with PKCE (RFC 7636), and builds the proof.
  • In a browser: on the host's login page (§5), after the host sign-in and the Google sign-in, the page builds the proof in the browser and shows the login proof to copy into the wallet.

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:

  • the proof, under the pinned oauth-zk circuit;
  • that googleKeyHash matches a key in Google's JWKS that is current or retired within 24 hours;
  • that aud is in the pinned googleClientIds;
  • that nonce is the binding;
  • that emailCommitment is the enrolled commitment.

6.4 Verifying a login proof

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

  • that the identifier signature verifies under the pinned attester key;
  • that purpose is as expected;
  • that the binding is the one being requested.

Only email that 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. A request stays open for at most seven days, and only an enrolled device can complete it;
  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": null
}

releaseAt is always null. It stays in the response for wallets that read it. 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. No relay can therefore 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, challenged, revealed, sent or refused) and rB once present. 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

When no signed-in device can approve, the user restores from their own backup (§9.3). The host never holds the vault or a share of it.

9.1 Recovery shares (withdrawn)

Earlier versions of this draft sent the host one share of a 2-of-3 BRC-140 split and had the user email a second share to the enrolled address. That is withdrawn. A host MUST NOT accept a share, and a wallet MUST NOT email one, under this draft.

9.2 With only the email (withdrawn)

Recovery with only the email is withdrawn. The enrolled address is the address that makes login proofs, so a share kept in that inbox is not an independent check. Whoever holds the inbox, or its provider, also holds the proof, and a timed release from the host gave them the threshold. A login request with no recent device only waits for one (§8). Recovery through independent holders (BRC-154 backup services) is specified separately.

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 waiting period.

  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. The device checks the address the user typed, so the host cannot substitute another.
  3. Close. If the device opened a login request, 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
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, alert devices and inbox
POST /login/release — withdrawn answers 410
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 and rB

Security considerations

Attacker Outcome
Host or insider Holds no share of the vault. 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. A login still needs approval on a signed-in device; with no device left, it gets nothing.
Mail provider or Google Could sign mail or ID tokens as the user. That is a login proof, not a share, so it still needs a signed-in device's approval.
Host and mail provider together Can put convincing prompts on the user's devices. Still need the user to type, on their own device, a code shown on the attacker's screen.
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.
  • A device that approves already holds the wallet, so one approval suffices. Refusing never moves funds, so one refusal suffices.
  • The circuits are part of the trusted base. Each MUST be audited, and its trusted setup published, before devices accept it.
  • Wallets SHOULD keep at least one device sending presence, so a login the user did not start meets a device that can refuse it.

Rationale

  • Only devices move the vault. The vault moves only from a device that already holds it, or is restored from the user's own backup. The host can never hand out the vault because it holds neither the vault nor a share of it.
  • No email-only recovery. The address that makes login proofs cannot also be the check that releases a share. That design made the inbox, and its provider, the wallet's last key.
  • Devices verify for themselves. The host's check only filters spam. The device's own check is the one that counts, so a dishonest host can only drop requests.
  • Pasting is safe. A login proof grants nothing but a prompt on the user's own devices, bound to a key the attacker does not hold.
  • A committed comparison code. Whoever creates requests can grind a short code derived only from the request: about 100 tries for two digits. Here the new device commits to rA before the approving device sends rB, as in Bluetooth numeric comparison, so nobody in the middle can steer the code. Typing it, rather than picking it from a list, removes guessing.
  • Prefer the in-wallet Google sign-in. It never runs host-served code. The browser path is also safe under the comparison code, but the in-wallet path keeps the host out entirely.
  • Provider signatures. Without them, the host could put convincing prompts on any user's devices. DKIM and Google signatures come from outside the host.
  • Binding in the recipient address. Every email the user sends carries a DKIM signature. Requiring the binding in the To address at the pinned host means only mail sent to the host for this login counts.
  • Not mail from the host. The host holds its own DKIM key and keeps copies of what it sends, so it could forge those.
  • Zero knowledge. Proofs can be pasted, relayed and logged without exposing the address.

References