import 'dart:typed_data'; import 'package:halkyon_keys/halkyon_keys.dart'; import 'package:sodium/sodium_sumo.dart'; import 'package:test/test.dart'; Future 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())); for (final control in ['\u0000', '\t', '\n', '\u007f', '\u0085']) { expect( () => normalizePassword('pass${control}word'), throwsA(isA()), ); } }); }); group('a new password', () { Matcher refusedFor(PasswordRefusal reason) => throwsA( isA().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(), ); }); }