halkyon/packages/halkyon_app/test/privacy/sodium_content_cipher_test.dart
Tizian 「ティツィアーン」 5faf3fce58
feat(app): a shared space's content taken from its members' signatures
ADR 0044, D20: a row in a shared space counts as genuine when any
member this device pinned signed it, current or former, never by the
author column the row names.

- `ContentCipher.trustSigners`: content sealed with a key is checked
  against the signing keys given for it, in place of the account's
  alone; `FakeContentCipher` tells authors apart likewise.
- `KeyPins` keeps who it saw in each space (`sawIn`) and the signing
  keys pinned for someone before a reset (`earlierSigning`), so what
  they wrote stays readable; `signersIn` gives a space's signers.
  `key_pins.json` holds both (`pins`, `spaces`); a file written by
  step 3, on no released app, counts as none.
- `SharedKeys` pins the person's own keys too, records the members it
  checks, and trusts the space's signers for each key it shares or
  opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 14:13:36 +01:00

187 lines
6.2 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
/// Content sealed with a data key (ADR 0019), with libsodium as its build
/// hook compiles it.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late SodiumContentCipher device;
final context = utf8.encode('card_revision/0192/payload');
final plain = Uint8List.fromList(utf8.encode('{"front":"火"}'));
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() async {
device = SodiumContentCipher();
await device.unlock(alice);
});
tearDown(() => device.dispose());
test("a key made on one device opens on another of the account's, and "
'what it seals opens there', () async {
final sealedKey = device.newKey('studyset-1');
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
final other = SodiumContentCipher();
await other.unlock(alice);
addTearDown(other.dispose);
expect(other.has('studyset-1'), isFalse);
expect(other.openKey('studyset-1', sealedKey), isTrue);
expect(other.has('studyset-1'), isTrue);
expect(other.unseal(sealed, context: context), plain);
expect(sealed.keyId, 'studyset-1');
expect(String.fromCharCodes(sealed.payload), isNot(contains('front')));
});
test("another account's device opens neither the key nor what it "
'sealed', () async {
final sealedKey = device.newKey('studyset-1');
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
final bobs = SodiumContentCipher();
await bobs.unlock(bob);
addTearDown(bobs.dispose);
expect(bobs.openKey('studyset-1', sealedKey), isFalse);
expect(bobs.unseal(sealed, context: context), isNull);
});
test('a key opens only under the ID it was sealed with, and only once '
'the account is unlocked', () async {
final sealedKey = device.newKey('studyset-1');
final other = SodiumContentCipher();
addTearDown(other.dispose);
expect(() => other.openKey('studyset-1', sealedKey), throwsStateError);
await other.unlock(alice);
expect(other.openKey('studyset-2', sealedKey), isFalse);
expect(other.openKey('studyset-1', sealedKey), isTrue);
});
test('a payload moved to another row, or changed, opens no more', () {
device.newKey('studyset-1');
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
final changed = Uint8List.fromList(sealed.payload)..last ^= 1;
expect(
device.unseal(sealed, context: utf8.encode('card_revision/0193/payload')),
isNull,
);
expect(
device.unseal(
SealedPayload(
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: changed,
),
context: context,
),
isNull,
);
expect(device.unseal(sealed, context: context), plain);
});
test(
"a row's keyed ID (ADR 0030, D57) is the same on every device of the "
"account, another on another account's, and none before unlocking",
() async {
final other = SodiumContentCipher();
await other.unlock(alice);
addTearDown(other.dispose);
final bobs = SodiumContentCipher();
await bobs.unlock(bob);
addTearDown(bobs.dispose);
final locked = SodiumContentCipher();
final id = device.rowId('train/day/2026-10-30');
expect(other.rowId('train/day/2026-10-30'), id);
expect(bobs.rowId('train/day/2026-10-30'), isNot(id));
expect(device.rowId('train/day/2026-10-31'), isNot(id));
expect(() => locked.rowId('train/day/2026-10-30'), throwsStateError);
},
);
test('without its key nothing opens, nor is sealed', () {
final sealed = SealedPayload(
keyId: 'unknown',
nonce: Uint8List(24),
payload: Uint8List(100),
);
expect(device.unseal(sealed, context: context), isNull);
expect(
() => device.seal(plain, keyId: 'unknown', context: context),
throwsStateError,
);
});
test("a shared key's content opens when a member it trusts signed it, "
"and only for that key (Halkyon's ADR 0044, D20)", () async {
final carol = (await crypto.newAccount('carol password')).keys;
PublicKeys keysOf(UnlockedAccountKeys account) => (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
Future<SodiumContentCipher> deviceOf(UnlockedAccountKeys account) async {
final cipher = SodiumContentCipher();
await cipher.unlock(account);
addTearDown(cipher.dispose);
return cipher;
}
final bobs = await deviceOf(bob);
final carols = await deviceOf(carol);
for (final keyId in ['shared', 'other']) {
device.newKey(keyId);
for (final (cipher, keys) in [(bobs, bob), (carols, carol)]) {
expect(
cipher.openSharedKey(
keyId,
device.sealKeyTo(keyId, keysOf(keys)),
sealedBy: alice.signingPublicKey,
),
isTrue,
);
}
}
final byBob = bobs.seal(plain, keyId: 'shared', context: context);
final byCarol = carols.seal(plain, keyId: 'shared', context: context);
final byAlice = device.seal(plain, keyId: 'shared', context: context);
// Only the account's own, until told whom to trust.
expect(device.unseal(byBob, context: context), isNull);
expect(device.unseal(byAlice, context: context), plain);
device.trustSigners('shared', [
alice.signingPublicKey,
bob.signingPublicKey,
]);
expect(device.unseal(byBob, context: context), plain);
expect(device.unseal(byAlice, context: context), plain);
expect(device.unseal(byCarol, context: context), isNull);
expect(
device.unseal(
bobs.seal(plain, keyId: 'other', context: context),
context: context,
),
isNull,
);
// What it trusts is replaced, not added to.
device.trustSigners('shared', [carol.signingPublicKey]);
expect(device.unseal(byCarol, context: context), plain);
expect(device.unseal(byBob, context: context), isNull);
expect(device.unseal(byAlice, context: context), isNull);
});
}