2026-10-20 11:17:31 +02:00
|
|
|
|
# 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
|
2026-10-20 12:28:56 +02:00
|
|
|
|
(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.
|
2026-10-20 11:17:31 +02:00
|
|
|
|
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).
|
2026-10-20 12:28:56 +02:00
|
|
|
|
- **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.
|
2026-10-20 14:27:40 +02:00
|
|
|
|
- **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.
|
2026-10-20 11:17:31 +02:00
|
|
|
|
|
2026-10-20 13:32:21 +02:00
|
|
|
|
## 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
|
2026-10-20 14:16:58 +02:00
|
|
|
|
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
|
2026-10-20 13:32:21 +02:00
|
|
|
|
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.
|
2026-10-20 14:16:58 +02:00
|
|
|
|
- **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.)
|
2026-10-20 13:32:21 +02:00
|
|
|
|
- **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;
|
2026-10-20 14:16:58 +02:00
|
|
|
|
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.
|
2026-10-20 13:32:21 +02:00
|
|
|
|
- **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
|
2026-10-20 14:16:58 +02:00
|
|
|
|
calls it off; confirmed by the developer on 20 Oct). Carried out: a new password and
|
|
|
|
|
|
new keys, no authenticators.
|
2026-10-20 13:32:21 +02:00
|
|
|
|
- **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.
|
2026-10-20 14:16:58 +02:00
|
|
|
|
- **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
|
2026-10-20 13:32:21 +02:00
|
|
|
|
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
|
2026-10-20 14:16:58 +02:00
|
|
|
|
that had a confirmed authenticator (`usePassword`), which a code alone would bypass.
|
2026-10-20 13:32:21 +02:00
|
|
|
|
|
2026-10-20 19:34:02 +02:00
|
|
|
|
## 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, for a code). A session alone changes neither.
|
|
|
|
|
|
- **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).
|
2026-10-20 13:32:21 +02:00
|
|
|
|
|
2026-10-20 11:17:31 +02:00
|
|
|
|
## 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.
|