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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 11:17:31 +02:00
commit ae0d88818b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,93 @@
# 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.