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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 13:39:18 +02:00
commit 8ec2743bfa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 79 additions and 17 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. - **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). - **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. 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 (D2). 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. 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.
- **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. - **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()`. - **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. - **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.
@ -115,7 +115,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash. - **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
- **Discussions** (ADR 0013, decided 2026-10-15): questions on a published studyset, a topic in it or one of its cards, notes or steps, answered by anyone and commented on (under the question and each answer, never under a comment), as Stack Exchange has them, without votes: the asker accepts an answer, which comes first, the rest oldest first. Unlike feedback they are server tables (`discussion_post`, `discussion_seen`, `lib/src/discussion/`), written through endpoints, online only, so posts can be edited and ordered and the synced tables stay as they are: `DiscussionsEndpoint` (`requireLogin`) lists (by topic, item or title, 30 to a page, last active first) and reads; `DiscussionEndpoint` (`identified`, and a public profile) asks, answers, comments, edits, deletes, accepts, hides (the studyset's author), and keeps the inbox. A question names its target as the release does, and the instance keeps the topics it is within from git's file list (`ReleaseRepository.paths`, `topicsOf`), so a topic lists what is asked within it and below; one about the studyset as a whole names no item. Questions and answers are rich text without files (the composer's Insert menu has no media, and content holding images or sounds is refused: the person's files are theirs alone), comments plain text of up to 600 characters, 30 posts an hour. The author hides posts and blocks people with feedback's own blocks; a withdrawn studyset takes no new questions. A post with others' replies under it is never deleted, only emptied ("Deleted question"), with its writer's account too (`deleteDiscussionsOf` in `deletePerson`). In the app (`feature/discussion/`): a copy's menu opens the discussions within that studyset or topic (`RoutePaths.discussions`, over the tabs), a studied card from a release offers Ask about this, Explore's summary and the Publish sheet open the whole studyset's, and the Profile tab's inbox lists the discussions the person follows, counted on the tab with the feedback (`InboxCountBloc`). - **Discussions** (ADR 0013, decided 2026-10-15): questions on a published studyset, a topic in it or one of its cards, notes or steps, answered by anyone and commented on (under the question and each answer, never under a comment), as Stack Exchange has them, without votes: the asker accepts an answer, which comes first, the rest oldest first. Unlike feedback they are server tables (`discussion_post`, `discussion_seen`, `lib/src/discussion/`), written through endpoints, online only, so posts can be edited and ordered and the synced tables stay as they are: `DiscussionsEndpoint` (`requireLogin`) lists (by topic, item or title, 30 to a page, last active first) and reads; `DiscussionEndpoint` (`identified`, and a public profile) asks, answers, comments, edits, deletes, accepts, hides (the studyset's author), and keeps the inbox. A question names its target as the release does, and the instance keeps the topics it is within from git's file list (`ReleaseRepository.paths`, `topicsOf`), so a topic lists what is asked within it and below; one about the studyset as a whole names no item. Questions and answers are rich text without files (the composer's Insert menu has no media, and content holding images or sounds is refused: the person's files are theirs alone), comments plain text of up to 600 characters, 30 posts an hour. The author hides posts and blocks people with feedback's own blocks; a withdrawn studyset takes no new questions. A post with others' replies under it is never deleted, only emptied ("Deleted question"), with its writer's account too (`deleteDiscussionsOf` in `deletePerson`). In the app (`feature/discussion/`): a copy's menu opens the discussions within that studyset or topic (`RoutePaths.discussions`, over the tabs), a studied card from a release offers Ask about this, Explore's summary and the Publish sheet open the whole studyset's, and the Profile tab's inbox lists the discussions the person follows, counted on the tab with the feedback (`InboxCountBloc`).
- **Deleting a person** goes through halkyon_base's `deletePerson`, in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then Learn's own data of theirs (`deleteLearnData`, `lib/src/auth/account.dart`, the hook Learn configures: their space with all its content through the foreign keys and its stored files, discussions, published studysets, blocks, what they read, the assistants they connected, their file allowance; their releases' repositories once the deletion holds), then the module's (their public profile with its avatar), then the person with their identities; an identified person's deletion also tells the instance's members (ADR 0016), the notices kept in the same transaction. What they wrote in discussions goes too, emptied where others replied under it (ADR 0013). A new table holding a person's data gets a line in that hook. The module's `account.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through halkyon_base's `deletePerson`, in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then Learn's own data of theirs (`deleteLearnData`, `lib/src/auth/account.dart`, the hook Learn configures: their space with all its content through the foreign keys and its stored files, discussions, published studysets, blocks, what they read, the assistants they connected, their file allowance; their releases' repositories once the deletion holds), then the module's (their public profile with its avatar), then the person with their identities; an identified person's deletion also tells the instance's members (ADR 0016), the notices kept in the same transaction. What they wrote in discussions goes too, emptied where others replied under it (ADR 0013). A new table holding a person's data gets a line in that hook. The module's `account.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` (Learn's, calling halkyon_base's functions; people go through its `deletePerson`, so Learn's data of theirs too) deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes the rate limits make (`serverpod_auth_idp_rate_limited_request_attempt`; anonymous sign-ups, codes by mail and password sign-ins are counted per network, each note keyed by the visitor's IP address: `networkOf`, which behind Caddy reads the last address of `X-Forwarded-For` from the proxies `TRUSTED_PROXIES` names, as `deploy/compose.yaml` names Caddy, never the `Forwarded` header) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that; hourly too, the deletions the instance's members haven't confirmed yet (`tellMembersOfDeletions`, ADR 0016). Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - **Pruning**: `PruningFutureCall` (Learn's, calling halkyon_base's functions; people go through its `deletePerson`, so Learn's data of theirs too) deletes anonymous people unused for 90 days and forgets the resets that lapsed (ADR 0017), daily, scheduled when the server starts; and hourly the notes the rate limits make (`serverpod_auth_idp_rate_limited_request_attempt`; anonymous sign-ups, codes by mail and password sign-ins are counted per network, each note keyed by the visitor's IP address: `networkOf`, which behind Caddy reads the last address of `X-Forwarded-For` from the proxies `TRUSTED_PROXIES` names, as `deploy/compose.yaml` names Caddy, never the `Forwarded` header) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that; hourly too, the deletions the instance's members haven't confirmed yet (`tellMembersOfDeletions`, ADR 0016). Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed. - **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them. - **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.

View file

@ -429,31 +429,83 @@ authenticator. What was done (`18ab2ee`, `f3c8706`, `403b88c`):
a warning and secures nothing. A new password clears it, as does deleting the person. 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. 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) ### The rules as they stand (21 Oct 2026)
Who changes what of an account, with which proofs. "Password" is SRP's proof bound to 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 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 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); unless said otherwise. Signed-in proofs count per session (ten wrong in a row end it);
sign-ins' codes per account (waits from ten wrong in a row, the owner mailed). 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 | | 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 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: 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 | | 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 | | First authenticator | — | password (no code) + the new one's code |
| Another authenticator | — | password + 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 | | Remove an authenticator | — | confirmed: password + code (never the last); unconfirmed: nothing |
| New recovery codes | — | password + code | | New recovery codes | — | password + code |
| New recovery phrase | — | password + code | | New recovery phrase | — | password + code |
| New password | phrase's proof + code (keys kept) | 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, owner mailed, called off by a sign-in with a 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, owner mailed, called off by any sign-in with the password | — | | 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`) | | Delete the account | — | password + code; where no second factor works, password + mailed code (`deletion`) |
Every reset carried out, and every new password, ends the account's sessions in every Every reset carried out, and every new password (a setup's replaced too), ends the
service (a change keeps the session asking). A session ended for ten wrong proofs leaves account's sessions in every service (a change keeps the session asking). A session ended
its owner a warning; dismissing it takes the session alone, and a new password clears it. 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 ## Consequences

