# ADR 0017: A password, TOTP and the account's keys - **Status:** decided 20 Oct 2026, by the developer (D1–D5 below). - **Decided before:** 19 Oct 2026, the developer: people sign in with a password that never leaves the device (SRP) and a mandatory TOTP code; the password unlocks the account's keys; recovery codes and a recovery phrase; Sign in with SimpleLogin later, as an extra way in. - **Builds on:** ADR 0002 (the content envelope: `keyId`, `nonce`, `payload`), ADR 0010 (email sign-in), ADR 0016 (the register). - **Followed by:** ADR 0018 (the agenda), the first data encrypted with these keys. ## Context Todo is to be the agenda of every Halkyon service, both ways (ADR 0016), and what people plan there may need to be encrypted end to end later: readable on their devices, never by an instance. Proton does this with one account key pair per person, unlocked on the device by a key derived from the password, and random keys per store (a calendar, a vault) wrapped with it; times a server must index stay readable but signed. Today people sign in with a code by mail (ADR 0010), GitHub, or no account. No secret exists that the instance doesn't also know, so nothing could be encrypted for the person alone. ## Proposal 1. **Signing in** (identified accounts): the email address (kept as its keyed hash, as ADR 0010 keeps it), a password and a TOTP code. The password never leaves the device. It replaces email codes for signing in outright (nobody signs in with them yet); a code by mail serves only resets (5, 6). 2. **The protocol (D1).** SRP-6a (RFC 5054, the 3072-bit group, SHA-256), as Proton runs it: the device proves it knows the password without sending it or anything that would sign in in its place; the register keeps a verifier. Its private value is derived from the password with Argon2id, so guessing a leaked verifier costs Argon2id per guess. Implemented in Dart (BigInt), held to RFC 5054's test vectors, and reviewed before release. 3. **The keys.** At sign-up the device makes the account's key pairs (X25519 to encrypt, Ed25519 to sign) and encrypts their private halves (XChaCha20-Poly1305) with a key derived from the password (Argon2id, a salt per account, parameters a phone manages in about a second; HKDF splits it from SRP's value). The register keeps the encrypted private keys, the public ones and the salt; it can never use them. 4. **Data keys** are random per store (Todo's agenda, later an app's space), sealed to the account's public key, so every device that unlocks the account opens them; sharing with someone later seals them to that person's key too, as Proton shares a calendar. Content goes into ADR 0002's envelope (`keyId`, `nonce`, the encrypted `payload`), signed by its author; what a server must read (a reminder's time) stays readable and signed. 5. **TOTP** (RFC 6238: SHA-1, 6 digits, 30 seconds, one step either way), mandatory for every identified account; its secret kept by the register, encrypted with a key of the instance's (a new password, `totpKey`). Ten recovery codes, shown once, kept as HMAC-SHA256 under a key of the instance (`recoveryCodeKey`, outside the database: a code's 40 bits leak nothing from a database without it), each used once. 6. **Recovery.** A recovery phrase (24 words, BIP-39's English list: 256 bits) also wraps the private keys: a lost password with the phrase keeps everything. Without it, the address (a code by mail) resets the account: new keys, and what was encrypted with the old ones is lost, as Proton warns. A lost TOTP device: a recovery code (D3). 7. **Libraries.** On devices libsodium through `sodium` and `sodium_libs` (Android, iOS, Linux, Windows, macOS: Argon2id, X25519, XChaCha20-Poly1305, Ed25519); on servers only SRP's arithmetic, SHA-256 and HMAC (`crypto`): no server ever decrypts a key. 8. **Where it lives:** with the register (ADR 0016); a member forwards SRP's two steps to it. 9. **Anonymous accounts** stay without a password. Their device makes the key pairs itself and keeps the private halves in its keyring, so their private studysets are encrypted as anyone's; setting an address and password makes them identified, wrapping those keys with the password and handing them to the register. 10. **Private or not, per studyset** (D5): a studyset, with everything inside it, is private (end to end encrypted with a data key of its own) or not, chosen when it is made and changeable later (the device re-encrypts or decrypts it and syncs it anew). New ones are private. A private studyset can't be published, read by the Claude connector, counted for "learn this here" or checked by the instance's LanguageTool; asking an AI assistant from the device (Lumo's hand-off) works as before. Other apps choose per store alike; Todo's agenda is always private. 11. **GitHub sign-in is deprecated** (D2): people who sign in with it add an address, a password and TOTP at their next sign-in; three months after the release GitHub signs no one in any more and the instance forgets the GitHub IDs. ## Decisions (20 Oct 2026, the developer's) - **D1** SRP-6a, as decided on 19 Oct (over OPAQUE, which has no Dart implementation, and Bitwarden's hash, which signs in in the password's place should TLS be broken). - **D2** GitHub sign-in is deprecated: its people set an address, a password and TOTP at their next sign-in; it ends three months after the release (11). - **D3** A lost authenticator without recovery codes, as Proton: the password plus a code by mail (or the recovery phrase) turns TOTP off, but only 72 hours later, with a mail at the start, and called off if the person signs in meanwhile; several authenticators may be linked. - **D4** Nobody signs in with email codes yet, so the password replaces them outright (1). - **D5** Privacy is the person's choice per studyset, private by default (10). - **Also (20 Oct, at the crypto core's review):** recovery codes kept under the instance's key (5); passwords taken as RFC 8265's OpaqueString (other spaces to a plain space, NFC, control characters refused) before Argon2id, so a password unlocks alike on every system. ## Settled in building the register's side (20 Oct 2026) In `halkyon_base_server` (its README has the details); members forward every step through `AccountRegister`, the session being theirs. - **Sign-up** (`password.set`) needs an address: the person's own, or one a mailed code proves in the same call, which makes an anonymous person identified under their ID. The password signs in only once an authenticator is confirmed; until then a new setup replaces it. The register checks what it can: salt 16 bytes, Argon2id `argon2id13` at 3–16 passes and 64 MiB–1 GiB, the verifier 1 < v < N padded to N, public keys 32 bytes, each wrapping a 24-byte nonce and 112 bytes. - **SRP's identity I is the sign-in's challenge ID**, not the account's: x comes from Argon2id over the password and salt, so I only binds the proof to this sign-in, and an account ID handed to whoever types an address would tell who has one. - **Addresses nobody has** (or whose account has no password) get a salt and verifier from a third instance key, `signInDecoyKey`, and the address's hash, the same queries and modular arithmetic (both are computed for every sign-in), a challenge row of their own, and in the second step the same refusal. One refusal, `wrongCredentials`, for a wrong password, an unknown address, and a wrong, used or missing code; `setupIncomplete` (no confirmed authenticator) and `notDue` (a reset waiting) only after a right password. Begun sign-ins are limited to 10 an hour per address and 30 per network; challenges last two minutes and are taken once. - **TOTP secrets** are sealed with XChaCha20-Poly1305 (pub.dev's `cryptography`, pure Dart, pinned) under a key derived from `totpKey`, bound to the row's account and ID; each step's code passes once (a conditional update of the last step). At most 10 authenticators; one not confirmed within a day is pruned; the last confirmed stays; removing one, or new recovery codes, takes a code (10 tries an hour per account). - **Resets** (D3): an authenticator reset takes effect 72 hours after the password and a mailed code or the recovery phrase ask, with a mail to the address at once (none sent, none started); a sign-in with a code calls off every reset waiting. Once due, the first sign-in with the password alone carries it out (authenticators and recovery codes go), and the password alone signs in until a new authenticator is confirmed. The recovery phrase is proved by the account's Ed25519 signature over `halkyon key proof v1|`, the challenge's ID and a nonce. A password set with the phrase, or an account reset, ends the account's sessions on the register's server. - **The account reset without the phrase waits 72 hours too, with a mail at the start** (a mailed code asks, a new one carries it out once due; a sign-in with the password calls it off): a proposal, for the developer to confirm. Carried out: a new password and new keys, no authenticators. - **Keys:** without all three keys (or without email sign-in) the instance offers no password sign-in (`passwordSignIn` in its basics), so an instance can take these releases before its operator makes keys; a key set but shorter than 32 characters stops the server. - **GitHub** (D2): an account with a password and a confirmed authenticator is refused (`usePassword`), one without signs in as before and the app leads to setting up an address, a password and an authenticator (its ways in say what is missing): a refusal there would lock out GitHub's people on a new device, as nothing else signs them in. Where passwords are offered, no new GitHub account is linked (`deprecated`). From `GITHUB_SIGN_IN_ENDS` (a day the operator sets) GitHub signs nobody in (`ended`), the basics stop offering it, and the daily pruning forgets every GitHub ID. - **Email codes** still sign in (until step 3), but not into an account with a password and a confirmed authenticator (`usePassword`), which a code alone would bypass. Step 3 (email codes go): sign-up keeps `password.set` with its code; `emailCode.verifyCode` and `emailSignIn.signIn` go, and with them the switch token; accounts with an address but no password, and accounts whose setup never finished, need a way in (a mailed code and a new password, as the account reset, or a password plus a mailed code until an authenticator is confirmed); a member's sessions should end too when the register resets an account; and a signed-in change of password (with the keys at hand) is still missing. ## Consequences - People keep a password, an authenticator app and a recovery phrase: more to keep than a code by mail, and the reason the agenda can be encrypted. - Every app gets the sign-up and sign-in screens (halkyon_app), the key store on the device (the keyring, as sessions are), and the recovery screens. - The instance can't read what is encrypted, can't recover it, and can't reset a password without the data encrypted under it being lost. - Learn's server-side features (publishing, the connector, the learner count, LanguageTool) ask whether a studyset is private and refuse it; each says why, and how to open it up. - SRP and the key handling are security-critical code of our own: test vectors, a second reading of every step, and no release before both.