halkyon/docs/adr/0017-password-totp-and-keys.md
Tizian 「ティツィアーン」 8ec2743bfa
docs(auth): the full reading, and the rules as they stand now (ADR 0017)
ADR 0017's "Full reading (21 Oct 2026)": each server finding and what was
done (H1, M1, M2, M3, L1, L4, L5, L6, L7, L10), those accepted with their
reasons (L2, L3, L8, L9), and the device's M1, the owner's proof bound to
its operation and the whole base URL, still to do. "The rules as they
stand" brought up to date; the module README and Learn's AGENTS.md too.

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

38 KiB
Raw Blame 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 (per address and network since the second reading, below); 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 a signature over halkyon key proof v1|, the challenge's ID and a nonce, by the phrase's own proof key (since the second reading: see below). 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 (with the password and a code); GitHub's end date.
  • The device holds the costs to the register's bounds: Argon2id's costs an instance names (as it begins a sign-in or a change) are refused below the floor (3 passes, 64 MiB: guessing would be cheaper) and above the ceiling (16 passes, 1 GiB, the register's own: a hostile instance could ask for any memory and crash the app), before anything is derived or sent (PasswordKdf.maximum, KeyRefusal.excessivePasswordKdf; the app's unsafeCosts, found at step 3's review, 20 Oct). The two bounds move together with the register's.
  • 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).

Second reading (20 Oct 2026)

An independent reading of everything above (f6dfbbe.. the branch) found nothing critical; what it found, and what was done (the developer decided M3, M4, M8 and deletion):

  • H1, high: the phrase's proof was the account's own key. A key proof was a signature by the account's signing key, which every signed-in device keeps: a readable keyring and one TOTP code set a new password, without the password or the phrase. The phrase now proves itself with an Ed25519 key derived from its bits alone (halkyon_keys' recoveryProofKeysOf, KDF context hlkrprf1), its public half sent with every setup, new phrase and account reset and kept as account_password.recoveryProofKey (nullable: an account without one proves no phrase until it sets a new one); the device derives the secret half only while the phrase is typed.
  • M1: decoy keys were random bytes, a real X25519 key one time in sixteen, so an address with an account could be told apart; decoys' keys are now made from seeds.
  • M2: the password alone, once an authenticator reset took effect, left an account reset waiting, which the mailbox's holder could carry out after the owner was back; every sign-in with the password now calls it off.
  • M3: anyone knowing an address could spend its hour. Sign-ins begun count per address and network (10), phrase proofs likewise (5), networks as before (30); failed proofs count per address from every network, 30 an hour, past which nothing begins for it (so many networks together make 30 guesses an hour, and holding an owner out takes failing without end from at least three; longer windows would outlive the hourly pruning of the limits' notes); changes begun, 10 an hour per account. Codes by mail: 3 an hour per address and network, 10 per address from every network, 10 per network, and up to 3 valid at once, a fourth voiding the oldest.
  • M4: codes could be guessed forever. Codes refused after a right password (or from a signed-in device) count per account; after every tenth in a row no code is taken (codesWait), an hour, then twice as long, a day at the most, the first wait mailed to the address where the device sent it (sign-in and the phrase's reset; the instance keeps none). At a day, ten guesses a day keep about a 1 % chance a year against one authenticator: the mail is what tells its owner to change the password.
  • M5: IPv6 visitors. Counted by their /64; and Caddy's network gets IPv6 (a unique local /64, Caddy's address in it trusted too), as Docker's userland proxy showed every IPv6 visitor as the gateway (reasoned, to be checked on the server: NEXT.md), in a commit of its own, as it needs the network made anew.
  • M6: nothing bound a proof to the instance. SRP's identity is now a label, the challenge's ID and the host the device addressed (passwordIdentity), and the phrase's proof signs that host, its purpose and the new password's digest (key proof v2); in production the host must be the server's apiServer.publicHost (otherInstance otherwise), any in development; members forward their own. A hostile instance relaying a sign-in or a phrase's proof to the real one, or swapping in a password, gets nothing.
  • M7: data keys weren't authenticated. A sealed data key is now signed by its sealer and checked against the expected one on opening, so the register can't plant a key of its own (before step 4 encrypts anything).
  • M8: passwords had no minimum. New ones have at least 12 characters (code points, once normalised), checked on the device; signing in takes any.
  • Deletion: an account with a password deletes itself only with its proof and a code (DeletionProof), as a change takes; others with their session, as before.
  • Lows: a member waits at most 5 seconds for the register's moment after a reset (maxClockSkew), refusing beyond (clocks must agree by NTP; a member's clock ahead by less leaves sessions made in that gap); instanceSecret stops the server when shorter than 32 characters; a change of password, a reset keeping the keys and a new phrase write their own columns alone.
  • Left as they are: the decoy salt never changes (an address's salt changes when it gets an account or a new password, which polling begin could see, as any SRP's does); SRP's x and the key-encryption key stay as plain bytes while a person types a code; the key proof's first step still gives anyone the account's public keys and its keys wrapped under the phrase (256 bits); an attacker failing from three networks or more can still hold an owner's sign-ins out for an hour at a time, and three requests void a code its owner is typing.

