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:
Tizian 「ティツィアーン」 2026-10-20 12:28:56 +02:00
commit 200a2df32f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 140 additions and 3 deletions

View file

@ -13,6 +13,8 @@ export 'src/data_keys.dart' show DataKey, openDataKey, sealDataKey;
export 'src/key_refused.dart';
export 'src/password_keys.dart'
show PasswordKdf, PasswordKeys, derivePasswordKeys, newPasswordSalt;
export 'src/password_text.dart'
show PasswordRefusal, PasswordRefused, normalizePassword;
export 'src/recovery_phrase.dart'
show
entropyOfRecoveryPhrase,

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:halkyon_keys/src/key_refused.dart';
import 'package:halkyon_keys/src/password_text.dart';
import 'package:sodium/sodium_sumo.dart';
/// The password's keys (ADR 0017): Argon2id over the password and the
@ -95,7 +96,8 @@ final class PasswordKeys._(
}
}
/// The keys [password] unlocks for the account with [salt], at the costs
/// The keys [password] (normalised, [normalizePassword]) unlocks for the
/// account with [salt], at the costs
/// [kdf] names (the account's own; [PasswordKdf.current] for a new one).
PasswordKeys derivePasswordKeys(
SodiumSumo sodium, {
@ -112,7 +114,8 @@ PasswordKeys derivePasswordKeys(
}
final masterKey = sodium.crypto.pwhash.callStr(
outLen: _keyBytes,
password: password,
// As RFC 8265's OpaqueString takes it, so it unlocks alike everywhere.
password: normalizePassword(password),
salt: salt,
opsLimit: kdf.opsLimit,
memLimit: kdf.memLimit,

View file

@ -0,0 +1,55 @@
import 'package:unorm_dart/unorm_dart.dart' as unorm;
/// A password as RFC 8265's OpaqueString profile takes it (decided 20 Oct
/// 2026), before anything is derived from it: every space other than the
/// plain one becomes the plain one, and the text is composed (Unicode NFC),
/// so a password typed on another system (macOS can write an accent as a
/// letter and a combining mark) unlocks the same keys. Nothing else changes:
/// case, width and every other character stay as typed. Refused
/// ([PasswordRefused]) when empty or holding a control character.
///
/// Once an account exists its password's normalisation must never change:
/// the library is pinned exactly, and NFC is stable for every assigned
/// character by Unicode's own policy.
String normalizePassword(String password) {
final spaced = String.fromCharCodes([
for (final rune in password.runes)
if (_otherSpaces.contains(rune)) 0x20 else rune,
]);
final normalized = unorm.nfc(spaced);
if (normalized.isEmpty) {
throw const PasswordRefused(PasswordRefusal.empty);
}
if (normalized.runes.any(_isControl)) {
throw const PasswordRefused(PasswordRefusal.controlCharacter);
}
return normalized;
}
/// Unicode's spaces (category Zs) other than U+0020.
const _otherSpaces = {
0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, //
0x2007, 0x2008, 0x2009, 0x200a, 0x202f, 0x205f, 0x3000,
};
/// Unicode's control characters (category Cc).
bool _isControl(int rune) => rune <= 0x1f || (rune >= 0x7f && rune <= 0x9f);
/// Why a password can't be taken.
final class const PasswordRefused(
/// Why.
final PasswordRefusal reason,
) implements Exception {
@override
String toString() => 'PasswordRefused(${reason.name})';
}
/// Why a password was refused.
enum PasswordRefusal() {
/// Nothing was typed.
empty,
/// It holds a control character (a tab, a line break, …), which no
/// keyboard types the same way everywhere.
controlCharacter,
}