docs(auth): what re-reading the fixes found, and what was done (ADR 0017)

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-20 23:41:41 +02:00
commit 70b434bb84
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 91 additions and 24 deletions

View file

@ -79,7 +79,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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.
- **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished and the resets. Eight Crockford characters (halkyon_base's `short_code.dart`, shared with connect codes), 15 minutes, 3 tries, up to 3 valid at once for an address (a fourth voids the oldest); at most 3 codes an hour per address and network, 10 per address from every network and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished, the resets and deleting an account whose second factor doesn't work, each code asked for one of those purposes (`EmailCodePurpose`), taken by it alone, its mail saying what it would do and to type it only in the app on this instance. Eight Crockford characters (halkyon_base's `short_code.dart`, shared with connect codes), 15 minutes, 3 tries, up to 3 valid at once for an address (a fourth voids the oldest); at most 3 codes an hour per address and network, 10 per address from every network and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_app): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.

View file

@ -291,6 +291,53 @@ deletion):
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.
## Consequences
- People keep a password, an authenticator app and a recovery phrase: more to keep than a

View file

@ -187,7 +187,12 @@ Built on the three parts above, on the register (members forward every step thro
leaves the account without one (a new password comes through the recovery phrase or
the account's reset alone).
- **Codes by mail** (`emailCode.sendCode`, without a session) only prove an address: for
a password's setup, a sign-up, a setup never finished and the resets. Since step 3
a password's setup, a sign-up, a setup never finished, the resets and deleting an
account without a working second factor. Each is asked for a purpose
(`EmailCodePurpose`: `signUp`, `finishSetup`, `authenticatorReset`, `accountReset`,
`deletion`; `email_code_request.purpose`), only that purpose takes it (any other is
refused as no longer valid and leaves it unused), and its mail says what it would do
and to type it only in the app on that instance. Since step 3
nothing turns a code into a session (`emailCode.verifyCode`, `emailSignIn.signIn` and
the switch token went with build 22, `minimumAppBuild` 22 in Learn).
- **Signing up** (`passwordSignIn.signUp(request, code, setup)`, without a session): an
@ -211,25 +216,36 @@ Built on the three parts above, on the register (members forward every step thro
password, an unknown address and a wrong, used or missing code are all
`wrongCredentials`; `setupIncomplete` and `notDue` come only after a right password.
At most 10 sign-ins begun per address and network an hour (5 proofs of the phrase),
30 per network, and, from every network together, 30 failed proofs per address
(wrong passwords, phrases' signatures by another key; an address nobody has alike),
past which no sign-in or proof begins for it anywhere until the hour has passed: so
whoever knows an address and fills its count from one network holds no other back,
and guessing from many makes at most 30 guesses an hour.
30 per network, and, from every network together, 30 proofs checked per address
(`proofsPerAddress`: every password and phrase proof, recorded before it is checked
whatever its outcome, so a right one counts as a wrong one and challenges begun ahead
get no more checked; an address nobody has alike), past which no proof is checked and
nothing begins for it anywhere until the hour has passed: so whoever knows an address
and fills its count from one network holds no other back, and guessing from many
makes at most 30 guesses an hour. A change's proofs (a `change` challenge) count per
account instead, 10 begun an hour, never toward the address, and no sign-in takes one.
Every call carrying a code after the password or phrase proved (sign-in, the phrase's
reset, a change, a new phrase, deletion) takes the account's `address` and `language`,
refused (`wrongCredentials`) before any code is checked when it isn't theirs.
- **Authenticators:** a secret made on the register, shown once as an `otpauth://` URI
(issuer: the account domain); confirmed by a code; while the account has a confirmed
one, another counts only with a code from that one too (`wrongExistingCode`), so a
session alone adds no second factor; the first confirmed brings ten recovery codes; at most 10 per account; the last confirmed one stays; removing a
confirmed one, or new recovery codes, takes a code (10 tries an hour per account).
Each step's code is accepted once, by a conditional update of `lastStep`.
- **Codes refused in a row** (`second_factor_waits.dart`, table
`second_factor_failures`): every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in device
(removing an authenticator, new recovery codes, the code from one confirmed before)
counts, per account, from every network; after every tenth in a row no code is taken
(`codesWait`, with when), an hour, then twice as long each time, a day at the most;
the first wait mails the address where a device sent it (sign-in and the phrase's
reset: the instance keeps none); a code accepted clears it.
session alone adds no second factor; the first confirmed brings ten recovery codes; at
most 10 per account; the last confirmed one stays; removing a confirmed one, or new
recovery codes, takes a code. Each step's code is accepted once, by a conditional
update of `lastStep`. A signed-in session's codes here count for that session alone
(`session_code_tries.dart`, table `session_code_failures`, kept where the session is,
on a member too), each counted before it is checked; the tenth wrong in a row ends the
session (`sessionEnded`), a code accepted clears the count, a try that checked no code
is taken back; never toward the account's waits, so a stolen session holds nobody out.
- **Codes tried in a row** (`second_factor_waits.dart`, table
`second_factor_failures`): every code tried against an account's second factor after
a right password or phrase (sign-in, the phrase's reset, a change, a new phrase,
deletion) counts, per account, 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
as long each time, a day at the most, and every wait mails the account's address
(which those calls take); a code accepted, and either reset carried out, clears it.
- **Resets:** a lost authenticator: SRP's proof plus a mailed code or the recovery
phrase's signature asks for it; it takes effect 72 hours later, a mail tells the
address at once, and signing in with a code calls it off; then the password alone
@ -246,10 +262,12 @@ Built on the three parts above, on the register (members forward every step thro
proof and code (a phrase of one's own would otherwise set a new password through the
phrase's reset). A session alone changes neither, nor a session and a code.
- **Deleting an account with a password** (`account.delete(proof:)`, a `DeletionProof`)
takes the same: the password's proof for a `beginChange` challenge and a code, bound
to the host the device addressed (`requireOwnerToDelete`; a member passes the proof
on and the register checks it). An account without a password deletes with its
session, as before.
takes the same: the password's proof for a `beginChange` challenge, the account's
address and a code, bound to the host the device addressed (`requireOwnerToDelete`; a
member passes the proof on and the register checks it). Where no second factor works
(a setup never finished, or the authenticators' reset taken effect) the code is one by
mail asked for deleting (`request`, `code`). An account without a password deletes
with its session, as before.
- **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
and keeps a `sessions_ended_notice` per member (told at once and hourly until it
@ -257,8 +275,10 @@ Built on the three parts above, on the register (members forward every step thro
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`). A refusal for a reset not
due says when it takes effect (`takesEffectAt`).
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. 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
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