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
7 changed files with 140 additions and 3 deletions

View file

@ -42,7 +42,9 @@ that the instance doesn't also know, so nothing could be encrypted for the perso
author; what a server must read (a reminder's time) stays readable and signed.
5. **TOTP** (RFC 6238: SHA-1, 6 digits, 30 seconds, one step either way), mandatory for every
identified account; its secret kept by the register, encrypted with a key of the instance's
(a new password, `totpKey`). Ten recovery codes, shown once, kept as hashes, each used once.
(a new password, `totpKey`). Ten recovery codes, shown once, kept as HMAC-SHA256 under a key
of the instance (`recoveryCodeKey`, outside the database: a code's 40 bits leak nothing
from a database without it), each used once.
6. **Recovery.** A recovery phrase (24 words, BIP-39's English list: 256 bits) also wraps the
private keys: a lost password with the phrase keeps everything. Without it, the address
(a code by mail) resets the account: new keys, and what was encrypted with the old ones
@ -78,6 +80,9 @@ that the instance doesn't also know, so nothing could be encrypted for the perso
linked.
- **D4** Nobody signs in with email codes yet, so the password replaces them outright (1).
- **D5** Privacy is the person's choice per studyset, private by default (10).
- **Also (20 Oct, at the crypto core's review):** recovery codes kept under the instance's
key (5); passwords taken as RFC 8265's OpaqueString (other spaces to a plain space, NFC,
control characters refused) before Argon2id, so a password unlocks alike on every system.
## Consequences

View file

@ -12,6 +12,7 @@ builds or tests a package depending on this one needs a C compiler and `make`.
| Piece | API | Parameters |
|---|---|---|
| The password's keys | `newPasswordSalt`, `derivePasswordKeys` → `PasswordKeys` (`srpSecret`, `srpPrivateValue`, `keyEncryptionKey`) | Argon2id v1.3, 64 MiB, 3 passes (`PasswordKdf.current`), a 16-byte salt, a 32-byte master key; libsodium's KDF (context `hlkpass1`) splits it into SRP's secret (subkey 1, whose 256 bits big-endian are SRP's x) and the key-encryption key (subkey 2) |
| The password as typed | `normalizePassword` (`PasswordRefused`) | RFC 8265's OpaqueString: other spaces to U+0020, Unicode NFC (`unorm_dart`, pinned exactly), empty or control characters refused; `derivePasswordKeys` applies it, so composed and decomposed accents unlock alike |
| The costs kept with an account | `PasswordKdf` (`toJson`, `fromJson`) | kept beside the salt, so they can be raised for new passwords; `fromJson` refuses another algorithm or costs below `PasswordKdf.minimum`, kept apart from the current costs so raising those never locks out an older account |
| The account's key pairs | `AccountKeys.generate` | X25519 to encrypt to, Ed25519 to sign with |
| Their wrapping | `wrapAccountKeys`, `unwrapAccountKeys` → `WrappedAccountKeys` (`nonce`, `cipherText`) | XChaCha20-Poly1305, a random 24-byte nonce, the private halves (32 + 64 bytes) as plaintext, both public keys and a version label as additional data; unwrapping also rebuilds both public keys from the private halves and compares them with those given |

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,
}

View file

@ -21,6 +21,9 @@ dependencies:
# which replaced sodium_libs's prebuilt binaries. 4.1.0+1, as 4.1.1 on needs
# code_assets 2, which Learn's mecab_for_dart (2.0.5) doesn't allow yet.
sodium: 4.1.0+1
# Unicode NFC for passwords (RFC 8265's OpaqueString), pinned exactly: a
# password's normalisation must never change once an account exists.
unorm_dart: 0.3.3
dev_dependencies:
test: ^1.31.1

View 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(),
);
});
}