fix(keys): new passwords have at least 12 characters

The register can't check a password it never sees, and should its
database leak, the verifier and the keys wrapped under the password fall
to guessing at Argon2id's cost (the second reading's M8). Devices now
refuse a new password shorter than 12 characters, counted as code points
once normalised (halkyon_keys' checkNewPassword), at sign-up, a reset
keeping the keys and a change alike, before anything is derived; the new
password's field says so, and the refusal has its own text (en, de).
Signing in takes any length, so a password set before keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 21:25:47 +02:00
commit b33b99a50a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 216 additions and 29 deletions

View file

@ -14,7 +14,12 @@ export 'src/key_refused.dart';
export 'src/password_keys.dart'
show PasswordKdf, PasswordKeys, derivePasswordKeys, newPasswordSalt;
export 'src/password_text.dart'
show PasswordRefusal, PasswordRefused, normalizePassword;
show
PasswordRefusal,
PasswordRefused,
checkNewPassword,
newPasswordMinLength,
normalizePassword;
export 'src/recovery_phrase.dart'
show
entropyOfRecoveryPhrase,

View file

@ -26,6 +26,25 @@ String normalizePassword(String password) {
return normalized;
}
/// The fewest characters (Unicode code points, once normalised) a new
/// password has (decided 20 Oct 2026, at the second reading): should the
/// register's database leak, its verifier and the keys wrapped under it
/// fall to guessing, at Argon2id's cost per guess, and the register can't
/// check a password it never sees.
const newPasswordMinLength = 12;
/// [password] as [normalizePassword] takes it, for a new password: refused
/// ([PasswordRefused], [PasswordRefusal.tooShort]) below
/// [newPasswordMinLength] characters too. Signing in takes any length: the
/// minimum holds only where a password is set.
String checkNewPassword(String password) {
final normalized = normalizePassword(password);
if (normalized.runes.length < newPasswordMinLength) {
throw const PasswordRefused(PasswordRefusal.tooShort);
}
return normalized;
}
/// Unicode's spaces (category Zs) other than U+0020.
const _otherSpaces = {
0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, //
@ -52,4 +71,7 @@ enum PasswordRefusal() {
/// It holds a control character (a tab, a line break, …), which no
/// keyboard types the same way everywhere.
controlCharacter,
/// A new password shorter than [newPasswordMinLength].
tooShort,
}

View file

@ -46,6 +46,53 @@ Future<void> main() async {
});
});
group('a new password', () {
Matcher refusedFor(PasswordRefusal reason) => throwsA(
isA<PasswordRefused>().having(
(refused) => refused.reason,
'reason',
reason,
),
);
test('has at least 12 characters, counted once normalised, as code '
'points: the keys it wraps fall to guessing should the database '
'leak', () {
expect(newPasswordMinLength, 12);
expect(checkNewPassword('twelve chars'), 'twelve chars');
expect(
() => checkNewPassword('eleven char'),
refusedFor(PasswordRefusal.tooShort),
);
// Twelve emoji are twelve characters, though 24 UTF-16 units.
expect(checkNewPassword('🔑' * 12), '🔑' * 12);
expect(
() => checkNewPassword('🔑' * 11),
refusedFor(PasswordRefusal.tooShort),
);
// é typed as e and a combining accent counts once, as composed.
expect(
() => checkNewPassword('café au lai'),
refusedFor(PasswordRefusal.tooShort),
);
expect(checkNewPassword('café au lait'), 'café au lait');
});
test('is refused as any password is: empty, or with a control '
'character', () {
expect(() => checkNewPassword(''), refusedFor(PasswordRefusal.empty));
expect(
() => checkNewPassword('twelve chars!'),
refusedFor(PasswordRefusal.controlCharacter),
);
});
test("an existing account's password of any length still unlocks: the "
'minimum holds only for new ones', () {
expect(normalizePassword('short'), 'short');
});
});
test('the keys are made from the normalised password: typed either way, '
'the same keys', () async {
final sodium = await SodiumSumoInit.init();