feat(crypto): SRP-6a, the register's half and the device's

ADR 0017's step 1. Generic over group and hash (SrpGroup); production
takes RFC 5054's 3072-bit group with SHA-256. k = H(N | PAD(g)),
u = H(PAD(A) | PAD(B)), K = H(PAD(S)),
M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K),
M2 = H(PAD(A) | M1 | K). A and B must lie in 1..N-1 and u must not be
0; a and b are 256 bits from Random.secure(); proofs are compared in
constant time. x comes from the caller (halkyon_keys makes it); RFC
5054's own x is there for the vectors only.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 12:01:52 +02:00
commit bc499713b8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 1369 additions and 27 deletions

View file

@ -1,6 +1,7 @@
# The Docker build context is the repository root. Nothing secret, local or bulky
# goes into it: Learn's server's and curator's Dockerfiles copy their whole package
# folders (and the server the module it builds on).
# folders (and the server the module it builds on, with the module's client, which the
# module's tests depend on).
# Secrets and local state
**/config/passwords.yaml
@ -26,7 +27,6 @@ apps/learn/halkyon_learn_client/
apps/learn/tool/
apps/todo/
packages/halkyon_app/
packages/halkyon_base_client/
packages/halkyon_core/
packages/halkyon_interop/
packages/halkyon_ui/

View file