Re-reading the fixes (20 Oct 2026)

A second independent reading, of the fixes above (7071933..1383640), found H1, M1, M2, M5 (in the code), M6, M7, M8, deletion and the lows fixed, M3 and M4 incomplete, and these; all but the ninth were fixed (the developer's choice):

  1. Codes sent at once all got checked before the tenth refused began a wait. Every attempt at a code for an account is now counted before it is checked, one at a time per account (a transaction holding an advisory lock on its key), the tenth in a row beginning the wait as it is counted: of codes sent at once at most ten are checked.
  2. The ceiling of proofs per address was looked at only as a sign-in began, and counted only wrong passwords: sign-ins begun ahead got more checked, and a right password could be told apart by whether the next one began. Every proof of a password or of the phrase is now recorded before it is checked, whatever its outcome, 30 an hour per address, refused past it; the check at begin stays as an early refusal.
  3. A stolen session alone could keep its owner's codes waiting, renewed without end, holding them out of signing in and of changing the password, unmailed. Codes from a signed-in session (removing an authenticator, new recovery codes, confirming one) now count per session, where the session is (session_code_failures), each counted before it is checked; the tenth wrong in a row ends that session (sessionEnded); a try that checked no code is taken back. The account's waits count only codes after a right password, and the per-account ten tries an hour, which a session could fill, go.
  4. Whoever guessed decided whether the owner was mailed, as the address was optional. Every call carrying a code after the password or the phrase proved (sign-in, the phrase's reset, a change, a new phrase, deletion) takes the account's address and is refused before any code is checked when it isn't theirs; every wait mails the owner. The app sends the address typed, or for changes while signed in the one the device signed in with (addressUnknown where it doesn't know it).
  5. Strangers' wrong passwords held a signed-in owner out of changing the password, a new phrase and deleting. A change's challenge has its own purpose (change), counted per account (10 an hour), never toward the address; no sign-in takes it.
  6. Waits survived the resets: both now delete the codes refused before.
  7. Codes by mail served anything. A code is asked for a purpose (signUp, finishSetup, authenticatorReset, accountReset, deletion; a nullable column of email_code_request), only that purpose takes it, and its mail says what it would do and to type it only in the app on that instance: a hostile instance relaying a code asked for one thing gets nothing else from it, and the person reads what they would hand over.
  8. An account whose setup never finished, or whose authenticators' reset took effect, couldn't delete itself. It does with the password and a code by mail asked for deleting.
  9. (Left as it is) codes for one address, ten an hour from every network, can still be used up from four networks, holding its owner's codes out for an hour at a time.
  10. A member behind the register's clock refused the session after a reset as unavailable, which the app read as "no password sign-in here": it has its own refusal now (passwordChanged), saying to sign in again with the new password.

Third reading (21 Oct 2026)

A third independent reading, of the re-reading's fixes (1383640..a45842f), found its items 1, 2, 4, 6, 7 and 10 fixed and 5 and 8 holding but for the points below. The first finding was a regression of item 3, so rather than count once more, the developer chose Proton's rule (21 Oct 2026):

  • A session alone never gets a code checked. Every signed-in change of how an account is secured takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge (password.beginChange, the person checked) and then a TOTP or recovery code. That is adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting. Only the first authenticator (none confirmed, after a reset carried out too) is confirmed with its own code alone, and removing one never confirmed takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked again for a session (confirming it again is refused as unknown), so no success passes a proof unchecked. wrongExistingCode goes: a wrong code from another authenticator is a wrong proof.
  • A session's proofs count for it alone (asOwnerProofTry, table session_code_failures, where the session is, on a member too): each counted before it is checked, cleared only by one accepted, the tenth wrong in a row ending the session. They never count toward the account's waits nor wait for them, and a new password forgets those waits.

The findings, and what was done:

  1. A stolen session guessed codes without end (high): nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the count, and again. Closed by the rule: no code is checked for a session without the password first, and only a proof accepted clears the count.
  2. Whoever knew the password held a signed-in owner out of changing it: ten wrong codes at sign-in made the account's codes wait, and the owner's change waited too. Signed-in proofs no longer wait for the account's waits, and the change forgets them.
  3. A stolen session spent the ten changes an account begins an hour, holding its owner out of changing the password, a new phrase and deleting. Beginning counts nothing against the account: at most 30 an hour per session, a bound on the work one session asks for that holds back no other; only failed proofs count, per session.
  4. Deleting, with the authenticators' reset taken effect but not carried out, was offered a code field (the app read "an authenticator was confirmed") which the instance refused (it read "no second factor works"). The instance takes a TOTP or recovery code wherever an authenticator is confirmed, a code by mail where no second factor works, and tells the app which (SignInWays.secondFactorWorks).
  5. A device that didn't know the account's address couldn't change the password, set a new phrase or delete (addressUnknown). Signed-in calls take no address any more: nothing there mails, the account's waits being the sign-in's. Deleting by mail asks for the address where the device doesn't know it, as the code goes there; the instance takes the code only for the account's own address.
  6. The code mail's subject hid its purpose, which a notification shows alone. The subject names it ("K7QX-M2PD: your Halkyon Learn code to delete your account"), and the sign-up mail says it also gives a password to an account with that address and none.

Fourth reading (21 Oct 2026)

A fourth independent reading, of the third's fixes (a45842f..263c171), found its five findings closed, and two older gaps in Proton's rule: the first password and the first authenticator. What was done (18ab2ee, f3c8706, 403b88c):

  1. A stolen session made a GitHub-only account, or an address account without a password, its own (high): password.set took the session alone, so the thief set their address (or, with the owner's address, their password) and their authenticator; GitHub then refused the owner. A first password now takes a fresh proof of the account's existing way in: a code mailed to the account's own address for it (EmailCodePurpose.firstPassword), or, for an account that signs in with GitHub alone, a GitHub sign-in of the last 10 minutes (gitHubSignInFreshFor: the session's method and creation time, where the session lives, a member telling the register), refused signInAgain before any code is used. Anonymous people still become identified with a code to the address they add. The app offers a password right after a GitHub sign-in, before the person's app (D2's "at their next sign-in"), and asks for a GitHub sign-in anew when the instance refuses an older one.
  2. A stolen session outlived a reset of the authenticators and claimed the first new one (medium-high): carrying the reset out ended no session, and the first authenticator took its own code alone. Carrying it out now ends every session of the account in every service (the sign-in that carries it out gets its session after that moment), and the first authenticator takes the password's proof for a change's challenge, without a code (proveOwnersPassword), counted per session as the owner's proofs are.
  3. Lows: a proof whose call to the register failed (RegisterUnavailableException) stays counted, as the register may have checked it; the sign-up mail's subject says it also gives an account its password.
  4. The owner is warned when ten wrong proofs end a session (b096cb0, a177a8f): someone with a session of theirs tried their password or codes. The register keeps the moment with the account's password (account_password.sessionEndedForProofsAt, a nullable column), set where the session ends (a member tells the register; one out of reach leaves the owner unwarned, the session ended all the same). The account's sign-in ways and its next password sign-in carry it: the app shows it before the sign-in is put in place (dismissed, or kept for later) and in the account section above the change of password. It names when, nothing of the session or its device. Dismissing takes the session alone (password.dismissSessionEndedWarning): it hides a warning and secures nothing. A new password clears it, as does deleting the person. An owner who stays signed in sees it only in the account section.

Full reading (21 Oct 2026)

The whole branch read again, the server and the device apart, against the rules below. The developer decided (21 Oct): resets wait while their owner is held out and devices show them; resets lapse; GitHub-only accounts go at GitHub's end; http:// instances are refused but on this machine; the phrase is saved, never copied. Server side:

  • H1, a reset pushed through while its owner was held out. Whoever knew the password and had the mailbox asked an authenticators' reset and kept the codes waiting until it took effect; whoever had the mailbox filled the address's ceiling from three networks until an account's reset did. Every hold-out after a reset was asked (a wait beginning, an attempt a wait refuses, a sign-in or proof the ceiling refuses) now moves the account's resets not carried out to 72 hours after it ends (pushOutResets); a reset carried out is never pushed, a lapsed one never brought back. SignInWays carries when each waiting reset takes effect; the account section shows them with Call it off, which takes the owner's proof (password.callOffResets), no wait holding it back.
  • M1 and L6, GitHub's end. At GITHUB_SIGN_IN_ENDS the daily run first deletes the accounts that sign in with GitHub alone (no address, never a password), members told; the app tells them so for the three months before. password.set adds an address without GitHub only for someone anonymous where the session lives (anonymousHere, which a member tells), so a stolen session of a forgotten GitHub account claims nothing.
  • M2, resets that never lapsed. A reset that took effect and wasn't carried out lapses a week later, as never asked (resetLapsesAfter; the pruning forgets it); asked again, it waits anew with a new mail. An authenticators' reset carried out never lapses.
  • M3, the phrase held back by the password's waits. Codes count per account and way (SecondFactorPath: after the password, after the phrase), each with its own waits; an accepted code clears both.
  • L1. Only a session made after the warning dismisses it (signedInBefore otherwise).
  • L4. Replacing a setup's password ends the account's other sessions.
  • L5. Members learn of ended sessions in the background, this account's notices first, never inside the request.
  • L7. A member asks the register whether the person is its account before deleting, rather than trusting the session's scope.
  • L10. A member clock more than five seconds ahead of the register's is refused as one behind is.
  • Accepted: L2, the phrase alone sets a password once an authenticators' reset took effect (the phrase is 256 bits, and the reset's 72 hours were the owner's to call it off); L3, a codes wait tells whoever guesses the password that it was right, which matters only once someone holding it made codes wait; L8, decoys answer today's costs (newPasswordCosts), revisit when PasswordKdf.current rises; L9, a key proof's first step gives out an address's public keys (step 4 shares keys: revisit then).
  • Not done yet, the device's M1: the owner's proof is bound to the challenge and the host, not to the operation it approves nor the instance's whole base URL; binding a purpose label and the digest of what is set into SRP's identity on both sides, and scheme, host, port and path in place of the host, is the next change. Until then a change challenge's proof can be replayed for another signed-in change by whoever reads and changes the traffic (plain http://, TLS intercepted), which the refusal of http:// instances (the device's part 2) narrows.

