halkyon/packages/halkyon_keys/test/password_text_test.dart
Tizian 「ティツィアーン」 200a2df32f
feat(keys): passwords taken as RFC 8265's OpaqueString
Other spaces become a plain space and the text is composed (NFC) before
Argon2id, so a password typed on another system, accents composed or
not, unlocks the same keys; empty passwords and control characters are
refused. The normalisation (unorm_dart) is pinned exactly, as it must
never change once an account exists. ADR 0017 records both decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:10 +02:00

68 lines
2.3 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>()),
);
}
});
});
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(),
);
});
}