docs(auth): what building the apps' side settled (ADR 0017)

ADR 0017's step-3 section: codes by mail sign nobody in, the ways in
for accounts without a working password, changes while signed in,
sessions ended in every service, the apps' screens (the QR package and
the alternatives considered), the keys in the keyring, anonymous
people's keys waiting for step 4, and libsodium on devices (results
copied before they leave an isolate; a C compiler and make for every
build). The module's and halkyon_app's READMEs and the AGENTS files say
the same; the snap's build installs make for sodium's build hook.

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 19:34:02 +02:00
commit 4f7b123ad4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 136 additions and 32 deletions

View file

@ -30,7 +30,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
## Flutter and Serverpod ## Flutter and Serverpod
Each app is a Flutter app (frontend) backed by a Serverpod server (backend). Always build an app's backend with Serverpod. Build for multiple users, with Serverpod's built-in authentication, which a server gets from `halkyon_base` (Learn's `server.dart` configures the module; Todo's scaffold still has Serverpod's own email sign-in). Each app is a Flutter app (frontend) backed by a Serverpod server (backend). Always build an app's backend with Serverpod. Build for multiple users, with Serverpod's built-in authentication, which a server gets from `halkyon_base` (Learn's and Todo's `server.dart` configure the module; people sign in with a password and TOTP, ADR 0017, through halkyon_app's screens).
The user starts an app's server and Flutter app with `serverpod start` in that app's server package (Learn's: `apps/learn/halkyon_learn_server`). There is no need to check if the server is running: make the changes and call the `serverpod` MCP tools as needed. If the server is not running, an informative error message will be received from the MCP server. Then STOP and ask the user to start it. NEVER start the server yourself. The Flutter app is started along with it, or can be launched from the MCP tool `spawn_flutter_app`. The user starts an app's server and Flutter app with `serverpod start` in that app's server package (Learn's: `apps/learn/halkyon_learn_server`). There is no need to check if the server is running: make the changes and call the `serverpod` MCP tools as needed. If the server is not running, an informative error message will be received from the MCP server. Then STOP and ask the user to start it. NEVER start the server yourself. The Flutter app is started along with it, or can be launched from the MCP tool `spawn_flutter_app`.

View file

