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

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