The rules as they stand (21 Oct 2026)

Who changes what of an account, with which proofs. "Password" is SRP's proof bound to the host the device addressed; "code" a TOTP code from a confirmed authenticator or a recovery code; "mailed code" one asked for that purpose, for the account's own address unless said otherwise. Signed-in proofs count per session (ten wrong in a row end it); sign-ins' codes per account and way, after the password or the phrase (waits from ten wrong in a row, the owner mailed).

What Without a session Signed in
Sign in password + code; password alone once an authenticator reset took effect, until one is confirmed; password + mailed code (finishSetup) while no authenticator was ever confirmed —
Sign up / first password mailed code (signUp) to an address nobody has, or whose account has no password anonymous where the session lives: mailed code (signUp) to the address added; with an address: mailed code (firstPassword); GitHub alone: GitHub sign-in of the last 10 minutes + mailed code (signUp) to the address added. Never once an authenticator was confirmed
First authenticator — password (no code) + the new one's code
Another authenticator — password + code + the new one's code
Remove an authenticator — confirmed: password + code (never the last); unconfirmed: nothing
New recovery codes — password + code
New recovery phrase — password + code
New password phrase's proof + code (keys kept) password + code
Authenticator reset password + (mailed code or phrase's proof); takes effect after 72 h (pushed out while the owner is held out), owner mailed, called off by a sign-in with a code; lapses a week after taking effect unless carried out password + code calls it off
Account reset (new keys) mailed code (accountReset) asks, a new one carries it out after 72 h (pushed out alike), owner mailed, called off by any sign-in with the password; lapses a week after taking effect password + code calls it off
Delete the account — password + code; where no second factor works, password + mailed code (deletion)

Every reset carried out, and every new password (a setup's replaced too), ends the account's sessions in every service (a change keeps the session asking). A session ended for ten wrong proofs leaves its owner a warning; a session made since dismisses it, and a new password clears it. At GitHub's end, accounts that sign in with GitHub alone go.

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.