@ -73,13 +73,13 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
## Rules the code relies on ## Rules the code relies on
- **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, a password) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address, one of each (`account.signInWays`, which also says whether they have a password and a confirmed authenticator); the Profile tab lists their ways in and adds the missing one. - **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; the server's side built 2026-10-20, the app's screens to come; halkyon_base's README): 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()`). - **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 change of password ends the account's other sessions and a reset all of them, on every service of the instance (`sessions_ended_notice`).
- **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.
- **Switching devices' person** (halkyon_app's `switchToGitHub`, `switchToEmail`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so. - **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail each time, no password: eight Crockford characters (halkyon_base's `short_code.dart`, shared with connect codes), 15 minutes, 3 tries, a newer one replacing the older; at most 3 codes an hour per address and 10 per network. `emailCode.sendCode` answers alike for every address; `emailCode.verifyCode` (`requireLogin`) makes a new address the caller's (as linking GitHub does) or, for another person's, returns a switch token that `emailSignIn.signIn` (without a session) turns into a session for them. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would. - **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished and the resets. Eight Crockford characters (halkyon_base's `short_code.dart`, shared with connect codes), 15 minutes, 3 tries, a newer one replacing the older; at most 3 codes an hour per address and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_app): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub." - **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_app): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc. - **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment. - **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
@ -121,6 +121,6 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them. - **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
- **Secrets** live in `apps/learn/halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in. Never change `emailSignInPepper` either: every address's hash would be lost, and with it those people's way in. Never change `totpKey` (every authenticator stops working) or `recoveryCodeKey` (every recovery code), nor `signInDecoyKey` without need (it shows which addresses were unused to whoever watched). - **Secrets** live in `apps/learn/halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in. Never change `emailSignInPepper` either: every address's hash would be lost, and with it those people's way in. Never change `totpKey` (every authenticator stops working) or `recoveryCodeKey` (every recovery code), nor `signInDecoyKey` without need (it shows which addresses were unused to whoever watched).
- **Checks on a device** live in `apps/learn/halkyon_learn_flutter/integration_test/` and run by hand, never in `tool/check.dart` or CI: `flutter test integration_test/<file> -d <device>`. `pdf_engine_test.dart` checks the real PDF engine (on the Linux desktop too), `youtube_player_test.dart` the embedded YouTube player (Android), `jellyfin_player_test.dart` the video player streaming from a Jellyfin server (`--dart-define`s for the server and the person, in the file), `japanese_analyser_test.dart` MeCab as its build hook compiles it, with the curator's IPADIC pack and an instance's JMdict export (`--dart-define`s for both). Never on the emulator from an agent session: it takes the editor down with it. - **Checks on a device** live in `apps/learn/halkyon_learn_flutter/integration_test/` and run by hand, never in `tool/check.dart` or CI: `flutter test integration_test/<file> -d <device>`. `pdf_engine_test.dart` checks the real PDF engine (on the Linux desktop too), `youtube_player_test.dart` the embedded YouTube player (Android), `jellyfin_player_test.dart` the video player streaming from a Jellyfin server (`--dart-define`s for the server and the person, in the file), `japanese_analyser_test.dart` MeCab as its build hook compiles it, with the curator's IPADIC pack and an instance's JMdict export (`--dart-define`s for both). Never on the emulator from an agent session: it takes the editor down with it.
- **Every build needs a C++ compiler**, `flutter test` too: `mecab_for_dart`'s build hook compiles MeCab for the platform (ADR 0012; on Linux g++ or clang, on Android the NDK's, on Windows Visual Studio's, on macOS and iOS Xcode's). The snap's build installs clang; CI's runner needs one too. - **Every build needs a C++ compiler and make**, `flutter test` too: `mecab_for_dart`'s build hook compiles MeCab for the platform (ADR 0012; on Linux g++ or clang, on Android the NDK's, on Windows Visual Studio's, on macOS and iOS Xcode's), and `sodium`'s compiles libsodium from the source it ships with its configure script and make (ADR 0017, halkyon_keys and halkyon_app). The snap's build installs clang and make; CI's runner needs both too.
- **Windows builds need Developer Mode**: building makes symbolic links (Flutter links each plugin's native code into the build, and pdfrx's build does too), which Windows lets ordinary users make only in Developer Mode. PDFium itself comes with the build as a Dart native asset. - **Windows builds need Developer Mode**: building makes symbolic links (Flutter links each plugin's native code into the build, and pdfrx's build does too), which Windows lets ordinary users make only in Developer Mode. PDFium itself comes with the build as a Dart native asset.
- **Linux builds need** `libwebkit2gtk-4.1-dev`, `libsecret-1-dev`, for sounds (`audioplayers`) `libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, and for videos (`media_kit_video`) `libmpv-dev`: the video player links libmpv, so the app doesn't start where `libmpv.so.2` is missing (the snap brings Ubuntu's `libmpv2` with ffmpeg's libraries, and its `cleanup` part removes every staged file the GNOME runtime or core24 holds at the same path, since older copies of cairo, pango or librsvg would hide the runtime's from its GTK; staging never runs packages' install scripts, so the snap makes the `update-alternatives` links for BLAS and LAPACK itself); that build also downloads mimalloc's source from GitHub (checked against its MD5) and links it in. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`. - **Linux builds need** `libwebkit2gtk-4.1-dev`, `libsecret-1-dev`, for sounds (`audioplayers`) `libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, and for videos (`media_kit_video`) `libmpv-dev`: the video player links libmpv, so the app doesn't start where `libmpv.so.2` is missing (the snap brings Ubuntu's `libmpv2` with ffmpeg's libraries, and its `cleanup` part removes every staged file the GNOME runtime or core24 holds at the same path, since older copies of cairo, pango or librsvg would hide the runtime's from its GTK; staging never runs packages' install scripts, so the snap makes the `update-alternatives` links for BLAS and LAPACK itself); that build also downloads mimalloc's source from GitHub (checked against its MD5) and links it in. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`.

View file

