halkyon/docs/adr/0017-password-totp-and-keys.md

233 lines
17 KiB
Markdown
Raw Normal View History

# 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.
- **Also (20 Oct, before the apps' screens):** they come with build 22, after the second
reading, not build 21 (which ships with password sign-in off on learn.halkyon.eu, its
keys unset); sign-up shows the recovery phrase once and goes on once three of its words,
chosen at random, are typed back; the unlocked private keys stay in the system's keyring
after signing in, as sessions do, and go with signing out; changing the password while
signed in takes the old password (SRP) and a TOTP code.
## 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, but never once one was, also while a reset leaves the account without
one (a reset's row stays until a new authenticator is confirmed): a session got
another way would skip the reset's 72 hours. 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.
- **Per-network limits count by the visitor's address** (`networkOf`): behind a
reverse proxy every connection is the proxy's, so for a connection from a proxy the
operator trusts (`TRUSTED_PROXIES`, Caddy's fixed address in `deploy/compose.yaml`) the
last address of `X-Forwarded-For`, the one the proxy saw itself; never the `Forwarded`
header, which Caddy passes on as sent. Serverpod's own limit on anonymous accounts
counts by the connection and is off; the anonymous endpoint counts them itself.
(Found reviewing this step: until then every per-network limit, anonymous sign-ups'
included, was the whole instance's.)
- **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), and
so does adding one while a confirmed one exists (a code from that one too, decided by
the developer on 20 Oct): a session alone adds no second factor of its own.
- **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; confirmed by the developer on 20 Oct). 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 that had a confirmed authenticator (one
now, or a reset standing: `SignInWays.passwordOnly`) 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
that had a confirmed authenticator (`usePassword`), which a code alone would bypass.
## Settled in building the apps' side (20 Oct 2026, step 3)
On branch `password-sign-in`, for build 22 (`minimumAppBuild` 22 in Learn; Todo, never
released, stays at 1), after the second reading.
- **Codes by mail sign nobody in** (D4): `emailCode.verifyCode`, `emailSignIn.signIn`
and the switch token went, with the register's operations for them. `emailCode.sendCode`
stays, without a session, as what proves an address: for `password.set`, a sign-up,
a setup never finished and the resets (`accountRecovery.sendCode` went).
- **Ways in for accounts without a working password:** `passwordSignIn.signUp` (a code
and a setup, no session) makes a new account at an address nobody has, or gives the
account with that address and no password (codes by mail before; no keys exist, so
nothing waits) its password, keeping its ID. A setup never finished signs in with its
password and a code mailed to the address the sign-in began with, until an
authenticator is confirmed, and that calls off an account reset waiting (a reset of
the account waiting no longer counts as having had an authenticator, so its owner
isn't locked out). Never for an account that had a confirmed authenticator.
- **Changing while signed in:** the password (`password.beginChange`, `password.change`:
the old password's SRP proof, a TOTP or recovery code, the same keys rewrapped on the
device; every other session ends) and the recovery phrase (`password.setRecoveryKeys`:
the keys wrapped under a new phrase, with the old password's proof and a code, as a
change of password takes). A session alone changes neither, nor a session with the
authenticator beside it (a phone signed in): its holder's own phrase would set a new
password through `resetPasswordWithPhrase` (found at step 3's review, 20 Oct).
- **Sessions end in every service:** a change of password ends every other session of
the account, a reset of the password or the account all of them, on the register and
on every member, by a notice kept until each confirms (`sessions_ended_notice`, a new
table; hourly with the deletions' notices). A notice names a moment, so sessions made
since stay: a member signing someone in after a reset makes the session once its clock
is past it.
- **A reset not due says when** it takes effect (`takesEffectAt` on the refusal), and the
basics say when GitHub sign-in ends (`gitHubSignInEnds`), which apps show.
- **The apps** (halkyon_app, Learn and Todo): signed out, Sign in and Create an account
(and GitHub, its end date beneath); signing in asks the address and password (Argon2id
on the device, about a second), then a code from the authenticator or a recovery code;
M2 is checked and the keys opened before the session is put in place. An account
never finished is mailed a code; one whose authenticators' reset took effect signs in
without a code; both then set up an authenticator with the session held back, and put
it in place only after. Creating an account: address, password twice, the recovery
phrase shown once (copy, save as a file) and three random words typed back, then the
mailed code (nothing reaches the instance before), the authenticator (a QR code of the
`otpauth://` URI, drawn by the app with pub.dev's `qr`, BSD-3-Clause, pinned; its key
as text; on phones Open in authenticator app; considered: `qr_flutter` and
`pretty_qr_code`, Flutter widgets on the same encoder) and the ten recovery codes
(copy, save). Getting back in: with the phrase (a new password, the keys staying), a
reset of the account asked for by a code (72 hours), and finishing it (a new code; new
keys and a new phrase, or with the phrase the old keys rewrapped, as the account's
reset keeps them for someone who lost both password and authenticator). The account
part (`AccountSecuritySection`): setting up a password (anonymous people, GitHub's, an
account with only an address), finishing the setup, the authenticators (adding one
takes a code from one before), new recovery codes, changing the password, a new
phrase; GitHub's end date.
- **The keys on the device:** unlocked after signing in, kept in the system's keyring
per instance and person (flutter_secure_storage), gone once another person signs in or
nobody, and with the instance's removal from the device. Anonymous people's device keys
wait for step 4: nothing is encrypted before it.
- **libsodium on devices:** sodium's results (salts, public keys, cipher texts,
signatures) are views of native memory its finalizers free once the isolate that made
them ends; each step runs on an isolate of its own, so whatever crosses back is copied
first (a view read freed memory and crashed the process). Every build of an app needs a
C compiler and make for sodium's build hook (Linux, Android's NDK, Xcode, Visual
Studio; the snap's build and CI's runner too).
## 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.