halkyon/packages/halkyon_base_server/README.md
Tizian 「ティツィアーン」 bc499713b8
feat(crypto): SRP-6a, the register's half and the device's
ADR 0017's step 1. Generic over group and hash (SrpGroup); production
takes RFC 5054's 3072-bit group with SHA-256. k = H(N | PAD(g)),
u = H(PAD(A) | PAD(B)), K = H(PAD(S)),
M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K),
M2 = H(PAD(A) | M1 | K). A and B must lie in 1..N-1 and u must not be
0; a and b are 256 bits from Random.secure(); proofs are compared in
constant time. x comes from the caller (halkyon_keys makes it); RFC
5054's own x is there for the vectors only.

- The register's half in halkyon_base_server (base.dart: srpChallenge,
  srpVerify), the device's written by hand in halkyon_base_client
  (srp.dart: SrpClient, srpVerifier). Serverpod's generator cleans and
  writes only a client's lib/src/protocol/ (serverpod_cli 4.0.4's
  _getDirectoriesRequiringCleaning; a generate leaves srp/ untouched).
- Tests: RFC 5054 Appendix B on both halves; the client against the
  register, succeeding and every refusal.
- The module's tests depend on its client, and pub resolves every
  member's dev dependencies, so Learn's server image takes the module's
  client into its workspace too.
- The format check covers the hand-written half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:08 +02:00