@ -156,12 +156,65 @@ through `AccountRegister`, the session being theirs.
- **Email codes** still sign in (until step 3), but not into an account with a password - **Email codes** still sign in (until step 3), but not into an account with a password
that had a confirmed authenticator (`usePassword`), which a code alone would bypass. that had a confirmed authenticator (`usePassword`), which a code alone would bypass.
Step 3 (email codes go): sign-up keeps `password.set` with its code; `emailCode.verifyCode` ## Settled in building the apps' side (20 Oct 2026, step 3)
and `emailSignIn.signIn` go, and with them the switch token; accounts with an address but
no password, and accounts whose setup never finished, need a way in (a mailed code and a On branch `password-sign-in`, for build 22 (`minimumAppBuild` 22 in Learn; Todo, never
new password, as the account reset, or a password plus a mailed code until an released, stays at 1), after the second reading.
authenticator is confirmed); a member's sessions should end too when the register resets
an account; and a signed-in change of password (with the keys at hand) is still missing. - **Codes by mail sign nobody in** (D4): `emailCode.verifyCode`, `emailSignIn.signIn`
and the switch token went, with the register's operations for them. `emailCode.sendCode`
stays, without a session, as what proves an address: for `password.set`, a sign-up,
a setup never finished and the resets (`accountRecovery.sendCode` went).
- **Ways in for accounts without a working password:** `passwordSignIn.signUp` (a code
and a setup, no session) makes a new account at an address nobody has, or gives the
account with that address and no password (codes by mail before; no keys exist, so
nothing waits) its password, keeping its ID. A setup never finished signs in with its
password and a code mailed to the address the sign-in began with, until an
authenticator is confirmed, and that calls off an account reset waiting (a reset of
the account waiting no longer counts as having had an authenticator, so its owner
isn't locked out). Never for an account that had a confirmed authenticator.
- **Changing while signed in:** the password (`password.beginChange`, `password.change`:
the old password's SRP proof, a TOTP or recovery code, the same keys rewrapped on the
device; every other session ends) and the recovery phrase (`password.setRecoveryKeys`:
the keys wrapped under a new phrase, for a code). A session alone changes neither.
- **Sessions end in every service:** a change of password ends every other session of
the account, a reset of the password or the account all of them, on the register and
on every member, by a notice kept until each confirms (`sessions_ended_notice`, a new
table; hourly with the deletions' notices). A notice names a moment, so sessions made
since stay: a member signing someone in after a reset makes the session once its clock
is past it.
- **A reset not due says when** it takes effect (`takesEffectAt` on the refusal), and the
basics say when GitHub sign-in ends (`gitHubSignInEnds`), which apps show.
- **The apps** (halkyon_app, Learn and Todo): signed out, Sign in and Create an account
(and GitHub, its end date beneath); signing in asks the address and password (Argon2id
on the device, about a second), then a code from the authenticator or a recovery code;
M2 is checked and the keys opened before the session is put in place. An account
never finished is mailed a code; one whose authenticators' reset took effect signs in
without a code; both then set up an authenticator with the session held back, and put
it in place only after. Creating an account: address, password twice, the recovery
phrase shown once (copy, save as a file) and three random words typed back, then the
mailed code (nothing reaches the instance before), the authenticator (a QR code of the
`otpauth://` URI, drawn by the app with pub.dev's `qr`, BSD-3-Clause, pinned; its key
as text; on phones Open in authenticator app; considered: `qr_flutter` and
`pretty_qr_code`, Flutter widgets on the same encoder) and the ten recovery codes
(copy, save). Getting back in: with the phrase (a new password, the keys staying), a
reset of the account asked for by a code (72 hours), and finishing it (a new code; new
keys and a new phrase, or with the phrase the old keys rewrapped, as the account's
reset keeps them for someone who lost both password and authenticator). The account
part (`AccountSecuritySection`): setting up a password (anonymous people, GitHub's, an
account with only an address), finishing the setup, the authenticators (adding one
takes a code from one before), new recovery codes, changing the password, a new
phrase; GitHub's end date.
- **The keys on the device:** unlocked after signing in, kept in the system's keyring
per instance and person (flutter_secure_storage), gone once another person signs in or
nobody, and with the instance's removal from the device. Anonymous people's device keys
wait for step 4: nothing is encrypted before it.
- **libsodium on devices:** sodium's results (salts, public keys, cipher texts,
signatures) are views of native memory its finalizers free once the isolate that made
them ends; each step runs on an isolate of its own, so whatever crosses back is copied
first (a view read freed memory and crashed the process). Every build of an app needs a
C compiler and make for sodium's build hook (Linux, Android's NDK, Xcode, Visual
Studio; the snap's build and CI's runner too).
## Consequences ## Consequences

View file

