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
3 changed files with 140 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
55
packages/halkyon_keys/lib/src/password_text.dart
Normal file
55
packages/halkyon_keys/lib/src/password_text.dart
Normal 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,
|
||||
}
|
||||
Loading…
Reference in a new issue