Beersy
BRC-250Draft (proposed number, not a BSVA assignment)

Device Login and Recovery

Signing in to a self-custodial wallet on a new phone is normally stuck between two bad options: either some server holds enough of your secret to log you in (so it can also be hacked or coerced into logging someone else in), or you have to manually copy a long recovery phrase between devices every time. This makes it hard to offer a simple "sign in with your email" experience without quietly giving a company the power to open your wallet.

About Backup and recovery, getting your keys back after losing the device.

AlsoIdentityKey linkage

Brandon Cryderman / HandCashchanged 29 Oct 202620 min read

Reference for an AI

Everything an assistant needs to answer questions about BRC-250 accurately, including what it depends on.

Summary

Why
A user needs an easy way to add a new device to their wallet using just their email, without any server ever being able to hold or release the actual wallet secret.
What
BRC-250 is a protocol for logging a new device into a self-custodial wallet by proving ownership of an email address, with an existing device verifying the proof and sending the wallet's master secret directly, never through the .
How
The new device generates a one-time login key, gets the host to sign an identifier binding it to the account, proves the user's email in zero knowledge against that binding, and an already-signed-in device checks the proof, has the user confirm a comparison code, and then sends the encrypted to the new…

What this lets you do

  • Let a user add a new device using only their email address
  • Verify email or Google ownership without exposing the email or token to the host
  • Approve a new device from an existing signed-in device using a typed comparison code
  • Keep the wallet's root secret off the host entirely
  • Fall back to recovery phrase or shares when no device is available to approve

Written by claude-sonnet-5 from the specification text. Where the two differ, the original is correct.

new devicesigned-in devilogin proof +

The specification

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 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 ; §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 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 . 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).

PartyHoldsDoesCannot
Mail provider or GoogleIts signing keys; the user's mailSigns the user's mail or ID tokenSign in by itself; open the vault
HostAttester key, account salt, device listSigns identifiers, validates proofs, relays requestsMake an email proof; open the vault
Signed-in deviceThe vault masterVerifies, approves and sends; or refuses—
New deviceLBuilds the login proof; receives the vault—

Terminology

TermDefinition
Vault masterThe wallet's root secret, for example BRC-157 entropy.
Signed-in deviceAn install holding the vault master and enrolled with the host.
New deviceThe install signing in.
HostThe service the user signs in to. It publishes a , signs identifiers, validates and relays.
Login key (L)A key the new device creates for one login.
IdentifierThe host-signed statement naming the account and L.
BindingThe base64url SHA-256 of the identifier preimage, without padding.
Login proofThe identifier plus a zero-knowledge email proof bound to it, in a pasteable form.
Comparison codeAn 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 keyA key only signed-in devices can derive. It signs presence, refusals and re-enrollment.
Email commitmentA salted Poseidon hash of the email address. The host and devices compare this, never the address.
RestoreRebuilding 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 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.
SignatureSignerMessage
identifierattester keythe identifier preimage (§5)
presencedevice authority keybrc250-presence\n<deviceId>\n<label>\n<state>\n<at>
pendingdevice authority keybrc250-pending\n<at>
refusedevice authority keybrc250-refuse\n<binding>
sentdevice authority keybrc250-sent\n<binding>
reenrollthe account's current device authority keybrc250-reenroll\n<binding>
restoredevice authority keybrc250-restore\n<at>
restoreddevice authority keybrc250-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 for protocol [2, "brc250 device"], 1, self.
  • Login key: fresh for each login, discarded when the login completes or fails.

Each install keeps a random deviceId (BRC-249) and a 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 ;
  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 <device> (seen <time>)". If recentDevices is empty, it shows "None of your devices has been online in 7 days. Open HandCash on one of them, or restore from your recovery phrase or slices" (§9.3).

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 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 ) 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

EndpointCallerAuthorized byEffect
POST /enrollsigned-in deviceenroll login proof; reenroll signature to changestore commitment, salt, device authority key
POST /presencesigned-in devicepresence signatureupdate lastSeen
POST /restorerestoring devicerestore signaturereturn the email commitment and salt
POST /login/saltnew device or login pagelogin identifierreturn the salt
POST /login/startnew devicelogin login proofopen a request, alert devices and inbox
POST /login/release—withdrawnanswers 410
GET /login/pendingsigned-in devicepending signaturereturn the open request and, once revealed, rA
POST /login/challengesigned-in devicethe binding; once per requeststore rB
POST /login/revealnew devicethe binding; once per request, after rBstore rA
POST /login/closesigned-in, restoring device or inbox linksent, refuse or restored signature, or refuse codeclose the request
GET /login/statusnew devicethe bindingstate and rB

Security considerations

AttackerOutcome
Host or insiderHolds 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 loginIt 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 LNeeds 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 startRequires 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 hostWrong attester key, wrong mail domain, wrong token audience. Devices reject it.
Mail the user sends elsewhereNot a proof: the binding must be in the To address at the pinned host.
Inbox or Google account takeoverProduces valid proofs. A login still needs approval on a signed-in device; with no device left, it gets nothing.
Mail provider or GoogleCould 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 togetherCan 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 accountCannot change the enrolled email without the reenroll signature.
Email guessingThe salt is released only for a signed login identifier.
Stolen signed-in deviceAlready 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 /restoreThe device checks the returned commitment against the address the user types. Another address, or a wrong salt, fails the check.
Forged DKIM key in DNSDNSSEC, or two agreeing resolvers.
ReplayThe binding covers the , 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

Was this helpful?

Search Beersy

Search standards by number, title, author or topic