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 { final sodium = await SodiumSumoInit.init(); // Cheap costs for most tests; the production costs are checked once below. const cheap = PasswordKdf(opsLimit: 1, memLimit: 8192); final salt = Uint8List.fromList(List.generate(16, (i) => i)); PasswordKeys keysOf(String password, {Uint8List? withSalt}) => derivePasswordKeys( sodium, password: password, salt: withSalt ?? salt, kdf: cheap, ); test('the same password and salt make the same keys, on any device', () { final first = keysOf('correct horse battery staple'); final second = keysOf('correct horse battery staple'); expect(first.srpPrivateValue, second.srpPrivateValue); expect( first.keyEncryptionKey.extractBytes(), second.keyEncryptionKey.extractBytes(), ); }); test('another password, or another salt, makes other keys', () { final keys = keysOf('correct horse battery staple'); final otherPassword = keysOf('correct horse battery stapler'); final otherSalt = keysOf( 'correct horse battery staple', withSalt: Uint8List.fromList(List.generate(16, (i) => i + 1)), ); for (final other in [otherPassword, otherSalt]) { expect(other.srpPrivateValue, isNot(keys.srpPrivateValue)); expect( other.keyEncryptionKey.extractBytes(), isNot(keys.keyEncryptionKey.extractBytes()), ); } }); test("SRP's secret and the key-encryption key are unrelated halves", () { final keys = keysOf('a password'); final srp = keys.srpSecret.extractBytes(); expect(srp, hasLength(32)); expect(keys.keyEncryptionKey.extractBytes(), hasLength(32)); expect(keys.keyEncryptionKey.extractBytes(), isNot(srp)); expect(keys.srpPrivateValue.bitLength, lessThanOrEqualTo(256)); expect(keys.srpPrivateValue, greaterThan(BigInt.zero)); }); test('a salt of another length is refused', () { expect( () => derivePasswordKeys( sodium, password: 'a password', salt: Uint8List(15), kdf: cheap, ), throwsArgumentError, ); }); test('new salts are 16 random bytes', () { final first = newPasswordSalt(sodium); expect(first, hasLength(16)); expect(newPasswordSalt(sodium), isNot(first)); }); test('new passwords cost 64 MiB and 3 passes, and that works', () { expect(PasswordKdf.current.memLimit, 64 * 1024 * 1024); expect(PasswordKdf.current.opsLimit, 3); // The floor is apart from what new passwords take, so raising the // latter never locks out an account made before. expect(PasswordKdf.minimum.memLimit, 64 * 1024 * 1024); expect(PasswordKdf.minimum.opsLimit, 3); expect( PasswordKdf.current.memLimit, greaterThanOrEqualTo(PasswordKdf.minimum.memLimit), ); expect( PasswordKdf.current.opsLimit, greaterThanOrEqualTo(PasswordKdf.minimum.opsLimit), ); final keys = derivePasswordKeys( sodium, password: 'a password', salt: salt, ); expect(keys.srpPrivateValue, greaterThan(BigInt.zero)); keys.dispose(); }); group('the costs kept with an account', () { test('the floor itself is read', () { final read = PasswordKdf.fromJson(PasswordKdf.minimum.toJson()); expect(read.opsLimit, PasswordKdf.minimum.opsLimit); expect(read.memLimit, PasswordKdf.minimum.memLimit); }); test('round-trip', () { const raised = PasswordKdf(opsLimit: 4, memLimit: 128 * 1024 * 1024); final json = raised.toJson(); expect(json['algorithm'], 'argon2id13'); final read = PasswordKdf.fromJson(json); expect(read.opsLimit, 4); expect(read.memLimit, 128 * 1024 * 1024); }); test('below the floor, or of another algorithm, are refused: a register ' "can't make guessing cheaper", () { for (final json in >[ {'algorithm': 'argon2id13', 'opsLimit': 2, 'memLimit': 64 << 20}, {'algorithm': 'argon2id13', 'opsLimit': 3, 'memLimit': 32 << 20}, {'algorithm': 'argon2i13', 'opsLimit': 3, 'memLimit': 64 << 20}, {'algorithm': 'argon2id13', 'opsLimit': '3', 'memLimit': 64 << 20}, {}, ]) { expect( () => PasswordKdf.fromJson(json), throwsA( isA().having( (refused) => refused.reason, 'reason', KeyRefusal.weakPasswordKdf, ), ), reason: '$json', ); } }); }); }