fix(train): no row's ID tells the instance a date: the days' IDs are keyed by the account (ADR 0030, D57)
A day's, night's and heart chunk's ID was a UUID of version 5 of the person and the date, which the instance can hash for every date itself: each ID told it which days the band was worn. halkyon_keys' keyedRowId hashes the name under the account's X25519 secret key (BLAKE2b, a UUID of version 8), the same on every device of the account and unreadable without its keys; ContentCipher.rowId and TrainKeys.rowId hand it on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9b9b8d08ca
commit
839d49c4cc
8 changed files with 249 additions and 36 deletions
|
|
@ -384,11 +384,7 @@ Future<TrainPerson> _openPersonData(
|
|||
keys,
|
||||
person: person.authUserId,
|
||||
);
|
||||
final days = DayRepository(
|
||||
SyncDayGateway(db, sync),
|
||||
keys,
|
||||
person: person.authUserId,
|
||||
);
|
||||
final days = DayRepository(SyncDayGateway(db, sync), keys);
|
||||
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
|
||||
final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
|
||||
final maps = MapSourceRepository(
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ class TrainKeys(
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the account's keys are open here: [refresh] found them.
|
||||
bool get unlocked => _unlocked;
|
||||
|
||||
/// The ID of the row named [name], keyed by the account (D57): the same
|
||||
/// on each of its devices, nothing of [name] read from it by the
|
||||
/// instance. Throws [KeysMissing] where [refresh] found no keys.
|
||||
String rowId(String name) =>
|
||||
_unlocked ? cipher.rowId(name) : throw KeysMissing();
|
||||
|
||||
/// Whether what is sealed with the key [keyId] opens here.
|
||||
bool canOpen(String keyId) =>
|
||||
_unlocked && _madeAt(keyId) != null && cipher.has(keyId);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart'
|
||||
show Namespace, Uuid;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/day_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/day_payload.dart';
|
||||
|
|
@ -17,17 +15,12 @@ const _chunkHours = 3;
|
|||
/// The person's days and nights from the Loop (ADR 0030, D37), sealed with
|
||||
/// their key as their workouts are (D7): one `day` row a date, one `night`
|
||||
/// row a date, a day's heart rate in rows of three hours sealed for their
|
||||
/// day; every ID made from the person and the date (and the chunk's place),
|
||||
/// so a day read again, here or on another phone, writes the same rows, the
|
||||
/// last writer winning. A row that doesn't open here is left out: what is
|
||||
/// day; every ID made from the date (and the chunk's place) under a key of
|
||||
/// the account's (D57), so a day read again, here or on another phone,
|
||||
/// writes the same rows, the last writer winning, and the instance reads no
|
||||
/// date from an ID. A row that doesn't open here is left out: what is
|
||||
/// sealed doesn't say its date.
|
||||
class DayRepository(
|
||||
final DayGateway _gateway,
|
||||
final TrainKeys _keys, {
|
||||
|
||||
/// Whose days they are: their auth user ID.
|
||||
required final String person,
|
||||
}) {
|
||||
class DayRepository(final DayGateway _gateway, final TrainKeys _keys) {
|
||||
late final _envelope = PayloadEnvelope(_keys.cipher);
|
||||
StreamSubscription<void>? _remote;
|
||||
|
||||
|
|
@ -39,15 +32,12 @@ class DayRepository(
|
|||
/// Fires when the days changed, here or on another device.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
String _dayId(DateTime date) => _id('day/$person/${dayKey(date)}');
|
||||
String _dayId(DateTime date) => _keys.rowId('train/day/${dayKey(date)}');
|
||||
|
||||
String _nightId(DateTime date) => _id('night/$person/${dayKey(date)}');
|
||||
String _nightId(DateTime date) => _keys.rowId('train/night/${dayKey(date)}');
|
||||
|
||||
String _chunkId(DateTime date, int index) =>
|
||||
_id('day/$person/${dayKey(date)}/heart/$index');
|
||||
|
||||
String _id(String path) =>
|
||||
const Uuid().v5(Namespace.url.value, 'https://halkyon.app/train/$path');
|
||||
_keys.rowId('train/day/${dayKey(date)}/heart/$index');
|
||||
|
||||
/// Every date with a day or a night that opens here, newest first.
|
||||
Future<List<StoredDay>> all() async {
|
||||
|
|
@ -80,12 +70,18 @@ class DayRepository(
|
|||
/// The day of [date], where one opens here.
|
||||
Future<DayContent?> day(DateTime date) async {
|
||||
await _keys.refresh();
|
||||
if (!_keys.unlocked) {
|
||||
return null;
|
||||
}
|
||||
return await _day(date);
|
||||
}
|
||||
|
||||
/// The night that ended [date], where one opens here.
|
||||
Future<NightContent?> night(DateTime date) async {
|
||||
await _keys.refresh();
|
||||
if (!_keys.unlocked) {
|
||||
return null;
|
||||
}
|
||||
final id = _nightId(date);
|
||||
return switch (await _gateway.night(id)) {
|
||||
final envelope? => switch (_open(envelope, table: 'night', id: id)) {
|
||||
|
|
@ -99,6 +95,9 @@ class DayRepository(
|
|||
/// The 24/7 heart rate of [date] that opens here, in time order.
|
||||
Future<List<HeartRun>> heart(DateTime date) async {
|
||||
await _keys.refresh();
|
||||
if (!_keys.unlocked) {
|
||||
return [];
|
||||
}
|
||||
return await _heart(date);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@ void main() {
|
|||
FakeContentCipher(),
|
||||
() async => null,
|
||||
),
|
||||
person: person.authUserId,
|
||||
),
|
||||
plans:
|
||||
plans ??
|
||||
|
|
@ -538,7 +537,7 @@ void main() {
|
|||
keys,
|
||||
person: 'alice',
|
||||
);
|
||||
days = DayRepository(MemoryDayGateway(), keys, person: 'alice');
|
||||
days = DayRepository(MemoryDayGateway(), keys);
|
||||
platform = FakeLoopPlatform()
|
||||
..nearby.add(band)
|
||||
..inReach.add('A1');
|
||||
|
|
@ -1731,7 +1730,7 @@ void main() {
|
|||
FakeLoopPlatform(),
|
||||
LoopStore(File('${folder.path}/loop.json')),
|
||||
ProfileRepository(MemoryProfileGateway(), keys, person: 'alice'),
|
||||
days: DayRepository(MemoryDayGateway(), keys, person: 'alice'),
|
||||
days: DayRepository(MemoryDayGateway(), keys),
|
||||
workouts: workouts,
|
||||
backgroundLines: File('${folder.path}/loop_days.txt'),
|
||||
);
|
||||
|
|
@ -1845,7 +1844,7 @@ void main() {
|
|||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
days = DayRepository(MemoryDayGateway(), keys, person: 'alice');
|
||||
days = DayRepository(MemoryDayGateway(), keys);
|
||||
});
|
||||
|
||||
tearDown(() => keys.dispose());
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart'
|
||||
show Namespace, Uuid;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
|
||||
|
|
@ -69,7 +71,7 @@ void main() {
|
|||
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return DayRepository(rows, keys, person: 'alice');
|
||||
return DayRepository(rows, keys);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
|
|
@ -227,6 +229,48 @@ void main() {
|
|||
}
|
||||
});
|
||||
|
||||
test("no row's ID tells its date: the instance, hashing every date as an "
|
||||
'ID of version 5 would be, finds none of them (D57); another phone of '
|
||||
'the account names the rows alike', () async {
|
||||
final phone = await device(alice);
|
||||
await phone.take(
|
||||
linesOf([
|
||||
summary(tuesday, 9876),
|
||||
heart(tuesday, [1, 4]),
|
||||
sleep,
|
||||
]),
|
||||
);
|
||||
final ids = {
|
||||
...rows.dayRows.keys,
|
||||
...rows.nightRows.keys,
|
||||
...rows.seriesRows.keys,
|
||||
};
|
||||
expect(ids, hasLength(4));
|
||||
|
||||
String v5(String path) => const Uuid().v5(
|
||||
Namespace.url.value,
|
||||
'https://halkyon.app/train/$path',
|
||||
);
|
||||
final guesses = {
|
||||
for (final person in ['alice', ''])
|
||||
for (final kind in ['day', 'night'])
|
||||
for (final date in [monday, tuesday]) ...[
|
||||
v5('$kind/$person/${dayKey(date)}'),
|
||||
for (var i = 0; i < 8; i++)
|
||||
v5('$kind/$person/${dayKey(date)}/heart/$i'),
|
||||
],
|
||||
};
|
||||
expect(ids.intersection(guesses), isEmpty);
|
||||
for (final id in ids) {
|
||||
expect(id[14], '8', reason: 'a UUID of version 8, keyed: $id');
|
||||
}
|
||||
|
||||
final other = await device(alice);
|
||||
await other.take(linesOf([summary(tuesday, 9877)]));
|
||||
expect(rows.dayRows.keys, hasLength(1));
|
||||
expect((await other.day(tuesday))!.steps, 9877);
|
||||
});
|
||||
|
||||
test(
|
||||
"a device without the account's keys sees no day and keeps none",
|
||||
() async {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ void main() {
|
|||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
rows = MemoryDayGateway();
|
||||
days = DayRepository(rows, keys, person: 'alice');
|
||||
days = DayRepository(rows, keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ void main() {
|
|||
() async => alice,
|
||||
);
|
||||
await keys.refresh();
|
||||
days = DayRepository(SyncDayGateway(db, sync), keys, person: 'alice');
|
||||
days = DayRepository(SyncDayGateway(db, sync), keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ void main() {
|
|||
person: 'person',
|
||||
);
|
||||
await profile.save(body);
|
||||
days = DayRepository(MemoryDayGateway(), keys, person: 'person');
|
||||
days = DayRepository(MemoryDayGateway(), keys);
|
||||
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
|
||||
now = DateTime.utc(2026, 10, 28, 9);
|
||||
day = today;
|
||||
|
|
@ -344,7 +344,7 @@ void main() {
|
|||
);
|
||||
addTearDown(keyless.dispose);
|
||||
await keyless.refresh();
|
||||
days = DayRepository(MemoryDayGateway(), keyless, person: 'person');
|
||||
days = DayRepository(MemoryDayGateway(), keyless);
|
||||
platform.days.add([summary('2026-10-27', 9876)]);
|
||||
final loop = await setUpBand();
|
||||
await settled(loop);
|
||||
|
|
|
|||
Loading…
Reference in a new issue