Преглед на файлове

Password policy, weak-seed warning, Argon2 rehash, generic errors (SA-7/8/16/17)

SA-8: ProfileValidation.PasswordPolicy is the single password policy — 8-128 code-point bounds enforced before any hashing (Argon2 memory-amplification cap) and an 11-entry deny-list compared case-insensitively. RegisterAction, ChangePasswordAction and AlterUserAction enforce it identically via three overridable messages (too short / too long / too common). Password verification is deliberately not capped so legacy users with longer passwords keep logging in.

SA-7: ensure_admin and the scaffold's seed_admin warn (never block) when a NEW admin account is seeded with a password shorter than 12 characters or on the deny-list; existing accounts are never re-judged. The README example is now a generated-looking password with an unset and a rotation note.

SA-16: UserService.parse_hash_parameters extracts algorithm/version/m/t/p from the stored PHC string; hash_needs_rehash compares them against the current MODERATE limits (PHC m= is KiB, libsodium MEMLIMIT is bytes). On successful login a stale hash is transparently rewritten at current parameters — best-effort, session epoch untouched. Malformed strings answer needs-rehash.

SA-17: every action catch site routes through ProfileValidation.internal_error_message, which logs the full e.message via warning() and returns the generic constant (now placeholder-free) to the client.

Login rate limiting was in the audit fix direction but is not approved in this pass; left as a follow-up.

Tests: spry-tests extended to 88 checks (bounds, deny-list, PHC parsing incl. malformed, rehash decision, fresh-hash cross-check, generic messages, admin thresholds). New tests/RehashProbe.vala verifies the full live-database rehash path (register, force-stale, authenticate, rewrite, epoch untouched) and runs as a second meson test. statum-tests still green (67/67); full usm-scripts build.sh green.
task-145 преди 4 седмици
родител
ревизия
c7364f716b

+ 42 - 0
docs/security-audit.md

@@ -308,6 +308,15 @@ 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)
@@ -335,6 +344,19 @@ 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)
@@ -528,6 +550,17 @@ 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)
@@ -550,6 +583,15 @@ 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)

+ 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));
 			}
 		}
 

+ 18 - 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);
@@ -76,12 +84,15 @@ namespace Spry.Actions {
 				// other device holding an older frame is revoked (SA-1/SA-14).
 				var refreshed = yield users.get_user(me.user_id);
 				if (refreshed == null) {
-					return directives().notify("error", change_failed_message.printf("the account could not be reloaded"));
+					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));
 			}
 		}
 	}

+ 28 - 1
src/Auth.vala

@@ -244,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);
@@ -254,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;
@@ -293,6 +300,26 @@ 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`,

+ 156 - 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;
 		}
 
@@ -110,6 +128,144 @@ namespace Spry.Authentication {
 			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.

+ 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);
+}

+ 194 - 0
tests/TestMain.vala

@@ -2,6 +2,7 @@ using InvercargillSql.Migrations;
 using InvercargillSql.Dialects;
 using Spry.Authentication;
 using Spry.Authentication.Migrations;
+using Spry.Actions;
 
 namespace Spry.Tests {
 
@@ -154,6 +155,193 @@ namespace Spry.Tests {
             "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();
@@ -166,6 +354,12 @@ namespace Spry.Tests {
         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) {

+ 21 - 4
tests/meson.build

@@ -1,9 +1,15 @@
 # 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) and the M0002 migration's additive-only SQL shape.
-# Prints PASS/FAIL lines and exits non-zero on failure. Live-database
-# paths (the actual ALTER on a real store) are manually verified — see
-# the task completion report.
+# 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'],
@@ -12,3 +18,14 @@ spry_tests = executable('spry-tests',
 )
 
 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()
+)

+ 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);
+    }
+}