|
|
@@ -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) {
|