@ -49,11 +49,32 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
account deletes it, so asks first) and deleting it (after asking), for an account deletes it, so asks first) and deleting it (after asking), for an
app's settings, in the app's words where only it can say them. app's settings, in the app's words where only it can say them.
- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`; - **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
`ServerpodAuthGateway` signs in anonymously, with GitHub (the system `ServerpodAuthGateway` signs in anonymously and with GitHub (the system
browser and a loopback address on Linux and Windows, `LoopbackWebAuth`) and browser and a loopback address on Linux and Windows, `LoopbackWebAuth`),
with a code by mail, through the instance's `halkyon_base`; `SessionBloc`, through the instance's `halkyon_base`, and puts a session granted elsewhere
`GitHubSignInBloc`, `EmailSignInBloc` (with `showEmailSignInSheet`) and in place (`useSession`); `SessionBloc`, `GitHubSignInBloc` and
`SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`. `SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`.
- **A password and TOTP** (`lib/src/password/`, ADR 0017): `PasswordRepository`
on a `PasswordGateway` (`ServerpodPasswordGateway`), an `AccountCrypto`
(`SodiumAccountCrypto`: halkyon_keys and SRP's client half, each step on an
isolate of its own, whose results are copied before they cross back:
sodium's are views of memory freed with the isolate that made them) and an
`AccountKeyStore` (`SecureAccountKeyStore`: the account's unlocked keys in
the system's keyring, per instance and person, gone once another person
signs in or nobody). Signing in (SRP's M2 checked and the keys opened before
anything is trusted), signing up (the recovery phrase shown once and three of
its words typed back before anything reaches the instance), a setup never
finished, the authenticators (QR codes drawn with pub.dev's `qr`), recovery
codes, changing the password, a new phrase, and getting back in. The
screens: the start screen's Sign in and Create an account,
`showPasswordSignInSheet`, `showAccountSetupSheet`,
`showAccountRecoverySheet`, and `AccountSecuritySection` for an app's
account part. Anonymous people get no keys until something is encrypted
(step 4). Every build of an app needs a C compiler and make: sodium's build
hook compiles libsodium.
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made
where the person chooses (a recovery phrase, recovery codes, an app's
exports).
- **Sync** (`lib/src/sync/`): `SyncService` (once at start, shortly after - **Sync** (`lib/src/sync/`): `SyncService` (once at start, shortly after
local changes, on resume and every few minutes, one sync at a time, counting local changes, on resume and every few minutes, one sync at a time, counting
the changes not on the server yet, `PendingChangesStore`), `AppCompatibility` the changes not on the server yet, `PendingChangesStore`), `AppCompatibility`
@ -63,7 +84,8 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
- **A client of `halkyon_base` alone** (`BaseClient`): to ask an instance - **A client of `halkyon_base` alone** (`BaseClient`): to ask an instance
about itself, and to sign in as another person than the app's client holds. about itself, and to sign in as another person than the app's client holds.
- **Testing** (`package:halkyon_app/testing.dart`): fakes of every boundary - **Testing** (`package:halkyon_app/testing.dart`): fakes of every boundary
(`FakeAuthGateway`, `FakeInstanceChecker`, the stores in memory), a frame (`FakeAuthGateway`, `FakePasswordGateway`, `FakeAccountCrypto`,
`FakeInstanceChecker`, the stores in memory), a frame
(`testFrame`), `testApp`, and `TestAppDependencies`. (`testFrame`), `testApp`, and `TestAppDependencies`.
## Using it in an app ## Using it in an app
@ -88,8 +110,12 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
say: what the first start's instance keeps of the person's, what a switch say: what the first start's instance keeps of the person's, what a switch
to another account leaves behind, and where GitHub is added later. to another account leaves behind, and where GitHub is added later.
5. **The dependencies**: the app's composition root implements 5. **The dependencies**: the app's composition root implements
`AppDependencies<P>` (`serverName`, `auth`, `signedOut`, `sourceCodeUrl`, `AppDependencies<P>` (`serverName`, `auth`, `signedOut`, `passwords`,
`openPerson`, `dispose`), its person's dependencies `Disposable`. Its `auth` is an `fileSaver`, `sourceCodeUrl`, `openPerson`, `dispose`), its person's
dependencies `Disposable`. Its `passwords` is a `PasswordRepository` on
`ServerpodPasswordGateway(client, base: client.modules.base)`,
`SodiumAccountCrypto()`, `SecureAccountKeyStore(storage.keysOf(instance))`,
the auth gateway and the app's `SignInAddresses`. Its `auth` is an
`AuthRepository` on `ServerpodAuthGateway(client, base: `AuthRepository` on `ServerpodAuthGateway(client, base:
client.modules.base, callbackScheme: …, appName: …)`; the scheme is the client.modules.base, callbackScheme: …, appName: …)`; the scheme is the
app's own (`app.halkyon.learn`), registered with each platform and in the app's own (`app.halkyon.learn`), registered with each platform and in the

View file

@ -15,16 +15,15 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| Endpoint | Methods | Who may call | | Endpoint | Methods | Who may call |
|---|---|---| |---|---|---|
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone | | `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one | signed in | | `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `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, for a code | 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 | | `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. `signUp`: an account at the address a mailed code proves, with a password; 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 | | `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `sendCode`, `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session | | `accountRecovery` | `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 | | `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 | | `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 | | `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `emailSignIn` | `signIn`: a session for the person a switch token names | without a session |
| `account` | `delete`, `signInWays` | signed in | | `account` | `delete`, `signInWays` | signed in |
| `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 | | `instance` | `basics`: the app it serves, GitHub's client ID if any and when GitHub sign-in ends, whether codes by mail 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` | | `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 | | `profiles` | `byHandle`: anyone's public profile, whatever the handle's case | signed in |
@ -182,9 +181,20 @@ Built on the three parts above, on the register (members forward every step thro
confirmed; until then a new setup replaces it, never once one was, even while a reset 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 leaves the account without one (a new password comes through the recovery phrase or
the account's reset alone). 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 and the resets. 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, - **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 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 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 the challenge's ID. An address nobody has (or whose
account has no password) gets a salt and verifier from `signInDecoyKey` and the 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 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 password, an unknown address and a wrong, used or missing code are all
@ -203,13 +213,26 @@ Built on the three parts above, on the register (members forward every step thro
account's Ed25519 signature over a nonce (`keyProofMessage`) and a code set a new 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 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 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 password, new keys, no authenticators.
sessions on the register's server. - **Changing while signed in:** `password.beginChange` and `password.change` take the old
password's SRP proof, a TOTP or recovery code and the same keys wrapped under the new
password; `password.setRecoveryKeys` replaces the phrase's wrapping for a code. A
session alone changes neither.
- **Sessions end everywhere:** a change of password ends every other session of the
account; 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 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;
a member signing someone in after a reset ends its own first and makes the new session
only once its clock is past the moment (`issueSessionAfter`). A refusal for a reset not
due says when it takes effect (`takesEffectAt`).
- **Older ways:** an account that signs in with a password (it had a confirmed - **Older ways:** an account that signs in with a password (it had a confirmed
authenticator, which a reset may have taken since: `passwordOnly` in its ways in) no authenticator, which a reset may have taken since: `passwordOnly` in its ways in) no
longer signs in by a mailed code (`usePassword`) or GitHub (`usePassword`); where passwords are offered, no new GitHub account is linked longer signs in with GitHub (`usePassword`); where passwords are offered, no new GitHub
(`deprecated`); from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in (`ended`) and account is linked (`deprecated`); from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in
`forgetGitHubAccounts` deletes every GitHub ID. (`ended`), the basics stop naming the day (`gitHubSignInEnds`, which apps show until
then) and `forgetGitHubAccounts` deletes every GitHub ID.
## Taking it into a host ## Taking it into a host

View file

@ -117,11 +117,13 @@ parts:
# runtime brings the same GStreamer (1.24) to run. The video player # runtime brings the same GStreamer (1.24) to run. The video player
# (media_kit, for Jellyfin) links libmpv, which neither has: the Snap # (media_kit, for Jellyfin) links libmpv, which neither has: the Snap
# brings Ubuntu's libmpv2 with what it needs (ffmpeg's libraries), or the # brings Ubuntu's libmpv2 with what it needs (ffmpeg's libraries), or the
# app wouldn't start. # app wouldn't start. sodium's build hook compiles libsodium with its
# configure script and make (ADR 0017), with the clang above.
build-packages: build-packages:
- libgstreamer1.0-dev - libgstreamer1.0-dev
- libgstreamer-plugins-base1.0-dev - libgstreamer-plugins-base1.0-dev
- libmpv-dev - libmpv-dev
- make
stage-packages: stage-packages:
- libmpv2 - libmpv2
override-build: | override-build: |