@ -25,7 +25,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically), and **keep their type prefix** (`feat`, `fix`, `perf`, `refactor`, `docs`, `build`, `chore`, a `!` or a `BREAKING CHANGE:` line for what breaks): releases work out their versions from them (Learn's `AGENTS.md`, *Releases*).
- **Servers are named in prose by the domain they serve** (learn.halkyon.eu's server, the halkyon.app server), which says what a reader needs; the SSH aliases and the CI runner's label keep the servers' own names (`sahaquiel-deploy`, `ramiel-fdroid`, `sahaquiel:host`).
- **Pushes** pass the checks first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/pre-push .git/hooks/pre-push`): `tool/pre-push` first refuses a push to `main` that brings a commit not signed by a key in `deploy/allowed-signers` (`tool/check_signatures.sh`, the deploy's own rule), so it fails on the laptop rather than after CI, then runs `tool/check.dart`. CI runs the checks again, and a green `main` deploys learn.halkyon.eu (see *CI and deploys*), so an unsigned commit on `main` blocks every deploy after it.
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file: Learn's and Todo's servers and apps, Learn's curator and every shared package include it; the generated client packages keep their own. Every `// ignore:` gets a comment saying why (`document_ignores`). Each server package switches off the four rules for Dart v3.13's constructor syntax (`use_primary_constructors`, `use_declaring_parameters`, `empty_container_bodies`, `unnecessary_type_name_in_constructor`): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file: Learn's and Todo's servers and apps, Learn's curator and every shared package include it; the generated client packages keep their own, but for the module's, which includes it for its SRP half and leaves `lib/src/protocol/` out. Every `// ignore:` gets a comment saying why (`document_ignores`). Each server package switches off the four rules for Dart v3.13's constructor syntax (`use_primary_constructors`, `use_declaring_parameters`, `empty_container_bodies`, `unnecessary_type_name_in_constructor`): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
## Flutter and Serverpod
@ -47,7 +47,7 @@ ALWAYS use the MCP server instead of the command line. Use the MCP server to:
The `serverpod` MCP tools come from `.mcp.json`: `serverpod mcp-server`, a bridge to the running `serverpod start` of one app's server, the one its `--server-dir` names (each app's `AGENTS.md` gives its own). They load when a session starts; if they are missing, the server wasn't running or the session predates `.mcp.json`. Like all agent configuration, `.mcp.json` is git-ignored. A session works with one app's server at a time; work on another app's server package with the CLI below, in a worktree if that app's `serverpod start` runs.
NEVER edit generated code. Each server's `lib/src/generated/` directory and its whole client package (`apps/<app>/halkyon_<app>_client`, `packages/halkyon_base_client`) are rewritten by the code generator. Change the `.spy.yaml` models, the endpoints, or the server's `lib/server.dart` instead.
NEVER edit generated code. Each server's `lib/src/generated/` directory and its whole client package (`apps/<app>/halkyon_<app>_client`, `packages/halkyon_base_client`) are rewritten by the code generator, but for the module client's SRP half, written by hand (`lib/srp.dart`, `lib/src/srp/`, `test/`): the generator writes only a client's `lib/src/protocol/` (checked with v4.0.4). Change the `.spy.yaml` models, the endpoints, or the server's `lib/server.dart` instead.
Migrations are a narrow exception: the `migration.sql` of a generated migration MAY be edited by hand when the generated SQL would lose data — to add a data transformation, or to reach a destructive change through non-destructive steps. Never touch the other files in the migration directory, and keep the schema the SQL ends up with identical to `definition.sql` — new databases are created from that file and never run `migration.sql`. A module's tables are part of each server's migrations that includes it; moving a table into a module unchanged needs no SQL but the version rows (`create-migration --empty`, ADR 0014).

View file

@ -10,7 +10,7 @@ The repository's packages carry different licences, each package's full text in
| `apps/learn/halkyon_learn_server` | Server Side Public License, version 1 | `SSPL-1.0` |
| `apps/learn/halkyon_learn_curator`, turning dictionary downloads into dictionary files | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_base_server`, the Serverpod module every Halkyon server includes | Server Side Public License, version 1 | `SSPL-1.0` |
| `packages/halkyon_base_client`, generated from the module's models | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_base_client`, generated from the module's models, with SRP's client half | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_app`, `packages/halkyon_ui` | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_core`, `packages/halkyon_interop` | GNU Affero General Public License, version 3 or any later version, as halkyon_shared, where they began, licensed them | `AGPL-3.0-or-later` |
| `apps/todo/halkyon_todo_flutter`, Todo's app | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |

View file

@ -65,7 +65,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **CI** checks Learn with every other package (root AGENTS.md, *CI and deploys*): its server's, curator's and app's tests through Melos, its server's generated code through `generate`.
- **A green `main` deploys learn.halkyon.eu** (paused until the hackathon's judging ends on 22 Oct 2026: the judges' v0.17.0 uses the instance, which stays at v0.17.0 meanwhile, while `main` moves on and is tested locally; CI's deploy job is `if: false` until then, and no release goes out, as its app would need a server it doesn't have) through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from that server itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 3 dumps stay in `~deploy/backups`: the way back from a deploy, not backups), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>` (the server's checkout keeps its folder `halkyon-learn`, so the command its key is pinned to stays). Never `docker compose down -v` there.
- **From the single repository** (ADR 0015): learn.halkyon.eu deploys this repository's `main`, whichever app or shared package a push changed. Its images are built from the repository's root with only what they need (Learn's server and the module it builds on; the curator), each Dockerfile narrowing the workspace to those packages (`tool/workspace_only.sh`). The first deploy after the move starts the server once more than it needs to, as the deployed copy of `redeploy.sh` looks for migrations at their old path.
- **From the single repository** (ADR 0015): learn.halkyon.eu deploys this repository's `main`, whichever app or shared package a push changed. Its images are built from the repository's root with only what they need (Learn's server and the module it builds on, with the module's client, which the module's tests depend on; the curator), each Dockerfile narrowing the workspace to those packages (`tool/workspace_only.sh`). The first deploy after the move starts the server once more than it needs to, as the deployed copy of `redeploy.sh` looks for migrations at their old path.
- **Sidecars** (`deploy/sidecars/README.md`, decided 2026-10-14): services an instance may run beside itself, each a compose file of its own beside `deploy/compose.yaml` (`compose.jellyfin.yaml`; later LanguageTool, Fedify), switched on in `deploy/.env` (`COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml`), so `redeploy.sh` deploys them with the instance. The instance's compose has two networks: `edge` (`halkyon-learn-edge`, 172.30.80.0/24), Caddy's at a fixed address (172.30.80.2), shared with the server, and the internal default one, where the server reaches Postgres; a sidecar joins `edge` alone and publishes no port (the CI runner may reach `127.0.0.1`, never Docker's 172.16.0.0/12). A sidecar reached from outside mounts its site from `deploy/sidecars/` into Caddy's `/etc/caddy/sidecars/`, which the Caddyfile imports (none without), and `redeploy.sh` recreates Caddy when a site changes. Each runs as a user ID of its own (Jellyfin 61096), never one a person, another container or the runner uses; its files on the server are git-ignored (`deploy/jellyfin/`). The Jellyfin sidecar sets the server's `JELLYFIN_URL`, so the instance suggests it.
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, then bundles each published studyset's repository (`git bundle`, from the server's `repositories` volume), and restic stores both in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session, starts a new anonymous account and deletes the old folder. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `dart run tool/release.dart learn` on `main` as pushed and green in CI (the apps' shared release tool, ADR 0015, configured in `apps/learn/release.dart`; `--dry-run` shows the version, the commits that count, every step and the notes without changing anything). Build 21 alone comes from `tool/release.sh`, the Bash script it replaces, tagged `v0.18.0`; then that script goes. Versions follow semantic versioning, worked out from the commits touching Learn since its last release (`apps/learn/`, the shared packages it uses, the root pubspec and lock, its release files, and its packages' folders at the root from before the move; `feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): learn-vX.Y.Z`) with a signed tag (`learn-vX.Y.Z`, by a key in `deploy/allowed-signers`; Learn's earlier `vX.Y.Z` tags count as its history), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The Snap Store upload is off until `storeChannel` in `apps/learn/release.dart` names a channel. A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the release tool releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-feature NonFreeNet declared with its reasons and F-Droid's own icon; not TetheredNet, as the app chooses its instance); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to the halkyon.app server (`ssh ramiel-fdroid`) as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.

View file

@ -15,13 +15,16 @@ COPY pubspec.yaml pubspec.lock ./
COPY tool/workspace_only.sh tool/
COPY apps/learn/halkyon_learn_server apps/learn/halkyon_learn_server
COPY packages/halkyon_base_server packages/halkyon_base_server
COPY packages/halkyon_base_client packages/halkyon_base_client
# The workspace root with only the server and the module it builds on: neither the
# other apps nor the client packages are needed to build the server, and the Flutter
# packages would need Flutter. Everything else stays as the repository has it, above
# all the dependency overrides, which bring the server our fork of the offline sync
# package (fetched with the git the Dart image has).
RUN sh tool/workspace_only.sh apps/learn/halkyon_learn_server packages/halkyon_base_server
# other apps nor Learn's client package are needed to build the server, and the Flutter
# packages would need Flutter. The module's client comes along only because the
# module's tests depend on it (SRP's two halves, held to each other), and pub resolves
# every member's dev dependencies; the server never imports it. Everything else stays
# as the repository has it, above all the dependency overrides, which bring the server
# our fork of the offline sync package (fetched with the git the Dart image has).
RUN sh tool/workspace_only.sh apps/learn/halkyon_learn_server packages/halkyon_base_server packages/halkyon_base_client
RUN dart pub get
WORKDIR /app/apps/learn/halkyon_learn_server
RUN dart build cli --target bin/main.dart --output build

View file

@ -5,4 +5,9 @@ what a Halkyon app calls to sign in, to manage its account, and to learn an inst
basics, under `client.base` in the app's own client. Never edited by hand; run
`serverpod --no-analytics generate` in `halkyon_base_server` instead.
The one exception is SRP-6a's client half (ADR 0017), written by hand beside the
generated code, which the generator leaves alone (it writes only `lib/src/protocol/`):
`package:halkyon_base_client/srp.dart`, tested by `dart test` here. The module's README
says how it pairs with the register's half.
Under the GNU Affero General Public License 3.0 or later.

View file

@ -1,12 +1,6 @@
# Defines a default set of lint rules enforced for
# projects at Google. For details and rationale,
# see https://github.com/dart-lang/pedantic#enabled-lints.
# For lint rules and documentation, see http://dart-lang.github.io/linter/lints.
# Uncomment to specify additional rules.
# linter:
# rules:
# - camel_case_types
# The workspace's lint rules (see ../../analysis_options.yaml) for what is written
# here by hand (lib/srp.dart, lib/src/srp/); the code Serverpod generates is left out.
include: ../../analysis_options.yaml
analyzer:
exclude:

View file

@ -1,2 +1,3 @@
export 'src/protocol/protocol.dart';
export 'package:serverpod_client/serverpod_client.dart';
export 'src/protocol/protocol.dart';

View file

@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:halkyon_base_client/src/srp/srp_group.dart';
import 'package:halkyon_base_client/src/srp/srp_math.dart';
import 'package:meta/meta.dart';
/// The verifier a register keeps for a password, v = g^x mod N, made on the
/// device at sign-up from [privateValue] (x), which only the device can make
/// (halkyon_keys derives it from the password with Argon2id). The verifier
/// can't sign in in the password's place; guessing the password from it
/// costs an Argon2id per guess.
BigInt srpVerifier(SrpGroup group, BigInt privateValue) =>
group.g.modPow(privateValue, group.n);
/// RFC 5054's x = H(s | H(I | ":" | P)), only to check this implementation
/// against RFC 5054's test vectors. Halkyon never hashes a password so: its
/// x comes from Argon2id (halkyon_keys), and no password reaches this
/// package.
@visibleForTesting
BigInt rfc5054PrivateValue(
SrpGroup group, {
required List<int> salt,
required String identity,
required String password,
}) => numberOf(
hashOf(group, [
salt,
hashOf(group, [utf8.encode('$identity:$password')]),
]),
);
/// SRP-6a's client side for one sign-in: it makes A ([publicValue]) from a
/// fresh secret a, then, given the register's salt and B, proves that it
/// knows the password (M1) without sending it, and checks the register's
/// proof (M2) in turn. A sign-in that fails starts anew with a new client.
final class SrpClient._(
/// The group it computes in.
final SrpGroup group,
final BigInt _secret,
) {
/// A client with a fresh secret a of 256 bits from [random] (the system's
/// secure one by default).
factory(SrpGroup group, {Random? random}) =>
SrpClient._(group, randomExponent(random ?? Random.secure()));
/// A client with the secret [a] given, only for RFC 5054's test vectors.
@visibleForTesting
factory withSecret(SrpGroup group, BigInt a) => SrpClient._(group, a);
/// A = g^a mod N, sent to the register first.
late final BigInt publicValue = group.g.modPow(_secret, group.n);
/// Answers the register's [serverValue] (B) for the account [identity]
/// with the [salt] it gave, from the [privateValue] x the device derived
/// from the password: the proof M1 to send, or the refusal of a B that
/// would give the password away.
SrpClientStep respond({
required List<int> identity,
required List<int> salt,
required BigInt privateValue,
required BigInt serverValue,
}) {
if (!isPublicValue(group, serverValue)) {
return const SrpClientRefused(SrpClientRefusal.invalidServerValue);
}
final scrambler = scramblerOf(group, publicValue, serverValue);
if (scrambler == BigInt.zero) {
return const SrpClientRefused(SrpClientRefusal.invalidServerValue);
}
final premasterSecret = premasterSecretOf(
privateValue: privateValue,
serverValue: serverValue,
scrambler: scrambler,
);
final sessionKey = sessionKeyOf(group, premasterSecret);
final proof = clientProofOf(
group,
identity: identity,
salt: salt,
clientValue: publicValue,
serverValue: serverValue,
sessionKey: sessionKey,
);
return SrpClientProof._(
proof,
sessionKey,
serverProofOf(
group,
clientValue: publicValue,
clientProof: proof,
sessionKey: sessionKey,
),
);
}
/// S = (B − k·g^x)^(a + u·x) mod N, exposed for RFC 5054's vectors.
@visibleForTesting
BigInt premasterSecretOf({
required BigInt privateValue,
required BigInt serverValue,
required BigInt scrambler,
}) {
final base =
(serverValue -
multiplierOf(group) * group.g.modPow(privateValue, group.n)) %
group.n;
return base.modPow(_secret + scrambler * privateValue, group.n);
}
}
/// What the client makes of the register's B.
sealed class const SrpClientStep();
/// The proof M1 to send, and what to expect back.
final class SrpClientProof._(
/// M1, sent to the register.
final Uint8List proof,
/// K, the key both sides now share; never sent.
final Uint8List sessionKey,
final Uint8List _expectedServerProof,
) extends SrpClientStep {
/// Whether the register's [serverProof] (M2) shows that it holds the
/// verifier: only then is the register the one the account was made with.
bool verifiesServer(List<int> serverProof) =>
constantTimeEquals(serverProof, _expectedServerProof);
}
/// A B the client refuses to answer.
final class const SrpClientRefused(
/// Why.
final SrpClientRefusal reason,
) extends SrpClientStep;
/// Why the client refused the register's B.
enum SrpClientRefusal() {
/// B is zero modulo N, or out of range, or makes u zero: answering would
/// let the proof be made without the password.
invalidServerValue,
}

View file

@ -0,0 +1,55 @@
import 'package:crypto/crypto.dart';
/// A group SRP-6a computes in: a safe prime [n], a generator [g] of it, and
/// the [hash] every step uses.
final class SrpGroup({
/// The safe prime N.
required final BigInt n,
/// The generator g.
required final BigInt g,
/// H, SHA-256 in production.
required final Hash hash,
}) {
/// RFC 5054's 3072-bit group (generator 5) with SHA-256: what Halkyon signs
/// in with.
static final rfc5054Group3072 = SrpGroup(
n: BigInt.parse(_n3072, radix: 16),
g: BigInt.from(5),
hash: sha256,
);
/// RFC 5054's 1024-bit group (generator 2) with SHA-1: only for the test
/// vectors of RFC 5054's Appendix B, never to sign in with.
static final rfc5054TestVectorGroup = SrpGroup(
n: BigInt.parse(_n1024, radix: 16),
g: BigInt.two,
hash: sha1,
);
/// How many bytes N takes: what PAD() fills to.
int get length => (n.bitLength + 7) ~/ 8;
}
/// RFC 5054, Appendix A, the 3072-bit group's N.
const _n3072 =
'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74'
'020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437'
'4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED'
'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05'
'98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB'
'9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B'
'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718'
'3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33'
'A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7'
'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864'
'D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2'
'08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF';
/// RFC 5054, Appendix A, the 1024-bit group's N.
const _n1024 =
'EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576'
'D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD1'
'5DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC'
'68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3';

View file

@ -0,0 +1,129 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:halkyon_base_client/src/srp/srp_group.dart';
/// The arithmetic both sides of SRP-6a share, as the server's half has it
/// too (halkyon_base_server's `srp_math.dart`, which must compute alike: an
/// interoperability test there holds them to each other).
///
/// Byte strings are unsigned and big-endian; PAD(x) is x filled with leading
/// zeros to the length of N.
/// [value] as the fewest unsigned big-endian bytes (one zero byte for zero).
Uint8List bytesOf(BigInt value) {
if (value.isNegative) {
throw ArgumentError.value(value, 'value', 'must not be negative');
}
var hex = value.toRadixString(16);
if (hex.length.isOdd) {
hex = '0$hex';
}
return Uint8List.fromList([
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
]);
}
/// PAD([value]): its bytes filled with leading zeros to [length].
Uint8List padded(BigInt value, int length) {
final bytes = bytesOf(value);
if (bytes.length > length) {
throw ArgumentError.value(value, 'value', 'is longer than $length bytes');
}
return Uint8List(length)..setRange(length - bytes.length, length, bytes);
}
/// The unsigned big-endian number [bytes] stand for.
BigInt numberOf(List<int> bytes) {
var value = BigInt.zero;
for (final byte in bytes) {
value = (value << 8) | BigInt.from(byte);
}
return value;
}
/// H over [parts], one after the other.
Uint8List hashOf(SrpGroup group, Iterable<List<int>> parts) =>
Uint8List.fromList(
group.hash.convert([for (final part in parts) ...part]).bytes,
);
/// k = H(N | PAD(g)).
BigInt multiplierOf(SrpGroup group) =>
numberOf(hashOf(group, [bytesOf(group.n), padded(group.g, group.length)]));
/// u = H(PAD(A) | PAD(B)).
BigInt scramblerOf(SrpGroup group, BigInt clientValue, BigInt serverValue) =>
numberOf(
hashOf(group, [
padded(clientValue, group.length),
padded(serverValue, group.length),
]),
);
/// K = H(PAD(S)), the key both sides end up with.
Uint8List sessionKeyOf(SrpGroup group, BigInt premasterSecret) =>
hashOf(group, [padded(premasterSecret, group.length)]);
/// M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K): the client's
/// proof that it knows the password.
Uint8List clientProofOf(
SrpGroup group, {
required List<int> identity,
required List<int> salt,
required BigInt clientValue,
required BigInt serverValue,
required List<int> sessionKey,
}) {
final hashOfN = hashOf(group, [bytesOf(group.n)]);
final hashOfG = hashOf(group, [bytesOf(group.g)]);
return hashOf(group, [
[for (var i = 0; i < hashOfN.length; i++) hashOfN[i] ^ hashOfG[i]],
hashOf(group, [identity]),
salt,
padded(clientValue, group.length),
padded(serverValue, group.length),
sessionKey,
]);
}
/// M2 = H(PAD(A) | M1 | K): the server's proof that it holds the verifier.
Uint8List serverProofOf(
SrpGroup group, {
required BigInt clientValue,
required List<int> clientProof,
required List<int> sessionKey,
}) => hashOf(group, [
padded(clientValue, group.length),
clientProof,
sessionKey,
]);
/// Whether a public value lies in 1 … N − 1: zero (mod N) would let the
/// other side's key be known without the password.
bool isPublicValue(SrpGroup group, BigInt value) =>
value > BigInt.zero && value < group.n;
/// Whether [a] and [b] are equal, in a time that depends only on their
/// lengths, which are public.
bool constantTimeEquals(List<int> a, List<int> b) {
if (a.length != b.length) {
return false;
}
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a[i] ^ b[i];
}
return difference == 0;
}
/// A fresh secret exponent of 256 bits from [random], never zero.
BigInt randomExponent(Random random) {
while (true) {
final value = numberOf([for (var i = 0; i < 32; i++) random.nextInt(256)]);
if (value != BigInt.zero) {
return value;
}
}
}

View file

@ -0,0 +1,16 @@
/// SRP-6a's client side (ADR 0017): the device proves it knows the password
/// without sending it, and checks that the register holds the verifier made
/// at sign-up. Hand-written beside the generated client: Serverpod's
/// generator only ever writes and cleans `lib/src/protocol/`.
library;
export 'src/srp/srp_client.dart'
show
SrpClient,
SrpClientProof,
SrpClientRefusal,
SrpClientRefused,
SrpClientStep,
rfc5054PrivateValue,
srpVerifier;
export 'src/srp/srp_group.dart';

View file

@ -2,7 +2,7 @@ name: halkyon_base_client
description: >-
The client side of halkyon_base, generated by Serverpod: what a Halkyon
app calls to sign in, to manage its account, and to learn an instance's
basics.
basics; and SRP-6a's client half, written by hand.
version: 0.1.0
publish_to: none
@ -12,5 +12,11 @@ environment:
resolution: workspace
dependencies:
# SRP-6a's hashes (lib/src/srp/, hand-written beside the generated code).
crypto: ^3.0.7
meta: ^1.16.0
serverpod_auth_idp_client: 4.0.4
serverpod_client: 4.0.4
dev_dependencies:
test: ^1.31.1

View file

@ -0,0 +1,188 @@
import 'dart:convert';
import 'package:halkyon_base_client/src/srp/srp_math.dart';
import 'package:halkyon_base_client/srp.dart';
import 'package:test/test.dart';
/// [hex] as RFC 5054 prints it (in groups, across lines) as a number.
BigInt _hex(String hex) =>
BigInt.parse(hex.replaceAll(RegExp(r'\s'), ''), radix: 16);
void main() {
group("RFC 5054's test vectors (Appendix B), value by value", () {
final group = SrpGroup.rfc5054TestVectorGroup;
final salt = bytesOf(_hex('BEB25379 D1A8581E B5A72767 3A2441EE'));
final identity = utf8.encode('alice');
final privateValue = rfc5054PrivateValue(
group,
salt: salt,
identity: 'alice',
password: 'password123',
);
final client = SrpClient.withSecret(
group,
_hex('''
60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD
DA2D4393'''),
);
final serverValue = _hex('''
BD0C6151 2C692C0C B6D041FA 01BB152D 4916A1E7 7AF46AE1 05393011
BAF38964 DC46A067 0DD125B9 5A981652 236F99D9 B681CBF8 7837EC99
6C6DA044 53728610 D0C6DDB5 8B318885 D7D82C7F 8DEB75CE 7BD4FBAA
37089E6F 9C6059F3 88838E7A 00030B33 1EB76840 910440B1 B27AAEAE
EB4012B7 D7665238 A8E3FB00 4B117B58''');
test('k = H(N | PAD(g))', () {
expect(
multiplierOf(group),
_hex('7556AA04 5AEF2CDD 07ABAF0F 665C3E81 8913186F'),
);
});
test('x = H(s | H(I | ":" | P))', () {
expect(
privateValue,
_hex('94B7555A ABE9127C C58CCF49 93DB6CF8 4D16C124'),
);
});
test('v = g^x mod N', () {
expect(
srpVerifier(group, privateValue),
_hex('''
7E273DE8 696FFC4F 4E337D05 B4B375BE B0DDE156 9E8FA00A 9886D812
9BADA1F1 822223CA 1A605B53 0E379BA4 729FDC59 F105B478 7E5186F5
C671085A 1447B52A 48CF1970 B4FB6F84 00BBF4CE BFBB1681 52E08AB5
EA53D15C 1AFF87B2 B9DA6E04 E058AD51 CC72BFC9 033B564E 26480D78
E955A5E2 9E7AB245 DB2BE315 E2099AFB'''),
);
});
test('A = g^a mod N', () {
expect(
client.publicValue,
_hex('''
61D5E490 F6F1B795 47B0704C 436F523D D0E560F0 C64115BB 72557EC4
4352E890 3211C046 92272D8B 2D1A5358 A2CF1B6E 0BFCF99F 921530EC
8E393561 79EAE45E 42BA92AE ACED8251 71E1E8B9 AF6D9C03 E1327F44
BE087EF0 6530E69F 66615261 EEF54073 CA11CF58 58F0EDFD FE15EFEA
B349EF5D 76988A36 72FAC47B 0769447B'''),
);
});
test('u = H(PAD(A) | PAD(B))', () {
expect(
scramblerOf(group, client.publicValue, serverValue),
_hex('CE38B959 3487DA98 554ED47D 70A7AE5F 462EF019'),
);
});
test('the premaster secret S = (B - k·g^x)^(a + u·x) mod N', () {
expect(
client.premasterSecretOf(
privateValue: privateValue,
serverValue: serverValue,
scrambler: scramblerOf(group, client.publicValue, serverValue),
),
_hex('''
B0DC82BA BCF30674 AE450C02 87745E79 90A3381F 63B387AA F271A10D
233861E3 59B48220 F7C4693C 9AE12B0A 6F67809F 0876E2D0 13800D6C
41BB59B6 D5979B5C 00A172B4 A2A5903A 0BDCAF8A 709585EB 2AFAFA8F
3499B200 210DCC1F 10EB3394 3CD67FC8 8A2F39A4 BE5BEC4E C0A3212D
C346D7E4 74B29EDE 8A469FFE CA686E5A'''),
);
});
test('the proof is made from those values', () {
final step = client.respond(
identity: identity,
salt: salt,
privateValue: privateValue,
serverValue: serverValue,
);
expect(step, isA<SrpClientProof>());
final proof = step as SrpClientProof;
// SHA-1 as the vectors' group has it: 20 bytes each.
expect(proof.proof, hasLength(20));
expect(proof.sessionKey, hasLength(20));
});
});
group("the production group, RFC 5054's 3072-bit with SHA-256", () {
final group = SrpGroup.rfc5054Group3072;
test('is the 3072-bit safe prime with the generator 5', () {
expect(group.n.bitLength, 3072);
expect(group.length, 384);
expect(group.g, BigInt.from(5));
// RFC 3526's MODP group 15: it starts and ends with 64 ones.
expect(group.n >> 3008, (BigInt.one << 64) - BigInt.one);
expect(
group.n & ((BigInt.one << 64) - BigInt.one),
(BigInt.one << 64) - BigInt.one,
);
});
test('every client takes a fresh secret, so a fresh A', () {
final first = SrpClient(group).publicValue;
final second = SrpClient(group).publicValue;
expect(first, isNot(second));
expect(first > BigInt.one && first < group.n, isTrue);
});
});
group("a register's B the client refuses, which would give the proof "
'away', () {
final group = SrpGroup.rfc5054Group3072;
final client = SrpClient(group);
SrpClientStep respondTo(BigInt serverValue) => client.respond(
identity: utf8.encode('someone'),
salt: List.filled(16, 7),
privateValue: BigInt.from(1234567),
serverValue: serverValue,
);
for (final (name, value) in [
('zero', BigInt.zero),
('N', group.n),
('a multiple of N', group.n * BigInt.two),
('above N', group.n + BigInt.one),
('negative', -BigInt.one),
]) {
test('B $name', () {
expect(
respondTo(value),
isA<SrpClientRefused>().having(
(refused) => refused.reason,
'reason',
SrpClientRefusal.invalidServerValue,
),
);
});
}
});
group('the bytes', () {
test('a number takes its fewest bytes, zero one', () {
expect(bytesOf(BigInt.zero), [0]);
expect(bytesOf(BigInt.from(255)), [255]);
expect(bytesOf(BigInt.from(256)), [1, 0]);
expect(
numberOf(bytesOf(BigInt.parse('123456789012345678901234567890'))),
BigInt.parse('123456789012345678901234567890'),
);
});
test('PAD fills with leading zeros, and refuses what is too long', () {
expect(padded(BigInt.from(5), 4), [0, 0, 0, 5]);
expect(() => padded(BigInt.from(1 << 16), 2), throwsArgumentError);
});
test('proofs compare in constant time, unequal lengths unequal', () {
expect(constantTimeEquals([1, 2, 3], [1, 2, 3]), isTrue);
expect(constantTimeEquals([1, 2, 3], [1, 2, 4]), isFalse);
expect(constantTimeEquals([1, 2, 3], [1, 2]), isFalse);
expect(constantTimeEquals(<int>[], <int>[]), isTrue);
});
});
}

View file

@ -101,6 +101,32 @@ the same ID. Sessions stay each service's own: every app signs in itself.
- **The register being down:** members can't sign anyone in, change profiles or delete
accounts; their open sessions go on working.
## SRP-6a (ADR 0017)
The password never reaches the register: the device proves it knows it with SRP-6a
(RFC 5054), and the register keeps only the verifier v = g^x mod N, which `halkyon_keys`'
x makes on the device (Argon2id, so a stolen verifier costs an Argon2id per guess). The
two halves share the arithmetic, generic over the group and the hash (`SrpGroup`):
- the register's in `package:halkyon_base_server/base.dart`: `srpChallenge(group,
verifier)` gives `SrpChallenge` (B for the client; b kept for this sign-in only), and
`srpVerify(group, identity:, salt:, verifier:, challenge:, clientValue:, clientProof:)`
gives `SrpAccepted` (M2 and K) or `SrpRefused` (`invalidClientValue`, `wrongProof`);
- the device's in `package:halkyon_base_client/srp.dart`, written by hand beside the
generated client (the generator writes only `lib/src/protocol/`): `SrpClient(group)`
with its `publicValue` A, `respond(identity:, salt:, privateValue:, serverValue:)`
giving `SrpClientProof` (M1, K, `verifiesServer(M2)`) or `SrpClientRefused`, and
`srpVerifier(group, x)` for sign-up.
Production uses `SrpGroup.rfc5054Group3072` (RFC 5054's 3072-bit group, g = 5) with
SHA-256. k = H(N | PAD(g)), u = H(PAD(A) | PAD(B)), K = H(PAD(S)),
M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K), M2 = H(PAD(A) | M1 | K), numbers as
unsigned big-endian bytes, PAD to N's length. A and B must lie in 1..N−1 (so neither is
≡ 0 mod N) and u must not be 0; a and b are 256 bits from `Random.secure()`; proofs are
compared in constant time. RFC 5054 Appendix B's vectors check both halves
(`SrpGroup.rfc5054TestVectorGroup`, SHA-1), and `test/unit/srp/srp_interop_test.dart` runs
the client against the register, every refusal included.
## Taking it into a host
1. Depend on both packages by path, within the repository's workspace (an app's

View file

@ -2,7 +2,7 @@
/// (`HalkyonBase.configure`), how people authenticate, the account's
/// deletion and the pruning, email sign-in's helpers, the instance's
/// basics, people's public profiles and their pages, the instance's register
/// (ADR 0016), and the module's models.
/// (ADR 0016), SRP's register side (ADR 0017), and the module's models.
library;
export 'src/auth/account.dart';
@ -37,3 +37,13 @@ export 'src/register/deletion_notices.dart'
export 'src/register/instance_routes.dart';
export 'src/register/member_pruning.dart';
export 'src/register/people.dart' show issueSession;
export 'src/srp/srp_group.dart';
export 'src/srp/srp_server.dart'
show
SrpAccepted,
SrpChallenge,
SrpRefusal,
SrpRefused,
SrpVerification,
srpChallenge,
srpVerify;

View file

@ -0,0 +1,59 @@
import 'package:crypto/crypto.dart';
/// A group SRP-6a computes in: a safe prime [n], a generator [g] of it, and
/// the [hash] every step uses. The same groups as halkyon_base_client's
/// (`lib/src/srp/srp_group.dart`), which the interoperability test holds
/// them to.
final class SrpGroup {
SrpGroup({required this.n, required this.g, required this.hash});
/// RFC 5054's 3072-bit group (generator 5) with SHA-256: what Halkyon signs
/// in with.
static final rfc5054Group3072 = SrpGroup(
n: BigInt.parse(_n3072, radix: 16),
g: BigInt.from(5),
hash: sha256,
);
/// RFC 5054's 1024-bit group (generator 2) with SHA-1: only for the test
/// vectors of RFC 5054's Appendix B, never to sign in with.
static final rfc5054TestVectorGroup = SrpGroup(
n: BigInt.parse(_n1024, radix: 16),
g: BigInt.two,
hash: sha1,
);
/// The safe prime N.
final BigInt n;
/// The generator g.
final BigInt g;
/// H, SHA-256 in production.
final Hash hash;
/// How many bytes N takes: what PAD() fills to.
int get length => (n.bitLength + 7) ~/ 8;
}
/// RFC 5054, Appendix A, the 3072-bit group's N.
const _n3072 =
'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74'
'020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437'
'4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED'
'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05'
'98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB'
'9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B'
'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718'
'3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33'
'A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7'
'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864'
'D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2'
'08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF';
/// RFC 5054, Appendix A, the 1024-bit group's N.
const _n1024 =
'EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576'
'D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD1'
'5DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC'
'68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3';

View file

@ -0,0 +1,129 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:halkyon_base_server/src/srp/srp_group.dart';
/// The arithmetic both sides of SRP-6a share, as the client's half has it
/// too (halkyon_base_client's `srp_math.dart`, which must compute alike: an
/// interoperability test here holds them to each other).
///
/// Byte strings are unsigned and big-endian; PAD(x) is x filled with leading
/// zeros to the length of N.
/// [value] as the fewest unsigned big-endian bytes (one zero byte for zero).
Uint8List bytesOf(BigInt value) {
if (value.isNegative) {
throw ArgumentError.value(value, 'value', 'must not be negative');
}
var hex = value.toRadixString(16);
if (hex.length.isOdd) {
hex = '0$hex';
}
return Uint8List.fromList([
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
]);
}
/// PAD([value]): its bytes filled with leading zeros to [length].
Uint8List padded(BigInt value, int length) {
final bytes = bytesOf(value);
if (bytes.length > length) {
throw ArgumentError.value(value, 'value', 'is longer than $length bytes');
}
return Uint8List(length)..setRange(length - bytes.length, length, bytes);
}
/// The unsigned big-endian number [bytes] stand for.
BigInt numberOf(List<int> bytes) {
var value = BigInt.zero;
for (final byte in bytes) {
value = (value << 8) | BigInt.from(byte);
}
return value;
}
/// H over [parts], one after the other.
Uint8List hashOf(SrpGroup group, Iterable<List<int>> parts) =>
Uint8List.fromList(
group.hash.convert([for (final part in parts) ...part]).bytes,
);
/// k = H(N | PAD(g)).
BigInt multiplierOf(SrpGroup group) =>
numberOf(hashOf(group, [bytesOf(group.n), padded(group.g, group.length)]));
/// u = H(PAD(A) | PAD(B)).
BigInt scramblerOf(SrpGroup group, BigInt clientValue, BigInt serverValue) =>
numberOf(
hashOf(group, [
padded(clientValue, group.length),
padded(serverValue, group.length),
]),
);
/// K = H(PAD(S)), the key both sides end up with.
Uint8List sessionKeyOf(SrpGroup group, BigInt premasterSecret) =>
hashOf(group, [padded(premasterSecret, group.length)]);
/// M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K): the client's
/// proof that it knows the password.
Uint8List clientProofOf(
SrpGroup group, {
required List<int> identity,
required List<int> salt,
required BigInt clientValue,
required BigInt serverValue,
required List<int> sessionKey,
}) {
final hashOfN = hashOf(group, [bytesOf(group.n)]);
final hashOfG = hashOf(group, [bytesOf(group.g)]);
return hashOf(group, [
[for (var i = 0; i < hashOfN.length; i++) hashOfN[i] ^ hashOfG[i]],
hashOf(group, [identity]),
salt,
padded(clientValue, group.length),
padded(serverValue, group.length),
sessionKey,
]);
}
/// M2 = H(PAD(A) | M1 | K): the server's proof that it holds the verifier.
Uint8List serverProofOf(
SrpGroup group, {
required BigInt clientValue,
required List<int> clientProof,
required List<int> sessionKey,
}) => hashOf(group, [
padded(clientValue, group.length),
clientProof,
sessionKey,
]);
/// Whether a public value lies in 1 … N − 1: zero (mod N) would let the
/// other side's key be known without the password.
bool isPublicValue(SrpGroup group, BigInt value) =>
value > BigInt.zero && value < group.n;
/// Whether [a] and [b] are equal, in a time that depends only on their
/// lengths, which are public.
bool constantTimeEquals(List<int> a, List<int> b) {
if (a.length != b.length) {
return false;
}
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a[i] ^ b[i];
}
return difference == 0;
}
/// A fresh secret exponent of 256 bits from [random], never zero.
BigInt randomExponent(Random random) {
while (true) {
final value = numberOf([for (var i = 0; i < 32; i++) random.nextInt(256)]);
if (value != BigInt.zero) {
return value;
}
}
}

View file

@ -0,0 +1,155 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:halkyon_base_server/src/srp/srp_group.dart';
import 'package:halkyon_base_server/src/srp/srp_math.dart';
import 'package:meta/meta.dart';
/// SRP-6a's register side (ADR 0017), in two steps a sign-in's two requests
/// take: [srpChallenge] answers the client's A with B, keeping the secret b
/// until [srpVerify] checks the client's proof M1 and answers with M2. The
/// register keeps only the verifier v = g^x mod N the device made at sign-up:
/// it never sees the password, nor anything that would sign in in its place.
/// B for the client, and the secret b that made it, which the register keeps
/// (for this one sign-in, briefly) and never sends.
final class SrpChallenge {
SrpChallenge._(this.secret, this.publicValue);
/// b.
final BigInt secret;
/// B = (k·v + g^b) mod N, sent to the client.
final BigInt publicValue;
}
/// The first step: B for the account whose [verifier] the register keeps,
/// from a fresh secret b of 256 bits from [random] (the system's secure one
/// by default).
SrpChallenge srpChallenge(SrpGroup group, BigInt verifier, {Random? random}) {
final source = random ?? Random.secure();
while (true) {
final challenge = srpChallengeWithSecret(
group,
verifier,
randomExponent(source),
);
// B ≡ 0 (mod N) would make the key known; its chance is nil, its check
// free.
if (isPublicValue(group, challenge.publicValue)) {
return challenge;
}
}
}
/// The first step with the secret [secret] given, only for RFC 5054's test
/// vectors.
@visibleForTesting
SrpChallenge srpChallengeWithSecret(
SrpGroup group,
BigInt verifier,
BigInt secret,
) => SrpChallenge._(
secret,
(multiplierOf(group) * verifier + group.g.modPow(secret, group.n)) % group.n,
);
/// The second step: whether the client's [clientProof] M1, made with its A
/// ([clientValue]) against this [challenge], shows it knows the password of
/// the account [identity] (whose [salt] and [verifier] the register keeps).
/// Accepted, it gives M2 for the client and the key K both now share.
SrpVerification srpVerify(
SrpGroup group, {
required List<int> identity,
required List<int> salt,
required BigInt verifier,
required SrpChallenge challenge,
required BigInt clientValue,
required List<int> clientProof,
}) {
if (!isPublicValue(group, clientValue)) {
return const SrpRefused(SrpRefusal.invalidClientValue);
}
final scrambler = scramblerOf(group, clientValue, challenge.publicValue);
if (scrambler == BigInt.zero) {
return const SrpRefused(SrpRefusal.invalidClientValue);
}
final sessionKey = sessionKeyOf(
group,
srpServerPremasterSecret(
group,
verifier: verifier,
challenge: challenge,
clientValue: clientValue,
scrambler: scrambler,
),
);
final expected = clientProofOf(
group,
identity: identity,
salt: salt,
clientValue: clientValue,
serverValue: challenge.publicValue,
sessionKey: sessionKey,
);
if (!constantTimeEquals(clientProof, expected)) {
return const SrpRefused(SrpRefusal.wrongProof);
}
return SrpAccepted._(
serverProofOf(
group,
clientValue: clientValue,
clientProof: expected,
sessionKey: sessionKey,
),
sessionKey,
);
}
/// S = (A·v^u)^b mod N, exposed for RFC 5054's vectors.
@visibleForTesting
BigInt srpServerPremasterSecret(
SrpGroup group, {
required BigInt verifier,
required SrpChallenge challenge,
required BigInt clientValue,
required BigInt scrambler,
}) => (clientValue * verifier.modPow(scrambler, group.n) % group.n).modPow(
challenge.secret,
group.n,
);
/// What the register makes of a client's proof.
sealed class SrpVerification {
const SrpVerification();
}
/// The proof showed the password: M2 for the client, K for both.
final class SrpAccepted extends SrpVerification {
SrpAccepted._(this.serverProof, this.sessionKey);
/// M2, sent to the client, which checks it.
final Uint8List serverProof;
/// K, the key both sides now share; never sent.
final Uint8List sessionKey;
}
/// The proof didn't show it.
final class SrpRefused extends SrpVerification {
const SrpRefused(this.reason);
/// Why.
final SrpRefusal reason;
}
/// Why the register refused a client's proof.
enum SrpRefusal {
/// A is zero modulo N, or out of range, or makes u zero: accepting it
/// would let anyone sign in.
invalidClientValue,
/// M1 isn't the one the password would make: a wrong password, or a value
/// tampered with on the way.
wrongProof,
}

View file

@ -21,9 +21,13 @@ dependencies:
http: ^1.6.0
# Mails email sign-in's codes over SMTP (Halkyon Learn's ADR 0010).
mailer: ^7.2.0
meta: ^1.16.0
serverpod: 4.0.4
serverpod_auth_idp_server: 4.0.4
dev_dependencies:
# SRP's client half, to hold the register's half to it (test/unit/srp/).
halkyon_base_client:
path: ../halkyon_base_client
serverpod_test: 4.0.4
test: ^1.25.5

Some files were not shown because too many files have changed in this diff Show more