View file

@ -263,8 +263,9 @@ Built on the three parts above, on the register (members forward every step thro
- **Codes tried in a row** (`second_factor_waits.dart`, table - **Codes tried in a row** (`second_factor_waits.dart`, table
`second_factor_failures`): every code tried against an account's second factor after `second_factor_failures`): every code tried against an account's second factor after
a right password or phrase at sign-in or in the phrase's reset (never the owner's a right password or phrase at sign-in or in the phrase's reset (never the owner's
proofs while signed in) counts, per account, from every network, before it is checked, one at a time proofs while signed in) counts, per account and way it came (`SecondFactorPath`:
per account (an advisory lock), so codes sent at once get no more checked; every tenth after the password, after the phrase, each with its own waits), from every network,
before it is checked, one at a time per account (an advisory lock), so codes sent at once get no more checked; every tenth
in a row begins a wait as it is counted (`codesWait`, with when), an hour, then twice in a row begins a wait as it is counted (`codesWait`, with when), an hour, then twice
as long each time, a day at the most, and every wait mails the account's address as long each time, a day at the most, and every wait mails the account's address
(which those calls take); a code accepted, either reset carried out, and a new (which those calls take); a code accepted, either reset carried out, and a new
@ -280,7 +281,12 @@ 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 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 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 pass (a mail at the start, a sign-in calls it off), a new code carries it out: new
password, new keys, no authenticators. 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.
- **Changing while signed in:** `password.change` takes the owner's proof (the old - **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 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` code) and the same keys wrapped under the new password; `password.setRecoveryKeys`
@ -297,22 +303,26 @@ Built on the three parts above, on the register (members forward every step thro
(`request`, `code`), to the account's own address. An account without a password (`request`, `code`), to the account's own address. An account without a password
deletes with its session, as before. deletes with its session, as before.
- **Sessions end everywhere:** a change of password ends every other session of the - **Sessions end everywhere:** a change of password ends every other session of the
account; a reset of the password or the account ends all. The register ends its own account (a setup's password replaced too); a reset of the password or the account ends
and keeps a `sessions_ended_notice` per member (told at once and hourly until it all. The register ends its own and keeps a `sessions_ended_notice` per member (told at
confirms, `deliverSessionNotices`, as deletions are told): it names a moment, so a 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 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; 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 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 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 `passwordChanged` (sign in again with the new password) when its clock is more than
`maxClockSkew` behind. A refusal for a reset not due says when it takes effect `maxClockSkew` behind or ahead. 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`). (`takesEffectAt`).
- **Older ways:** an account that signs in with a password (it had a confirmed - **Older ways:** an account that signs in with a password (it had a confirmed
authenticator, which a reset may have taken since: `passwordOnly` in its ways in) no authenticator, which a reset may have taken since: `passwordOnly` in its ways in) no
longer signs in with GitHub (`usePassword`); where passwords are offered, no new GitHub longer signs in with GitHub (`usePassword`); where passwords are offered, no new GitHub
account is linked (`deprecated`); from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in account is linked (`deprecated`); from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in
(`ended`), the basics stop naming the day (`gitHubSignInEnds`, which apps show until (`ended`), the basics stop naming the day (`gitHubSignInEnds`, which apps show until
then) and `forgetGitHubAccounts` deletes every GitHub ID. then) and `forgetGitHubAccounts` deletes every GitHub ID, first deleting the accounts
that sign in with GitHub alone (no address, never a password). `password.set` adds an
address without GitHub only for someone anonymous where the session lives
(`anonymousHere`, which a member tells the register).
## Taking it into a host ## Taking it into a host