The developer's decisions of 21 Oct: private studysets ship with build 22; a data key per private studyset and a personal key for tags and settings, sealed to the account and kept in a synced table; files encrypted too; a release's tag names and the copies counted come from the device; the shape, size and rhythm of studying stay readable and are listed; existing studysets get one offer; accounts without keys at the register wait for a password. ADR 0002 and ADR 0017 point to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
713 lines
53 KiB
Markdown
713 lines
53 KiB
Markdown
# 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 0019 (private studysets), the first data encrypted with these keys;
|
||
ADR 0018 (the agenda).
|
||
|
||
## 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.
|
||
(Since the full reading, the instance's whole base URL in place of the host.)
|
||
- **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).
|
||
- **The device's medium 1, the owner's proof bound to the challenge and the host
|
||
alone.** Whoever read and changed the traffic (plain `http://`, TLS intercepted or
|
||
broken: what SRP is for) could keep the owner's proof and code and swap in a password,
|
||
a phrase or an authenticator of their own, or turn a proof made for new recovery codes
|
||
into a deletion; and instances on one host under another port, path or scheme could
|
||
relay each other's proofs (the device's low 4). SRP's identity is now a label, the
|
||
challenge's ID, the instance's base URL, what the proof is for and the digest of what
|
||
it sets (`passwordIdentity`, identity v2), and the phrase's proof signs the base URL
|
||
too (key proof v3). The base URL is written alike on both sides
|
||
(`canonicalInstanceUrl`: scheme and host in lower case, the port only where it isn't
|
||
the scheme's own, the path ending in `/`); the register takes only its own public
|
||
scheme, host and port at the root (`publicInstanceUrlOf`; Serverpod serves at the root,
|
||
so an instance under a path takes no password sign-in), any in development, a member's
|
||
as the member names it. The purposes are signing in, an authenticator's reset (each of
|
||
its own, so one never stands for the other), a new password (the digest of the
|
||
`NewPassword` sent), a new phrase (of its wrapping and proof key), the first or another
|
||
authenticator and removing one (of its ID), new recovery codes, calling resets off and
|
||
deleting (none); the register works out purpose and digest from the call that brings
|
||
the proof, never from the device. `OwnerProof`'s and `DeletionProof`'s `host`, and
|
||
every endpoint's, are `instanceUrl`. Each refusal has a test, which fails with the
|
||
binding taken out on both sides.
|
||
|
||
The device side's rest (21 Oct 2026):
|
||
|
||
- **The device's medium 2, secrets on the clipboard.** The recovery phrase is saved as a
|
||
file or written down, never copied (clipboard histories and cloud clipboard sync would
|
||
keep it); recovery codes and an authenticator's key are copied and the clipboard
|
||
emptied 60 seconds later if it still holds them (`copySecret`), and they are no longer
|
||
selectable, as the system's own copy wouldn't empty it.
|
||
- **`http://` instances**, refused but on this machine (`localhost`, `127.0.0.0/8`,
|
||
`::1`; `Instance.isSecure`): an address over plain HTTP elsewhere is refused before
|
||
anything is sent there; one kept from before is never opened, the app saying why and
|
||
offering another instance or removing it, never dropping it silently
|
||
(`InstanceChoice.allows`, `leave`). Development builds still use their own instance
|
||
over HTTP (an emulator reaches the developer's machine as 10.0.2.2); releases never.
|
||
- **Low 1, sheets left busy.** halkyon_keys checks the length of what comes from
|
||
elsewhere before libsodium sees it (`KeyRefusal.malformed`: a salt, a wrapping, a
|
||
sealed key's box, a sealed payload), and the device's cryptography and every password
|
||
cubit turn anything unexpected into the general failure, logged by its type alone, so
|
||
a sheet always closes again.
|
||
- **Low 2, what Save leaves.** On iOS the copy file_picker writes into the temporary
|
||
folder for the system's picker goes once it is done; on the desktop a saved file is the
|
||
own user's alone (0600, set before anything is written; Windows keeps its folder's
|
||
rights).
|
||
- **Low 3, secrets in debug logs.** The password states and `AuthenticatorSetup` say
|
||
only where they stand, never an authenticator's secret, the recovery codes or the
|
||
address; Learn's bloc observer's test holds what it logs to that.
|
||
- **Low 5, hardening.** Keyboards learn nothing typed into a password, a code or the
|
||
phrase (`enableIMEPersonalizedLearning: false`); the phrase, recovery codes and an
|
||
authenticator's QR code and key show in a `SecretScreen`, kept out of screenshots, screen
|
||
recordings and recents on Android (`FLAG_SECURE`, each app's `ScreenGuard.kt`) and
|
||
covered while the app isn't in front on iOS (not checked on a device); the account's
|
||
keys stay on this device on iOS and macOS (`first_unlock_this_device`, never iCloud
|
||
Keychain); only an `otpauth:` URI is shown as a QR code or opened.
|
||
- **Low 6, memory.** The account's and data keys go from guarded memory straight into
|
||
the buffer libsodium encrypts, no copy left on the way; halkyon_keys' README says
|
||
exactly what is wiped and what Dart can't wipe (x as a `BigInt`, `String`s, what
|
||
isolates copy, the keyring's base64).
|
||
- **Low 7, the device's real cryptography against the register's checks.**
|
||
`register_interop_test.dart` (halkyon_app) holds what libsodium makes on the device to
|
||
`isPasswordSetup`, `srpVerify` and M2, `isNewPassword` and the phrase's proof key, bound
|
||
to the instance and the purpose.
|
||
|
||
### Reading the fixes (21 Oct 2026)
|
||
|
||
A reading of the full reading's 26 fixes, server and device apart, by two reviewers who
|
||
hadn't seen them made. Nothing critical or high; the proof binding held byte for byte.
|
||
What it found, and what was done (the developer decided A1+A2, the clipboard and the
|
||
keychain on 21 Oct; the 30-day grace for GitHub-only accounts was proposed and kept):
|
||
|
||
- **A1, A2 (medium):** every refusal at the address's ceiling moved a reset to 72 hours
|
||
after it, so someone knowing only the address kept the owner's own reset from ever
|
||
taking effect with one burst every three days, and kept a forgotten one alive. Now a
|
||
hold-out that begins before a reset takes effect moves it on only by the time it holds
|
||
the owner out and no hold-out counted before did (`account_reset.heldOutUntil`, under
|
||
a row lock): the owner gets 72 hours outside hold-outs in total. And every reset not
|
||
carried out lapses 30 days after it was asked (`resetLivesAtMost`), whatever moved it,
|
||
besides a week after it took effect.
|
||
- **C1 (low):** a member whose clock ran ahead kept sessions made in its gap. A member
|
||
now ends every session of the account made before the register's moment or before
|
||
now, whichever is later, and neither waits nor refuses; the register sends its clock
|
||
with each notice (`sentAt`), so each member takes the moment by its own. The
|
||
`passwordChanged` refusal, sent only by the clock checks, went.
|
||
- **B1 (low):** a member asked the register whose account it is inside the proof's try,
|
||
so tries while the register was down ended the owner's session. Asked first now; while
|
||
the register is down, a person still anonymous on the member deletes there.
|
||
- **D1, D2 (low):** a member would have deleted everyone with a GitHub ID (it keeps no
|
||
addresses), and each deletion delivered every notice, one failure aborting the run.
|
||
The deletion runs on the register alone, 30 days after the end
|
||
(`gitHubAccountsKeptAfterEnd`: a date set wrong can be put right first), each account
|
||
on its own (one failing kept, with its GitHub ID, for the next day), the members told
|
||
once after all of them.
|
||
- **Nits:** the background delivery of session notices logs; a begin checks the
|
||
address's ceiling after the network's limits; the module's README lists every pruning
|
||
step.
|
||
- **The clipboard (medium):** copies reached clipboard histories and cloud clipboards,
|
||
and Android often couldn't empty it again. Nothing secret is copied any more: codes
|
||
are saved, like the phrase; an authenticator's key is scanned, opened or typed.
|
||
- **Saving (medium-low):** a saved file was made, then set to 0600, and written into
|
||
whatever the path held. Now a new file beside it, 0600 as it is made (`O_EXCL`,
|
||
`O_NOFOLLOW`), written through its descriptor and renamed over the path; a symbolic
|
||
link or another user's file is refused. Windows writes in place, with its folder's
|
||
rights.
|
||
- **The phrase typed back (medium-low):** shown in recents and screenshots; its fields
|
||
are inside a `SecretScreen` now.
|
||
- **The keychain (low-medium):** `first_unlock_this_device` left the keys readable on a
|
||
locked phone unlocked once since starting. Now `unlocked_this_device`; keeping keys
|
||
deletes the entry first, which a delete finds whatever its class.
|
||
- **http:// and https:// on one host (low-medium):** one place on the device, so
|
||
removing one deleted the other's account. The places keep their names; such addresses
|
||
are one account there: choosing one takes the other's place, and removing one forgets
|
||
nothing the other uses (`InstanceStorage.sharesWith`).
|
||
- **Lows:** no address in debug logs (`SignInWaysState`, `sign_in.json`'s errors); the
|
||
keyring that can't be read counts the keys as not here, and Delete goes on whatever
|
||
reading the account met; secrets kept out of captures on macOS (`sharingType = .none`)
|
||
and Windows (`WDA_EXCLUDEFROMCAPTURE`) too, untried on either; the main isolate's x and
|
||
key-encryption key wiped once done with (`PasswordUnlock.forget`).
|
||
- **Left as they are:** the per-address cap on codes by mail (the re-review's 9);
|
||
`sharingType = .none` doesn't hold against ScreenCaptureKit on macOS 15 and later.
|
||
|
||
### The short reading (21 Oct 2026)
|
||
|
||
A short reading of the 14 commits above, by a reviewer who hadn't seen them made.
|
||
Nothing critical; one high, three mediums. The developer decided the four questions on
|
||
21 Oct: resets are held back 14 days at most, macOS writes in place, a member deletes
|
||
nobody while the register is out of reach, and the end-to-end trial follows without
|
||
another reading.
|
||
|
||
- **H1 (high):** `open(2)` takes its mode among variable arguments, which Apple's Arm
|
||
passes on the stack; bound with fixed ones, a phrase or codes saved on an Apple
|
||
silicon Mac took their mode from whatever the stack held. Bound as variadic now, and
|
||
every file written is made 0600 through its descriptor (`fchmod`), checked.
|
||
- **M1 (medium):** macOS's sandbox lets an app reach the file chosen and nothing beside
|
||
it, so the new file beside it was refused and nothing saved; Learn's entitlement was
|
||
read-only, so its exports never saved there either. On macOS the chosen file is
|
||
written in place now: opened without following a link, waiting on a pipe or
|
||
truncating, refused with nothing written unless `fstat` finds a file of the own
|
||
user's, then made 0600 and emptied. Both apps' entitlements grant reading and writing
|
||
chosen files. Whoever held the file open reads what is saved; Linux keeps the new file
|
||
renamed over it, and flushes the folder. Untried on a Mac.
|
||
- **M2 (medium):** a member took the register's notice of a reset by its own clock, so
|
||
a notice that took longer on its way than the reset's answer ended the very session
|
||
the reset had signed in. A member remembers that session, by the account and the
|
||
register's moment, and keeps it when the notice comes (in memory for a day; a member
|
||
restarted meanwhile ends it as before).
|
||
- **M3 (medium):** someone knowing only the address can fill its ceiling every hour, so
|
||
hold-outs kept any reset, the owner's own recovery too, from ever taking effect. A
|
||
reset now takes effect 14 days after it was asked at the latest, held out or not
|
||
(`resetWaitsAtMost`): a stranger delays a recovery two weeks at most, and whoever
|
||
means to take the account over with the password and the mailbox (or the phrase) must
|
||
hold its owner out for all of them, every wait mailing the owner. It lapses a week
|
||
after it took effect, as before, so within 21 days; the 30-day bound stays as the
|
||
outer one.
|
||
- **L1 (low):** a refusal at the ceiling pushed a waiting reset inside the request, and a
|
||
write's commit waits for the log, so its time told that the address had an account
|
||
with a reset waiting. The push runs after the refusal now, on a session of its own.
|
||
- **L2 (low):** with the register out of reach, a member deleted a person still
|
||
anonymous there on the session alone, though the register might have linked them (a
|
||
becoming identified that failed). A member deletes nobody while it can't ask the
|
||
register; the app gives an anonymous account up all the same (signing out, or a
|
||
switch), leaving it to the pruning of anonymous accounts unused for 90 days.
|
||
- **L3 (low):** the fields the phrase is typed back into offered Copy, Cut and Share.
|
||
Paste and Select all alone now, and their copy and cut keys do nothing.
|
||
- **L4 (low):** on iOS the cover rebuilt what it covered, so a phrase field lost its
|
||
focus at each switch of apps. The cover sits in a stack always there.
|
||
- **L5 (low):** keys an earlier build kept with another keychain accessibility stayed
|
||
after signing out (an Apple keychain's read finds only its own accessibility's). They
|
||
are found with every accessibility a build used and deleted by name; a locked keyring
|
||
stops nothing, its error no longer unhandled.
|
||
- **L6 and nits:** the test of another user's file is skipped as root; a GitHub-only
|
||
account deleted whose step afterwards failed counts as deleted; the app says the real
|
||
day such an account goes (30 days after the end); `signInWays` has its comment back;
|
||
the file saver wipes its buffer whatever happens, calls again when interrupted, names
|
||
its new file apart from the one chosen (a name as long as the folder takes), and reads
|
||
types and owners on Linux with `statx` (`__lxstat`'s version was wrong on riscv64);
|
||
Windows chooses its capture affinity by its real version (`RtlGetVersion`: before 10
|
||
2004 `WDA_EXCLUDEFROMCAPTURE` acts as `WDA_MONITOR` without failing); Delete says
|
||
when the instance is out of reach rather than showing a spinner, then nothing;
|
||
`sharesStorage` is required; a forgotten `PasswordUnlock` is spent.
|
||
- **Left as they are:** the per-address cap on codes by mail (the re-review's 9); a
|
||
hot restart in a debug build leaves the native capture count held; nothing has run on
|
||
macOS or Windows.
|
||
|
||
### The rules as they stand (21 Oct 2026, after the short reading)
|
||
|
||
Who changes what of an account, with which proofs. "Password" is SRP's proof bound to
|
||
the instance's base URL the device addressed, and to what it approves (the purpose and
|
||
the digest of what it sets: a proof made for one change counts for no other); "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 once 72 h outside hold-outs of the owner passed, 14 days after it was asked at the latest, 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 once 72 h outside hold-outs passed, 14 days after it was asked at the latest, 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. 30 days after GitHub's end, accounts that sign in with GitHub
|
||
alone go, on the register. Where passwords are offered, GitHub is added to nobody, and
|
||
the apps offer it to nobody: an anonymous person's Sign in with GitHub reaches only the
|
||
account someone has (linking names it taken before refusing), and every refusal says
|
||
why (`usePassword`, `deprecated`, `ended`; found in the end-to-end trial). A member ends the sessions an account had on it before the
|
||
register's moment by its own clock, but the one a reset signed in there, and deletes no
|
||
account while the register is out of reach. On the device nothing secret is copied
|
||
(the phrase typed back is pasted, never copied out), and a saved file is the own user's
|
||
alone (on macOS, the chosen file written in place). Every secret shown or typed back is
|
||
kept out of screenshots, screen recordings and recents on Android; out of captures on
|
||
Windows 10 2004 and later, before that shown black in them; on macOS out of
|
||
screenshots, but not out of ScreenCaptureKit's captures on macOS 15 and later; on iOS
|
||
covered in the app switcher's picture, screenshots and recordings not held back; on
|
||
Linux not at all.
|
||
|
||
## 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.
|