2026-10-20 12:28:56 +02:00
|
|
|
|
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>()),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-20 21:25:47 +02:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-20 12:28:56 +02:00
|
|
|
|
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(),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|