halkyon/packages/halkyon_base_server/README.md
Tizian 「ティツィアーン」 a29ad45ef1
All checks were successful
CI / check (push) Successful in 46m54s
build: the organisation on code.halkyon.app is halkyon-days
ADR 0045, D12, step 1: project-halkyon was renamed halkyon-days, its
repositories keeping their names. Every URL naming it moves along: the
servers' source-code links, which the AGPL requires, and their tests;
the forks in the pubspecs and the lock (appflowy-editor, pure-zstd,
serverpod-offline-sync); the snaps, the F-Droid listings and the
release configurations; the docs. The curator's credit for the
alphabets names the repository by its name since ADR 0015, halkyon,
rather than halkyon_learn. Forgejo redirects the old addresses until
the name is taken again.

ADRs 0015 and 0039 keep the name they were written with, and the local
folders theirs.

halkyon_editor's gen-l10n caches under build/, committed before its
.gitignore named the folder, are no longer tracked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-12 16:30:46 +01:00

587 lines
48 KiB
Markdown
Raw Permalink 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 (with SimpleLogin, Halkyon Learn's
ADR 0020, 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
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 |
|---|---|---|
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one, and their first handle where they have none (Halkyon Learn's ADR 0020, D28); `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code; `setProofKey`: the keys' proof key the same password makes, with its proof and a code, so a device signed in with SimpleLogin opens the keys with the password (Halkyon Learn's ADR 0020, D34; a new password takes it away) | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code; a setup never finished, a code by mail); a session. `checkHandle(request, handle)`: whether a sign-up could take a handle, while its mailed code waits (ADR 0020, D29); `signUp`: an account at the address a mailed code proves, with a password and a handle (an account there keeps its own where it has one); a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name; signed in (`identified` only, `wrongCaller` otherwise), SimpleLogin added to the caller's account; for an account, whether it has its keys, whether its password signs in too and has the keys' proof key (D34), when a reset of its keys takes effect (D31) and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `keys` | for an account that signs in with SimpleLogin (Halkyon Learn's ADR 0020, D19 and D20): `begin`, `unlock`: the caller's keys for their device, once the password is proven with its own proof key; `set`: the first keys (and handle) of an account SimpleLogin was added to, with a SimpleLogin sign-in of the last ten minutes; `changePassword`, `beginPhrase` and `resetPassword` (a lost password, with the recovery phrase), `setRecoveryKeys`: each with the password's proof (the phrase's) and such a sign-in (`KeysOwnerProof`), a new password ending every other session; the password and the phrase lost (D31–D33), `startReset` with such a sign-in, `completeReset` 72 hours later with another (new keys, every other session ending), `callOffResets` with the password's proof, which any proof of the password or the phrase also does; only an account that signs in with SimpleLogin alone changes here, one whose password signs in only unlocking (D34); the tenth wrong proof in a row ends the session, as ADR 0017's do | signed in |
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays`, `operating`: how many accounts wait, for an operator (Halkyon Learn's ADR 0021, D34; null for anyone else) | signed in |
| `waiting` | with a waiting account's pass (Halkyon Learn's ADR 0021, D31): `look` (waiting, suspended, approved, which takes the pass, or unknown), `deleteAccount` (a new account never approved), `forget` | without a session |
| `instanceOperator` | `counts` (who waits, operates, has an account: D62), `waiting` (50 from an offset), `approve`, `refuse` (a new account, deleted), `accounts` (by the start of a handle, 50 from an offset), `suspend` (every session ending, the account waiting anew; never an operator), `operators`, `appoint` (by handle, approving one that waits), `takeBack` (never the last operator) | `identified` and `operator`, on the register |
| `instance` | `basics`: the app it serves, whether codes by mail, password sign-in and SimpleLogin are on, who may sign up (`SIGN_UP`), the source link, the oldest app build, where data and backups are, whether people may add assistants with their own keys | anyone |
| `assistant` | Halkyon Learn's ADR 0021, step 2: `offered`, the assistants granted to the caller that are set, with what goes where, their effort and whether they agreed, and the caller's budget with what is used of it (none on a member, D43); `agree(assistant)`, to what goes where, its provider as it is now (D40), and so for every assistant of that provider, those granted later too (D66); `ask(question)`, at an effort offered or the operator's default (D63), a stream: the answer's text as the model writes it, then the whole turn with why it stopped and its tokens, once counted (D35, D38); `figures`, the caller's own, month by month | `identified` |
| `assistantOperator` | `setUp`: the assistants set, without their keys, with their effort, and how many people have any; `grantees` (by the start of a handle, 50 from an offset) and `granteesOf(accounts)`: each person's assistants, budget and what they used; `grant(accounts, assistants, budget)`: each person the assistants they lack, the agreements of the others kept, and the budget (D58); `change(account, assistants, budget)`: exactly those; `revoke(account)`: every assistant and the budget; `figures(month)`: everyone's of a UTC month, per person and assistant | `identified` and `operator`, on the register |
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove` (what it shows goes, the handle stays, private again: Halkyon Learn's ADR 0020, D27): the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
| `sharedSpace` | spaces people share (Halkyon's ADR 0044, step 2), on the host's sync engine through its `SpaceGrants` (refused, `unavailable`, without one): `create(kind)`, the caller its Owner; `mine`; `members(space)`, by membership ID and public profile, never by account, those invited and waiting last; `invite(space, handle, role)`, the Owner's (or any member's where the space lets members invite), a member at once where the person has an account on this service, else invited until they first sign in there (D13, D17), counted as an answer about a handle; `setRole`, `remove`, `handOn`, `delete`, the Owner's; `leave`, the Owner naming who takes the space on, its only member deleting it instead. A space the caller isn't in can't be told from one that doesn't exist (`SharedSpaceNotFoundException`); Owner and Editor write in the engine, a Viewer reads | `identified` |
| `sharedSpaceKeys` | a shared space's data keys, each sealed on a device to one member (Halkyon's ADR 0044, D3, step 3), which the instance keeps and never opens: `memberKeys(space)`, the public keys of its members who have keys, by account, as the register hands them out, for a device to seal to and pin (D18); `keysNeeded(space)`, the keys the caller holds that members lack, for the Owner and Editors, none for a Viewer; `seal(space, keys)`, refused for a Viewer (`notAllowed`) and for anyone outside the space, the first key sealed to a member staying; `mine(space)`, those sealed to the caller, each naming its sealer. A member's keys go with their membership. A space the caller isn't in can't be told from one that doesn't exist | `identified` |
- 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; up to 3 valid at once for an address (a fourth voids the
oldest, so codes others ask for don't void the one its owner is typing); at most 3 an
hour per address and network, 10 per address from every network (no mailbox flooded)
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. A handle stays its person's alone until they go public
(Halkyon Learn's ADR 0020, D5: a handle fixed, or a display name, bio, link or
avatar): until then its page and `profiles.byHandle` answer as for a handle nobody
has, while the host's own code still finds whose it is (`personWithHandle`). Every
answer about a new handle tells whether an account has it, so they are counted
together (`countHandleCheck`, on the register: checking one, saving one, signing up,
setting a first password or first keys with one, and the host's own lookups, such as
Halkyon Learn's blocks), at most 30 an hour per person (or attempt, or code request)
and 60 per network
(`tooManyHandleChecks`); a person's own handle and what is no handle aren't
counted.
- SimpleLogin (Halkyon Learn's ADR 0020) is halkyon_oidc's sign-in (ADR 0023) with its
SimpleLogin preset, on the register alone, which holds the instance's SimpleLogin app
and exchanges every code; a member asks it to begin and to complete, and issues its own
session. The attempt (ten minutes, taken once) holds a nonce and the caller, its state
the app's and platform's marker; the ID token is checked by halkyon_oidc (RS256, the
issuer, the audience, the expiry, the nonce). Only SimpleLogin's ID is kept
(`halkyon_oidc_identity`, the provider `simplelogin`): one per account, each an
account's. Someone new's identity waits 30 minutes under the attempt's ID, with a
handle suggested from the name SimpleLogin shared (`handleFromName`: `anna_mueller`,
a number added where taken), which nothing keeps. Signing up (`signUp`, D18) takes
the identity and makes the account in one transaction: `identified`, linked, a
profile with the handle (refused as saving a profile refuses it) and the keys, the
password's own proof key (Ed25519) in place of SRP's verifier (D19). The password
opens the keys and signs nobody in, so such an account has no authenticator (`enrol`
refuses it `noPassword`). A sign-in into an account keeps its attempt ten minutes
more, the account its caller: it confirms a change the device asks for next (D20),
which takes it, so each sign-in serves one change. A change checks the sign-in before
the proof, and takes it with the change, so a wrong proof leaves it standing.
- Mending a broken link (Halkyon Learn's ADR 0020, 8 and D7): someone whose identity
waits as a newcomer's names their handle (whatever its case); the register answers the
keys wrapped under the account's recovery phrase and a nonce, which the device signs
with the phrase's proof key for `mendLink`, binding the attempt (`mendLinkDigest`), so
a proof relayed from another sign-in links nobody's identity but the device's own. The
new identity takes the old one's place, every session of the account ends, in every
service, and the sign-in stands ten minutes for a change (a lost password). Only an
account with keys and a SimpleLogin identity is mended (nobody adds SimpleLogin signed
out); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`
and the same refusal. At most 10 an hour for a handle and 30 per network.
- SimpleLogin's forwarding page (Halkyon Learn's ADR 0020; `addSimpleLoginCallbackRoute`,
on the register, on halkyon_oidc's `addOidcCallbackForwarding`, ADR 0023):
`/simplelogin-callback`, the one address the instance's SimpleLogin
app registers, as SimpleLogin takes only https addresses, each registered exactly. It
answers a 303 to the app that began the sign-in, with SimpleLogin's `code` (or its
`error`) and the `state`, and nothing else: the state is `<app>-<way back>.<rest>`, the
marker one of `simpleLoginReturns` (the loopback on Linux and Windows, `127.0.0.1` on a
port of each app's own, or the app's scheme, its ID, on Android, iOS and macOS;
`simpleLogin.begin` takes the way back as a `SimpleLoginReturn`), the rest 1 to 512
characters of base64url; anything else gets a page saying the sign-in can't go on,
repeating nothing it was given, in English or German. It keeps and logs nothing.
- **Sign-ups and operators** (Halkyon Learn's ADR 0021, D19 to D26 and D31 to D33), on
the register: `SIGN_UP` is `open` (unset), `approval` or `closed`. Closed, nobody new
signs up (`SignUpRefusedException`, `closed`), SimpleLogin's sign-up refused before
anything is counted, an address's once its code proved it (only that tells whether it
has an account, which still takes its password). With approval, a new account waits as
it is made (`waiting_account`), at most 10 at once (`tooManyWaiting`), with a matching
code of four words of BIP-39's English list (halkyon_core's). Every session in every
service is made by `issueSession`, which asks the register first: an account that
waits gets none, and a pass for that device is thrown in its place
(`AccountWaitingException`: the pass, the words, the handle, whether suspended),
before a member makes it a person. The register keeps the pass's SHA-256
(`waiting_pass`), 30 days, at most five per account. Approved, the pass says so once
and goes, and the person signs in once more as before, which opens the keys; a pass is
never worth a session. An operator is an account with the scope `operator` (on its
sessions too, as scopes are copied there), the first made by SQL (the ADR gives it),
others appointed in the app; an operator never waits. Suspending ends the account's
sessions everywhere and makes it wait anew, its data kept, deleted by its pass never:
deleting it takes the password and a code. The daily pruning deletes new accounts
unapproved after 14 days and forgets passes past their time
(`forgetUnapprovedSignUps`).
- **No anonymous accounts** (Halkyon Learn's ADR 0020, D25): every account has a way
in. An anonymous account left from before signs into nothing new and keeps no
password or SimpleLogin; the app deletes its own as it starts (`account.delete`), and
the pruning deletes the rest (`pruneAnonymousPeople`), whatever their age.
- **Per-network limits** (codes by mail, password sign-ins, SimpleLogin's sign-ins,
handle checks, proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the
connection's, or, for a connection from a proxy the operator trusts
(`TRUSTED_PROXIES`), the last address of `X-Forwarded-For`, the one the proxy saw
itself. Never the `Forwarded` header, which Caddy passes on as the visitor wrote it,
so never Serverpod's `remoteInfo`. An IPv6 visitor counts by their /64
(`networkKeyOf`), which one line or server holds whole.
- **Assistants** (Halkyon Learn's ADR 0021, points 4 to 8, D27 to D46): the operator's
language models, set in `deploy/assistants.env` (`assistantsFrom`, read as the server
starts, anything it can't use stopping it), each through halkyon_llm with the
operator's key; nobody has one until an operator grants it (`assistant_grant`), and
each person has one budget for all their assistants together (`assistant_budget`,
D58, D59): a limit per UTC day and month in money and in tokens, each optional,
money counting the assistants with prices, tokens every one. `ask` checks, in order:
at most `maxQuestionsAtOnce` (2) on their way per person (`tooManyAtOnce`), the grant
(`notGranted`), the agreement (`notAgreed`), the budget's month, then its day
(`limitReached`, with when it resets), then the question itself (`requestOf`:
`invalidEffort` for an effort above the operator's highest or where there is none to
choose, `malformed`, `imagesNotRead`, `tooLarge` past
`assistantQuestionMaxCharacters`, `assistantImageMaxBytes` or
`assistantQuestionMaxImages`). The effort is the question's or the operator's default
(`_EFFORT`, `_MAX_EFFORT`, D63), and the output is capped at that effort's ceiling
(halkyon_llm's `LlmEffort.outputCeiling`, 16,384 without one). Each
request carries the person's opaque ID (HMAC-SHA256 under `ASSISTANT_ID_KEY`,
`personIdOf`, D30), never their account. Once sent, the provider's reply is read to
its end whatever the app does and counted on a session of its own
(`assistant_figures`: requests, tokens and cost per person, assistant and UTC day),
before the last piece goes, so the next question is checked against it; the
provider's failure is logged with its kind and status, never a question. The figures
are kept for the month and the two before it (`forgetOldAssistantFigures`, daily),
and go with the account (`deletePerson`). Nothing of a question or an answer is kept.
- **The privacy page** (Halkyon Learn's ADR 0021, D39; `addPrivacyPageRoute`, on the
register): `/privacy`, made from the instance's settings, in English or German, no
JavaScript, no cookies: who may sign up, where data and backups are, what the module
keeps of a person (the account, sessions, IP addresses, the server's log), the
host's `privacyPagePoints`, each assistant with its model, host and whether it reads
images, what goes, what is counted, and whether people may add their own keys. Never
a key.
- Refusals are `PasswordRefusedException`, `EmailSignInRefusedException`,
`ProfileRefusedException`, `SignUpRefusedException`, `OperatorRefusedException` and
`AssistantRefusedException`,
each with a reason; `ProfileNotFoundException` where there is no profile;
`AccountWaitingException` in a session's place.
- For the host's own code, `package:halkyon_base_server/base.dart`: the configuration,
`tokenManagerBuilders` and `identityProviderBuilders`, the `identified` scope and
`grantIdentified`, the `instanceOperator` scope, `deletePerson`, the pruning
(`pruneAnonymousPeople`, `forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`,
`forgetUnconfirmedAuthenticators`, `forgetLapsedResets`, `forgetUnapprovedSignUps`),
the sign-up mode (`signUpModeOf`, `useSignUpMode` for tests), 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`,
`deliverSessionNotices`, `forgetPeopleTheRegisterForgot`, `issueSession`), the
assistants (`assistantsOf`, `useAssistants` and `useLlmClient` for tests,
`assistantUsageCounted`, `forgetOldAssistantFigures`), `privacyPage`,
`addPrivacyPageRoute`, 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, SimpleLogin
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`, `halkyon_oidc_identity`), the
handles and profiles; the others are its members and ask it. An anonymous account left
from before stays its service's own. 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) and each `INSTANCE_MEMBER_<NAME>` names (one each, so that every
service's compose file names its own, where one variable would be replaced by the
last; each address once) 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, 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). Adding 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:** a deletion on a member asks the register first whose account it is
(`knownAccounts`), and is refused while it can't be reached, deleting nothing, a
person still anonymous on the member too: the register may have linked them while
their becoming identified there failed (the app then deletes an anonymous account
left from before all the same, leaving it to the pruning). An account of the register's goes there,
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 account goes
from its 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.
## TOTP and recovery codes (ADR 0017)
The second factor, in `package:halkyon_base_server/base.dart`:
- `newTotpSecret()`: 160 random bits, shown to the person in RFC 4648 Base32
(`base32Encode`, `base32Decode`) through `otpauthUri(issuer:, account:, secret:)`, its
label and issuer escaped (a space as `%20`).
- `checkTotp(secret:, code:, now:, lastAcceptedStep:)`: RFC 6238 with HMAC-SHA1, 6 digits,
30-second steps, one step either side of now. The account's last accepted step is a
required argument, and a code from that step or an earlier one is refused (`reused`),
so no code is accepted twice; `TotpAccepted.step` is the step to keep before answering.
Refusals: `malformed`, `wrong`, `reused`. `totpCode` makes a code (SHA-256 and SHA-512
for RFC 6238's vectors).
- `RecoveryCodes.generate(key)`: ten codes of eight Crockford characters (`K7QX-M2PD`, as
email sign-in's codes), shown once, kept as HMAC-SHA256 under the instance's key
(`recoveryCodeKey`, at least 32 characters, kept outside the database: a code's 40 bits
in a leaked database give nothing to guess against without it); `useRecoveryCode(typed,
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, or for an account that signs in with SimpleLogin the password's proof key in its
place, 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, and a
session alone sets none (ADR 0017's fourth reading): an account with an address proves
it anew by a code mailed to it for this (`firstPassword`). Anyone else, an account
that signs in with SimpleLogin alone or one left from before with an anonymous account
or a GitHub ID among them (Halkyon Learn's ADR 0020, D36), sets none
(`wrongCredentials`, 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
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
(`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,
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
address nobody has becomes a new identified account, an account with the address and
no password takes it (keeping its ID); refused (`passwordExists`) for an address whose
account has a password. The session it gives sets up the authenticator.
- **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. A setup never finished (never a confirmed authenticator) signs in with a code
mailed to the address it began with in its place (`request`, `code`), which calls off
a reset of the account waiting; any other account takes no code by mail there and
leaves it unused. SRP's identity I is `passwordIdentity`: the challenge's ID, the
instance's base URL the device addressed (`instanceUrl` on every proving call, written
alike on both sides by `canonicalInstanceUrl`: in production it must be the server's
own public scheme, host and port at the root, `publicInstanceUrlOf`, else
`otherInstance`, so an instance served under a path takes no password sign-in; in
development any, as an emulator reaches the machine as 10.0.2.2), what the proof is for
and the digest of what it sets (`PasswordProofPurpose`; signing in and an
authenticator's reset each its own, with none), so a hostile instance relaying a
sign-in or a phrase's proof here gets nothing taken, nor one beside it on the same host;
a member forwards its own base URL, and the phrase's proof for a new password signs
that URL and that password's digest (`newPasswordDigest`). 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 network an hour (5 proofs of the phrase),
30 per network, and, from every network together, 30 proofs checked per address
(`proofsPerAddress`: every password and phrase proof, recorded before it is checked
whatever its outcome, so a right one counts as a wrong one and challenges begun ahead
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 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. The proof is bound to
what it approves: SRP's identity carries the purpose and the digest of what is set
(`newPasswordDigest` of the new password, `newPhraseDigest` of the phrase's wrapping
and proof key, `authenticatorDigest` of the authenticator confirmed or removed; none
for new recovery codes, calling resets off and deleting), which the register works out
from the call itself, so whoever stands between swaps nothing in and a proof made for
one change makes no other. 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. A session ended so
warns its owner: the register keeps the moment (`account_password`'s
`sessionEndedForProofsAt`; a member's ended session tells it through
`noteSessionEndedForProofs`, a register out of reach leaving the owner unwarned), which
`account.signInWays` and the next `passwordSignIn.complete` carry until the owner
dismisses it (`password.dismissSessionEndedWarning`, the session alone: it hides a
warning) or sets a new password.
- **Authenticators:** a secret made on the register, shown once as an `otpauth://` URI
(issuer: the account domain); confirmed by a code from it and the owner's proof, so a
session alone adds no second factor: while the account has a confirmed one, the
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
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 at sign-in or in the phrase's reset (never the owner's
proofs while signed in) counts, per account and way it came (`SecondFactorPath`:
after the password, after the phrase, each with its own waits), 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, 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; the sign-in that carries it
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,
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
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. A reset takes effect once the owner had 72 hours
outside hold-outs (codes waiting, the address's ceiling of proofs filled), and 14
days after it was asked at the latest, held out or not (`resetWaitsAtMost`): each
hold-out that begins before it takes effect moves it on by the time it holds the owner
out and no hold-out counted before did, never past that (`pushOutResets`,
`account_resets.dart`, the row's `heldOutUntil`; a refusal at the address's ceiling
pushes after it has answered, on a session of its own, so its time tells nothing); a
reset that took effect and wasn't carried out lapses a week later
(`resetLapsesAfter`), and none outlives 30 days after it was asked
(`resetLivesAtMost`), both pruned by `forgetLapsedResets`; `SignInWays` tells
signed-in devices when each waiting reset takes effect, and `password.callOffResets`
calls them off for the owner's proof.
- **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 and a code, bound
to the base URL the device addressed and made for deleting (`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 setup's password replaced too); 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 in the background, and hourly until it confirms, `deliverSessionNotices`, as deletions are told): it names a moment, so a
session made since stays, and the session that asked for a change. A member answering
one (`/halkyon-base/member/sessions-ended`) ends its sessions made before the moment,
taken by its own clock (the notice carries the register's, `sentAt`, so the clocks'
offset drops out); a member signing someone in after a reset ends every session of the
account it holds made before the register's moment or before now, whichever is later,
then makes the new one (`issueSessionAfter`), waiting for nothing, and keeps it when
the register's notice of that moment comes, however late (remembered in memory for a
day). The owner's warning of a session ended for its proofs is
dismissed only by a session made since (`signedInBefore`). A refusal for a reset not due says when it takes effect
(`takesEffectAt`).
- **Older ways:** GitHub signs nobody in (Halkyon Learn's ADR 0020, D36, ending ADR
0017's D2 sooner): the module has no GitHub endpoint and reads no GitHub settings. The
IDs left in Serverpod's `serverpod_auth_idp_github_account` table, which stays as
Serverpod's migrations make it, go with their accounts (`ON DELETE CASCADE`); an
operator deletes them before with the SQL of D36.
## 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/halkyon-days/halkyon',
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'),
),
privacyPagePoints: [ // what the privacy page says of the app's data
(
english: 'Your to-dos are kept to sync them between your devices.',
german: 'Deine Aufgaben werden aufbewahrt, um sie abzugleichen.',
),
],
// 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)`; on the register, SimpleLogin's forwarding page:
`addSimpleLoginCallbackRoute(pod)` and the privacy page: `addPrivacyPageRoute(pod)`;
and the profile pages: `addProfilePageRoute(pod)`, after any literal routes of its
own, the privacy page's too.
Then schedule the pruning in a future call of the host's own: daily
`pruneAnonymousPeople`, `forgetLapsedResets`, `forgetOldAssistantFigures` and `forgetPeopleTheRegisterForgot` (a member's), hourly
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`,
`forgetUnconfirmedAuthenticators`, `forgetUnfinishedSimpleLogins` (the
register's), `deliverDeletionNotices` and `deliverSessionNotices` (the register's). Each marked as one mode's does nothing in
the other.
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
after the person's sessions ended and their shared spaces were handed on (each to the
Editor who joined first, else the Viewer, Halkyon's ADR 0044, D11) or deleted with no
other member, 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.
A host keeping spaces people share hands over its sync engine (`HalkyonBase.spaces`, a
`SpaceGrants` on `OfflineSyncSpaces`: Halkyon Learn's `SyncSpaceGrants`); every call
runs in the module's transaction.
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 email `emailSignInPepper` and `emailSmtpHost`,
`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); for SimpleLogin, on the register, `simpleLoginClientId` and
`simpleLoginClientSecret` (the instance's SimpleLogin app, both or none), with
`signInDecoyKey` alone of the three (`addInstanceRoutes` stops a server with the app
and without it: mending answers decoys); 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). The environment: `ACCOUNT_REGISTER_URL` and `INSTANCE_MEMBERS` (above);
`ACCOUNT_DOMAIN`, the domain handles are on, which can never change once anyone follows
one; `SIMPLELOGIN_CALLBACK_URL`, the https address the SimpleLogin app registered, where
it isn't the register's own page (`/simplelogin-callback` on its web server's public
address; development names learn.halkyon.eu's); `SIGN_IN_WAYS`, the ways in the
register offers of those set up, `simplelogin`, `password` or both (unset: every one), a
name it doesn't know stopping the server (`addInstanceRoutes`); `SIGN_UP`, who may make
an account, `open` (unset), `approval` or `closed`, anything else stopping the server;
`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;
the assistants' (`ASSISTANTS`, `ASSISTANT_ID_KEY`, `ASSISTANT_<ID>_…`,
`ASSISTANT_CURRENCY`, `ASSISTANT_OWN_KEYS`), from `deploy/assistants.env`, its
example commented, on the register alone (a member offers none);
`TRUSTED_PROXIES`, the addresses of the reverse proxies in front of the server (Caddy's
on its compose network, which Halkyon Learn's `deploy/compose.learn.yaml` sets), separated by
commas or spaces: without, every visitor behind the proxy shares each per-network limit;
anything but addresses stops the server (`addInstanceRoutes`).
## 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`. 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
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.