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
3 changed files with 249 additions and 36 deletions

View file

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

View file

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

View file

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