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

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
(`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
refused as no longer valid and leaves it unused), and its mail says what it would do,
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
the switch token went with build 22, `minimumAppBuild` 22 in Learn).
- **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
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.
makes at most 30 guesses an hour. A signed-in owner's proofs (a `change` challenge,
below) never count toward the address, and no sign-in takes one. A sign-in or the
phrase's reset carrying a code takes the account's `address` and `language`, 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
(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. 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.
(issuer: the account domain); confirmed by a code from it; while the account has a
confirmed one, another counts only with the owner's proof too, so a session alone adds
no second factor; confirming one confirmed already is refused as unknown (its code is
never checked for a session); 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 the owner's proof, removing one never confirmed nothing. Each step's code
is accepted once, by a conditional update of `lastStep`.
- **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
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
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.
(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
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
@ -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
pass (a mail at the start, a sign-in calls it off), a new code carries it out: new
password, new keys, no authenticators.
- **Changing while signed in:** `password.beginChange` and `password.change` take the old
password's SRP proof, a TOTP or recovery code and the same keys wrapped under the new
password; `password.setRecoveryKeys` replaces the phrase's wrapping with the same
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.
- **Changing while signed in:** `password.change` takes the owner's proof (the old
password's SRP proof for a `password.beginChange` challenge, and a TOTP or recovery
code) and the same keys wrapped under the new password; `password.setRecoveryKeys`
replaces the phrase's wrapping with the same proof (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, 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.
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), counted for the session as every owner's proof is. The
code is a TOTP or recovery code wherever an authenticator is confirmed; where no second
factor works (a setup never finished, or the authenticators' reset taken effect;
`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
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