halkyon/packages/halkyon_keys/test/password_text_test.dart
Tizian 「ティツィアーン」 b33b99a50a
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>
2026-10-23 14:12:43 +02:00

115 lines
3.9 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:sodium/sodium_sumo.dart';
import 'package:test/test.dart';
Future<void> main() async {
group("a password as RFC 8265's OpaqueString takes it", () {
test('accents composed or decomposed, as systems type them, are one '
'password', () {
// é as one character, and as e with a combining accent (macOS).
expect(
normalizePassword('café au lait'),
normalizePassword('café au lait'),
);
expect(normalizePassword('café'), 'café');
});
test('every other space is a plain space', () {
for (final space in [
' ', // no-break space
' ', // en space
' ', // thin space
' ', // narrow no-break space
' ', // ideographic space
]) {
expect(normalizePassword('two${space}words'), 'two words');
}
});
test('nothing else changes: case, width and what is typed stay', () {
expect(normalizePassword('Correct Horse'), 'Correct Horse');
expect(normalizePassword('FULL'), 'FULL');
expect(normalizePassword(' padded '), ' padded ');
expect(normalizePassword('пароль 密码 🔑'), 'пароль 密码 🔑');
});
test('an empty password, or one with control characters, is refused', () {
expect(() => normalizePassword(''), throwsA(isA<PasswordRefused>()));
for (final control in ['\u0000', '\t', '\n', '\u007f', '\u0085']) {
expect(
() => normalizePassword('pass${control}word'),
throwsA(isA<PasswordRefused>()),
);
}
});
});
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();
const cheap = PasswordKdf(opsLimit: 1, memLimit: 8192);
final salt = Uint8List.fromList(List.generate(16, (i) => i));
PasswordKeys keysOf(String password) => derivePasswordKeys(
sodium,
password: password,
salt: salt,
kdf: cheap,
);
final composed = keysOf('café noir');
final decomposed = keysOf('café noir');
expect(composed.srpPrivateValue, decomposed.srpPrivateValue);
expect(
composed.keyEncryptionKey.extractBytes(),
decomposed.keyEncryptionKey.extractBytes(),
);
});
}