feat(train): the person's key on Train's devices (ADR 0030, D15)
TrainKeys holds the one key everything of Train's is sealed with: made on the first write where the account's keys are, kept in the synced content_key rows sealed to the account, opened on the person's other devices. Only keys sealed under Train's own IDs open, so the instance can't relabel one, and a key the account sealed for another app never counts; two devices that each made one offline write with the older. A device without the account's keys opens and writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d34e2b751b
commit
61fc5c5f7c
2 changed files with 265 additions and 0 deletions
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
|
||||
/// The person's key on Train's devices (ADR 0030, D15), with libsodium as
|
||||
/// its build hook compiles it.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway rows;
|
||||
final opened = <TrainKeys>[];
|
||||
|
||||
/// A device of the account whose keys are [account] (none: signed in
|
||||
/// without them), on [rows].
|
||||
Future<TrainKeys> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TrainKeys(rows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return keys;
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() => rows = MemoryContentKeyGateway());
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test("the first write makes the person's key, which their other devices "
|
||||
'open', () async {
|
||||
final phone = await device(alice);
|
||||
expect(rows.rows, isEmpty);
|
||||
|
||||
final keyId = await phone.keyToWrite();
|
||||
|
||||
expect(rows.rows.map((row) => row.dataKeyId), [keyId]);
|
||||
expect(keyId, startsWith('train1.'));
|
||||
final laptop = await device(alice);
|
||||
expect(laptop.canOpen(keyId), isTrue);
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test('two devices that made a key each, offline, both write with the '
|
||||
'older one once they see both', () async {
|
||||
final phone = await device(alice);
|
||||
final laptop = await device(alice);
|
||||
final older = await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 28, 8)),
|
||||
phone.keyToWrite,
|
||||
);
|
||||
// The laptop hasn't seen the phone's yet: as offline.
|
||||
final row = rows.rows.removeLast();
|
||||
final newer = await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 28, 9)),
|
||||
laptop.keyToWrite,
|
||||
);
|
||||
rows.rows.add(row);
|
||||
|
||||
await phone.refresh();
|
||||
await laptop.refresh();
|
||||
|
||||
expect(await phone.keyToWrite(), older);
|
||||
expect(await laptop.keyToWrite(), older);
|
||||
expect(laptop.canOpen(newer), isTrue);
|
||||
expect(phone.canOpen(newer), isTrue);
|
||||
});
|
||||
|
||||
test("a device without the account's keys opens nothing and writes "
|
||||
'nothing', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
|
||||
final elsewhere = await device(null);
|
||||
|
||||
expect(elsewhere.canOpen(keyId), isFalse);
|
||||
expect(elsewhere.keyToWrite, throwsA(isA<KeysMissing>()));
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test("another account's key, put among the person's by the instance, is "
|
||||
'never opened or written with', () async {
|
||||
final bobs = await device(bob);
|
||||
final bobsKey = await bobs.keyToWrite();
|
||||
|
||||
final phone = await device(alice);
|
||||
|
||||
expect(phone.canOpen(bobsKey), isFalse);
|
||||
expect(await phone.keyToWrite(), isNot(bobsKey));
|
||||
});
|
||||
|
||||
test('a key relabelled by the instance, or one the account sealed for '
|
||||
'another app, is never opened', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
// The same sealed key under an ID that would sort before it.
|
||||
rows.rows.add((
|
||||
dataKeyId: 'train1.1.relabelled',
|
||||
sealedKey: rows.rows.single.sealedKey,
|
||||
));
|
||||
// A key of the same account sealed under Learn's ID for its personal
|
||||
// key, which would open as what it was sealed for.
|
||||
final learn = SodiumContentCipher();
|
||||
await learn.unlock(alice);
|
||||
addTearDown(learn.dispose);
|
||||
rows.rows.add((
|
||||
dataKeyId: 'p1.1.learn',
|
||||
sealedKey: learn.newKey('p1.1.learn'),
|
||||
));
|
||||
|
||||
final laptop = await device(alice);
|
||||
|
||||
expect(laptop.canOpen('train1.1.relabelled'), isFalse);
|
||||
expect(laptop.canOpen('p1.1.learn'), isFalse);
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/content_key_gateway.dart';
|
||||
|
||||
/// [ContentKeyGateway] in memory, which tests share between "devices" as the
|
||||
/// sync engine would, and change as an instance could.
|
||||
class MemoryContentKeyGateway() implements ContentKeyGateway {
|
||||
/// The rows, as the instance holds them.
|
||||
final rows = <ContentKeyRow>[];
|
||||
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => List.of(rows);
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required Uint8List sealedKey,
|
||||
}) async => rows.add((dataKeyId: dataKeyId, sealedKey: sealedKey));
|
||||
}
|
||||
Loading…
Reference in a new issue