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:
Tizian 「ティツィアーン」 2026-10-30 16:12:00 +01:00
commit 839d49c4cc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 249 additions and 36 deletions

View file

@ -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 {

View file

@ -38,7 +38,7 @@ void main() {
() async => FakeAccountCrypto().keysNumbered(1),
);
rows = MemoryDayGateway();
days = DayRepository(rows, keys, person: 'alice');
days = DayRepository(rows, keys);
});
tearDown(() async {

View file

@ -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 {

View file

@ -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);