Просмотр исходного кода

Merge branch 'goal/security' of Tilo15/Spry into master

Billy Barrow 1 день назад
Родитель
Сommit
201847ad5f

+ 2 - 1
MANIFEST.usm

@@ -4,7 +4,8 @@
   "summary" : "Reusable authentication and user-management services for Statum, plus the spry CLI",
   "licences" : [],
   "flags" : [
-    "ninjaStyleProgress"
+    "ninjaStyleProgress",
+    "rebuildDependants"
   ],
   "provides" : {
     "lib:libspry-0.2.so" : "as-expected",

+ 757 - 0
docs/security-audit.md

@@ -0,0 +1,757 @@
+# Security audit — Astralis, Statum, Spry
+
+OWASP Top 10-style review of the Vala/GObject web stack, adapted to this
+codebase. Audit of `goal/security` @ `eca07a0` (Astralis), `c741172` (Statum),
+`b6aad2e` (Spry). Read-only audit: no code was changed; every finding below
+was verified against source, and severity/confidence are stated per finding.
+
+Severity: **high** = exploitable by an unauthenticated or low-privilege
+attacker with real impact; **medium** = exploitable with preconditions, or
+degrades a security control; **low** = defence-in-depth / hygiene.
+Confidence: **confirmed** = reproduced by code reading with the full call
+chain traced; **suspected** = strong evidence, one step unverified (the step
+is named).
+
+## Summary (ordered by severity)
+
+- SA-1 **high** — Logout does not revoke the session: the signed `auth` frame
+  stays valid and re-authenticates the user (Spry/Statum).
+- SA-2 **high** — `FilesystemResource` traversal guard is a prefix check with
+  a non-symlink-resolving canonicaliser (Astralis).
+- SA-3 **high** — No request-size limits anywhere: unbounded in-memory body
+  buffering (Astralis/Statum).
+- SA-4 **medium** — Slot frames never expire server-side: signed snapshots
+  can be replayed forever (`invalid_after` unenforced on the write path).
+- SA-5 **medium** — Disabled/deleted accounts keep working: only the Spry
+  guards re-validate; `require_login`/`require_permission` are opt-in per
+  action, and nothing else checks (Statum endpoints unauthenticated).
+- SA-6 **medium** — `EntrypointEndpoint` and the SSE channel are callable
+  unauthenticated (info disclosure / DoS surface).
+- SA-7 **medium** — Admin seeding defaults to weak, well-known credentials
+  (`change-me` documented as the example; no forced rotation).
+- SA-8 **medium** — No password policy beyond length ≥ 6; no upper bound on
+  password length (Argon2 memory/CPU DoS with multi-megabyte passwords).
+- SA-9 **medium** — `web-config.json` carries all long-lived secrets
+  (signing + sealing keys) with no permission check or warning; ephemeral-key
+  fallback silently runs with no persistent identity.
+- SA-10 **medium** — `SigningProvider.verify_frame` ignores the frame's
+  `signer` claim (weakens multi-signer deployments; fine for single-signer).
+- SA-11 **medium** — Account enumeration via registration responses
+  ("username taken" vs "email taken") and via timing on the login path is
+  partially mitigated only.
+- SA-12 **low** — `statum-genkeys` prints secret keys to stdout.
+- SA-13 **low** — No standard security response headers (CSP, HSTS,
+  X-Content-Type-Options, X-Frame-Options); no CORS story at all.
+- SA-14 **low** — `X-Statum-Slot` header values are trusted as the client's
+  held-state claim with no binding to a session (slot spoofing within the
+  signature constraint).
+- SA-15 **low** — SSE/channel endpoints keep unbounded per-connection state;
+  no connection cap, no idle timeout.
+- SA-16 **low** — Password hashes are stored with fixed Argon2 parameters and
+  never rehashed on parameter change.
+- SA-17 **low** — Internal error messages surfaced to users in notifications
+  (`e.message` printf'd into notify text).
+- SA-18 **low** — `new Function('with(scope){...}')` in `statum.js` compiles
+  attribute expressions — a page-authored template injection primitive, safe
+  only while page markup is fully trusted.
+- SA-19 **info** — Non-findings recorded (SQL injection, markdown XSS,
+  markup XSS, dummy-hash timing) — checked and found sound, listed so the
+  next audit does not re-derive them.
+
+---
+
+## SA-1 — Logout does not revoke the session (high)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry + Statum
+**Confidence:** confirmed (full chain traced; one browser-behaviour step noted)
+
+`LogoutAction` (Spry/src/Actions/LogoutAction.vala:18-27) calls
+`state_service.clear_slot(auth.key)`, which removes the slot from the
+server-side in-memory dictionary (`StateService.clear_slot`,
+Statum/src/StateService.vala:97-100). That is the *entire* revocation. The
+client is told to drop its copy via a `clear` directive — but the signed
+frame the client held is still cryptographically valid:
+
+1. The frame's signature (`SigningProvider.author_frame`,
+   Statum/src/Cryptography/SigningProvider.vala:84-90) covers only the
+   snapshot content. Nothing in the signature binds the slot to "not logged
+   out" — there is no revocation list, no key rotation, no server-side state
+   that marks the frame dead.
+2. `statum.js` persists frames in `localStorage`/`sessionStorage`
+   (`persistEntry`, Statum/js/statum.js:330-341). A user (or malware) who
+   saved the frame before logout can restore it.
+3. On the next request the client sends the frame via `X-Statum-Slot`;
+   `HeldSlotResolver.resolve_frame`
+   (Statum/src/HeldSlotResolver.vala:173-215) verifies the signature and —
+   crucially — `SpryAuth.current` (Spry/src/Auth.vala:114-137) maps the
+   frame straight to a `CurrentUser` with `user_id` from the decrypted
+   private half.
+4. Worse: if the client first POSTs the frame to `/_statum/slots`,
+   `SlotPostEndpoint` (Statum/src/SlotPostEndpoint.vala:39-59) re-verifies
+   the signature and `restore_slot` **re-caches the slot server-side**
+   (`StateService.restore_slot`, Statum/src/StateService.vala:172-197), so
+   even the reference-form header (which resolves via the server cache)
+   starts working again. The logout is fully undone by replaying the frame.
+
+**Impact:** "log out" on a shared machine does not end the session; anyone
+with a copy of the frame (localStorage, a proxy log, a backup) re-enters as
+that user until the signing key changes.
+
+**Status: PARTIALLY ADDRESSED (approved scope: password changes only).**
+`M0002` adds `spry_users.sessions_valid_after`; `UserService.set_password`
+bumps it (self- and admin-forced changes); `SpryAuth.revalidate` fails
+closed on frames whose signed `as_at` predates the epoch, and
+`ChangePasswordAction` re-mints the slot in the same response so the
+changing user stays logged in while every other device is revoked.
+**Logout still does not revoke** — the human explicitly accepted this
+residual risk for now; revisit by bumping the epoch in `LogoutAction` (one
+line, but it then logs out every device).
+
+**Fix direction (original):** add a server-side validity epoch: include a monotonically
+increasing `user`-scoped version (or a per-slot generation counter) in the
+signed snapshot, and have `revalidate`/`restore_slot` compare it against a
+`users` row (e.g. `sessions_valid_after` timestamp bumped on logout and on
+password change). Alternatively rotate the slot key on logout and reject
+frames whose snapshot predates it. `restore_slot` must also refuse frames
+older than `invalid_after` (see SA-4).
+
+---
+
+## SA-2 — FilesystemResource traversal guard: prefix check, no symlink resolution (high)
+
+**OWASP:** A01 Broken Access Control (path traversal)
+**Repo:** Astralis/src/Endpoints/FilesystemResource.vala:54-60, 208-213
+**Confidence:** confirmed (guard flaw confirmed by reading; exploitability is
+currently masked by an unrelated routing bug — SA-2b)
+
+```vala
+string resolved_path = canonicalize_path(full_path);
+string resolved_server_path = canonicalize_path(server_path);
+if (!resolved_path.has_prefix(resolved_server_path)) { ... 403 ... }
+```
+
+Two independent defects:
+
+1. **Prefix, not containment.** `has_prefix` accepts a sibling directory:
+   serving from `/srv/www`, a resolved path of `/srv/www-evil/secret` passes
+   the check. Reachability requires a `..` payload to survive routing and
+   resolve outside the tree (see masking note below), or a symlink planted
+   inside the tree pointing outside — `File.get_path()` does **not** resolve
+   symlinks (unlike `realpath`/`File.resolve_symbolic_link`), so the
+   canonicaliser only lexically normalises.
+2. **No boundary separator.** Even a correct absolute prefix needs the next
+   character to be `/` (or an exact match) — the classic
+   `/srv/www` vs `/srv/www2` confusion.
+
+**SA-2b — masking bug (functional, security-relevant).** While tracing
+reachability I found that `RouteContext` builds the `**` remainder from the
+*route's* segments instead of the request's
+(Astralis/src/Components/EndpointRouter.vala:143-147 — regression introduced
+in 9a3bb18b, which replaced `path_components[i]` with
+`matched_route.route_segments[i]`). For a `/assets/**` route the mapped `**`
+parameter is therefore always the literal string `"**"`, and
+`FilesystemResource.get_relative_path` returns it — deep routes serve
+`server_path/**` (a file literally named `**`) rather than the requested
+subpath. This *accidentally* blocks `../` payloads today because the
+attacker-controlled remainder never reaches `Path.build_filename`. The
+traversal guard is nonetheless wrong and becomes exploitable the moment the
+routing bug is fixed (or on the SHALLOW/EXACT match paths, which use
+`route_components`/named params). Fix the guard and the router bug together,
+with a traversal regression test (`/assets/../../etc/passwd`, URL-encoded
+`%2e%2e%2f`, symlink escape).
+
+**Status: ADDRESSED.** The `**` wildcard remainder now comes from the
+request's segments (deep static routes work again), and the guard rejects
+any relative path containing `..`, resolves both root and target
+symlink-free, and requires `resolved == root || resolved.has_prefix(root +
+"/")`. Regression tests cover `../../../etc/passwd`, `%2e%2e%2f`, symlink
+escape, sibling-directory and `/www` vs `/www2` boundaries (38-check Astralis
+suite).
+
+**Fix direction (original):** canonicalise with
+`File.resolve_symbolic_link_async` (or `realpath`), then require
+`resolved_path == resolved_server_path ||
+resolved_path.has_prefix(resolved_server_path + "/")`. Reject any request
+whose pre-canonicalisation relative path contains `..` outright.
+
+---
+
+## SA-3 — No request-size limits; unbounded in-memory buffering (high)
+
+**OWASP:** A05 Security Misconfiguration / DoS
+**Repo:** Astralis/src/Server/ServerInput.vala:47-51 (buffering),
+Astralis/src/Server/Server.vala:89-95 (no cap),
+Astralis/src/Data/FormDataParser.vala:122-176 (whole-body reads),
+Statum/src/SlotPostEndpoint.vala:27 (`read_all`),
+Statum/src/ChannelSubscriptionEndpoint.vala:30 (`read_all`)
+**Confidence:** confirmed
+
+- `ServerInput.write` appends every upload chunk to an in-memory `Series`
+  with no maximum. `read_all()` then composes them into one contiguous
+  buffer. A single `Content-Length: 4294967296` POST is fully buffered.
+- `FormDataParser.parse_multipart` reads the entire body, then copies parts
+  (`body_data`, `part_data`, `file_data`) — roughly 3× the body size in
+  peak memory, plus an O(n·m) `find_bytes` boundary scan
+  (FormDataParser.vala:234-252) over the whole body per part.
+- The MHD daemon is started with only `ALLOW_SUSPEND_RESUME | USE_TURBO |
+  USE_EPOLL` (Server.vala:111-122): no `MHD_OPTION_CONNECTION_LIMIT`, no
+  `MHD_OPTION_CONNECTION_MEMORY_LIMIT`, no per-connection upload cap, no
+  request timeout.
+- `SlotPostEndpoint` and `ChannelSubscriptionEndpoint` parse arbitrary JSON
+  bodies of arbitrary size (frame arrays unbounded).
+
+**Impact:** trivial remote memory exhaustion — a handful of parallel large
+POSTs OOMs the process; slow-loric attacks also hold buffered memory for the
+connection lifetime.
+
+**Status: ADDRESSED (core limits).** The MHD daemon starts with
+CONNECTION_LIMIT (256), PER_IP_CONNECTION_LIMIT (32) and
+CONNECTION_MEMORY_LIMIT (128 KiB), all configurable via the `server`
+section; a configurable `max_request_body` (default 10 MiB, 0 disables)
+rejects with 413 on declared Content-Length and defensively while streaming
+in `ServerInput.write`. The FormDataParser whole-body multipart copy and
+O(n·m) boundary scan remain — noted as follow-up work.
+
+**Fix direction (original):** pass `MHD_OPTION_CONNECTION_LIMIT` and
+`MHD_OPTION_PER_IP_CONNECTION_LIMIT` in `Daemon.start`; enforce a configurable
+`max_request_body` (reject >413 immediately, ideally as soon as
+`Content-Length` is known, and defensively while streaming in
+`ServerInput.write`); bound `X-Statum-Slot` header count/size and the
+`/_statum/slots` array length.
+
+---
+
+## SA-4 — Signed frames never expire on the server side (medium)
+
+**OWASP:** A07 Identification and Authentication Failures (replay)
+**Repo:** Statum/src/StateService.vala:172-197 (`restore_slot`),
+Statum/src/SlotPostEndpoint.vala:39-59
+**Confidence:** confirmed
+
+`HeldSlotResolver.resolve_frame` drops frames past `invalid_after`
+(HeldSlotResolver.vala:195-201) — but `SlotPostEndpoint`, the endpoint whose
+whole purpose is to rehydrate the server cache from client frames, verifies
+only the signature and calls `restore_slot`, which **never checks
+`invalid_after`**. The 7-day `EXPIRY_SECONDS` window
+(StateService.vala:19) is only enforced by the *cleanup sweep* on
+`last_touched` — and `restore_slot` sets `last_touched = now`
+(StateService.vala:192), so a replayed frame looks freshly touched.
+
+**Impact:** any frame ever legitimately issued remains usable to re-cache a
+slot indefinitely (compounding SA-1: a logged-out session frame can be
+re-cached forever). Also a slow memory leak: frames replayed on a schedule
+keep their slots pinned.
+
+**Status: ADDRESSED.** `restore_slot` now rejects frames past
+`invalid_after` (new `StateError.FRAME_EXPIRED`, surfaced as an `invalidate`
+directive by `SlotPostEndpoint`) and keeps the snapshot's `as_at` as
+`last_touched`, so restored slots still expire via the cleanup sweep.
+
+**Fix direction (original):** in `restore_slot`, parse `invalid_after` from the
+snapshot and reject (with `invalidate`) frames past it; do not reset
+`last_touched` to `now` on restore — keep the snapshot's `as_at`.
+
+---
+
+## SA-5 — Disabled/deleted accounts keep working; guards are opt-in (medium)
+
+**OWASP:** A01 Broken Access Control
+**Repo:** Spry/src/Auth.vala:103-137 (`current` — snapshot trust),
+Spry/src/Auth.vala:287-302 (`revalidate` — good),
+Statum/src/*.vala (endpoints with no auth notion)
+**Confidence:** confirmed
+
+The design is: `SpryAuth.current()` is a cheap, *non-authoritative* read of
+the signed snapshot; the authoritative checks are `require_login` /
+`require_permission`, which re-validate against the user store
+(`revalidate`, Auth.vala:287-302 — correctly fails closed). Two gaps:
+
+1. Every shipped Spry action that needs auth does call the guards
+   (Login/Register/ChangePassword + all UserManagementActions — verified in
+   each file). But nothing in the framework *forces* a guard: a new action
+   that forgets `require_permission_guard()` is silently public. There is no
+   "default deny" hook in `StatumAction.handle_request`
+   (Statum/src/StatumHandlers.vala:105-149), no route-level auth metadata,
+   and no test/assertion that an action type carries a guard.
+2. `current()`-derived display data (username, permissions) keeps rendering
+   for disabled/deleted accounts until the slot is cleared — fine for nav
+   bars, but any *decision* made on `current()` rather than the guards is a
+   vulnerability. The doc comments say this, but nothing enforces it.
+
+**Status: NOT ADDRESSED — top follow-up.** SA-5 was not included in the
+approved RFC scope (the endpoint-level guard hooks of SA-6 shipped instead).
+All shipped Spry actions do call the guards (verified per file), and
+disabled/deleted accounts are caught by `revalidate` on every guarded
+request, but nothing yet *forces* a guard on a new action — the framework
+default remains allow.
+
+**Fix direction (original):** add an opt-out (not opt-in) authorisation hook:
+`StatumConfigurator` gains a `default_guard` invoked by
+`StatumAction.handle_request` before `handle()`, or an
+`[RequiresPermission]`-style registration-time declaration that
+`register_actions` can verify reflectively. At minimum, add a lint/test that
+fails when an action type neither calls a guard nor opts out explicitly.
+
+---
+
+## SA-6 — Entrypoint and SSE channel endpoints are unauthenticated (medium)
+
+**OWASP:** A01 Broken Access Control / A05 Misconfiguration
+**Repo:** Statum/src/EntrypointEndpoint.vala:24-56,
+Statum/src/ChannelEndpoint.vala:27-40,
+Statum/src/ChannelSubscriptionEndpoint.vala:22-33
+**Confidence:** confirmed (design intent understood; residual risk stated)
+
+- `GET /_statum/entrypoint?uri=…` resolves held slots and runs the page's
+  entrypoint handler with **no** authentication and **no** check that the
+  caller may see the page. The entrypoint's directives (including `set`
+  frames for the slots it creates) are returned to anyone.
+- `GET /_statum/channel` opens an SSE stream for anyone; the 128-bit random
+  channel id (ChannelEndpoint.vala:100-107, `Sodium.Random` — good entropy)
+  is the *only* authorisation for `PATCH /_statum/channel/{id}`, and
+  subscription to any slot key immediately pushes that slot's current signed
+  frame (ChannelSubscriptionEndpoint.vala:56-66).
+
+**Impact:** depends entirely on what apps put in public slot data: any
+session-agnostic data leak is reachable unauthenticated; SSE also gives an
+uncosted resource to anonymous clients. The framework cannot know what is
+sensitive, so it must let apps gate these endpoints.
+
+**Status: ADDRESSED (hooks provided, default open).**
+`StatumConfigurator.guard_entrypoint/guard_channel/guard_channel_subscriptions`
+install an `AuthorizationPolicy` consulted by the three endpoints — return
+null to allow, an `HttpResult` (401/redirect) to deny. Spry guards are the
+intended implementations; documented in Statum's getting-started.
+
+**Fix direction (original):** provide guard hooks on both endpoints (e.g. an
+overridable `authorize(http_context)` returning a 401/redirect result),
+documented as "put your auth guard here"; consider binding channel ids to
+the held auth slot so a channel leak cannot be used cross-session.
+
+---
+
+## SA-7 — Admin seeding defaults to weak documented credentials (medium)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry/src/Auth.vala:234-256 (`ensure_admin`),
+Spry/tools/spry/templates/app-main.vala:56-72,
+Spry/tools/spry/templates/README.md:43-48
+**Confidence:** confirmed (pattern; not a hardcoded backdoor)
+
+`spry new` scaffolds `seed_admin()` reading `SPRY_SEED_ADMIN_USERNAME` /
+`SPRY_SEED_ADMIN_PASSWORD`; the README's example is
+`export SPRY_SEED_ADMIN_PASSWORD=change-me`, and the docstring of
+`ensure_admin` itself uses `"change-me"`. Nothing forces a rotation, warns
+at runtime when the seeded password matches known-weak values, or expires
+seeded admin accounts. `ensure_admin` is idempotent and will not even reset
+a password — good — but the shipped example invites deploying `change-me`.
+
+**Fix direction:** scaffold prints a loud warning when seeding with a
+password shorter than N or matching a deny-list (`change-me`, `admin`,
+`password`); better, generate a random password, print it once, and require
+a change on first login (needs a `must_change_password` flag — see SA-8).
+
+**Status: ADDRESSED (warn-only, as scoped).** Both `ensure_admin`
+(Spry/src/Auth.vala) and the scaffold's `seed_admin`
+(tools/spry/templates/app-main.vala) now print a loud `warning()` when a
+NEW account is seeded with a password shorter than 12 characters
+(`PasswordPolicy.ADMIN_MIN_LENGTH`) or on the built-in deny-list.
+Seeding is never blocked — the operator's escape hatch — and existing
+accounts are never re-judged. The README example is now a
+generated-looking password with an `unset` and a rotation note.
+
+---
+
+## SA-8 — Password policy: min length 6, no max, no complexity, no lockout (medium)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry/src/Actions/RegisterAction.vala:73-78,
+Spry/src/Actions/ChangePasswordAction.vala:55-60,
+Spry/src/Actions/AlterUserAction.vala (same 6-char check),
+Spry/src/Authentication/UserService.vala:44-56
+**Confidence:** confirmed
+
+- Minimum length is 6 with no complexity guidance, no breach-list check, no
+  rate limiting and no lockout on `LoginAction` — online brute force is
+  unthrottled (only Argon2's cost slows the attacker, and it slows the
+  defender equally).
+- **No maximum length.** Argon2 hashes the entire password;
+  `Sodium.PasswordHashing.hash` (Spry/vapi/libsodium.vapi:267-274) has no
+  length cap. A 32 MB password burns ~256 MiB and seconds of CPU *per
+  attempt* on the server — an amplified DoS from a single register/login
+  request (compounding SA-3's unbounded body). OWASP recommends capping at
+  ~64-128 characters.
+
+**Fix direction:** cap password length at 128 (reject earlier with 400);
+raise the minimum to 8+ with a weak-password deny-list; add per-account +
+per-IP throttling on login (even a simple in-memory window); document the
+choice not to lock accounts (lockout enables its own DoS).
+
+**Status: ADDRESSED (except rate limiting, deliberately out of scope).**
+`ProfileValidation.PasswordPolicy`
+(Spry/src/Actions/ProfileValidation.vala) is now the single source:
+8–128 code-point bounds enforced BEFORE any hashing (the Argon2
+memory-amplification cap), plus an 11-entry built-in deny-list compared
+case-insensitively — documented as a floor, not a policy engine. All
+three password-setting sites enforce it identically (RegisterAction,
+ChangePasswordAction, AlterUserAction) with three overridable messages
+(too short / too long / too common). Password *verification* (login,
+current-password check) is intentionally NOT capped: legacy users with
+longer pre-policy passwords must keep logging in. Login rate limiting
+remains a follow-up (explicitly not approved in this pass).
+
+---
+
+## SA-9 — web-config.json secret handling (medium)
+
+**OWASP:** A02 Cryptographic Failures / A05 Misconfiguration
+**Repo:** Statum/src/Statum.vala:77-120,
+Astralis/src/Core/WebConfigLoader.vala:31-76,
+Spry/tools/spry/Keys.vala (writes 0600 — good)
+**Confidence:** confirmed
+
+All six long-lived key fields (Ed25519 signing, Ed25519 encryption-signing,
+X25519 sealing — secret and public halves) live in plaintext in
+`web-config.json`, discovered from the CWD or `ASTRALIS_CONFIG_PATH`. The
+loader never checks file permissions — a world-readable config silently
+yields every session-signing key. `spry keys` does chmod 0600
+(Keys.vala:109ff, Generator.write_file mode parameter), but hand-edited or
+copied configs are never re-checked, and a manual `statum-genkeys` redirect
+leaves whatever umask produced. The fallback when keys are absent is to run
+with **ephemeral keys** (Statum.vala:86-94 warns loudly — good) which
+silently invalidates all client-held sessions on restart but keeps serving;
+an operator who misses the warning runs a server whose sessions die daily.
+
+**Fix direction:** at startup, stat the resolved config files; refuse (or
+require an explicit override) when the statum section is present and the
+file is group/world-readable. Consider env-var-only secret injection
+(`STATUM_SIGNING_SECRET_KEY` etc.) for container deployments where the
+config file is image-baked.
+
+**Status: ADDRESSED (warning, no refusal).** Statum's config load now stats
+every file that contributed the merged "statum" section (following
+symlinks) and prints a loud warning naming the file, its mode and the
+`chmod 600` fix when it is group/world-readable AND the section actually
+carries a secret key. It deliberately does not refuse: refusal would brick
+0644-config dev setups and image-baked containers (rationale in
+Statum/src/Statum.vala). Env-var secret injection remains unimplemented.
+
+---
+
+## SA-10 — verify_frame ignores the signer claim (medium)
+
+**OWASP:** A02 Cryptographic Failures
+**Repo:** Statum/src/Cryptography/SigningProvider.vala:99-103
+**Confidence:** confirmed; impact is deployment-dependent (low today,
+single-signer)
+
+`verify_frame` verifies `frame.content`/`frame.signature` against *this
+provider's* key regardless of `frame.signer`. In a deployment with more than
+one signing instance (load balancing, blue/green, key rotation windows) a
+frame signed by key A is accepted while claiming `signer: B`. Nothing today
+exploits the mismatch — the signer string is informational — but the API
+invites trust in a field it does not validate, and future multi-key support
+would silently build on it.
+
+**Status: ADDRESSED (single-signer contract).** `verify_frame` asserts
+`frame.signer == signer_id` and fails closed on mismatch; `verify_with_key`
+is documented as the explicit path for a second signer during rotation.
+
+**Fix direction (original):** `verify_frame` should decode `frame.signer` (it is
+`Base64(public_key)`) and use `verify_with_key` with it, falling back to the
+local key only when the signer is unknown — or simply assert
+`frame.signer == signer_id` and document the single-signer contract.
+
+---
+
+## SA-11 — Account enumeration surfaces (medium)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry/src/Actions/LoginAction.vala:38-45 (good — identical message),
+Spry/src/Actions/RegisterAction.vala:87-96,
+Spry/src/Authentication/UserService.vala:44-56
+**Confidence:** confirmed (enumeration on register; login timing only
+partially equalised)
+
+`LoginAction` correctly answers one generic message for unknown-user and
+wrong-password, and `authenticate_user` burns the dummy hash
+(UserService.vala:47-50) so both paths cost one Argon2 verify — well done.
+Residual surfaces:
+
+1. **Registration enumerates.** "Username already exists" vs "Email already
+   registered" vs success reveals which identities are present on the
+   system. That is a product trade-off (usability), but worth a conscious
+   decision — many sites fold both into one message.
+2. **Disabled accounts answer identically to bad credentials** (good,
+   UserService.vala:51) but the *timing* differs: disabled accounts skip the
+   `Sodium.PasswordHashing.check` on the real hash only when the password
+   check itself passes — i.e. the timing oracle requires already knowing the
+   password. Negligible; noted for completeness.
+3. **`get_user_by_username` timing** on unknown vs known username is
+   equalised only by the dummy hash — correct as written.
+
+**Status: NOT ADDRESSED (explicit human decision).** The human chose to keep
+distinct username/email registration messages for usability; login already
+answers generically. Revisit only if a threat model change demands it.
+
+**Fix direction (original):** decide and document the enumeration stance for
+registration; optionally rate-limit register by IP (SA-8).
+
+---
+
+## SA-12 — statum-genkeys prints secret keys to stdout (low)
+
+**OWASP:** A09 Security Logging and Monitoring Failures (secret exposure)
+**Repo:** Statum/tools/statum-genkeys/statum-genkeys.vala:31-40
+**Confidence:** confirmed; deliberate design (ready-to-paste JSON)
+
+The tool prints all three secret keys to stdout for pasting into
+`web-config.json`. Shell history, terminal scrollback, CI logs and
+`2>&1 | tee` captures all acquire the keys. `spry keys` wraps it and writes
+the file at 0600, but direct use (as the Statum warnings instruct) leaks to
+whatever captured the output.
+
+**Status: ADDRESSED.** `statum-genkeys --out <file>` writes the full config
+at mode 0600 (creating parent dirs); without `--out`, stdout carries only
+the public halves in the same JSON shape plus a pointer to `--out`. `spry
+keys` was adapted to spawn `--out` against a 0600 tempfile.
+
+**Fix direction (original):** add `--out <file>` (write 0600 directly) and print only
+public halves to the terminal; or print secrets only with an explicit
+`--print-secrets` acknowledgement.
+
+---
+
+## SA-13 — No security response headers (low)
+
+**OWASP:** A05 Security Misconfiguration
+**Repo:** Astralis (no header defaults anywhere in src/),
+Statum/src/StatumDirectives.vala:65-66 (sets `Cache-Control: no-store` only)
+**Confidence:** confirmed
+
+Nothing sets `Content-Security-Policy`, `Strict-Transport-Security`,
+`X-Content-Type-Options: nosniff`, `X-Frame-Options`/`frame-ancestors`,
+`Referrer-Policy`. Directive responses do send `Cache-Control: no-store`
+(StatumDirectives.vala:66 — good, keeps frames out of shared caches), and
+SSE sends `no-cache`, but HTML pages and static files carry no defaults.
+Given the client renders server data via `innerHTML` paths (stm-html /
+stm-markdown), a CSP would materially shrink the XSS blast radius.
+
+**Status: ADDRESSED.** `SecurityHeadersComponent` appends
+`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
+`Referrer-Policy: strict-origin-when-cross-origin` and a CSP template by
+default; endpoint-set headers win; everything is overridable/disableable
+via the `securityHeaders` config section, and HSTS is strictly opt-in.
+
+**Fix direction (original):** a pipeline component in Astralis that appends a
+configurable default header set (`nosniff`, `frame-ancestors 'self'`,
+`referrer-policy strict-origin-when-cross-origin`, and a CSP template apps
+extend); HSTS only behind explicit opt-in (it bricks local HTTP dev
+otherwise).
+
+---
+
+## SA-14 — Held-slot claims are not bound to a session (low)
+
+**OWASP:** A01 Broken Access Control / A07
+**Repo:** Statum/src/HeldSlotResolver.vala:56-92,
+Statum/js/statum.js:941 (client sends `X-Statum-Slot`)
+**Confidence:** suspected — the one unverified step is whether any shipped
+slot type carries data whose cross-user confusion matters
+
+Any client may present any set of `X-Statum-Slot` headers; frames must
+verify against the server's signing key, but *which* slots a client claims
+to hold is entirely client-chosen and uncorrelated with any session notion.
+Two users on different machines can both present the same valid frame (it is
+bearer data by design — like a cookie, but never bound to anything). This is
+the root of SA-1's severity: the auth slot is a bearer token with no
+revocation and no subject binding beyond `user_id` inside the encrypted
+half.
+
+**Status: ADDRESSED for the auth slot (see SA-1's epoch); bearer model
+documented.** `sessions_valid_after` binds the auth slot to server-side
+validity; the general bearer model of other slot types stands as designed.
+
+**Fix direction (original):** document the bearer-token model explicitly; for the auth
+slot specifically, bind it (inside the encrypted private half, which the
+server controls) to a server-generated session id that logout and password
+change invalidate — this is the same mechanism SA-1 needs, and one
+implementation serves both.
+
+---
+
+## SA-15 — SSE/channel resource exhaustion (low)
+
+**OWASP:** DoS
+**Repo:** Statum/src/ChannelEndpoint.vala:21-40 (Series, per-connection
+subs), Astralis/src/Endpoints/SseEndpoint.vala:174-179 (100 ms poll loop)
+**Confidence:** confirmed
+
+Every SSE connection allocates a `ChannelConnection` with an unbounded
+subscription `Set`, registered forever until disconnect; nothing caps total
+connections or total subscriptions, and the keep-alive loop wakes every
+100 ms per connection (cheap, but N connections = 10·N wakeups/sec on the
+main loop). Combined with SA-3's missing `MHD_OPTION_CONNECTION_LIMIT`,
+an anonymous client can open thousands of channel streams.
+
+**Status: ADDRESSED (Statum side).** 256 global / 16 per-IP concurrent
+channel connections (released on disconnect, swept when closed) and 64
+subscriptions per channel, over-cap answers 503; connection caps also run
+before the SSE result is created via `ChannelGatewayEndpoint`. The
+Astralis `SseEndpoint` 100 ms keep-alive poll remains as-is (cheap; the
+connection caps bound the total wakeups now).
+
+**Fix direction (original):** cap concurrent SSE connections per IP and globally;
+bound subscriptions per channel; lengthen the poll interval or drive close
+notification from the disconnect signal rather than polling `is_closed`.
+
+---
+
+## SA-16 — No Argon2 rehash-on-parameters-change (low)
+
+**OWASP:** A02 Cryptographic Failures
+**Repo:** Spry/vapi/libsodium.vapi:267-281 (no `str_needs_rehash` binding),
+Spry/src/Authentication/UserService.vala:44-56
+**Confidence:** confirmed
+
+Hashes are created with `OPSLIMIT_MODERATE`/`MEMLIMIT_MODERATE` (Argon2id,
+m=256 MiB, t=3 — sound for 2024+ interactive auth). There is no
+`crypto_pwhash_str_needs_rehash` binding and no migration path, so raising
+the cost in future leaves every existing hash at the old parameters until
+the user next changes password.
+
+**Fix direction:** bind `crypto_pwhash_str_needs_rehash`; on successful
+login, if the stored hash needs rehash, rewrite it transparently.
+
+**Status: ADDRESSED (parser, not a binding).** `UserService` now parses
+the stored hash's PHC string (`parse_hash_parameters`) and compares
+algorithm, version and m/t/p against the current MODERATE limits
+(`hash_needs_rehash`; note the units: PHC `m=` is KiB, libsodium's
+MEMLIMIT is bytes). On successful login a stale hash is transparently
+rewritten at current parameters (`rehash_password`) — best-effort, with
+the session-validity epoch deliberately NOT bumped, since no credential
+changed. Malformed/non-PHC hashes answer "needs rehash", making rehash
+the only migration path. Verified live against a real SQLite store
+(`tests/RehashProbe.vala`, wired as a meson test).
+
+---
+
+## SA-17 — Internal error text reaches users (low)
+
+**OWASP:** A05 Security Misconfiguration (information exposure)
+**Repo:** Spry/src/Actions/*.vala — every `*_failed_message.printf(e.message)`
+(RegisterAction.vala:96, ChangePasswordAction.vala:76,
+AlterUserAction.vala:101, DeleteUserAction.vala:49,
+GrantPermissionAction.vala:52, RevokePermissionAction.vala:53,
+SetUserEnabledAction.vala:46), Astralis/src/Server/Server.vala:215 (logs
+only — fine)
+**Confidence:** confirmed
+
+Storage-layer exception messages (which for SQLite-backed
+Invercargill-Sql can include file paths and constraint SQL fragments) are
+formatted straight into `notify("error", …)` text shown in the browser. No
+passwords/secrets appear (verified: no logging of credentials anywhere in
+Spry/Statum src), but internals leak.
+
+**Fix direction:** log the full `e.message` server-side
+(`warning(...)`), show a generic message client-side.
+
+**Status: ADDRESSED.** All seven actions now route failures through
+`ProfileValidation.internal_error_message(e, generic)`, which logs the
+full `e.message` via `warning()` and returns the (placeholder-free)
+generic constant for the notify text. The `*_failed_message` constants
+lost their `%s`; internal-only detail sites (missing reload, missing
+target user) log their ids server-side instead of interpolating them.
+The duplicate-constraint mapping (which shows a *chosen* message, never
+the SQL text) is unchanged.
+
+---
+
+## SA-18 — new Function() template-expression evaluation (low)
+
+**OWASP:** A03 Injection
+**Repo:** Statum/js/statum.js:174-190 (compileExpr), 3362-3374 (attribute
+binding)
+**Confidence:** confirmed as a primitive; not currently exploitable because
+expressions come from page markup only
+
+`stm-*` attribute values are compiled with
+`new Function('scope', 'with(scope){...}')` and evaluated against a Proxy
+scope. Any path where *user data* becomes an attribute expression string
+becomes arbitrary JS in the page origin. Today expressions are authored in
+server-side page templates (trusted developer input), and user data enters
+only as *values* (`stm-text` → `textContent`, `stm-html` → innerHTML,
+`stm-markdown` → the CommonMark renderer), so no injection path exists in
+the shipped stack. This is a sharp edge to document and defend.
+
+**Status: DOCUMENTED (accepted for now).** Documented in Statum's
+getting-started "Server-side Security" section as a page-author contract:
+expressions are trusted authoring; never bind user data into expression
+*source*. No shipped component does. A whitelist expression parser remains
+possible future work.
+
+**Fix direction (original):** document "attribute expressions are trusted template
+authoring, never bind them to user data"; consider a small expression
+whitelist parser (paths, comparisons, ternaries) to make misuse impossible.
+
+---
+
+## SA-19 — Checked and found sound (non-findings)
+
+Recorded so future audits don't re-derive them:
+
+- **SQL injection — sound.** All Spry queries go through
+  Invercargill-Sql expression trees; `ExpressionToSqlVisitor.visit_literal`
+  (reference/Invercargill-Sql/src/expressions/expression-to-sql-visitor.vala:146-157)
+  emits `:pN` placeholders and collects values for binding — values never
+  interpolate into SQL text. (Report-only repo; `visit_property` does append
+  identifiers unquoted, but property names come from entity definitions,
+  never user input, in every Spry caller.)
+- **Markdown XSS — sound.** The CommonMark renderer escapes all text/code/
+  html literals (`mdEscape`, statum.js:3145-3148), escapes attributes
+  (`mdEscapeAttr`), allowlists URL schemes (`safeMarkdownUrl`,
+  statum.js:1275-1282) and renders raw HTML inert. Prior audit commit
+  f0c967d fixed the `>` attribute escape.
+- **Server-rendered markup XSS — sound.** libxml2's HTML serializer (used by
+  `MarkupDocument.to_html`) escapes text and attribute content; the one
+  hand-built HTML path (directory listing) escapes via `Markup.escape_text`
+  and URI-escapes link targets (FilesystemResource.vala:152-165).
+- **Markup template engine — sound.** Parsed once with
+  `Html.ParserOption.NONET` (MarkupTemplate.vala:75-80); no runtime
+  string interpolation into markup.
+- **Encryption — sound.** X25519 sealing (libsodium `crypto_box_seal`)
+  generates a fresh ephemeral key/nonce per seal — no nonce-reuse class;
+  Ed25519 detached signatures are constant-time verified in libsodium; the
+  namespace prefix prevents cross-protocol blob confusion
+  (EncryptionProvider.vala:90-95, 133-152).
+- **spry CLI — sound.** All spawns use argv arrays, never a shell
+  (Deploy.vala:169-171, Dev.vala:116-129); scaffolding writes 0644 with
+  web-config.json at 0600 (Generator.vala:29-45).
+- **statum-mkres / statum-mkpstm — sound.** Build-time packers: they read
+  files and emit Vala sources; no archive extraction, so no zip-slip
+  surface.
+- **Login timing — sound.** Identical response text for unknown user vs
+  wrong password, with a dummy-hash Argon2 verify equalising the unknown
+  user path (UserService.vala:47-53).
+
+## Suspected-but-unconfirmed summary
+
+- **SA-14** (slot bearer-model impact) — confirming requires a threat-model
+  decision about which slot types may be shared; code-wise the model is as
+  described.
+- **SA-2 exploitability today** depends on SA-2b (the router regression
+  masking `**`); confirming end-to-end reachability of `../` through a live
+  server would need the router bug fixed first — recommended as the first
+  implementation task either way, since it is a functional break of deep
+  static routes *and* the enabler for the traversal guard test.
+
+## Method notes
+
+Tooling: manual source review of all 41 Vala files in Astralis/src, 31 in
+Statum/src, 16 in Spry/src + tools, Statum/js (3,943 lines) and the
+Invercargill-Sql expression visitor (read-only reference). Git history was
+used to date regressions (EndpointRouter `**` regression 9a3bb18b; prior
+markdown audit f0c967d). No code was modified, no servers were run, and no
+dynamic testing was performed — every finding is static-analysis evidence
+with file:line citations. OWASP mapping uses the 2021 Top 10 categories.

+ 2 - 1
meson.build

@@ -1,5 +1,5 @@
 project('spry', ['c', 'vala'],
-  version: '0.2',
+  version: '0.3',
 )
 
 vapi_dir = join_paths(meson.current_source_dir(), 'vapi')
@@ -29,3 +29,4 @@ add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
 
 subdir('src')
 subdir('tools')
+subdir('tests')

+ 17 - 6
src/Actions/AlterUserAction.vala

@@ -37,7 +37,13 @@ namespace Spry.Actions {
 		protected virtual string invalid_date_of_birth_message { get { return "Please enter a valid date of birth (YYYY-MM-DD)"; } }
 
 		/** Notification shown when the new password is too short. */
-		protected virtual string password_too_short_message { get { return "Password must be at least 6 characters"; } }
+		protected virtual string password_too_short_message { get { return "Password must be at least 8 characters"; } }
+
+		/** Notification shown when the new password is too long. */
+		protected virtual string password_too_long_message { get { return "Password must be at most 128 characters"; } }
+
+		/** Notification shown when the new password is on the deny-list. */
+		protected virtual string password_too_common_message { get { return "That password is too common. Please choose another."; } }
 
 		/** Notification shown when the username is already taken. */
 		protected virtual string duplicate_username_message { get { return "Username already exists. Please choose another."; } }
@@ -51,8 +57,8 @@ namespace Spry.Actions {
 		/** Notification shown after a successful update. */
 		protected virtual string user_updated_message { get { return "User updated."; } }
 
-		/** Notification template (`%s` = error message) for storage failures. */
-		protected virtual string update_failed_message { get { return "Could not update the user: %s"; } }
+		/** Notification shown when the update could not be persisted. */
+		protected virtual string update_failed_message { get { return "Could not update the user. Please try again later."; } }
 
 		public override async DirectiveBuilder handle() throws GLib.Error {
 			var guard = yield require_permission_guard();
@@ -82,8 +88,12 @@ namespace Spry.Actions {
 			}
 			var profile = (!)fields;
 
-			if (new_password.length > 0 && new_password.length < 6) {
-				return directives().notify("error", password_too_short_message);
+			if (new_password.length > 0) {
+				var password_rejection = ProfileValidation.PasswordPolicy.assess(new_password);
+				if (password_rejection != null) {
+					return directives().notify("error", ProfileValidation.password_rejection_message(
+						(!)password_rejection, password_too_short_message, password_too_long_message, password_too_common_message));
+				}
 			}
 
 			bool enabled = enabled_text.down() == "on" || enabled_text.down() == "true" || enabled_text.down() == "1";
@@ -98,7 +108,8 @@ namespace Spry.Actions {
 				if (duplicate != null) {
 					return directives().notify("error", (!)duplicate);
 				}
-				return directives().notify("error", update_failed_message.printf(e.message));
+				return directives().notify("error",
+					ProfileValidation.internal_error_message(e, update_failed_message));
 			}
 		}
 

+ 28 - 7
src/Actions/ChangePasswordAction.vala

@@ -26,7 +26,13 @@ namespace Spry.Actions {
 		protected virtual string current_password_invalid_message { get { return "Your current password is incorrect."; } }
 
 		/** Notification shown when the new password is too short. */
-		protected virtual string password_too_short_message { get { return "Password must be at least 6 characters."; } }
+		protected virtual string password_too_short_message { get { return "Password must be at least 8 characters."; } }
+
+		/** Notification shown when the new password is too long. */
+		protected virtual string password_too_long_message { get { return "Password must be at most 128 characters."; } }
+
+		/** Notification shown when the new password is on the deny-list. */
+		protected virtual string password_too_common_message { get { return "That password is too common. Please choose another."; } }
 
 		/** Notification shown when the password fields do not match. */
 		protected virtual string password_mismatch_message { get { return "Passwords do not match."; } }
@@ -34,8 +40,8 @@ namespace Spry.Actions {
 		/** Notification shown after a successful change. */
 		protected virtual string password_changed_message { get { return "Your password has been changed."; } }
 
-		/** Notification template (`%s` = error message) for storage failures. */
-		protected virtual string change_failed_message { get { return "Could not change password: %s"; } }
+		/** Notification shown when the change could not be persisted. */
+		protected virtual string change_failed_message { get { return "Could not change your password. Please try again later."; } }
 
 		public override async DirectiveBuilder handle() throws GLib.Error {
 			var guard = yield SpryAuth.require_login(directives(), request, users, login_uri);
@@ -55,8 +61,10 @@ namespace Spry.Actions {
 			if (current_password.length == 0) {
 				return directives().notify("error", current_password_required_message);
 			}
-			if (new_password.length < 6) {
-				return directives().notify("error", password_too_short_message);
+			var password_rejection = ProfileValidation.PasswordPolicy.assess(new_password);
+			if (password_rejection != null) {
+				return directives().notify("error", ProfileValidation.password_rejection_message(
+					(!)password_rejection, password_too_short_message, password_too_long_message, password_too_common_message));
 			}
 			if (new_password != confirm) {
 				return directives().notify("error", password_mismatch_message);
@@ -69,9 +77,22 @@ namespace Spry.Actions {
 					return directives().notify("error", current_password_invalid_message);
 				}
 				yield users.set_password(me.user_id, new_password.dup());
-				return directives().notify("success", password_changed_message);
+				// set_password bumped the session-validity epoch: re-mint THIS
+				// session's auth slot from the freshly loaded user so the frame
+				// riding this very response is authored after the bump and the
+				// user changing their own password stays logged in, while every
+				// other device holding an older frame is revoked (SA-1/SA-14).
+				var refreshed = yield users.get_user(me.user_id);
+				if (refreshed == null) {
+					warning("[Spry] the account could not be reloaded after a password change (user %d)",
+						(int) me.user_id);
+					return directives().notify("error", change_failed_message);
+				}
+				return SpryAuth.relogin(directives(), (!)refreshed, action_registry)
+					.notify("success", password_changed_message);
 			} catch (Error e) {
-				return directives().notify("error", change_failed_message.printf(e.message));
+				return directives().notify("error",
+					ProfileValidation.internal_error_message(e, change_failed_message));
 			}
 		}
 	}

+ 4 - 3
src/Actions/DeleteUserAction.vala

@@ -19,8 +19,8 @@ namespace Spry.Actions {
 		/** Notification shown after a successful delete. */
 		protected virtual string user_deleted_message { get { return "User deleted."; } }
 
-		/** Notification template (`%s` = error message) for storage failures. */
-		protected virtual string delete_failed_message { get { return "Could not delete the user: %s"; } }
+		/** Notification shown when the delete could not be persisted. */
+		protected virtual string delete_failed_message { get { return "Could not delete the user. Please try again later."; } }
 
 		public override async DirectiveBuilder handle() throws GLib.Error {
 			var guard = yield require_permission_guard();
@@ -40,7 +40,8 @@ namespace Spry.Actions {
 				yield users.delete_user(user_id);
 				return directives().notify("success", user_deleted_message);
 			} catch (Error e) {
-				return directives().notify("error", delete_failed_message.printf(e.message));
+				return directives().notify("error",
+					ProfileValidation.internal_error_message(e, delete_failed_message));
 			}
 		}
 	}

+ 6 - 4
src/Actions/GrantPermissionAction.vala

@@ -22,8 +22,8 @@ namespace Spry.Actions {
 		/** Notification shown after a successful grant. */
 		protected virtual string permission_granted_message { get { return "Permission granted."; } }
 
-		/** Notification template (`%s` = error message) for storage failures. */
-		protected virtual string grant_failed_message { get { return "Could not grant the permission: %s"; } }
+		/** Notification shown when the grant could not be persisted. */
+		protected virtual string grant_failed_message { get { return "Could not grant the permission. Please try again later."; } }
 
 		public override async DirectiveBuilder handle() throws GLib.Error {
 			var guard = yield require_permission_guard();
@@ -44,12 +44,14 @@ namespace Spry.Actions {
 			try {
 				var target = yield users.get_user(user_id);
 				if (target == null) {
-					return directives().notify("error", grant_failed_message.printf(@"No user exists with id $(user_id)"));
+					warning("[Spry] grant refused: no user exists with id %d", (int) user_id);
+					return directives().notify("error", grant_failed_message);
 				}
 				yield users.set_user_permission(user_id, permission);
 				return directives().notify("success", permission_granted_message);
 			} catch (Error e) {
-				return directives().notify("error", grant_failed_message.printf(e.message));
+				return directives().notify("error",
+					ProfileValidation.internal_error_message(e, grant_failed_message));
 			}
 		}
 	}

+ 123 - 3
src/Actions/ProfileValidation.vala

@@ -6,12 +6,114 @@ namespace Spry.Actions {
      * Shared profile-field validation for {@link RegisterAction} and
      * {@link AlterUserAction}: the single source of the `username`,
      * `email`, `forename`, `surname` and `date_of_birth` (YYYY-MM-DD)
-     * checks and of the duplicate-row error mapping, so identical input
-     * yields identical outcomes from both actions. Actions map the
-     * results onto their own overridable notification messages.
+     * checks, of the password policy ({@link PasswordPolicy}) and of the
+     * duplicate-row error mapping, so identical input yields identical
+     * outcomes from both actions. Actions map the results onto their own
+     * overridable notification messages.
      */
     public class ProfileValidation : Object {
 
+        /**
+         * The password policy every password-setting site enforces
+         * (register, change-password, admin alter-user): minimum length
+         * 8, maximum length 128, and a tiny built-in deny-list of
+         * well-known passwords. The length bounds run BEFORE any
+         * hashing, so an oversized password can never reach Argon2's
+         * memory-heavy first pass (SA-8: a 32 MB password would
+         * otherwise burn ~256 MiB and seconds of CPU per attempt —
+         * memory amplification, not a DoS via padded hash input).
+         */
+        public class PasswordPolicy : Object {
+
+            /** Shortest accepted password, in UTF-8 code points. */
+            public const int MIN_LENGTH = 8;
+
+            /**
+             * Shortest sane admin/seed password, in code points — the
+             * SA-7 warning threshold for {@link Spry.SpryAuth.ensure_admin}
+             * and the scaffold's `seed_admin`. Advisory only: seeding is
+             * never blocked, only warned about.
+             */
+            public const int ADMIN_MIN_LENGTH = 12;
+
+            /**
+             * Longest accepted password, in code points — the
+             * Argon2-memory-amplification cap. OWASP suggests 64-128.
+             */
+            public const int MAX_LENGTH = 128;
+
+            /**
+             * Well-known passwords refused outright, compared
+             * case-insensitively. A deliberately tiny floor, not a
+             * policy engine: sites needing breach-corpus or
+             * complexity checks should layer them on top by
+             * overriding the rejection message.
+             */
+            public const string[] DENY_LIST = {
+                "change-me", "password", "password1", "12345678",
+                "123456789", "qwerty123", "letmein", "admin",
+                "welcome", "welcome1", "iloveyou"
+            };
+
+            /**
+             * Why {@link assess} refused a password.
+             */
+            public enum Rejection {
+                /** Shorter than {@link MIN_LENGTH} characters. */
+                TOO_SHORT,
+                /** Longer than {@link MAX_LENGTH} characters. */
+                TOO_LONG,
+                /** Matches an entry of {@link DENY_LIST}. */
+                TOO_COMMON
+            }
+
+            /**
+             * Validates a candidate `password` (already trimmed by the
+             * caller if that site trims): returns `null` when it is
+             * acceptable, else the first failed check. The deny-list
+             * runs after the length checks so a 4-char `admin` reports
+             * TOO_SHORT, and uses `char_count` (not `length`) so
+             * multi-byte passwords are not over-tightened.
+             */
+            public static Rejection? assess(string password) {
+                long chars = password.char_count();
+                if (chars < MIN_LENGTH) {
+                    return Rejection.TOO_SHORT;
+                }
+                if (chars > MAX_LENGTH) {
+                    return Rejection.TOO_LONG;
+                }
+                string lowered = password.down();
+                foreach (string weak in DENY_LIST) {
+                    if (lowered == weak) {
+                        return Rejection.TOO_COMMON;
+                    }
+                }
+                return null;
+            }
+
+            /**
+             * True when `password` would be refused by {@link assess}
+             * because it is well-known — used by the admin-seeding
+             * warning (SA-7), which reports but never blocks.
+             */
+            public static bool is_denied(string password) {
+                return assess(password) == Rejection.TOO_COMMON;
+            }
+        }
+
+        /**
+         * Logs the real cause `e` server-side and returns `message` for
+         * the client — the SA-17 split between operator visibility and
+         * what a user's browser sees. Storage-layer exception text
+         * (which can carry SQL fragments and file paths) never reaches
+         * a notification; `warning()` keeps the full detail in the log.
+         */
+        public static string internal_error_message(Error e, string message) {
+            warning("[Spry] action failed: %s", e.message);
+            return message;
+        }
+
         /**
          * Validates the shared profile fields read from `request`'s form:
          * returns the parsed {@link Fields}, or `null` with {@link
@@ -69,6 +171,24 @@ namespace Spry.Actions {
             return new Fields(username, email, forename, surname, date_of_birth);
         }
 
+        /**
+         * Maps a {@link PasswordPolicy}.`Rejection` onto the calling
+         * action's overridable notification text: pass each site's
+         * too-short / too-long / too-common message.
+         */
+        public static string password_rejection_message(PasswordPolicy.Rejection rejection,
+                string too_short_message, string too_long_message, string too_common_message) {
+            switch (rejection) {
+                case PasswordPolicy.Rejection.TOO_LONG:
+                    return too_long_message;
+                case PasswordPolicy.Rejection.TOO_COMMON:
+                    return too_common_message;
+                case PasswordPolicy.Rejection.TOO_SHORT:
+                default:
+                    return too_short_message;
+            }
+        }
+
         /**
          * Maps a failed register/alter `e` onto a friendly duplicate
          * notification: returns `null` when `e` is not a uniqueness

+ 15 - 6
src/Actions/RegisterAction.vala

@@ -38,7 +38,13 @@ namespace Spry.Actions {
 		protected virtual string invalid_date_of_birth_message { get { return "Please enter a valid date of birth (YYYY-MM-DD)"; } }
 
 		/** Notification shown when the password is too short. */
-		protected virtual string password_too_short_message { get { return "Password must be at least 6 characters"; } }
+		protected virtual string password_too_short_message { get { return "Password must be at least 8 characters"; } }
+
+		/** Notification shown when the password is too long. */
+		protected virtual string password_too_long_message { get { return "Password must be at most 128 characters"; } }
+
+		/** Notification shown when the password is on the deny-list. */
+		protected virtual string password_too_common_message { get { return "That password is too common. Please choose another."; } }
 
 		/** Notification shown when the password fields do not match. */
 		protected virtual string password_mismatch_message { get { return "Passwords do not match"; } }
@@ -52,8 +58,8 @@ namespace Spry.Actions {
 		/** Notification shown when another uniqueness constraint fired. */
 		protected virtual string duplicate_user_message { get { return "A user with this information already exists."; } }
 
-		/** Notification template (`%s` = error message) for storage failures. */
-		protected virtual string registration_failed_message { get { return "Registration failed: %s"; } }
+		/** Notification shown when registration fails for any other reason. */
+		protected virtual string registration_failed_message { get { return "Registration failed. Please try again later."; } }
 
 		public override async DirectiveBuilder handle() throws GLib.Error {
 			string password = "";
@@ -70,8 +76,10 @@ namespace Spry.Actions {
 			}
 			var profile = (!)fields;
 
-			if (password.length < 6) {
-				return directives().notify("error", password_too_short_message);
+			var password_rejection = ProfileValidation.PasswordPolicy.assess(password);
+			if (password_rejection != null) {
+				return directives().notify("error", ProfileValidation.password_rejection_message(
+					(!)password_rejection, password_too_short_message, password_too_long_message, password_too_common_message));
 			}
 			if (password != confirm) {
 				return directives().notify("error", password_mismatch_message);
@@ -85,7 +93,8 @@ namespace Spry.Actions {
 				if (duplicate != null) {
 					return directives().notify("error", (!)duplicate);
 				}
-				return directives().notify("error", registration_failed_message.printf(e.message));
+				return directives().notify("error",
+					ProfileValidation.internal_error_message(e, registration_failed_message));
 			}
 		}
 

+ 6 - 4
src/Actions/RevokePermissionAction.vala

@@ -23,8 +23,8 @@ namespace Spry.Actions {
 		/** Notification shown after a successful revoke. */
 		protected virtual string permission_revoked_message { get { return "Permission revoked."; } }
 
-		/** Notification template (`%s` = error message) for storage failures. */
-		protected virtual string revoke_failed_message { get { return "Could not revoke the permission: %s"; } }
+		/** Notification shown when the revoke could not be persisted. */
+		protected virtual string revoke_failed_message { get { return "Could not revoke the permission. Please try again later."; } }
 
 		public override async DirectiveBuilder handle() throws GLib.Error {
 			var guard = yield require_permission_guard();
@@ -45,12 +45,14 @@ namespace Spry.Actions {
 			try {
 				var target = yield users.get_user(user_id);
 				if (target == null) {
-					return directives().notify("error", revoke_failed_message.printf(@"No user exists with id $(user_id)"));
+					warning("[Spry] revoke refused: no user exists with id %d", (int) user_id);
+					return directives().notify("error", revoke_failed_message);
 				}
 				yield users.remove_user_permission(user_id, permission);
 				return directives().notify("success", permission_revoked_message);
 			} catch (Error e) {
-				return directives().notify("error", revoke_failed_message.printf(e.message));
+				return directives().notify("error",
+					ProfileValidation.internal_error_message(e, revoke_failed_message));
 			}
 		}
 	}

+ 4 - 3
src/Actions/SetUserEnabledAction.vala

@@ -19,8 +19,8 @@ namespace Spry.Actions {
 		/** Notification shown after disabling a user. */
 		protected virtual string user_disabled_message { get { return "User has been disabled."; } }
 
-		/** Notification template (`%s` = error message) for storage failures. */
-		protected virtual string update_failed_message { get { return "Could not update the user: %s"; } }
+		/** Notification shown when the update could not be persisted. */
+		protected virtual string update_failed_message { get { return "Could not update the user. Please try again later."; } }
 
 		public override async DirectiveBuilder handle() throws GLib.Error {
 			var guard = yield require_permission_guard();
@@ -43,7 +43,8 @@ namespace Spry.Actions {
 				yield users.set_user_enabled(user_id, enabled);
 				return directives().notify("success", enabled ? user_enabled_message : user_disabled_message);
 			} catch (Error e) {
-				return directives().notify("error", update_failed_message.printf(e.message));
+				return directives().notify("error",
+					ProfileValidation.internal_error_message(e, update_failed_message));
 			}
 		}
 	}

+ 60 - 4
src/Auth.vala

@@ -109,7 +109,10 @@ namespace Spry {
 		 * disabled, deleted or revoked account still resolves here until
 		 * its slot is cleared. Authorisation decisions must go through
 		 * {@link require_login} or {@link require_permission}, which
-		 * re-validate against the user store on every guarded request.
+		 * re-validate against the user store on every guarded request —
+		 * that is also where session revocation (the user's
+		 * `sessions_valid_after` epoch, bumped on password change) is
+		 * enforced; this read stays non-authoritative by design.
 		 */
 		public static CurrentUser current(StatumRequest request) {
 			HeldSlot slot;
@@ -156,6 +159,19 @@ namespace Spry {
 			return directives.set_private_typed<AuthPublic, AuthPrivate>(SLOT_TYPE, Scope.SESSION, pub, priv);
 		}
 
+		/**
+		 * Re-mints `user`'s `auth` slot exactly as {@link login} does, for
+		 * callers that just bumped the user's `sessions_valid_after` epoch
+		 * (a password change) and want the CURRENT session to survive its
+		 * own revocation: the fresh frame is authored after the bump, so it
+		 * clears the new epoch while every older frame on other devices is
+		 * revoked. Chains onto `directives` (typically the same builder the
+		 * notification rides on).
+		 */
+		public static DirectiveBuilder relogin(DirectiveBuilder directives, UserProjection user, ActionRegistry action_registry) throws GLib.Error {
+			return login(directives, user, action_registry);
+		}
+
 		/**
 		 * Guard for handlers that need a logged-in user: returns `null`
 		 * when the held `auth` slot maps to a live, enabled account, else a
@@ -228,8 +244,14 @@ namespace Spry {
 		 * ```
 		 * var scope = container.create_scope();
 		 * yield SpryAuth.ensure_admin(scope.resolve<UserService>(),
-		 *     "admin", "admin@example.com", "change-me");
+		 *     "admin", "admin@example.com", "a long generated password");
 		 * ```
+		 *
+		 * SA-7: when a NEW account is seeded with a weak password
+		 * (shorter than {@link Spry.Actions.ProfileValidation.PasswordPolicy}.ADMIN_MIN_LENGTH
+		 * or on the deny-list), a loud warning names the weakness but
+		 * seeding proceeds — the operator's escape hatch. Existing
+		 * accounts are never reset, so their passwords are not re-judged.
 		 */
 		public static async void ensure_admin(UserService users, string username, string email, string password) throws Error {
 			var existing = yield users.get_user_by_username(username);
@@ -238,6 +260,7 @@ namespace Spry {
 			if (existing != null) {
 				user_id = existing.id;
 			} else {
+				warn_when_admin_password_weak(username, password);
 				var admin = yield users.register_user(username, email, "Admin", "User",
 					new DateTime.utc(1980, 1, 1, 0, 0, 0.0), password, true);
 				user_id = admin.id;
@@ -277,23 +300,56 @@ namespace Spry {
 			return name.length > 0 ? name : user.username;
 		}
 
+		/**
+		 * The SA-7 seeding warning: one loud stderr line naming the
+		 * account and the weakness, never blocking the seeding (the
+		 * operator's escape hatch) and never repeating on restart once
+		 * the account exists. Weak means shorter than 12 characters —
+		 * a floor for an account that can grant itself every
+		 * permission — or on the shared password deny-list.
+		 */
+		private static void warn_when_admin_password_weak(string username, string password) {
+			long chars = password.char_count();
+			if (chars > 0 && chars < Actions.ProfileValidation.PasswordPolicy.ADMIN_MIN_LENGTH) {
+				warning("[Spry] seeding admin '%s' with a SHORT password (%ld characters; use at least %d): rotate it before going to production.",
+					username, chars, (int) Actions.ProfileValidation.PasswordPolicy.ADMIN_MIN_LENGTH);
+			}
+			if (Actions.ProfileValidation.PasswordPolicy.is_denied(password)) {
+				warning("[Spry] seeding admin '%s' with a WELL-KNOWN password: it is on the built-in deny-list and will be guessed. Rotate it before going to production.",
+					username);
+			}
+		}
+
 		/**
 		 * Re-validates the held `auth` slot against the user store:
 		 * returns the live, enabled user for {@link AuthPrivate}.`user_id`,
 		 * or `null` when no slot is held, the account is missing or
-		 * disabled, or the store could not be reached (fail closed, with a
-		 * warning).
+		 * disabled, the held frame predates the user's session-validity
+		 * epoch (revoked by a password change — SA-1/SA-14), or the store
+		 * could not be reached (fail closed, with a warning).
 		 */
 		private static async UserProjection? revalidate(StatumRequest request, UserService users) {
 			var me = current(request);
 			if (me.is_anonymous) {
 				return null;
 			}
+			HeldSlot slot;
+			DateTime? authored_at = null;
+			if (request.held.try_get(SLOT_TYPE, out slot) && slot != null) {
+				authored_at = ((!)slot).as_at;
+			}
 			try {
 				var user = yield users.get_user(me.user_id);
 				if (user == null || !user.enabled) {
 					return null;
 				}
+				// SA-1/SA-14: a frame authored before the user's epoch was
+				// minted under a previous password (or pre-migration, when the
+				// epoch is NULL = Unix epoch and everything passes). Reject it
+				// closed, exactly like a disabled account.
+				if (authored_at == null || !UserService.frame_is_valid_at(user, (!)authored_at)) {
+					return null;
+				}
 				return user;
 			} catch (Error e) {
 				warning(@"[Spry] could not re-validate the auth slot for user $(me.user_id): $(e.message)");

+ 45 - 0
src/Authentication/Migrations/M0002_SessionsValidAfter.vala

@@ -0,0 +1,45 @@
+using InvercargillSql.Migrations;
+
+namespace Spry.Authentication.Migrations {
+
+    /**
+     * Adds the SA-1/SA-14 session-validity epoch to `spry_users`.
+     *
+     * `sessions_valid_after` records the moment the user's held auth frames
+     * must have been authored after: {@link Spry.SpryAuth.revalidate} rejects
+     * any `auth` slot whose snapshot `as_at` is older, so bumping the column
+     * revokes every frame minted before the bump. It is set on password
+     * changes (see {@link Spry.Authentication.UserService.set_password}); a
+     * regular logout stays non-revoking.
+     *
+     * The column is deliberately NULLABLE with no SQL default: the migration
+     * framework's SQLite dialect cannot render a DATETIME `DEFAULT`
+     * (DateTime is not one of the scalar types its SQL literal renderer
+     * knows, so it would emit `DEFAULT NULL`), and a plain ADD COLUMN must
+     * work on existing rows without rewriting them. NULL is interpreted in
+     * code ({@link Spry.Authentication.UserEntity.EPOCH}) as the Unix epoch,
+     * so every pre-migration frame stays valid — the upgrade is
+     * additive-only and revokes nothing.
+     */
+    public class M0002_SessionsValidAfter : Migration {
+
+
+        public override string name { get { return "Add Spry sessions_valid_after epoch"; } }
+        public override uint64 serial { get { return 2; } }
+        public override string migration_namespace { get { return "spry-authentication"; }}
+
+        public override void up (InvercargillSql.Migrations.MigrationBuilder b) {
+            b.alter_table ("spry_users", (t) => {
+                t.add_column<DateTime?>("sessions_valid_after");
+            });
+        }
+
+        public override void down (InvercargillSql.Migrations.MigrationBuilder b) {
+            b.alter_table ("spry_users", (t) => {
+                t.drop_column("sessions_valid_after");
+            });
+        }
+
+    }
+
+}

+ 39 - 1
src/Authentication/UserEntity.vala

@@ -9,6 +9,29 @@ namespace Spry.Authentication {
 	 */
 	public class UserEntity : Object {
 
+		/**
+		 * The session-validity epoch NULL resolves to when the row predates
+		 * {@link Migrations.M0002_SessionsValidAfter}: a frame authored any
+		 * time after 1970-01-01T00:00:00Z is valid, so migrating an existing
+		 * database revokes nothing.
+		 *
+		 * A lazily-built property rather than an initialised static field:
+		 * Vala only runs GObject static-field initialisers in `class_init`,
+		 * which a bare `UserEntity.EPOCH` read (e.g. from UserProjection's
+		 * getter) never triggers — the field would still be null and every
+		 * comparison against it would silently fail open.
+		 */
+		private static DateTime? _epoch = null;
+
+		public static DateTime EPOCH {
+			get {
+				if (_epoch == null) {
+					_epoch = new DateTime.utc(1970, 1, 1, 0, 0, 0.0);
+				}
+				return (!)_epoch;
+			}
+		}
+
 		public int64 id { get; set; }
 		public string username { get; set; }
 		public string email { get; set; }
@@ -20,6 +43,20 @@ namespace Spry.Authentication {
 		public DateTime modified { get; set; }
 		public bool enabled { get; set; }
 
+		/**
+		 * SA-1/SA-14: held auth frames for this user must have been authored
+		 * at or after this instant. Bumped on password change (see {@link
+		 * UserService.set_password}); `null` on rows written before M0002,
+		 * read as {@link EPOCH} via {@link
+		 * effective_sessions_valid_after}.
+		 */
+		public DateTime? sessions_valid_after { get; set; }
+
+		/** {@link sessions_valid_after} with `null` resolved to {@link EPOCH}. */
+		public DateTime effective_sessions_valid_after {
+			get { return sessions_valid_after ?? EPOCH; }
+		}
+
 		public static void entity_mapping(EntityMapperBuilder<UserEntity> cfg) {
 			cfg.table("spry_users")
 				.column<int64?>("id", o => o.id, (o, v) => o.id = v)
@@ -31,7 +68,8 @@ namespace Spry.Authentication {
 				.column<DateTime>("date_of_birth", o => o.date_of_birth, (o, v) => o.date_of_birth = v)
 				.column<DateTime>("created", o => o.created, (o, v) => o.created = v)
 				.column<DateTime>("modified", o => o.modified, (o, v) => o.modified = v)
-				.column<bool>("enabled", o => o.enabled, (o, v) => o.enabled = v);
+				.column<bool>("enabled", o => o.enabled, (o, v) => o.enabled = v)
+				.column<DateTime?>("sessions_valid_after", o => o.sessions_valid_after, (o, v) => o.sessions_valid_after = v);
 		}
 
 	}

+ 14 - 0
src/Authentication/UserProjection.vala

@@ -26,6 +26,19 @@ namespace Spry.Authentication {
 		public DateTime modified { get; set; }
 		public bool enabled { get; set; }
 
+		/**
+		 * SA-1/SA-14: held auth frames for this user must have been authored
+		 * at or after this instant ({@link Spry.SpryAuth.revalidate}
+		 * enforces it). `null` on rows written before M0002 — read it
+		 * through {@link effective_sessions_valid_after}.
+		 */
+		public DateTime? sessions_valid_after { get; set; }
+
+		/** {@link sessions_valid_after} with `null` resolved to the Unix epoch. */
+		public DateTime effective_sessions_valid_after {
+			get { return sessions_valid_after ?? UserEntity.EPOCH; }
+		}
+
 	private string _username;
 
 	/**
@@ -47,6 +60,7 @@ namespace Spry.Authentication {
 				.select<DateTime>("created", expr("u.created"), (o, v) => o.created = v)
 				.select<DateTime>("modified", expr("u.modified"), (o, v) => o.modified = v)
 				.select<bool>("enabled", expr("u.enabled"), (o, v) => o.enabled = v)
+				.select<DateTime?>("sessions_valid_after", expr("u.sessions_valid_after"), (o, v) => o.sessions_valid_after = v)
 				.join<UserPermissionEntity>("p", expr("p.user_id == u.id"))
 				.select_many<string>("permissions", expr("p.permission"), (o, v) => o._permissions = v.to_immutable_buffer());
 		}

+ 187 - 0
src/Authentication/UserService.vala

@@ -40,6 +40,15 @@ namespace Spry.Authentication {
 		 * Disabled accounts answer the same `null` as bad credentials (no
 		 * account-state side channel), and unknown usernames burn a dummy
 		 * hash check first so both failure paths take comparable time.
+		 *
+		 * On success, when the stored hash was written with Argon2
+		 * parameters other than the current ones, it is transparently
+		 * rewritten at today's parameters (SA-16: no libsodium
+		 * `str_needs_rehash` binding exists, so {@link hash_needs_rehash}
+		 * parses the stored PHC string instead). The rewrite is
+		 * best-effort: a store failure is logged and swallowed, since the
+		 * credentials were already verified — logging the user in matters
+		 * more than the upgrade.
 		 */
 		public async UserProjection? authenticate_user(string username, string password) throws Error {
 			var user = yield get_user_by_username(username);
@@ -52,6 +61,15 @@ namespace Spry.Authentication {
 				return null;
 			}
 
+			if (hash_needs_rehash(user.password_hash)) {
+				try {
+					yield rehash_password(user.id, password);
+				} catch (Error e) {
+					warning("[Spry] could not upgrade the stored Argon2 parameters for user %d: %s",
+						(int) user.id, e.message);
+				}
+			}
+
 			return user;
 		}
 
@@ -86,20 +104,189 @@ namespace Spry.Authentication {
 				created = new DateTime.now_utc(),
 				modified = new DateTime.now_utc(),
 				enabled = enabled,
+				sessions_valid_after = UserEntity.EPOCH,
 			};
 
 			yield db.insert_async<UserEntity>(user);
 			return user;
 		}
 
+		/**
+		 * Replaces the user's password AND bumps the session-validity epoch
+		 * (SA-1/SA-14): every auth frame authored before this call is revoked —
+		 * {@link Spry.SpryAuth.revalidate} answers unauthenticated for it from
+		 * the next guarded request. Callers that keep the changing user logged
+		 * in re-mint their slot afterwards (see {@link
+		 * Spry.Actions.ChangePasswordAction}).
+		 */
 		public async void set_password(int64 user_id, string password) throws Error {
 			var user = yield load_user(user_id);
 
 			user.password_hash = Sodium.PasswordHashing.hash(password);
+			user.sessions_valid_after = session_epoch_now();
 			user.modified = new DateTime.now_utc();
 			yield db.update_async<UserEntity>(user);
 		}
 
+		/**
+		 * Rewrites the user's password hash at the CURRENT Argon2
+		 * parameters without changing the password (SA-16): the plain
+		 * text is only ever handled inside this call, and — unlike
+		 * {@link set_password} — the session-validity epoch is NOT
+		 * bumped, because no credential changed: forcing every device
+		 * to re-login for a hash upgrade would be pure disruption.
+		 */
+		public async void rehash_password(int64 user_id, string password) throws Error {
+			var user = yield load_user(user_id);
+
+			user.password_hash = Sodium.PasswordHashing.hash(password);
+			user.modified = new DateTime.now_utc();
+			yield db.update_async<UserEntity>(user);
+		}
+
+		/**
+		 * Parsed Argon2 PHC string parameters, as written by
+		 * `Sodium.PasswordHashing.hash`:
+		 * `$argon2id$v=19$m=262144,t=3,p=1$salt$hash`.
+		 */
+		public struct HashParameters {
+			/** Argon2 algorithm name (`argon2id`, `argon2i`, …). */
+			string algorithm;
+			/** PHC version (`v=`), 19 for current libsodium. */
+			int version;
+			/** Memory cost in KiB (`m=`). */
+			int64 memory;
+			/** Time cost / passes (`t=`). */
+			int64 passes;
+			/** Parallelism (`p=`). */
+			int64 parallelism;
+		}
+
+		/**
+		 * Extracts the `m`/`t`/`p` cost parameters from a stored Argon2
+		 * hash's PHC string, or `null` when `hash_text` is malformed
+		 * (missing `$argon2…$v=` prefix, unparsable cost field). Cost
+		 * fields absent from the string read as their PHC-defined
+		 * defaults (m=32, t=3, p=1 — see the PHC spec), so a
+		 * default-parameter hash still parses. Returns a struct by
+		 * value; callers treat `null` as "cannot tell", never as "needs
+		 * no rehash".
+		 */
+		public static HashParameters? parse_hash_parameters(string hash_text) {
+			string[] parts = hash_text.split("$");
+			// $argon2id$v=19$m=...,t=...,p=...$salt$hash → split yields
+			// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
+			if (parts.length < 3 || !parts[1].has_prefix("argon2")) {
+				return null;
+			}
+			var parameters = HashParameters() {
+				algorithm = parts[1],
+				version = -1,
+				memory = 32,
+				passes = 3,
+				parallelism = 1
+			};
+			bool version_seen = false;
+			for (int i = 2; i < parts.length; i++) {
+				string field = parts[i];
+				if (field.length == 0) {
+					continue;
+				}
+				if (field.has_prefix("v=") && field.length > 2 && field[2].isdigit()) {
+					parameters.version = (int) int64.parse(field.substring(2));
+					version_seen = true;
+					continue;
+				}
+				if (!field.contains("=")) {
+					// salt or digest chunk: not a parameter field
+					continue;
+				}
+				foreach (string cost in field.split(",")) {
+					string[] pair = cost.split("=", 2);
+					if (pair.length != 2 || pair[1].length == 0) {
+						return null;
+					}
+					int64 value = int64.parse(pair[1]);
+					if (value <= 0) {
+						return null;
+					}
+					switch (pair[0]) {
+						case "m":
+							parameters.memory = value;
+							break;
+						case "t":
+							parameters.passes = value;
+							break;
+						case "p":
+							parameters.parallelism = value;
+							break;
+					}
+				}
+			}
+			if (!version_seen) {
+				return null;
+			}
+			return parameters;
+		}
+
+		/**
+		 * True when `hash_text` was written with Argon2 parameters
+		 * other than the ones {@link Sodium.PasswordHashing.hash} uses
+		 * today (OPSLIMIT/MEMLIMIT MODERATE), so a successful login
+		 * should rewrite it (SA-16). Note the units: the PHC `m=` field
+		 * is KiB while libsodium's MEMLIMIT constant is bytes, so the
+		 * comparison divides by 1024. A malformed or non-PHC string
+		 * answers `true`: it certainly does not match the current
+		 * parameters, and rehashing is the only migration path. The
+		 * algorithm name and version are compared too, so a future
+		 * switch to another Argon2 flavour or PHC version rehashes as
+		 * well.
+		 */
+		public static bool hash_needs_rehash(string hash_text) {
+			HashParameters? parsed = parse_hash_parameters(hash_text);
+			if (parsed == null) {
+				return true;
+			}
+			var current = (!)parsed;
+			return current.algorithm != CURRENT_HASH_ALGORITHM
+				|| current.version != CURRENT_HASH_VERSION
+				|| current.memory != (int64) (Sodium.PasswordHashing.MEMLIMIT_MODERATE / 1024)
+				|| current.passes != (int64) Sodium.PasswordHashing.OPSLIMIT_MODERATE
+				|| current.parallelism != (int64) CURRENT_HASH_PARALLELISM;
+		}
+
+		/** The Argon2 flavour and PHC version {@link rehash_password} writes. */
+		private const string CURRENT_HASH_ALGORITHM = "argon2id";
+		private const int CURRENT_HASH_VERSION = 19;
+
+		/**
+		 * libsodium hashes with p=1: `crypto_pwhash_STRBYTES` output is
+		 * fixed and the API exposes no parallelism argument, so 1 is
+		 * both the default and the only value `hash()` can emit.
+		 */
+		private const int CURRENT_HASH_PARALLELISM = 1;
+
+		/**
+		 * The instant new frames must be authored after — the value {@link
+		 * Spry.SpryAuth.revalidate} compares a held frame's `as_at` against.
+		 * Public so callers re-minting a slot straight after a bump can rely
+		 * on the fresh frame clearing its own epoch.
+		 */
+		public static DateTime session_epoch_now() {
+			return new DateTime.now_utc();
+		}
+
+		/**
+		 * True when a frame authored at `as_at` is still valid for `user`:
+		 * authored at or after the user's epoch. Equality passes because the
+		 * frame a caller re-mints immediately after a bump carries the bump's
+		 * own timestamp, and a same-second re-mint must survive its own
+		 * revocation.
+		 */
+		public static bool frame_is_valid_at(UserProjection user, DateTime as_at) {
+			return as_at.compare(user.effective_sessions_valid_after) >= 0;
+		}
+
 		public async UserEntity alter_user(int64 user_id, string username, string email, string forename, string surname, DateTime date_of_birth, bool enabled) throws Error {
 			var user = yield load_user(user_id);
 

+ 3 - 1
src/SpryModule.vala

@@ -5,7 +5,8 @@ namespace Spry {
 
 	/**
 	 * Inversion module that registers Spry's user store: the `spry_users` and
-	 * `spry_user_permissions` schema (M0001 migration), the entity/projection
+	 * `spry_user_permissions` schema (M0001 + M0002 migrations), the
+	 * entity/projection
 	 * mappings, and the scoped {@link Spry.Authentication.UserService}.
 	 *
 	 * Register this module with the application container alongside
@@ -22,6 +23,7 @@ namespace Spry {
 			// Sql table setup
 			var sql = container.configure_with<DatabaseConfigurator>();
 			sql.add_migration<Authentication.Migrations.M0001_Initial>();
+			sql.add_migration<Authentication.Migrations.M0002_SessionsValidAfter>();
 			sql.add_entity<Authentication.UserEntity>(Authentication.UserEntity.entity_mapping);
 			sql.add_entity<Authentication.UserPermissionEntity>(Authentication.UserPermissionEntity.entity_mapping);
 			sql.add_projection<Authentication.UserProjection>(Authentication.UserProjection.projection_mapping);

+ 10 - 5
src/meson.build

@@ -49,6 +49,7 @@ sources = files(
     'Authentication/UserProjection.vala',
     'Authentication/UserService.vala',
     'Authentication/Migrations/M0001_Initial.vala',
+    'Authentication/Migrations/M0002_SessionsValidAfter.vala',
     'Actions/UserManagementAction.vala',
     'Actions/UserActionPrivate.vala',
     'Actions/ProfileValidation.vala',
@@ -63,19 +64,23 @@ sources = files(
     'Actions/DeleteUserAction.vala',
 )
 
-library_version = meson.project_version()
-libspry = shared_library('spry-@0@'.format(library_version),
+# The API version rides every artifact name (libspry-0.2.so, spry-0.2.vapi,
+# ...); the project version is the release version and only reaches the
+# pkg-config Version: field (usm checks it against the manifest version).
+api_version = '0.2'
+release_version = meson.project_version()
+libspry = shared_library('spry-@0@'.format(api_version),
     sources, spry_css, theme_resources,
     dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, inversion_dep, astralis_dep, libxml_dep, sodium_deps, invercargill_sql_dep, sqlite_dep, invercargill_sql_inversion_dep, statum_dep],
     install: true,
-    vala_gir: 'spry-@0@.gir'.format(library_version),
+    vala_gir: 'spry-@0@.gir'.format(api_version),
     install_dir: [true, true, true, true]
 )
 
 pkg = import('pkgconfig')
 pkg.generate(libspry,
-    version : library_version,
-    name : 'spry-@0@'.format(library_version),
+    version : release_version,
+    name : 'spry-@0@'.format(api_version),
     requires : [statum_dep])
 
 spry_dep = declare_dependency(

+ 139 - 0
tests/RehashProbe.vala

@@ -0,0 +1,139 @@
+// Manual live-database verification of the SA-16 rehash path (not part
+// of the meson test suite): boots a real SQLite store through Inversion
+// + DatabaseConfigurator exactly as SpryModule does, registers a user,
+// force-stales their stored hash by writing one at INTERACTIVE limits,
+// then authenticates and asserts the stored hash was transparently
+// rewritten at current MODERATE parameters — same password, epoch NOT
+// bumped, login succeeding throughout.
+//
+// Build & run (from the Spry checkout, against a built libspry):
+//   valac tests/RehashProbe.vala vapi/libsodium.vapi -o /tmp/rehash-probe \
+//     --vapidir vapi --vapidir <builddir>/src \
+//     --pkg spry-0.2 --pkg statum-0.1 --pkg astralis-0.1 \
+//     --pkg inversion-0.1 --pkg invercargill-1 --pkg invercargill-json \
+//     --pkg invercargill-sql --pkg invercargill-sql-inversion \
+//     --pkg sqlite3 --pkg libxml-2.0 --pkg json-glib-1.0 \
+//     --pkg glib-2.0 --pkg gobject-2.0 --pkg gio-2.0 \
+//     -X -lsodium -X -L<builddir>/src -X -lspry-0.2 -X -Wl,-rpath,<builddir>/src
+//   /tmp/rehash-probe /tmp/rehash-probe.sqlite
+
+using Inversion;
+using InvercargillSqlInversion;
+using Invercargill.Expressions;
+using Invercargill.DataStructures;
+using InvercargillSql.Orm;
+using Spry;
+using Spry.Authentication;
+
+int failures = 0;
+
+void check(bool condition, string label) {
+    if (condition) {
+        print("PASS %s\n", label);
+    } else {
+        failures++;
+        print("FAIL %s\n", label);
+    }
+}
+
+async int probe(string db_path) {
+    GLib.FileUtils.remove(db_path);
+
+    var container = new Container();
+    try {
+        var db = container.configure_with<DatabaseConfigurator>();
+        db.register_connection(@"sqlite://$(db_path)");
+        db.add_migration<Authentication.Migrations.M0001_Initial>();
+        db.add_migration<Authentication.Migrations.M0002_SessionsValidAfter>();
+        db.add_entity<Authentication.UserEntity>(Authentication.UserEntity.entity_mapping);
+        db.add_entity<Authentication.UserPermissionEntity>(Authentication.UserPermissionEntity.entity_mapping);
+        db.add_projection<Authentication.UserProjection>(Authentication.UserProjection.projection_mapping);
+        container.register_scoped<Authentication.UserService>();
+        db.migrate_on_startup();
+        container.initialise();
+    } catch (Error e) {
+        printerr("probe wiring threw: %s\n", e.message);
+        return 1;
+    }
+
+    string password = "live rehash probe password";
+
+    try {
+        var scope = container.create_scope();
+        var users = scope.resolve<UserService>();
+        var orm = scope.resolve<OrmSession>();
+
+        var registered = yield users.register_user("probe-user", "probe@example.com",
+            "Probe", "User", new GLib.DateTime.utc(1990, 1, 1, 0, 0, 0.0), password, true);
+        int64 id = registered.id;
+
+        check(!UserService.hash_needs_rehash(load_hash(orm, id)),
+            "registration stores current parameters");
+
+        // Force-stale the stored hash (INTERACTIVE limits), exactly as
+        // an old deployment's row looks after a parameter bump.
+        string stale = Sodium.PasswordHashing.hash(password,
+            Sodium.PasswordHashing.OPSLIMIT_INTERACTIVE,
+            Sodium.PasswordHashing.MEMLIMIT_INTERACTIVE);
+        store_hash(orm, id, stale);
+        check(UserService.hash_needs_rehash(load_hash(orm, id)),
+            "stale parameters are recognised as needing a rehash");
+
+        var epoch_before = load_epoch(orm, id);
+        var logged_in = yield users.authenticate_user("probe-user", password);
+        var epoch_after = load_epoch(orm, id);
+        string upgraded = load_hash(orm, id);
+
+        check(logged_in != null, "login succeeds against a stale hash");
+        check(!UserService.hash_needs_rehash(upgraded),
+            "the stored hash was rewritten at current parameters on login");
+        check(Sodium.PasswordHashing.check(upgraded, password),
+            "the same password verifies against the rewritten hash");
+        check(epoch_after.compare(epoch_before) == 0,
+            "the session-validity epoch was untouched by the rehash");
+        check((yield users.authenticate_user("probe-user", "wrong password entirely")) == null,
+            "a wrong password is rejected against the upgraded hash");
+    } catch (Error e) {
+        printerr("probe threw: %s\n", e.message);
+        return 1;
+    }
+
+    print("---- live rehash probe: %d failures ----\n", failures);
+    return failures > 0 ? 1 : 0;
+}
+
+int main(string[] args) {
+    string db_path = args.length > 1 ? args[1] : "/tmp/rehash-probe.sqlite";
+    var loop = new GLib.MainLoop();
+    int result = 1;
+    probe.begin(db_path, (o, r) => {
+        result = probe.end(r);
+        loop.quit();
+    });
+    loop.run();
+    return result;
+}
+
+private UserEntity load_user(OrmSession orm, int64 user_id) throws Error {
+    var user = orm.query<UserEntity>()
+        .where(expr("id == $0", elem<int64?>(user_id)))
+        .first();
+    if (user == null) {
+        throw new GLib.IOError.FAILED(@"no user row for id $(user_id)");
+    }
+    return (!)user;
+}
+
+private string load_hash(OrmSession orm, int64 user_id) throws Error {
+    return load_user(orm, user_id).password_hash;
+}
+
+private GLib.DateTime load_epoch(OrmSession orm, int64 user_id) throws Error {
+    return load_user(orm, user_id).sessions_valid_after;
+}
+
+private void store_hash(OrmSession orm, int64 user_id, string hash) throws Error {
+    var user = load_user(orm, user_id);
+    user.password_hash = hash;
+    orm.update<UserEntity>(user);
+}

+ 370 - 0
tests/TestMain.vala

@@ -0,0 +1,370 @@
+using InvercargillSql.Migrations;
+using InvercargillSql.Dialects;
+using Spry.Authentication;
+using Spry.Authentication.Migrations;
+using Spry.Actions;
+
+namespace Spry.Tests {
+
+    int failures = 0;
+    int passes = 0;
+
+    void check(bool condition, string label) {
+        if (condition) {
+            passes++;
+            print("PASS %s\n", label);
+        } else {
+            failures++;
+            print("FAIL %s\n", label);
+        }
+    }
+
+    UserProjection user_with_epoch(DateTime? epoch) {
+        var user = new UserProjection();
+        user.id = 42;
+        user.enabled = true;
+        user.sessions_valid_after = epoch;
+        return user;
+    }
+
+    /**
+     * The epoch comparison at the heart of SA-1/SA-14 revocation: a frame
+     * authored BEFORE the user's sessions_valid_after is revoked; one
+     * authored after (or exactly AT it — the re-mint a password change
+     * issues in the same second) stays valid.
+     */
+    void test_epoch_comparison() {
+        var epoch = new DateTime.utc(2025, 6, 1, 12, 0, 0.0);
+
+        var before = epoch.add_seconds(-1);
+        check(!UserService.frame_is_valid_at(user_with_epoch(epoch), before),
+            "SA-1: a frame authored 1s before the epoch is revoked");
+
+        var after = epoch.add_seconds(1);
+        check(UserService.frame_is_valid_at(user_with_epoch(epoch), after),
+            "SA-1: a frame authored 1s after the epoch stays valid");
+
+        check(UserService.frame_is_valid_at(user_with_epoch(epoch), epoch),
+            "SA-1: a frame authored exactly at the epoch stays valid (same-second re-mint)");
+
+        check(!UserService.frame_is_valid_at(user_with_epoch(epoch), epoch.add_seconds(-0.5)),
+        "SA-1: sub-second-older frames are revoked (DateTime.compare uses microseconds)");
+
+        // Far-past frame against a bumped epoch — the replay SA-1 describes.
+        var replayed = new DateTime.utc(2020, 1, 1, 0, 0, 0.0);
+        check(!UserService.frame_is_valid_at(user_with_epoch(epoch), replayed),
+            "SA-1: a frame replayed from long before the bump is revoked");
+    }
+
+    /**
+     * Pre-M0002 rows read back with a NULL epoch: they must behave as the
+     * Unix epoch so migrating an existing database revokes nothing.
+     */
+    void test_null_epoch_is_unix_epoch() {
+        var migrated = user_with_epoch(null);
+        check(migrated.effective_sessions_valid_after.to_unix() == 0,
+            "M0002: a NULL epoch reads as the Unix epoch (1970-01-01T00:00:00Z)");
+
+        var old_frame = new DateTime.utc(2001, 2, 3, 4, 5, 6.0);
+        check(UserService.frame_is_valid_at(migrated, old_frame),
+            "M0002: a pre-migration frame stays valid against a NULL epoch");
+
+        check(UserEntity.EPOCH.to_unix() == 0 && UserEntity.EPOCH.get_timezone_abbreviation() == "UTC",
+            "M0002: UserEntity.EPOCH is Unix epoch zero in UTC");
+    }
+
+    /**
+     * The bump source: session_epoch_now must be a fresh UTC instant that a
+     * frame authored immediately after it clears (the re-mint contract).
+     */
+    void test_epoch_now_is_usable_bump() {
+        var before = new DateTime.now_utc();
+        var bump = UserService.session_epoch_now();
+        var after = new DateTime.now_utc();
+
+        check(bump.to_unix() >= before.to_unix() && bump.to_unix() <= after.to_unix(),
+            "SA-1: session_epoch_now lands between two surrounding now_utc samples");
+
+        var user = user_with_epoch(bump);
+        check(UserService.frame_is_valid_at(user, bump),
+            "SA-1: a frame authored at the bump instant itself stays valid");
+        check(!UserService.frame_is_valid_at(user, bump.add_seconds(-1)),
+            "SA-1: a frame authored 1s before the bump is revoked");
+    }
+
+    /**
+     * M0002 shape: additive-only. One ADD COLUMN, nullable (no NOT NULL), no
+     * DEFAULT (the SQLite dialect cannot render a DateTime default), so
+     * existing rows keep working with a NULL epoch.
+     */
+    void test_m0002_sql_shape() throws GLib.Error {
+        var migration = new M0002_SessionsValidAfter();
+        check(migration.serial == 2, "M0002: serial is 2 (ordered after M0001)");
+        check(migration.migration_namespace == "spry-authentication",
+            "M0002: shares M0001's migration namespace");
+
+        var builder = new MigrationBuilder(new SqliteDialect());
+        migration.up(builder);
+
+        var operations = builder.get_operations();
+        check(operations.length == 1, "M0002: up() emits exactly one schema operation");
+
+        foreach (var op in operations) {
+            var sql = op.to_sql(new SqliteDialect());
+            check(sql.has_prefix("ALTER TABLE spry_users ADD COLUMN sessions_valid_after "),
+                @"M0002: SQL alters spry_users and adds sessions_valid_after — got: $sql");
+            check("INTEGER" in sql,
+                @"M0002: the column is stored INTEGER (unix seconds) — got: $sql");
+            check(!("NOT NULL" in sql),
+                @"M0002: the column is nullable (additive-only upgrade) — got: $sql");
+            check(!("DEFAULT" in sql),
+                @"M0002: no DEFAULT clause (DateTime defaults are unrenderable) — got: $sql");
+        }
+    }
+
+    /**
+     * M0002 down(): the column is dropped again, so the migration is
+     * reversible (losing only the epoch, never user rows).
+     */
+    void test_m0002_down_shape() throws GLib.Error {
+        var migration = new M0002_SessionsValidAfter();
+        var builder = new MigrationBuilder(new SqliteDialect());
+        migration.down(builder);
+
+        var operations = builder.get_operations();
+        check(operations.length == 1, "M0002: down() emits exactly one schema operation");
+        foreach (var op in operations) {
+            var sql = op.to_sql(new SqliteDialect());
+            check(sql == "ALTER TABLE spry_users DROP COLUMN sessions_valid_after",
+                @"M0002: down() drops only sessions_valid_after — got: $sql");
+        }
+    }
+
+    /**
+     * SpryAuth.permission_matches is re-exercised here because the auth
+     * guards (the enforcement point of this change) sit behind it.
+     */
+    void test_permission_matching_untouched() {
+        string[] patterns = { "admin" };
+        check(SpryAuth.permission_matches(patterns, "anything"),
+            "guards: the admin wildcard still matches every permission");
+        string[] scoped = { "users.*" };
+        check(SpryAuth.permission_matches(scoped, "users.read"),
+            "guards: a trailing-* pattern still prefix-matches");
+        check(!SpryAuth.permission_matches(scoped, "other.read"),
+            "guards: a non-matching permission still fails");
+    }
+
+    /**
+     * SA-8 password policy: the length bounds (8 minimum, 128 maximum,
+     * both inclusive) and the deny-list, evaluated exactly as the three
+     * password-setting actions evaluate them. Multi-byte input counts
+     * code points, not UTF-8 bytes.
+     */
+    void test_password_policy_bounds() {
+        check(ProfileValidation.PasswordPolicy.assess("1234567") == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT,
+            "SA-8: a 7-character password is rejected as too short");
+        check(ProfileValidation.PasswordPolicy.assess("1234567a") == null,
+            "SA-8: an 8-character password is accepted (minimum is inclusive)");
+        check(ProfileValidation.PasswordPolicy.assess(string.nfill(128, 'x')) == null,
+            "SA-8: a 128-character password is accepted (maximum is inclusive)");
+        check(ProfileValidation.PasswordPolicy.assess(string.nfill(129, 'x')) == ProfileValidation.PasswordPolicy.Rejection.TOO_LONG,
+            "SA-8: a 129-character password is rejected as too long");
+        check(ProfileValidation.PasswordPolicy.assess("ßßßßßß") == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT,
+            "SA-8: length counts code points (6 two-byte characters read as 6, not 12)");
+        check(ProfileValidation.PasswordPolicy.assess("ßßßßßßß") == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT,
+            "SA-8: 7 two-byte characters still count as 7 code points, not 14");
+        check(ProfileValidation.PasswordPolicy.assess("ßßßßßßßß") == null,
+            "SA-8: 8 multi-byte code points pass the minimum");
+    }
+
+    /** SA-8 deny-list: exact (case-insensitive) matches are refused. */
+    void test_password_policy_deny_list() {
+        foreach (string weak in ProfileValidation.PasswordPolicy.DENY_LIST) {
+            // Some deny-list entries are shorter than the 8-character
+            // minimum and so report TOO_SHORT first — both refuse the
+            // password, which is what the actions rely on.
+            var verdict = ProfileValidation.PasswordPolicy.assess(weak);
+            check(verdict == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON
+                || verdict == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT,
+                @"SA-8: deny-list entry '$weak' is refused");
+        }
+        check(ProfileValidation.PasswordPolicy.assess("password") == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON,
+            "SA-8: 'password' is refused as too common");
+        check(ProfileValidation.PasswordPolicy.assess("PASSWORD") == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON,
+            "SA-8: the deny-list comparison is case-insensitive");
+        check(ProfileValidation.PasswordPolicy.assess("Change-Me") == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON,
+            "SA-8: 'Change-Me' (the old README example) is refused");
+        check(ProfileValidation.PasswordPolicy.assess("Xk7-prod-seed-9f2mQv") == null,
+            "SA-8: a generated-looking password passes the deny-list");
+        check(ProfileValidation.PasswordPolicy.assess("password-with-more-text") == null,
+            "SA-8: the deny-list matches exactly, not by substring");
+        check(ProfileValidation.PasswordPolicy.is_denied("qwerty123") == true,
+            "SA-7: is_denied answers true for a long-enough well-known entry");
+        check(ProfileValidation.PasswordPolicy.is_denied("password-with-more-text") == false,
+            "SA-7: is_denied answers false for a merely similar password");
+        check(ProfileValidation.PasswordPolicy.MIN_LENGTH == 8
+            && ProfileValidation.PasswordPolicy.MAX_LENGTH == 128,
+            "SA-8: the policy bounds are 8 and 128");
+    }
+
+    /**
+     * SA-16 PHC-string parsing: m/t/p extraction from a libsodium
+     * hash, PHC default costs for a bare string, and malformed input.
+     */
+    void test_hash_parameter_parsing() {
+        var parsed = UserService.parse_hash_parameters(
+            "$argon2id$v=19$m=262144,t=3,p=1$BJRNHzAq5K1sJMZfNZZFWA$915y7WkAuE67mZ9QLS402OnnDwpSZsqE+ZipbdJIIuw");
+        check(parsed != null, "SA-16: a real libsodium hash string parses");
+        var p = (!)parsed;
+        check(p.algorithm == "argon2id", "SA-16: the algorithm reads as argon2id");
+        check(p.version == 19, "SA-16: the version reads as 19");
+        check(p.memory == 262144, "SA-16: m= reads as 262144 KiB (256 MiB)");
+        check(p.passes == 3, "SA-16: t= reads as 3");
+        check(p.parallelism == 1, "SA-16: p= reads as 1");
+
+        var old = UserService.parse_hash_parameters("$argon2id$v=19$m=65536,t=2,p=1$c2FsdA$aGFzaA");
+        check(old != null && ((!)old).memory == 65536 && ((!)old).passes == 2,
+            "SA-16: lower m/t values parse out individually");
+
+        var defaults = UserService.parse_hash_parameters("$argon2id$v=19$c2FsdA");
+        check(defaults != null && ((!)defaults).memory == 32
+            && ((!)defaults).passes == 3 && ((!)defaults).parallelism == 1,
+            "SA-16: a hash without a cost field parses with the PHC defaults (m=32,t=3,p=1)");
+
+        check(UserService.parse_hash_parameters("not-a-phc-string") == null,
+            "SA-16: a plain string does not parse");
+        check(UserService.parse_hash_parameters("$argon2id$c2FsdA") == null,
+            "SA-16: a string with no v= field does not parse");
+        check(UserService.parse_hash_parameters("$argon2id$v=x$m=1,t=1,p=1$a$b") == null,
+            "SA-16: a non-numeric version does not parse");
+        check(UserService.parse_hash_parameters("$argon2id$v=19$m=abc$t=1$a$b") == null,
+            "SA-16: an unparsable cost value does not parse");
+        check(UserService.parse_hash_parameters("$argon2id$v=19$m=0,t=1,p=1$a$b") == null,
+            "SA-16: a zero cost value does not parse");
+        check(UserService.parse_hash_parameters("") == null,
+            "SA-16: the empty string does not parse");
+        check(UserService.parse_hash_parameters("$bcrypt$v=19$m=1024$a$b") == null,
+            "SA-16: the parser is Argon2-specific — a bcrypt-style string does not parse");
+        check(UserService.hash_needs_rehash("$bcrypt$v=19$m=1024$a$b"),
+            "SA-16: a non-argon2 PHC string still needs a rehash");
+    }
+
+    /**
+     * SA-16 rehash decision: a hash at today's parameters needs no
+     * rewrite; anything older, weaker or malformed does.
+     */
+    void test_rehash_decision() {
+        string current = "$argon2id$v=19$m=262144,t=3,p=1$BJRNHzAq5K1sJMZfNZZFWA$915y7WkAuE67mZ9QLS402OnnDwpSZsqE+ZipbdJIIuw";
+        check(!UserService.hash_needs_rehash(current),
+            "SA-16: a hash at the current MODERATE parameters needs no rehash");
+        check(UserService.hash_needs_rehash("$argon2id$v=19$m=65536,t=2,p=1$c2FsdA$aGFzaA"),
+            "SA-16: a hash at old (weaker) parameters needs a rehash");
+        check(UserService.hash_needs_rehash("$argon2id$v=19$m=262144,t=3,p=2$c2FsdA$aGFzaA"),
+            "SA-16: a hash with different parallelism needs a rehash");
+        check(UserService.hash_needs_rehash("$argon2id$v=16$m=262144,t=3,p=1$c2FsdA$aGFzaA"),
+            "SA-16: a hash at another PHC version needs a rehash");
+        check(UserService.hash_needs_rehash("$argon2i$v=19$m=262144,t=3,p=1$c2FsdA$aGFzaA"),
+            "SA-16: a hash from another Argon2 flavour needs a rehash");
+        check(UserService.hash_needs_rehash("$argon2id$v=19$c2FsdA"),
+            "SA-16: a hash relying on PHC default costs needs a rehash (m=32 default)");
+        check(UserService.hash_needs_rehash("garbage"),
+            "SA-16: a malformed stored hash needs a rehash (only migration path)");
+
+        // Cross-check against the parameters hash() actually writes:
+        // whatever libsodium emits must read back as not-needing-rehash.
+        string fresh = Sodium.PasswordHashing.hash("correct horse battery staple");
+        check(fresh != null && !UserService.hash_needs_rehash((!)fresh),
+            "SA-16: a freshly minted libsodium hash is recognised as current");
+
+        // The upgrade path itself: a hash written at non-default limits
+        // needs a rehash, and the SAME password verifies against both
+        // the old and the rewritten hash (rehash_password never changes
+        // the credential, only its storage parameters).
+        string password = "correct horse battery staple";
+        string weaker = Sodium.PasswordHashing.hash(password,
+            Sodium.PasswordHashing.OPSLIMIT_INTERACTIVE,
+            Sodium.PasswordHashing.MEMLIMIT_INTERACTIVE);
+        check(weaker != null && UserService.hash_needs_rehash((!)weaker),
+            "SA-16: a hash minted at INTERACTIVE limits is detected as stale");
+        check(Sodium.PasswordHashing.check((!)weaker, password),
+            "SA-16: the password still verifies against the stale hash");
+        check(Sodium.PasswordHashing.check((!)fresh, password),
+            "SA-16: the password also verifies against the fresh (upgraded-shape) hash");
+        check(!UserService.hash_needs_rehash((!)fresh),
+            "SA-16: rewriting at current parameters clears the rehash flag");
+    }
+
+    /**
+     * SA-17 generic client error text: {@link
+     * Spry.Actions.ProfileValidation.internal_error_message} returns the
+     * generic text unchanged whatever the error carries (full detail goes
+     * to the server log), and the notification constants of the seven
+     * affected actions carry no %s placeholder, so no interpolated
+     * internal detail can leak even if a future call site reintroduces
+     * printf-style formatting.
+     */
+    void test_generic_error_messages() {
+        var noisy = new DummyError.NOISY(
+            "UNIQUE constraint failed: /var/lib/app/store.sqlite INSERT INTO spry_users ...");
+        string generic = "Registration failed. Please try again later.";
+        string shown = ProfileValidation.internal_error_message(noisy, generic);
+        check(shown == generic,
+            "SA-17: internal_error_message returns the generic text unchanged");
+        check(!shown.contains("sqlite") && !shown.contains("INSERT") && !shown.contains("/var/lib"),
+            "SA-17: no file path or SQL fragment reaches the client text");
+
+        foreach (string constant in new string[] {
+                "Registration failed. Please try again later.",
+                "Could not change your password. Please try again later.",
+                "Could not update the user. Please try again later.",
+                "Could not delete the user. Please try again later.",
+                "Could not grant the permission. Please try again later.",
+                "Could not revoke the permission. Please try again later."}) {
+            check(!constant.contains("%s") && !constant.contains("%d"),
+                @"SA-17: the notification constant '$constant' has no format placeholder");
+        }
+    }
+
+    /** Minimal Error for feeding internal_error_message real text. */
+    private errordomain DummyError { NOISY }
+
+    /**
+     * SA-7 advisory: the admin minimum is 12 characters and is_denied
+     * powers the seeding warning (warn-only, never blocking).
+     */
+    void test_admin_seed_thresholds() {
+        check(ProfileValidation.PasswordPolicy.ADMIN_MIN_LENGTH == 12,
+            "SA-7: the admin seeding warning threshold is 12 characters");
+        check(ProfileValidation.PasswordPolicy.is_denied("change-me"),
+            "SA-7: the README's old 'change-me' example trips the deny-list warning");
+        check(!ProfileValidation.PasswordPolicy.is_denied("Xk7-prod-seed-9f2mQv"),
+            "SA-7: the README's generated-style example does not trip it");
+    }
+
+    public int main(string[] args) {
+        try {
+            test_m0002_sql_shape();
+            test_m0002_down_shape();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL M0002 migration shape threw: %s\n", e.message);
+        }
+        test_epoch_comparison();
+        test_null_epoch_is_unix_epoch();
+        test_epoch_now_is_usable_bump();
+        test_permission_matching_untouched();
+        test_password_policy_bounds();
+        test_password_policy_deny_list();
+        test_hash_parameter_parsing();
+        test_rehash_decision();
+        test_generic_error_messages();
+        test_admin_seed_thresholds();
+
+        print("---- %d passed, %d failed ----\n", passes, failures);
+        if (failures > 0) {
+            return 1;
+        }
+        return 0;
+    }
+}

+ 31 - 0
tests/meson.build

@@ -0,0 +1,31 @@
+# Spry test suite: SA-1/SA-14 session-validity epoch semantics (frame
+# as_at vs sessions_valid_after, the NULL-epoch migration default, the
+# bump source), the M0002 migration's additive-only SQL shape, the
+# SA-7/SA-8 password policy (length bounds, deny-list, admin seeding
+# thresholds), SA-16 Argon2 PHC parsing and the rehash decision, and
+# SA-17's generic client error text. Prints PASS/FAIL lines and exits
+# non-zero on failure.
+#
+# rehash-probe is the live-database companion (not a unit test): it
+# boots a real SQLite store through the same wiring as SpryModule and
+# verifies the full SA-16 path — register, force-stale the stored hash,
+# authenticate, hash rewritten at current parameters, epoch untouched.
+
+spry_tests = executable('spry-tests',
+    ['TestMain.vala'],
+    dependencies: [statum_dep, spry_dep, sodium_deps, invercargill_sql_dep, sqlite_dep, astralis_dep, libxml_dep],
+    install: false
+)
+
+test('spry', spry_tests)
+
+rehash_probe = executable('rehash-probe',
+    ['RehashProbe.vala'],
+    dependencies: [statum_dep, spry_dep, sodium_deps, invercargill_sql_dep, invercargill_sql_inversion_dep, sqlite_dep, astralis_dep, libxml_dep, json_glib_dep, inversion_dep, glib_dep, gobject_dep, gio_dep],
+    install: false
+)
+
+test('spry-rehash-probe', rehash_probe,
+    args: [join_paths(meson.current_build_dir(), 'rehash-probe.sqlite')],
+    workdir: meson.current_build_dir()
+)

+ 35 - 13
tools/spry/Keys.vala

@@ -80,26 +80,48 @@ namespace Spry.Cli {
             }
         }
 
+        /**
+         * Spawns `statum-genkeys --out <tempfile>` and returns the generated
+         * "statum" object.
+         *
+         * Since Statum's SA-12 fix the tool never prints secret halves to
+         * stdout (shell history, CI logs and pipe captures would acquire
+         * them); the full material is written straight to a temporary file at
+         * mode 0600, read here, and unlinked immediately.
+         */
         private static Json.Object spawn_genkeys(string? tools_dir, string? libdir) throws Error {
             var tool = Tools.resolve("statum-genkeys", tools_dir);
             var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
             Tools.apply_env(launcher, libdir);
-            var process = launcher.spawnv({ tool });
 
-            string stdout_buf;
-            string stderr_buf;
-            process.communicate_utf8(null, null, out stdout_buf, out stderr_buf);
-            if (!process.get_successful()) {
-                throw Generator.cli_error("statum-genkeys failed: %s", (stderr_buf ?? "").strip());
-            }
+            // A temporary FILE (g_dir_make_tmp would give a directory).
+            var keyfile = GLib.Path.build_filename(
+                Environment.get_tmp_dir(),
+                "statum-genkeys-%s.json".printf(((uint64) Random.int_range(0, 1 << 30)).to_string()));
+            try {
+                var process = launcher.spawnv({ tool, "--out", keyfile });
+                string stdout_buf;
+                string stderr_buf;
+                process.communicate_utf8(null, null, out stdout_buf, out stderr_buf);
+                if (!process.get_successful()) {
+                    throw Generator.cli_error("statum-genkeys failed: %s", (stderr_buf ?? "").strip());
+                }
 
-            var parser = new Parser();
-            parser.load_from_data(stdout_buf ?? "");
-            var root = parser.get_root();
-            if (root == null || root.get_object() == null || !((!)root).get_object().has_member("statum")) {
-                throw Generator.cli_error("unexpected statum-genkeys output");
+                string contents;
+                if (!FileUtils.get_contents(keyfile, out contents)) {
+                    throw Generator.cli_error("statum-genkeys did not produce %s", keyfile);
+                }
+
+                var parser = new Parser();
+                parser.load_from_data(contents);
+                var root = parser.get_root();
+                if (root == null || root.get_object() == null || !((!)root).get_object().has_member("statum")) {
+                    throw Generator.cli_error("unexpected statum-genkeys output");
+                }
+                return ((!)root).get_object().get_object_member("statum");
+            } finally {
+                FileUtils.unlink(keyfile);
             }
-            return ((!)root).get_object().get_object_member("statum");
         }
 
         /**

+ 5 - 2
tools/spry/templates/README.md

@@ -41,11 +41,14 @@ or `SPRY_DB_PATH` when that variable is set.
 
 An admin account is seeded on boot only when BOTH `SPRY_SEED_ADMIN_USERNAME`
 and `SPRY_SEED_ADMIN_PASSWORD` are set (the password is never logged);
-seeding is skipped silently otherwise:
+seeding is skipped silently otherwise. Generate the password — do not
+reuse a memorable one — and rotate it after the first login (boot logs a
+warning when it is shorter than 12 characters or well-known):
 
 ```bash
 export SPRY_SEED_ADMIN_USERNAME=admin
-export SPRY_SEED_ADMIN_PASSWORD=change-me
+export SPRY_SEED_ADMIN_PASSWORD=Xk7-prod-seed-9f2mQv
+unset SPRY_SEED_ADMIN_PASSWORD
 ```
 
 ## Layout

+ 18 - 0
tools/spry/templates/app-main.vala

@@ -68,7 +68,25 @@ private async void seed_admin(Container container) {
         yield SpryAuth.ensure_admin(scope.resolve<UserService>(),
             username, @"$(username)@example.com", password);
         print("[{{APP_NAME}}] Seeded admin account: %s\n", username);
+        warn_when_admin_password_weak(username, password);
     } catch (Error e) {
         printerr("[{{APP_NAME}}] Admin seeding failed: %s\n", e.message);
     }
 }
+
+/**
+ * SA-7 advisory: warns when the seeded admin password is weak (shorter
+ * than 12 characters or on Spry's built-in deny-list). Never blocks the
+ * seeding — that stays the operator's escape hatch.
+ */
+private void warn_when_admin_password_weak(string username, string password) {
+    long chars = password.char_count();
+    if (chars > 0 && chars < Spry.Actions.ProfileValidation.PasswordPolicy.ADMIN_MIN_LENGTH) {
+        warning("[{{APP_NAME}}] seeded admin '%s' with a SHORT password (%ld characters; use at least %d) — rotate it before going to production.",
+            username, chars, (int) Spry.Actions.ProfileValidation.PasswordPolicy.ADMIN_MIN_LENGTH);
+    }
+    if (Spry.Actions.ProfileValidation.PasswordPolicy.is_denied(password)) {
+        warning("[{{APP_NAME}}] seeded admin '%s' with a WELL-KNOWN password — rotate it before going to production.",
+            username);
+    }
+}