docs(auth): the fourth reading, and the rules as they stand (ADR 0017)
ADR 0017 records what the fourth reading found and what was done, and states who changes what of an account with which proofs, as a table the full reading can check against; the module README and Learn's AGENTS.md follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f154b7ea7c
commit
858fffdce3
3 changed files with 75 additions and 12 deletions
|
|
@ -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. 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. 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.
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,61 @@ The findings, and what was done:
|
||||||
subject names it ("K7QX-M2PD: your Halkyon Learn code to delete your account"), and 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.
|
sign-up mail says it also gives a password to an account with that address and none.
|
||||||
|
|
||||||
|
### Fourth reading (21 Oct 2026)
|
||||||
|
|
||||||
|
A fourth independent reading, of the third's fixes (`a45842f..263c171`), found its five
|
||||||
|
findings closed, and two older gaps in Proton's rule: the first password and the first
|
||||||
|
authenticator. What was done (`18ab2ee`, `f3c8706`, `403b88c`):
|
||||||
|
|
||||||
|
1. **A stolen session made a GitHub-only account, or an address account without a
|
||||||
|
password, its own** (high): `password.set` took the session alone, so the thief set
|
||||||
|
their address (or, with the owner's address, their password) and their authenticator;
|
||||||
|
GitHub then refused the owner. A first password now takes a fresh proof of the
|
||||||
|
account's existing way in: a code mailed to the account's own address for it
|
||||||
|
(`EmailCodePurpose.firstPassword`), or, for an account that signs in with GitHub
|
||||||
|
alone, a GitHub sign-in of the last 10 minutes (`gitHubSignInFreshFor`: the session's
|
||||||
|
method and creation time, where the session lives, a member telling the register),
|
||||||
|
refused `signInAgain` before any code is used. Anonymous people still become
|
||||||
|
identified with a code to the address they add. The app offers a password right after
|
||||||
|
a GitHub sign-in, before the person's app (D2's "at their next sign-in"), and asks
|
||||||
|
for a GitHub sign-in anew when the instance refuses an older one.
|
||||||
|
2. **A stolen session outlived a reset of the authenticators and claimed the first new
|
||||||
|
one** (medium-high): carrying the reset out ended no session, and the first
|
||||||
|
authenticator took its own code alone. Carrying it out now ends every session of the
|
||||||
|
account in every service (the sign-in that carries it out gets its session after
|
||||||
|
that moment), and the first authenticator takes the password's proof for a change's
|
||||||
|
challenge, without a code (`proveOwnersPassword`), counted per session as the owner's
|
||||||
|
proofs are.
|
||||||
|
3. **Lows**: a proof whose call to the register failed (`RegisterUnavailableException`)
|
||||||
|
stays counted, as the register may have checked it; the sign-up mail's subject says it
|
||||||
|
also gives an account its password. A notice for the owner when ten wrong proofs end a
|
||||||
|
session is still to come (NEXT.md).
|
||||||
|
|
||||||
|
### The rules as they stand (21 Oct 2026)
|
||||||
|
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
sign-ins' codes per account (waits from ten wrong in a row, the owner mailed).
|
||||||
|
|
||||||
|
| 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 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 |
|
||||||
|
| First authenticator | — | password (no 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 |
|
||||||
|
| New recovery codes | — | password + code |
|
||||||
|
| New recovery phrase | — | 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 | — |
|
||||||
|
| 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 | — |
|
||||||
|
| 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
|
||||||
|
service (a change keeps the session asking).
|
||||||
|
|
||||||
## 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
|
||||||
|
|
|
||||||
|
|
@ -180,12 +180,17 @@ Built on the three parts above, on the register (members forward every step thro
|
||||||
of each kind per account, with when it takes effect).
|
of each kind per account, with when it takes effect).
|
||||||
- **Setting a password** (`password.set`): the device sends salt, costs (`argon2id13`,
|
- **Setting a password** (`password.set`): the device sends salt, costs (`argon2id13`,
|
||||||
3–16 passes, 64 MiB–1 GiB), verifier (1 < v < N, as long as N), public keys (32 bytes
|
3–16 passes, 64 MiB–1 GiB), verifier (1 < v < N, as long as N), public keys (32 bytes
|
||||||
each) and both wrappings (24-byte nonce, 112 bytes). It needs an address: the
|
each) and both wrappings (24-byte nonce, 112 bytes). It needs an address, and a
|
||||||
caller's, or the one a mailed code proves, which becomes theirs as email sign-in makes
|
session alone sets none (ADR 0017's fourth reading): an account with an address proves
|
||||||
it (anonymous → `identified`, same ID). It signs in once an authenticator is
|
it anew by a code mailed to it for this (`firstPassword`); an anonymous person by a
|
||||||
confirmed; until then a new setup replaces it, never once one was, even while a reset
|
mailed code (`signUp`) for the address they add, which becomes theirs as email sign-in
|
||||||
leaves the account without one (a new password comes through the recovery phrase or
|
makes it (anonymous → `identified`, same ID); an account that signs in with GitHub
|
||||||
the account's reset alone).
|
alone does that within 10 minutes of a GitHub sign-in on the session asking
|
||||||
|
(`gitHubSignInFreshFor`, the session row's method and creation time, a member telling
|
||||||
|
the register; else `signInAgain`, before any code is used). It signs in once an
|
||||||
|
authenticator is confirmed; until then a new setup replaces it, never once one was,
|
||||||
|
even while a reset 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
|
- **Codes by mail** (`emailCode.sendCode`, without a session) only prove an address: for
|
||||||
a password's setup, a sign-up, a setup never finished, the resets and deleting an
|
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
|
account without a working second factor. Each is asked for a purpose
|
||||||
|
|
@ -241,9 +246,10 @@ Built on the three parts above, on the register (members forward every step thro
|
||||||
account's waits, nor waiting for them: whoever knows the password holds no signed-in
|
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.
|
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 from it; while the account has a
|
(issuer: the account domain); confirmed by a code from it and the owner's proof, so a
|
||||||
confirmed one, another counts only with the owner's proof too, so a session alone adds
|
session alone adds no second factor: while the account has a confirmed one, the
|
||||||
no second factor; confirming one confirmed already is refused as unknown (its code is
|
password and a code from it; the first (none confirmed, after a reset carried out
|
||||||
|
too) the password alone (`proveOwnersPassword`, the fourth reading); 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
|
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
|
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
|
codes, takes the owner's proof, removing one never confirmed nothing. Each step's code
|
||||||
|
|
@ -259,8 +265,10 @@ Built on the three parts above, on the register (members forward every step thro
|
||||||
password clear it.
|
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; the sign-in that carries it
|
||||||
signs in until a new authenticator is confirmed. A lost password with the phrase: a
|
out ends every session of the account in every service (its own session comes after
|
||||||
|
that moment), and then the password alone signs in until a new authenticator is
|
||||||
|
confirmed. A lost password with the phrase: a
|
||||||
signature over a nonce (`keyProofMessage`) by the phrase's own proof key (Ed25519,
|
signature over a nonce (`keyProofMessage`) by the phrase's own proof key (Ed25519,
|
||||||
derived from the phrase alone, its public half kept as `recoveryProofKey`; never the
|
derived from the phrase alone, its public half kept as `recoveryProofKey`; never the
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue