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