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:
parent
ae57cbe864
commit
4f7b123ad4
6 changed files with 136 additions and 32 deletions
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue