feat(keys): halkyon_keys, an account's keys on the device
ADR 0017's step 1, on libsodium (sodium 4.1.0+1, whose build hook
compiles the libsodium 1.0.22 it ships: a C compiler and make wherever
it builds):
- Argon2id (64 MiB, 3 passes, kept with the account and never below
that) makes a master key, which libsodium's KDF splits into SRP's
secret (x) and the key-encryption key.
- The account's X25519 and Ed25519 key pairs, their private halves
wrapped with XChaCha20-Poly1305 under the KEK and under the recovery
key, bound to their public keys.
- A 24-word BIP-39 recovery phrase (the English list, MIT) and the
recovery key derived from it.
- Data keys sealed to the account with crypto_box_seal, bound to their
IDs; content in ADR 0002's envelope, encrypted with a data key and
signed with Ed25519 (detached), bound to a context the caller names.
- Secrets in libsodium's guarded memory where its API takes them.
- Tests: libsodium's Argon2id known answers, BIP-39's English vectors,
round trips and every refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 12:01:52 +02:00
|
|
|
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 {
|
|
|
|
|
final sodium = await SodiumSumoInit.init();
|
|
|
|
|
const cheap = PasswordKdf(opsLimit: 1, memLimit: 8192);
|
|
|
|
|
final salt = Uint8List.fromList(List.generate(16, (i) => i));
|
|
|
|
|
|
|
|
|
|
Matcher refusedFor(KeyRefusal reason) => throwsA(
|
|
|
|
|
isA<KeyRefused>().having((refused) => refused.reason, 'reason', reason),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('key pairs: X25519 to encrypt to, Ed25519 to sign with', () {
|
|
|
|
|
final keys = AccountKeys.generate(sodium);
|
|
|
|
|
expect(keys.encryptionPublicKey, hasLength(32));
|
|
|
|
|
expect(keys.signingPublicKey, hasLength(32));
|
|
|
|
|
expect(
|
|
|
|
|
AccountKeys.generate(sodium).encryptionPublicKey,
|
|
|
|
|
isNot(keys.encryptionPublicKey),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(app): sign in with a password and TOTP on the device
ADR 0017, step 3: halkyon_app's side, which Learn and Todo take.
- The account's cryptography on the device (`SodiumAccountCrypto`):
halkyon_keys and SRP's client half, each step on an isolate of its
own. sodium hands out results as views of native memory its
finalizers free once the isolate that made them ends, so everything
crossing back is copied first: a view read freed memory and crashed
the process.
- `PasswordGateway` on the module's endpoints (steps without a session
through a client holding none; an authenticator set up with a session
granted but not yet the device's), and `PasswordRepository` running
the flows: signing in (M2 checked, the keys opened before anything is
trusted), signing up, a password for someone signed in, an account
never finished, the authenticators, recovery codes, changing the
password, a new recovery phrase, and getting back in.
- The unlocked keys in the system's keyring per instance and person
(`SecureAccountKeyStore`), gone once another person signs in or
nobody, and with the instance's removal.
- The screens: the start screen's Sign in and Create an account (and
when GitHub sign-in ends), the sign-in sheet (codes, recovery codes,
an account never finished, a lost authenticator), the account's setup
(the recovery phrase shown once, three random words typed back before
anything reaches the instance; the authenticator's QR code, drawn
with pub.dev's qr; the recovery codes), getting back in, and the
account section (`AccountSecuritySection`).
- `FileSaver` moves from Learn into halkyon_app, for the phrase and the
codes in Todo too; `AppDependencies` names it and `passwords`.
- halkyon_keys rebuilds account keys from the secret keys a device kept;
the key proof's message is in halkyon_base_client, checked against
the register's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 15:42:45 +02:00
|
|
|
test('come back from the secret keys a device kept, refused for others', () {
|
|
|
|
|
final keys = AccountKeys.generate(sodium);
|
|
|
|
|
final again = AccountKeys.fromSecretKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
encryptionSecretKey: keys.encryption.secretKey.extractBytes(),
|
|
|
|
|
signingSecretKey: keys.signing.secretKey.extractBytes(),
|
|
|
|
|
);
|
|
|
|
|
expect(again.encryptionPublicKey, keys.encryptionPublicKey);
|
|
|
|
|
expect(again.signingPublicKey, keys.signingPublicKey);
|
|
|
|
|
|
|
|
|
|
final tampered = keys.signing.secretKey.extractBytes();
|
|
|
|
|
tampered[tampered.length - 1] ^= 1;
|
|
|
|
|
expect(
|
|
|
|
|
() => AccountKeys.fromSecretKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
encryptionSecretKey: keys.encryption.secretKey.extractBytes(),
|
|
|
|
|
signingSecretKey: tampered,
|
|
|
|
|
),
|
|
|
|
|
refusedFor(KeyRefusal.notTheAccountsKeys),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => AccountKeys.fromSecretKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
encryptionSecretKey: Uint8List(31),
|
|
|
|
|
signingSecretKey: keys.signing.secretKey.extractBytes(),
|
|
|
|
|
),
|
|
|
|
|
refusedFor(KeyRefusal.notTheAccountsKeys),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(keys): halkyon_keys, an account's keys on the device
ADR 0017's step 1, on libsodium (sodium 4.1.0+1, whose build hook
compiles the libsodium 1.0.22 it ships: a C compiler and make wherever
it builds):
- Argon2id (64 MiB, 3 passes, kept with the account and never below
that) makes a master key, which libsodium's KDF splits into SRP's
secret (x) and the key-encryption key.
- The account's X25519 and Ed25519 key pairs, their private halves
wrapped with XChaCha20-Poly1305 under the KEK and under the recovery
key, bound to their public keys.
- A 24-word BIP-39 recovery phrase (the English list, MIT) and the
recovery key derived from it.
- Data keys sealed to the account with crypto_box_seal, bound to their
IDs; content in ADR 0002's envelope, encrypted with a data key and
signed with Ed25519 (detached), bound to a context the caller names.
- Secrets in libsodium's guarded memory where its API takes them.
- Tests: libsodium's Argon2id known answers, BIP-39's English vectors,
round trips and every refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 12:01:52 +02:00
|
|
|
group('wrapped under the password', () {
|
|
|
|
|
final keys = AccountKeys.generate(sodium);
|
|
|
|
|
final password = derivePasswordKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
password: 'a password',
|
|
|
|
|
salt: salt,
|
|
|
|
|
kdf: cheap,
|
|
|
|
|
);
|
|
|
|
|
final wrapped = wrapAccountKeys(sodium, keys, password.keyEncryptionKey);
|
|
|
|
|
|
|
|
|
|
AccountKeys unwrap(
|
|
|
|
|
WrappedAccountKeys what,
|
|
|
|
|
SecureKey key, {
|
|
|
|
|
Uint8List? encryptionPublicKey,
|
|
|
|
|
Uint8List? signingPublicKey,
|
|
|
|
|
}) => unwrapAccountKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
what,
|
|
|
|
|
key,
|
|
|
|
|
encryptionPublicKey: encryptionPublicKey ?? keys.encryptionPublicKey,
|
|
|
|
|
signingPublicKey: signingPublicKey ?? keys.signingPublicKey,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('the same password opens them on another device, the same keys', () {
|
|
|
|
|
final again = derivePasswordKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
password: 'a password',
|
|
|
|
|
salt: salt,
|
|
|
|
|
kdf: cheap,
|
|
|
|
|
);
|
|
|
|
|
final opened = unwrap(
|
|
|
|
|
WrappedAccountKeys.fromJson(wrapped.toJson()),
|
|
|
|
|
again.keyEncryptionKey,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
opened.encryption.secretKey.extractBytes(),
|
|
|
|
|
keys.encryption.secretKey.extractBytes(),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
opened.signing.secretKey.extractBytes(),
|
|
|
|
|
keys.signing.secretKey.extractBytes(),
|
|
|
|
|
);
|
|
|
|
|
// They work: a signature made with the opened key checks.
|
|
|
|
|
final message = Uint8List.fromList([1, 2, 3]);
|
|
|
|
|
expect(
|
|
|
|
|
sodium.crypto.sign.verifyDetached(
|
|
|
|
|
message: message,
|
|
|
|
|
signature: sodium.crypto.sign.detached(
|
|
|
|
|
message: message,
|
|
|
|
|
secretKey: opened.signing.secretKey,
|
|
|
|
|
),
|
|
|
|
|
publicKey: keys.signingPublicKey,
|
|
|
|
|
),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('nothing secret is in what the register keeps', () {
|
|
|
|
|
final json = wrapped.toJson();
|
|
|
|
|
expect(json.keys, unorderedEquals(['nonce', 'cipherText']));
|
|
|
|
|
expect(wrapped.nonce, hasLength(24));
|
|
|
|
|
// 32 + 64 bytes of keys and Poly1305's 16-byte tag.
|
|
|
|
|
expect(wrapped.cipherText, hasLength(112));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a wrong password is refused', () {
|
|
|
|
|
final wrong = derivePasswordKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
password: 'a passwort',
|
|
|
|
|
salt: salt,
|
|
|
|
|
kdf: cheap,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => unwrap(wrapped, wrong.keyEncryptionKey),
|
|
|
|
|
refusedFor(KeyRefusal.wrongKeyOrTampered),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a ciphertext or nonce changed is refused', () {
|
|
|
|
|
final cipherText = Uint8List.fromList(wrapped.cipherText)..[5] ^= 1;
|
|
|
|
|
final nonce = Uint8List.fromList(wrapped.nonce)..[0] ^= 1;
|
|
|
|
|
for (final tampered in [
|
|
|
|
|
WrappedAccountKeys(nonce: wrapped.nonce, cipherText: cipherText),
|
|
|
|
|
WrappedAccountKeys(nonce: nonce, cipherText: wrapped.cipherText),
|
|
|
|
|
WrappedAccountKeys(
|
|
|
|
|
nonce: wrapped.nonce,
|
|
|
|
|
cipherText: wrapped.cipherText.sublist(1),
|
|
|
|
|
),
|
|
|
|
|
]) {
|
|
|
|
|
expect(
|
|
|
|
|
() => unwrap(tampered, password.keyEncryptionKey),
|
|
|
|
|
refusedFor(KeyRefusal.wrongKeyOrTampered),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("another account's public keys don't open them: they are bound to "
|
|
|
|
|
'their own', () {
|
|
|
|
|
final other = AccountKeys.generate(sodium);
|
|
|
|
|
expect(
|
|
|
|
|
() => unwrap(
|
|
|
|
|
wrapped,
|
|
|
|
|
password.keyEncryptionKey,
|
|
|
|
|
encryptionPublicKey: other.encryptionPublicKey,
|
|
|
|
|
),
|
|
|
|
|
refusedFor(KeyRefusal.wrongKeyOrTampered),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => unwrap(
|
|
|
|
|
wrapped,
|
|
|
|
|
password.keyEncryptionKey,
|
|
|
|
|
signingPublicKey: other.signingPublicKey,
|
|
|
|
|
),
|
|
|
|
|
refusedFor(KeyRefusal.wrongKeyOrTampered),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => unwrap(
|
|
|
|
|
wrapped,
|
|
|
|
|
password.keyEncryptionKey,
|
|
|
|
|
signingPublicKey: Uint8List(31),
|
|
|
|
|
),
|
|
|
|
|
refusedFor(KeyRefusal.notTheAccountsKeys),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('wrapped again under the recovery key, the phrase opens them', () {
|
|
|
|
|
final keys = AccountKeys.generate(sodium);
|
|
|
|
|
final phrase = newRecoveryPhrase(sodium);
|
|
|
|
|
final wrapped = wrapAccountKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
keys,
|
|
|
|
|
recoveryKeyOf(sodium, phrase),
|
|
|
|
|
);
|
|
|
|
|
final opened = unwrapAccountKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
wrapped,
|
|
|
|
|
recoveryKeyOf(sodium, phrase),
|
|
|
|
|
encryptionPublicKey: keys.encryptionPublicKey,
|
|
|
|
|
signingPublicKey: keys.signingPublicKey,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
opened.encryption.secretKey.extractBytes(),
|
|
|
|
|
keys.encryption.secretKey.extractBytes(),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => unwrapAccountKeys(
|
|
|
|
|
sodium,
|
|
|
|
|
wrapped,
|
|
|
|
|
recoveryKeyOf(sodium, newRecoveryPhrase(sodium)),
|
|
|
|
|
encryptionPublicKey: keys.encryptionPublicKey,
|
|
|
|
|
signingPublicKey: keys.signingPublicKey,
|
|
|
|
|
),
|
|
|
|
|
refusedFor(KeyRefusal.wrongKeyOrTampered),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('every wrapping takes a new nonce', () {
|
|
|
|
|
final keys = AccountKeys.generate(sodium);
|
|
|
|
|
final key = sodium.crypto.aeadXChaCha20Poly1305IETF.keygen();
|
|
|
|
|
final first = wrapAccountKeys(sodium, keys, key);
|
|
|
|
|
final second = wrapAccountKeys(sodium, keys, key);
|
|
|
|
|
expect(first.nonce, isNot(second.nonce));
|
|
|
|
|
expect(first.cipherText, isNot(second.cipherText));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("what isn't wrapped account keys can't be read as such", () {
|
|
|
|
|
expect(
|
|
|
|
|
() => WrappedAccountKeys.fromJson({'nonce': 'AAAA'}),
|
|
|
|
|
throwsFormatException,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|