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>
This commit is contained in:
parent
7ad0f92f23
commit
200a2df32f
1 changed files with 140 additions and 3 deletions
68
packages/halkyon_keys/test/password_text_test.dart
Normal file
68
packages/halkyon_keys/test/password_text_test.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue