halkyon/packages/halkyon_app/test/privacy/sodium_content_cipher_test.dart
Tizian 「ティツィアーン」 64fdfa091d
feat(privacy): every synced payload sealed and opened through one envelope (ADR 0019)
- `ContentKeys` opens the person's data keys (`content_key`) with their
  account's keys on the device, an anonymous person's device keys made as
  their data first opens; it makes a private studyset's key and the
  personal key. Without the account's keys nothing opens and nothing new
  is private (D8).
- `PayloadEnvelope` is the one place payloads are sealed and opened, each
  for its table, row and column, so a payload moved to another row never
  opens; one that doesn't open is `Locked`, never half-read.
- `Privacy` gives each gateway the key of the row's tree (its top-level
  studyset's), and rewrites a tree under another key, or readable, in
  place (`rekeyTree`).
- The studyset, card, note, learning path, path answer and PDF gateways
  seal with their tree's key and open what they read: a new top-level
  studyset is private where the device holds the keys, a new one inside a
  tree takes its privacy, and one moved into another tree is rewritten
  under that tree's key. Tags and settings are sealed with the personal
  key; a tag or settings row that doesn't open here is left out, so
  nothing folds or deletes it. A studyset whose key doesn't open shows as
  locked (`Studyset.isLocked`); other rows that don't open read as blank.
- Every payload codec round-trips sealed, and refuses another row.

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

108 lines
3.3 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.newDeviceKeys();
bob = await crypto.newDeviceKeys();
});
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('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,
);
});
}