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
5 changed files with 265 additions and 0 deletions
|
|
@ -0,0 +1,17 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// A `content_key` row (ADR 0030, D15): the key's ID as the row says it,
|
||||||
|
/// which is never trusted, and the key sealed to the account.
|
||||||
|
typedef ContentKeyRow = ({String dataKeyId, Uint8List sealedKey});
|
||||||
|
|
||||||
|
/// Where the person's data keys are kept: their synced `content_key` rows.
|
||||||
|
abstract interface class ContentKeyGateway() {
|
||||||
|
/// Every key row of the person's.
|
||||||
|
Future<List<ContentKeyRow>> all();
|
||||||
|
|
||||||
|
/// Keeps a new key.
|
||||||
|
Future<void> insert({
|
||||||
|
required String dataKeyId,
|
||||||
|
required Uint8List sealedKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/core/privacy/content_key_gateway.dart';
|
||||||
|
|
||||||
|
/// Refused: this device holds none of the account's keys (signed in without
|
||||||
|
/// them), so it opens none of the person's data and writes none (ADR 0030,
|
||||||
|
/// D15; as Learn's ADR 0019 has it).
|
||||||
|
final class KeysMissing() implements Exception {
|
||||||
|
@override
|
||||||
|
String toString() => "KeysMissing: the account's keys aren't on this device";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person's key on this device (ADR 0030, D15): one key, with which
|
||||||
|
/// everything of Train's is sealed (D7), kept in their synced `content_key`
|
||||||
|
/// rows sealed to the account, so their other devices open it.
|
||||||
|
///
|
||||||
|
/// A key's ID is sealed with it and signed by the account (halkyon_keys'
|
||||||
|
/// `sealDataKey` opens a key only under the ID it was sealed with), so the
|
||||||
|
/// instance can't relabel one, and only IDs of Train's own form are opened:
|
||||||
|
/// a key the account sealed for another app (Learn's personal key) never
|
||||||
|
/// counts here. Two devices that each made a key while offline both write
|
||||||
|
/// with the older once they see both; every key that opens still reads.
|
||||||
|
class TrainKeys(
|
||||||
|
final ContentKeyGateway _gateway,
|
||||||
|
|
||||||
|
/// Seals and opens with the keys; [TrainKeys] owns it.
|
||||||
|
final ContentCipher cipher,
|
||||||
|
|
||||||
|
/// The account's keys on this device; null where none are.
|
||||||
|
final Future<UnlockedAccountKeys?> Function() _accountKeys,
|
||||||
|
) {
|
||||||
|
var _unlocked = false;
|
||||||
|
String? _writeKey;
|
||||||
|
|
||||||
|
/// Reads the key rows again and opens the person's keys among them: as
|
||||||
|
/// the person's data opens, and after a sync brought others' rows.
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final account = await _accountKeys();
|
||||||
|
if (account == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_unlocked) {
|
||||||
|
await cipher.unlock(account);
|
||||||
|
_unlocked = true;
|
||||||
|
}
|
||||||
|
for (final row in await _gateway.all()) {
|
||||||
|
final id = row.dataKeyId;
|
||||||
|
if (_madeAt(id) != null &&
|
||||||
|
(cipher.has(id) || cipher.openKey(id, row.sealedKey)) &&
|
||||||
|
_older(id, _writeKey)) {
|
||||||
|
_writeKey = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether what is sealed with the key [keyId] opens here.
|
||||||
|
bool canOpen(String keyId) =>
|
||||||
|
_unlocked && _madeAt(keyId) != null && cipher.has(keyId);
|
||||||
|
|
||||||
|
/// The key to seal with, made and kept the first time; throws
|
||||||
|
/// [KeysMissing] without the account's keys.
|
||||||
|
Future<String> keyToWrite() async {
|
||||||
|
if (_writeKey case final key?) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
if (!_unlocked) {
|
||||||
|
throw KeysMissing();
|
||||||
|
}
|
||||||
|
final random = Random.secure();
|
||||||
|
final nonce = base64Url
|
||||||
|
.encode([for (var i = 0; i < 12; i++) random.nextInt(256)])
|
||||||
|
.replaceAll('=', '');
|
||||||
|
final id = 'train1.${clock.now().toUtc().millisecondsSinceEpoch}.$nonce';
|
||||||
|
await _gateway.insert(dataKeyId: id, sealedKey: cipher.newKey(id));
|
||||||
|
return _writeKey = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes the keys kept open here.
|
||||||
|
void dispose() => cipher.dispose();
|
||||||
|
|
||||||
|
/// When the key [id] was made, as its ID says (`train1.<ms>.<nonce>`);
|
||||||
|
/// null for an ID of another form, which is never Train's.
|
||||||
|
static int? _madeAt(String id) => switch (id.split('.')) {
|
||||||
|
['train1', final ms, final nonce] when nonce.isNotEmpty => int.tryParse(ms),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Whether [id] was made before [other] (none at all: yes), ties going to
|
||||||
|
/// the smaller ID, so every device picks the same.
|
||||||
|
static bool _older(String id, String? other) {
|
||||||
|
if (other == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final (a, b) = (_madeAt(id)!, _madeAt(other)!);
|
||||||
|
return a < b || (a == b && id.compareTo(other) < 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ resolution: workspace
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
clock: ^1.1.3
|
||||||
equatable: ^3.0.0
|
equatable: ^3.0.0
|
||||||
flutter_bloc: ^9.1.1
|
flutter_bloc: ^9.1.1
|
||||||
# What every Halkyon app is built around (a shared package): the instances a
|
# What every Halkyon app is built around (a shared package): the instances a
|
||||||
|
|
|
||||||
|
|
@ -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