docs(auth): reading the full reading's fixes (ADR 0017)

ADR 0017 gets "Reading the fixes (21 Oct 2026)": each finding and what
was done, and "The rules as they stand" brought up to date (resets take
effect once 72 hours outside hold-outs passed and lapse 30 days after
they were asked; GitHub-only accounts go 30 days after the end; a member
takes the register's moment by its own clock; nothing secret copied on
the device). The module README and Learn's AGENTS.md say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 16:49:57 +02:00
commit 8d6c54ef22
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 80 additions and 16 deletions

View file

@ -74,7 +74,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address with a password) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address and password, one of each (`account.signInWays`, which also says whether they have a password and a confirmed authenticator); the Profile tab lists their ways in, adds GitHub where offered, and holds halkyon_app's `AccountSecuritySection` (setting up a password, the authenticators, changing it).
- **A password and an authenticator** (ADR 0017, built 2026-10-20 for build 22; halkyon_base's and halkyon_app's READMEs): the only way an identified person signs in besides GitHub, which is deprecated. The address, a password that never leaves the device (SRP-6a, `passwordSignIn.begin` and `complete`, the verifier and the account's wrapped keys on the register) and a code from an authenticator app (TOTP, mandatory) or a recovery code. An address nobody has is answered as an account's would be (decoys from `signInDecoyKey`), and every wrong credential is refused alike (`wrongCredentials`). Lost authenticator: the password and a mailed code (or the recovery phrase) turn it off 72 hours later, a mail at the start, a sign-in with a code calling it off; lost password: the recovery phrase's signature and a code set a new one, keys kept, or without the phrase a mailed code resets the whole account (new keys) after the same 72 hours. While the owner is held out of signing in (codes waiting, the address's ceiling filled), a waiting reset is pushed to 72 hours after it ends; one that took effect and wasn't carried out lapses a week later; the Profile tab's account section shows waiting resets, called off with the password and a code (ADR 0017's full reading). An account with a password and a confirmed authenticator no longer signs in by a mailed code or GitHub (`usePassword`); where passwords are offered, no new GitHub account is linked (`deprecated`), and from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in and pruning forgets every GitHub ID, deleting first the accounts that signed in with GitHub alone (no address), which the app warns of until then (D2; the full reading's M1). Password sign-in is on only where the passwords hold `totpKey`, `recoveryCodeKey` and `signInDecoyKey` (`TOTP_KEY`, `RECOVERY_CODE_KEY`, `SIGN_IN_DECOY_KEY` in `deploy/.env`) and email sign-in is set up (`passwordSignIn` in `instance.basics()`). On the device (halkyon_app): signing up shows the recovery phrase once and checks three of its words before anything reaches the instance; signing in checks SRP's M2 and opens the keys before the session is put in place; an authenticator still to set up is set up with the session held back; the unlocked keys stay in the system's keyring per instance and person, gone once another person signs in or nobody. A session alone never gets a code checked (ADR 0017's third reading, as Proton has it): every signed-in change of how the account is secured (adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password, deleting) takes the password and a code (`OwnerProof`), each sheet asking for both; a session's wrong proofs count for it alone and the tenth in a row ends it, never making the account's codes wait. Nor does a session alone set a first password or confirm a first authenticator (the fourth reading): an account with an address proves it by a code mailed there (`firstPassword`), one that signs in with GitHub alone by a GitHub sign-in of the last 10 minutes (the app offers a password right after one, before the person's app, and asks for GitHub anew when the instance says `signInAgain`), and the first authenticator takes the password. A change of password ends the account's other sessions and every reset all of them, the authenticators' included as the sign-in carries it out, on every service of the instance (`sessions_ended_notice`). ADR 0017's "The rules as they stand" lists who changes what with which proofs.
- **A password and an authenticator** (ADR 0017, built 2026-10-20 for build 22; halkyon_base's and halkyon_app's READMEs): the only way an identified person signs in besides GitHub, which is deprecated. The address, a password that never leaves the device (SRP-6a, `passwordSignIn.begin` and `complete`, the verifier and the account's wrapped keys on the register) and a code from an authenticator app (TOTP, mandatory) or a recovery code. An address nobody has is answered as an account's would be (decoys from `signInDecoyKey`), and every wrong credential is refused alike (`wrongCredentials`). Lost authenticator: the password and a mailed code (or the recovery phrase) turn it off 72 hours later, a mail at the start, a sign-in with a code calling it off; lost password: the recovery phrase's signature and a code set a new one, keys kept, or without the phrase a mailed code resets the whole account (new keys) after the same 72 hours. A reset takes effect only once the owner had 72 hours outside hold-outs (codes waiting, the address's ceiling filled: each moves it on by the time it holds the owner out); one that took effect and wasn't carried out lapses a week later, and every reset 30 days after it was asked; the Profile tab's account section shows waiting resets, called off with the password and a code (ADR 0017's full reading). An account with a password and a confirmed authenticator no longer signs in by a mailed code or GitHub (`usePassword`); where passwords are offered, no new GitHub account is linked (`deprecated`), and from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in and, 30 days later (a date set wrong can be put right first), the register's pruning forgets every GitHub ID, deleting first the accounts that signed in with GitHub alone (no address), which the app warns of until then (D2; the full reading's M1). Password sign-in is on only where the passwords hold `totpKey`, `recoveryCodeKey` and `signInDecoyKey` (`TOTP_KEY`, `RECOVERY_CODE_KEY`, `SIGN_IN_DECOY_KEY` in `deploy/.env`) and email sign-in is set up (`passwordSignIn` in `instance.basics()`). On the device (halkyon_app): signing up shows the recovery phrase once and checks three of its words before anything reaches the instance; signing in checks SRP's M2 and opens the keys before the session is put in place; an authenticator still to set up is set up with the session held back; the unlocked keys stay in the system's keyring per instance and person, gone once another person signs in or nobody. A session alone never gets a code checked (ADR 0017's third reading, as Proton has it): every signed-in change of how the account is secured (adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password, deleting) takes the password and a code (`OwnerProof`), each sheet asking for both; a session's wrong proofs count for it alone and the tenth in a row ends it, never making the account's codes wait. Nor does a session alone set a first password or confirm a first authenticator (the fourth reading): an account with an address proves it by a code mailed there (`firstPassword`), one that signs in with GitHub alone by a GitHub sign-in of the last 10 minutes (the app offers a password right after one, before the person's app, and asks for GitHub anew when the instance says `signInAgain`), and the first authenticator takes the password. A change of password ends the account's other sessions and every reset all of them, the authenticators' included as the sign-in carries it out, on every service of the instance (`sessions_ended_notice`). ADR 0017's "The rules as they stand" lists who changes what with which proofs.
- **One account across the instance** (ADR 0016, decided 2026-10-19; halkyon_base's README): Learn's server holds the instance's register, so its tables keep every service's ways in, handles and profiles, and an account's ID is the `AuthUser` ID in every service. The instance's other services (Todo) are its members: they ask it over its web server (`/halkyon-base/register/…`, `addInstanceRoutes` in `server.dart`, never forwarded by Caddy) with the secret the services share (`instanceSecret`, `INSTANCE_SECRET` in `deploy/.env`), and a member's anonymous person adding a way in becomes an account here under the same ID. Deleting an account (here, or a member passing it on) keeps a notice per member in the same transaction (`account_deletion_notice`), told at once and hourly until each confirms (`tellMembersOfDeletions`). Learn's endpoints and code go through the module as on any register; nothing changes for Learn's apps.
- **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.

View file

@ -535,7 +535,64 @@ The device side's rest (21 Oct 2026):
`isPasswordSetup`, `srpVerify` and M2, `isNewPassword` and the phrase's proof key, bound
to the instance and the purpose.
### The rules as they stand (21 Oct 2026)
### 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 rules as they stand (21 Oct 2026, after reading the fixes)
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
@ -556,14 +613,18 @@ wrong in a row, the owner mailed).
| 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 |
| Authenticator reset | password + (mailed code or phrase's proof); takes effect once 72 h outside hold-outs of the owner passed, owner mailed, called off by a sign-in with a code; lapses a week after taking effect unless carried out, and 30 days after it was asked | 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, owner mailed, called off by any sign-in with the password; lapses a week after taking effect, and 30 days after it was asked | 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.
new password clears it. 30 days after GitHub's end, accounts that sign in with GitHub
alone go, on the register. A member ends the sessions an account had on it before the
register's moment by its own clock. On the device nothing secret is copied, every
secret shown or typed back is kept out of captures, and a saved file is the own user's
from the start.
## Consequences

View file

@ -292,12 +292,15 @@ Built on the three parts above, on the register (members forward every step thro
account's signing key, which every signed-in device keeps) and a code set a new
password, the keys staying. A lost password without it: a mailed code asks, 72 hours
pass (a mail at the start, a sign-in calls it off), a new code carries it out: new
password, new keys, no authenticators. While the owner is held out of signing in (codes
waiting, the address's ceiling of proofs filled), every reset not carried out is pushed
to 72 hours after the hold-out ends (`pushOutResets`, `account_resets.dart`); a reset
that took effect and wasn't carried out lapses a week later (`resetLapsesAfter`,
pruned by `forgetLapsedResets`); `SignInWays` tells signed-in devices when each waiting
reset takes effect, and `password.callOffResets` calls them off for the owner's proof.
password, new keys, no authenticators. A reset takes effect once the owner had 72 hours
outside hold-outs (codes waiting, the address's ceiling of proofs filled): each
hold-out that begins before it takes effect moves it on by the time it holds the owner
out and no hold-out counted before did (`pushOutResets`, `account_resets.dart`, the
row's `heldOutUntil`); a reset that took effect and wasn't carried out lapses a week
later (`resetLapsesAfter`), and every reset not carried out 30 days after it was asked
(`resetLivesAtMost`), both pruned by `forgetLapsedResets`; `SignInWays` tells
signed-in devices when each waiting reset takes effect, and `password.callOffResets`
calls them off for the owner's proof.
- **Changing while signed in:** `password.change` takes the owner's proof (the old
password's SRP proof for a `password.beginChange` challenge, and a TOTP or recovery
code) and the same keys wrapped under the new password; `password.setRecoveryKeys`
@ -318,11 +321,11 @@ Built on the three parts above, on the register (members forward every step thro
all. The register ends its own and keeps a `sessions_ended_notice` per member (told at
once in the background, and hourly until it confirms, `deliverSessionNotices`, as deletions are told): it names a moment, so a
session made since stays, and the session that asked for a change. A member answering
one (`/halkyon-base/member/sessions-ended`) ends its sessions made before the moment;
a member signing someone in after a reset ends its own first and makes the new session
only once its clock is past the moment (`issueSessionAfter`), refusing with
`passwordChanged` (sign in again with the new password) when its clock is more than
`maxClockSkew` behind or ahead. The owner's warning of a session ended for its proofs is
one (`/halkyon-base/member/sessions-ended`) ends its sessions made before the moment,
taken by its own clock (the notice carries the register's, `sentAt`, so the clocks'
offset drops out); a member signing someone in after a reset ends every session of the
account it holds made before the register's moment or before now, whichever is later,
then makes the new one (`issueSessionAfter`), waiting for nothing. The owner's warning of a session ended for its proofs is
dismissed only by a session made since (`signedInBefore`). A refusal for a reset not due says when it takes effect
(`takesEffectAt`).
- **Older ways:** an account that signs in with a password (it had a confirmed