docs(auth): what the third reading found, and Proton's rule (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-21 10:45:46 +02:00
commit 1ada2e27f6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 103 additions and 34 deletions

View file

@ -74,12 +74,12 @@ 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 change of password ends the account's other sessions and a reset all of them, on every service of the instance (`sessions_ended_notice`). - **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. A change of password ends the account's other sessions and a reset all of them, on every service of the instance (`sessions_ended_notice`).
- **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.
- **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. - **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, 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. - **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 and what it is for 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." - **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. - **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. - **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

@ -338,6 +338,57 @@ these; all but the ninth were fixed (the developer's choice):
unavailable**, which the app read as "no password sign-in here": it has its own 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. 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.
## Consequences ## Consequences
- People keep a password, an authenticator app and a recovery phrase: more to keep than a - People keep a password, an authenticator app and a recovery phrase: more to keep than a

View file

@ -69,7 +69,11 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
screens: the start screen's Sign in and Create an account, screens: the start screen's Sign in and Create an account,
`showPasswordSignInSheet`, `showAccountSetupSheet`, `showPasswordSignInSheet`, `showAccountSetupSheet`,
`showAccountRecoverySheet`, and `AccountSecuritySection` for an app's `showAccountRecoverySheet`, and `AccountSecuritySection` for an app's
account part. Anonymous people get no keys until something is encrypted account part. Every change of how the account is secured asks for the
password with the code, one answer per sheet (`OwnerAnswer`, the owner's
proof: a session alone gets no code checked); deleting by a code by mail
follows `secondFactorWorks`, as the instance tells it, and asks for the
address only where the device doesn't know it. Anonymous people get no keys until something is encrypted
(step 4). Every build of an app needs a C compiler and make: sodium's build (step 4). Every build of an app needs a C compiler and make: sodium's build
hook compiles libsodium. hook compiles libsodium.
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made - **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made

View file

@ -191,8 +191,9 @@ Built on the three parts above, on the register (members forward every step thro
account without a working second factor. Each is asked for a purpose account without a working second factor. Each is asked for a purpose
(`EmailCodePurpose`: `signUp`, `finishSetup`, `authenticatorReset`, `accountReset`, (`EmailCodePurpose`: `signUp`, `finishSetup`, `authenticatorReset`, `accountReset`,
`deletion`; `email_code_request.purpose`), only that purpose takes it (any other is `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 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 in its subject too (a notification shows the subject alone), 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 nothing turns a code into a session (`emailCode.verifyCode`, `emailSignIn.signIn` and
the switch token went with build 22, `minimumAppBuild` 22 in Learn). the switch token went with build 22, `minimumAppBuild` 22 in Learn).
- **Signing up** (`passwordSignIn.signUp(request, code, setup)`, without a session): an - **Signing up** (`passwordSignIn.signUp(request, code, setup)`, without a session): an
@ -222,30 +223,40 @@ Built on the three parts above, on the register (members forward every step thro
get no more checked; an address nobody has alike), past which no proof is checked and 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 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 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 makes at most 30 guesses an hour. A signed-in owner's proofs (a `change` challenge,
account instead, 10 begun an hour, never toward the address, and no sign-in takes one. below) never count toward the address, and no sign-in takes one. A sign-in or the
Every call carrying a code after the password or phrase proved (sign-in, the phrase's phrase's reset carrying a code takes the account's `address` and `language`, refused
reset, a change, a new phrase, deletion) takes the account's `address` and `language`, (`wrongCredentials`) before any code is checked when it isn't theirs.
refused (`wrongCredentials`) before any code is checked when it isn't theirs. - **The owner's proof** (`OwnerProof`, ADR 0017's third reading, as Proton has it): a
session alone never gets a code checked. Every signed-in change of how an 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's SRP proof
for a challenge `password.beginChange` gave (30 begun an hour per session, holding
back no other) and then a TOTP or recovery code (`proveOwner`, `ownersCode`). A proof
given where none is taken is refused, so none passes unchecked. Each proof counts for
its session alone (`owner_proof_tries.dart`, `asOwnerProofTry`, table
`session_code_failures`, kept where the session is, on a member too), before it is
checked; the tenth wrong in a row ends the session (`sessionEnded`), only a proof
accepted clears the count, and a try that checked none is taken back. Never toward the
account's waits, nor waiting for them: whoever knows the password holds no signed-in
owner out of changing it, and a new password forgets those waits.
- **Authenticators:** a secret made on the register, shown once as an `otpauth://` URI - **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 (issuer: the account domain); confirmed by a code from it; while the account has a
one, another counts only with a code from that one too (`wrongExistingCode`), so a confirmed one, another counts only with the owner's proof too, so a session alone adds
session alone adds no second factor; the first confirmed brings ten recovery codes; at no second factor; confirming one confirmed already is refused as unknown (its code is
most 10 per account; the last confirmed one stays; removing a confirmed one, or new never checked for a session); the first confirmed brings ten recovery codes; at most
recovery codes, takes a code. Each step's code is accepted once, by a conditional 10 per account; the last confirmed one stays; removing a confirmed one, or new recovery
update of `lastStep`. A signed-in session's codes here count for that session alone codes, takes the owner's proof, removing one never confirmed nothing. Each step's code
(`session_code_tries.dart`, table `session_code_failures`, kept where the session is, is accepted once, by a conditional update of `lastStep`.
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 - **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 (sign-in, the phrase's reset, a change, a new phrase, a right password or phrase at sign-in or in the phrase's reset (never the owner's
deletion) counts, per account, from every network, before it is checked, one at a time proofs while signed in) 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 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, and either reset carried out, clears it. (which those calls take); a code accepted, either reset carried out, and a new
password clear it.
- **Resets:** a lost authenticator: SRP's proof plus a mailed code or the recovery - **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 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 address at once, and signing in with a code calls it off; then the password alone
@ -256,18 +267,21 @@ Built on the three parts above, on the register (members forward every step thro
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.
- **Changing while signed in:** `password.beginChange` and `password.change` take the old - **Changing while signed in:** `password.change` takes the owner's proof (the old
password's SRP proof, a TOTP or recovery code and the same keys wrapped under the new password's SRP proof for a `password.beginChange` challenge, and a TOTP or recovery
password; `password.setRecoveryKeys` replaces the phrase's wrapping with the same code) and the same keys wrapped under the new password; `password.setRecoveryKeys`
proof and code (a phrase of one's own would otherwise set a new password through the replaces the phrase's wrapping with the same proof (a phrase of one's own would
phrase's reset). A session alone changes neither, nor a session and a code. 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`) - **Deleting an account with a password** (`account.delete(proof:)`, a `DeletionProof`)
takes the same: the password's proof for a `beginChange` challenge, the account's takes the same: the password's proof for a `beginChange` challenge and a code, bound
address and a code, bound to the host the device addressed (`requireOwnerToDelete`; a to the host the device addressed (`requireOwnerToDelete`; a member passes the proof on
member passes the proof on and the register checks it). Where no second factor works and the register checks it), counted for the session as every owner's proof is. The
(a setup never finished, or the authenticators' reset taken effect) the code is one by code is a TOTP or recovery code wherever an authenticator is confirmed; where no second
mail asked for deleting (`request`, `code`). An account without a password deletes factor works (a setup never finished, or the authenticators' reset taken effect;
with its session, as before. `SignInWays.secondFactorWorks` tells the app) it may be one by mail asked for deleting
(`request`, `code`), to the account's own address. An account without a password
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 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 and keeps a `sessions_ended_notice` per member (told at once and hourly until it