docs(auth): what building the register's side settled

ADR 0017 records the decisions taken in building (SRP's identity, the
decoys, the 72-hour account reset proposed for confirmation, GitHub's
deprecation) and what step 3 must do; the module's README and the
AGENTS files say how password sign-in works.

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 13:32:21 +02:00
commit c67b492bfd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,7 +1,8 @@
# halkyon_base
What every Halkyon server has, as a Serverpod module (`halkyon_base_server`, with its
generated client `halkyon_base_client`): signing in, the account's ways in and its
generated client `halkyon_base_client`): signing in (with a password and TOTP, ADR 0017,
and the older ways), getting back into an account, the account's ways in and its
deletion, the pruning of what nobody can return to, what an instance tells its apps
before anyone signs in, and people's public profiles with their pages on the web, which
the instance's register keeps (ADR 0016). It comes from Halkyon Learn's server, whose
@ -14,12 +15,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| Endpoint | Methods | Who may call |
|---|---|---|
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per IP address and hour) | anyone |
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified` | signed in |
| `gitHubSignIn` | `signIn`: a session for whoever has that GitHub account | without a session |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code); a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `sendCode`, `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified`; refused where passwords are offered (deprecated) | signed in |
| `gitHubSignIn` | `signIn`: a session for whoever has that GitHub account, unless they sign in with a password; none once `GITHUB_SIGN_IN_ENDS` has come | without a session |
| `emailCode` | `sendCode`, `verifyCode`: a code by mail; a new address becomes the caller's, another person's gives a switch token | signed in |
| `emailSignIn` | `signIn`: a session for the person a switch token names | without a session |
| `account` | `delete`, `signInWays` | signed in |
| `instance` | `basics`: the app it serves, GitHub's client ID if any, whether email sign-in is on, the source link, the oldest app build, where data and backups are | anyone |
| `instance` | `basics`: the app it serves, GitHub's client ID if any, whether email and password sign-in are on, the source link, the oldest app build, where data and backups are | anyone |
| `profile` | `mine`, `save`, `checkHandle`, `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile, whatever the handle's case | signed in |
@ -36,13 +41,14 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host),
served by `addProfilePageRoute`, in English or German, its foot what the host's
`profilePageNote` says.
- Refusals are `GitHubSignInRefusedException`, `EmailSignInRefusedException` and
`ProfileRefusedException`, each with a reason; `ProfileNotFoundException` where there is
no profile.
- Refusals are `PasswordRefusedException`, `GitHubSignInRefusedException`,
`EmailSignInRefusedException` and `ProfileRefusedException`, each with a reason;
`ProfileNotFoundException` where there is no profile.
- For the host's own code, `package:halkyon_base_server/base.dart`: the configuration,
`tokenManagerBuilders` and `identityProviderBuilders`, the `identified` scope and
`grantIdentified`, `deletePerson`, the pruning (`pruneAbandonedAnonymousPeople`,
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`), short codes, `sourceCodeUrl`,
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`, `forgetUnconfirmedAuthenticators`,
`forgetGitHubAccounts`), short codes, `sourceCodeUrl`,
`dataLocation`, `backupLocation`, people's profiles as the host's code reads them
(`profileOf`, `profilesOf`, `personWithHandle`, `fixHandle`; `saveProfile` for its
tests), `accountDomain`, `profilePageUrl`, `addProfilePageRoute`, the register
@ -147,6 +153,53 @@ The second factor, in `package:halkyon_base_server/base.dart`:
hashes, key)` compares every hash in constant time and gives the hashes left
(`RecoveryCodeAccepted.remaining`), so each code works once.
## Signing in with a password (ADR 0017)
Built on the three parts above, on the register (members forward every step through
`AccountRegister`, `RemoteRegister` included; the session is the member's own).
- **Tables** (`serverOnly`, each row deleted with its person by `deletePerson`):
`account_password` (one per account: Argon2id's salt and costs, SRP's verifier padded
to N, the public keys, the private keys wrapped under the password and under the
recovery phrase), `totp_authenticator` (the secret sealed with XChaCha20-Poly1305 under
a key derived from `totpKey`, bound to the row's account and ID; `confirmedAt`;
`lastStep`), `recovery_code` (keyed hashes), `sign_in_challenge` (a step begun: SRP's b
and B, or a nonce to sign; two minutes, taken once by a delete), `account_reset` (one
of each kind per account, with when it takes effect).
- **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
each) and both wrappings (24-byte nonce, 112 bytes). It needs an address: the
caller's, or the one a mailed code proves, which becomes theirs as email sign-in makes
it (anonymous → `identified`, same ID). It signs in once an authenticator is
confirmed; until then a new setup replaces it.
- **Signing in:** `begin(address)` answers salt, costs and B; `complete(challenge, A, M1,
totpCode | recoveryCode)` answers M2, the public keys, the password's wrapping and a
session. SRP's identity I is the challenge's ID. An address nobody has (or whose
account has no password) gets a salt and verifier from `signInDecoyKey` and the
address's hash, the same queries and arithmetic, and the same refusal: a wrong
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 30 per network an hour.
- **Authenticators:** a secret made on the register, shown once as an `otpauth://` URI
(issuer: the account domain); confirmed by a code; 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`.
- **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
signs in until a new authenticator is confirmed. A lost password with the phrase: the
account's Ed25519 signature over a nonce (`keyProofMessage`) and a code set a new
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. Either change of password ends the account's
sessions on the register's server.
- **Older ways:** an account that signs in with a password (and a confirmed
authenticator) no longer signs in by a mailed code (`usePassword`) or GitHub
(`usePassword`); where passwords are offered, no new GitHub account is linked
(`deprecated`); from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in (`ended`) and
`forgetGitHubAccounts` deletes every GitHub ID.
## Taking it into a host
1. Depend on both packages by path, within the repository's workspace (an app's
@ -199,8 +252,9 @@ The second factor, in `package:halkyon_base_server/base.dart`:
literal routes of its own.
Then schedule the pruning in a future call of the host's own: daily
`pruneAbandonedAnonymousPeople` and `forgetPeopleTheRegisterForgot` (a member's),
hourly `forgetOldSignUpAttempts`, `forgetExpiredEmailCodes` and
`pruneAbandonedAnonymousPeople`, `forgetGitHubAccounts` and
`forgetPeopleTheRegisterForgot` (a member's), hourly `forgetOldSignUpAttempts`,
`forgetExpiredEmailCodes`, `forgetUnconfirmedAuthenticators` and
`deliverDeletionNotices` (the register's). Each does nothing in the other mode.
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
@ -217,13 +271,17 @@ The second factor, in `package:halkyon_base_server/base.dart`:
The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClientId` and
`githubClientSecret`; for email `emailSignInPepper` and `emailSmtpHost`,
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; between the
instance's services `instanceSecret`. Never change either pepper once people sign in:
every session or address hash would be lost. A member needs no email settings of its
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; for passwords
`totpKey`, `recoveryCodeKey` and `signInDecoyKey` (each at least 32 characters, or
`addInstanceRoutes` stops the server; all three and email sign-in, or no password
sign-in); between the instance's services `instanceSecret`. Never change either pepper
once people sign in: every session or address hash would be lost; changing `totpKey`
voids every authenticator, `recoveryCodeKey` every recovery code. A member needs no email settings of its
own (the register mails the codes), but GitHub's (its own OAuth app's, whose callback is
its app's). The environment: `ACCOUNT_REGISTER_URL` and `INSTANCE_MEMBERS` (above);
`ACCOUNT_DOMAIN`, the domain handles are on, which can never change once anyone follows
one; `SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
one; `GITHUB_SIGN_IN_ENDS`, the day GitHub stops signing anyone in (`2027-01-22`);
`SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166-2,
`US-CA`, `DE-BY`), the backups also `none`; where the disks are, not which law governs.
@ -233,7 +291,9 @@ and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166
`serverpod --no-analytics create-migration` after changing a table. Tests need no Docker:
`dart test` here starts Serverpod's embedded PostgreSQL. They read `config/passwords.yaml`
(git-ignored) with throwaway values under `test:` for `database`, `serviceSecret`,
`serverSideSessionKeyHashPepper` and `emailSignInPepper`.
`serverSideSessionKeyHashPepper` and `emailSignInPepper`. Password sign-in's keys may be
there too (CI's are), but the tests set their own (`usePasswordKeys`) and switch them
off otherwise.
## Licences