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:
parent
5f646a4e6b
commit
c67b492bfd
1 changed files with 139 additions and 18 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue