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>
228 lines
7 KiB
Dart
228 lines
7 KiB
Dart
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),
|
|
);
|
|
});
|
|
|
|
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),
|
|
);
|
|
});
|
|
|
|
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,
|
|
);
|
|
});
|
|
}
|