223 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
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
rules it keeps.
## What it provides
Endpoints, under the nickname `base` in a host's client (`client.modules.base.emailCode`):
| 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 |
| `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 |
| `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 |
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only
HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its
codes as Argon2id hashes (`email_code_request`). Codes are eight Crockford characters,
valid 15 minutes, 3 tries; at most 3 an hour per address and 10 per network.
- Profiles: a handle as Mastodon's (1 to 30 letters, digits or underscores, unique
whatever their case, a few reserved for the instance), fixed once the host says so
(`fixHandle`; Halkyon Learn at the first publish); a display name, a bio, up to 4
links verified as Mastodon verifies them (`rel="me"` back to the profile's page,
fetched over HTTPS from public addresses only), and an avatar, a PNG of at most 1 MB
in the public storage at `avatars/<SHA-256>.png`. The page is at
`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.
- 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`,
`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
(`AccountRegister`, `AccountsMode`, `addInstanceRoutes`, `deliverDeletionNotices`,
`forgetPeopleTheRegisterForgot`, `issueSession`), and the models. A host never reads
the profiles' table itself.
## The instance's register (ADR 0016)
One operator's services share one account per person: the same address, GitHub account
and handle reach the same person in each, under one account ID (each service's
`AuthUser` has it), and deleting the account deletes it everywhere. One service holds
the register, its database keeping the ways in (`email_identity`, GitHub's IDs), the
handles and profiles; the others are its members and ask it. Anonymous people stay each
service's own until they add a way in, which makes them an account of the register under
the same ID. Sessions stay each service's own: every app signs in itself.
- **Modes** (`AccountsMode`, `HalkyonBase.accounts`; a host reads it with
`AccountsMode.fromEnvironment()`): `HoldsRegister` by default, also for an instance of
one service, telling the members `INSTANCE_MEMBERS` names (web servers, separated by
commas or spaces) when an account goes; `MemberOfRegister` where
`ACCOUNT_REGISTER_URL` names the register's web server. Empty values count as unset.
- **Trust:** the instance's shared secret (`instanceSecret` in the passwords), sent as a
bearer token with every call between the services and compared in constant time. A
server without one takes no such call and makes none (a member without one fails to
ask anything). A member is trusted as the register's own code: whoever holds the secret
can act for any account, so it never leaves the operator's servers.
- **The interface:** `AccountRegister`, which every endpoint and host function asks:
`LocalRegister` on the register (its own tables), `RemoteRegister` on a member (a POST
of JSON to `/halkyon-base/register/<operation>` on the register's web server, 30 s;
refusals come back as the exceptions the register threw, anything else as
`RegisterUnavailableException`); tests put an in-memory one in its place
(`AccountRegister.use`). The register answers on its web server (`addInstanceRoutes`),
a member on `/halkyon-base/member/account-gone`; neither is reachable from outside (the
host's Caddyfile never forwards them).
- **Signing in on a member:** the app calls the same endpoints in both modes. The member
asks the register to mail a code (the device's network passed on, as the register
counts codes per network) and to check it, or to say whose GitHub ID it is, and opens a
session of its own for that account (`issueSession`, which makes them a person there
under the account's ID the first time). Linking a way in makes the person identified on
the register and then on the member; a switch token signs in as the account the
address is already. Every refusal is the register's, as on the register itself.
- **Deleting:** an identified person's deletion on a member goes to the register first
(refused while it can't be reached, deleting nothing), which deletes the account and
keeps a notice for each member in the same transaction (`account_deletion_notice`),
told at once and again hourly (`deliverDeletionNotices`, tried least first) until each
confirms; notices to a member no longer named are dropped. An anonymous person goes
from their service alone. Each member also deletes, daily, the identified people the
register no longer knows (`forgetPeopleTheRegisterForgot`): it asks about everyone
before deleting anyone, deletes nobody while the register knows none of them (a
register misnamed or emptied), and fails, deleting nobody, while it can't reach it.
After restoring the register's database from a backup, accounts made since are
unknown to it: stop the members' pruning until those people are dealt with.
- **Privacy:** the register learns when someone signs in to which service; the
instance's privacy page has to say so.
- **The register being down:** members can't sign anyone in, change profiles or delete
accounts; their open sessions go on working.
## SRP-6a (ADR 0017)
The password never reaches the register: the device proves it knows it with SRP-6a
(RFC 5054), and the register keeps only the verifier v = g^x mod N, which `halkyon_keys`'
x makes on the device (Argon2id, so a stolen verifier costs an Argon2id per guess). The
two halves share the arithmetic, generic over the group and the hash (`SrpGroup`):
- the register's in `package:halkyon_base_server/base.dart`: `srpChallenge(group,
verifier)` gives `SrpChallenge` (B for the client; b kept for this sign-in only), and
`srpVerify(group, identity:, salt:, verifier:, challenge:, clientValue:, clientProof:)`
gives `SrpAccepted` (M2 and K) or `SrpRefused` (`invalidClientValue`, `wrongProof`);
- the device's in `package:halkyon_base_client/srp.dart`, written by hand beside the
generated client (the generator writes only `lib/src/protocol/`): `SrpClient(group)`
with its `publicValue` A, `respond(identity:, salt:, privateValue:, serverValue:)`
giving `SrpClientProof` (M1, K, `verifiesServer(M2)`) or `SrpClientRefused`, and
`srpVerifier(group, x)` for sign-up.
Production uses `SrpGroup.rfc5054Group3072` (RFC 5054's 3072-bit group, g = 5) with
SHA-256. k = H(N | PAD(g)), u = H(PAD(A) | PAD(B)), K = H(PAD(S)),
M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K), M2 = H(PAD(A) | M1 | K), numbers as
unsigned big-endian bytes, PAD to N's length. A and B must lie in 1..N−1 (so neither is
≡ 0 mod N) and u must not be 0; a and b are 256 bits from `Random.secure()`; proofs are
compared in constant time. RFC 5054 Appendix B's vectors check both halves
(`SrpGroup.rfc5054TestVectorGroup`, SHA-1), and `test/unit/srp/srp_interop_test.dart` runs
the client against the register, every refusal included.
## Taking it into a host
1. Depend on both packages by path, within the repository's workspace (an app's
packages sit in `apps/<app>/`):
```yaml
# the server's pubspec.yaml
halkyon_base_server:
path: ../../../packages/halkyon_base_server
# the client's pubspec.yaml: the same with halkyon_base_client
```
2. Name it in the server's `config/generator.yaml`:
```yaml
modules:
halkyon_base:
nickname: base
```
3. Configure it once, before the pod starts, and run its authentication and pruning:
```dart
final pod = Serverpod(args); // the host's generated Serverpod
HalkyonBase.configure(
pod,
HalkyonBase(
app: 'todo', // its apps accept only instances naming it; never change it
appName: 'Halkyon Todo', // in sign-in mails
upstreamSourceCodeUrl: 'https://code.halkyon.app/project-halkyon/halkyon_todo',
minimumAppBuild: 1, // raise it in the commit that breaks older apps
deletePersonData: [deleteTodosOf], // the host's own data of a person
profilePageNote: ( // what profile pages say of the app at their foot
english: 'On Halkyon Todo, a to-do list:',
german: 'Auf Halkyon Todo, einer Aufgabenliste:',
link: Uri.parse('https://halkyon.app/todo'),
),
// The register, or a member of the one the operator names (ADR 0016).
accounts: AccountsMode.fromEnvironment(),
),
);
pod.initializeAuthServices(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders(pod),
);
```
Serve the calls between the instance's services on its web server:
`addInstanceRoutes(pod)`; and the profile pages: `addProfilePageRoute(pod)`, after any
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
`deliverDeletionNotices` (the register's). Each does nothing in the other mode.
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
after the person's sessions ended and before their identities and the person go, in
the order given; it deletes the person's rows in the host's tables, and returns what
has to wait until the deletion is committed (files outside the database), or null.
Pruning deletes through the same function.
5. Create the host's migration. A host taking the tables over from its own models (as
Halkyon Learn does) finds no change once its copies are gone: Serverpod matches tables
by name, and these are identical. `create-migration --empty` then records the module's
version, with no SQL besides. Every service has the module's tables, whichever its
mode: a member's stay empty but for its people's sessions.
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
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
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.
## Developing it
`serverpod --no-analytics generate` here after changing a model or an endpoint, and
`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`.
## Licences
The server package is under the Server Side Public License 1.0, as the Halkyon servers
are; the client package under the GNU Affero General Public License 3.0 or later, as the
Halkyon apps are. So each combines with its counterpart in a host exactly as the host's
own do.