halkyon/docs/adr/0017-password-totp-and-keys.md
Tizian 「ティツィアーン」 ae0d88818b
docs(adr): a password, TOTP and the account's keys (ADR 0017)
SRP-6a with Argon2id, the password never leaving the device and
unlocking the account's key pairs (libsodium on devices); mandatory
TOTP with recovery codes, a 72-hour reset by mail as Proton has it, a
recovery phrase; data keys per store sealed to the account; studysets
private or not, private by default; email codes only for resets;
GitHub sign-in deprecated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:08 +02:00

93 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 hashes, 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).
## 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.