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.
- 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.
- The proof asks the user's existing devices for approval.
- 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):
- 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.
- 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
- The new device creates a one-time login key
Land a secret random value, and commits to both. - The user signs in to the host (
handcash.io). The host signs an identifier naming the account,Land that commitment. The identifier's hash is the binding. - 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.
- Any address: send one email to
- The wallet sends the login proof to the host. The host validates it and alerts the user's signed-in devices.
- 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. - 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:
- 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).
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: 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 ofs, right-padded with zero bytes to31·n, asnbig-endian BN254 field elements.- Poseidon: circomlib Poseidon over BN254.
- Email commitment:
Poseidon(pack_9(lowercase(address)), salt), wheresaltis 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> |
| 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
- Device authority key: the BRC-42 child of account 0's identity key for protocol
[2, "brc250 device"], key id1, counterpartyself. - 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:
- It picks a random
salt, obtains an identifier with purposeenroll, and builds a login proof for it (§6). This proves the address before it is used. - It cuts recovery shares (§9.1).
- It sends
POST /enrollto 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:
- 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:
- A
DKIM-Signaturewitha=rsa-sha256verifies over the relaxed-canonicalized headers it signs, under a 2048-bit RSA modulus with Poseidon hashdkimKeyHash. - Its
d=isdkimDomain, itss=isselector, and itsh=includesfromandto. - The
Fromaddress's domain isdkimDomainor a subdomain of it. - The
Toheader containstoAddress. emailCommitmentis the commitment of theFromaddress.
Public signals: dkimKeyHash, pack_9(dkimDomain), pack_3(selector), pack_9(toAddress), emailCommitment.
A verifier MUST check:
- the proof, under the pinned
email-zkcircuit; - that the key at
<selector>._domainkey.<dkimDomain>hashes todkimKeyHash, fetched over DNSSEC or from two independent DNS-over-HTTPS resolvers that agree; - that
toAddressis<localPart>+<binding>@<domain>from the pinned manifest; - that
emailCommitmentis 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:
- The RS256 signature verifies under a modulus with Poseidon hash
googleKeyHash. issishttps://accounts.google.com,email_verifiedistrue, andaud,nonceandexpequal the public signals.emailCommitmentis the commitment of theemailclaim.
Public signals: googleKeyHash, pack_5(aud), pack_2(nonce), exp, emailCommitment.
A verifier MUST check:
- the proof, under the pinned
oauth-zkcircuit; - that
googleKeyHashmatches a key in Google's JWKS that is current or retired within 24 hours; - that
audis in the pinnedgoogleClientIds; - that
nonceis the binding; - that
emailCommitmentis 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
purposeis as expected; - that the binding is the one being requested.
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:
- verifies it (§6, purpose
login,now < exp); - 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 afterreleaseAt; - sends a BRC-33 message to every enrolled device authority key's messagebox;
- 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
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.
- 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.
- Challenge. It generates 32 random bytes
rBand sendsPOST /login/challengewith{ binding, rB }. It MUST send one challenge per request, and only after step 1. - Reveal. The new device receives
rBfromGET /login/status, then sendsPOST /login/revealwith{ binding, rA }. It MUST reveal only after receivingrB, and only once. - Check. The signed-in device receives
rAand checks it against the identifier'scommitment. It MUST refuse on a mismatch. - 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 showscontextand asks the user to type the code shown on the new device. It MUST NOT display the code itself. - Decide. On a wrong code, the device sends
refuse. On the right code and confirmation, it sends the vault master toL: a BRC-78 message from its device authority key toL, delivered throughL's BRC-33 messagebox at the host. It then sendsPOST /login/closewith{ 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:
- decrypt the message, and check that the master derives
deviceAuthorityKey, discarding it otherwise; - 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):
- Host share: sent with
POST /enroll. The host keeps at most one share per account, as a BRC-154 backup service does, and releases it only under §9.2. - Mailbox share: the wallet opens a
mailto:from the enrolled address to itself, subjectHandCash recovery share, bodybrc250-share:<share>. The user sends it, then pastes it back from their inbox, and the device checks that it combines with the host share to the vault entropy. The message passes through the user's mail provider only. The host never sees it. - User share: shown to the user as a key slice to keep offline, or deposited with an independent BRC-154 service. The wallet MAY discard it.
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
- Start. The new device sends a login request (§8). With no recent device, the host answers with
releaseAtand 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 beforereleaseAt. - 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.
- 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 sendsPOST /login/releasewith{ binding, signature }(release). The host answers with its share as a BRC-78 message toL, and closes the request. It MUST NOT release earlier, or to any key butL. - 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 discardsL. - Rejoin. The device cuts new recovery shares and re-enrolls (§4) with the
reenrollsignature, 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.
- Restore. The device restores the vault master from the phrase or the slices (BRC-157), and derives the device authority key (§3).
- Match. It sends
POST /restorewith{ at, signature }(restore). The host answers with the account's email commitment and salt, or404if 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. - Close. If the device opened a login request or a recovery, it sends
POST /login/closewith{ binding, signature }(restored). The host closes it without the cooldown that follows a refusal. - 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'sreenrollsignature. 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. - 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. |
- 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.
- The host enforces the wait. It stops an inbox takeover while the host is honest, not a host working with the mail provider (see above).
- Wallets SHOULD keep at least one device sending presence, so a recovery the user did not start meets a device that can refuse it.
Rationale
- Two shares, two parties. The vault moves only from a device that already holds it, or is rebuilt on the new device from two shares that no single party holds: the host's, released after a public wait, and the one in the user's own mailbox. The host can never hand out the vault, because it never has it.
- A seven-day wait. Recovery with only an email makes the email the last key. The wait gives every device, and the user's inbox, a week to stop a recovery the user did not start, and a device that appears turns it back into an approval.
- Devices verify for themselves. The host's check filters spam. The device's own check is what matters, 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. A short code derived only from the request can be ground by whoever creates requests: about 100 tries for two digits. Here the new device commits to
rAbefore the approving device sendsrB, 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
Toaddress 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
- BRC-33: PeerServ Message Relay Interface
- BRC-42: BSV Key Derivation Scheme
- BRC-78: Serialization Format for Portable Encrypted Messages
- BRC-140: Threshold Key Sharing and Backup via Shamir's Secret Sharing Scheme
- BRC-154: Pluggable Backup Services for BRC-140 Share Vaults
- BRC-157: Entropy-Rooted Backup and Recovery with Mnemonics and Backup Shares
- BRC-208: Vault Sub-Accounts
- OpenID Connect Core 1.0, §3.1.2.1
- RFC 6376: DKIM Signatures
- RFC 7489: DMARC, §3.1 (alignment)
- RFC 7636: PKCE
- Groth16