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

View file

@ -12,7 +12,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how. - `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app. - `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart). - `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`. - `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
- `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`. - `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`.
- `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first. - `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first.
- **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files are to join Learn's. **`snap/`**: Learn's snap, built from the root, which its build needs whole. **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below). - **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files are to join Learn's. **`snap/`**: Learn's snap, built from the root, which its build needs whole. **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below).

File diff suppressed because one or more lines are too long

View file

@ -384,11 +384,7 @@ Future<TrainPerson> _openPersonData(
keys, keys,
person: person.authUserId, person: person.authUserId,
); );
final days = DayRepository( final days = DayRepository(SyncDayGateway(db, sync), keys);
SyncDayGateway(db, sync),
keys,
person: person.authUserId,
);
final plans = PlanRepository(SyncPlanGateway(db, sync), keys); final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
final routes = RouteRepository(SyncRouteGateway(db, sync), keys); final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
final maps = MapSourceRepository( 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. /// Whether what is sealed with the key [keyId] opens here.
bool canOpen(String keyId) => bool canOpen(String keyId) =>
_unlocked && _madeAt(keyId) != null && cipher.has(keyId); _unlocked && _madeAt(keyId) != null && cipher.has(keyId);

View file

@ -2,8 +2,6 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:halkyon_app/halkyon_app.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/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_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_payload.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 /// 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` /// 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 /// 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), /// day; every ID made from the date (and the chunk's place) under a key of
/// so a day read again, here or on another phone, writes the same rows, the /// the account's (D57), so a day read again, here or on another phone,
/// last writer winning. A row that doesn't open here is left out: what is /// 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. /// sealed doesn't say its date.
class DayRepository( class DayRepository(final DayGateway _gateway, final TrainKeys _keys) {
final DayGateway _gateway,
final TrainKeys _keys, {
/// Whose days they are: their auth user ID.
required final String person,
}) {
late final _envelope = PayloadEnvelope(_keys.cipher); late final _envelope = PayloadEnvelope(_keys.cipher);
StreamSubscription<void>? _remote; StreamSubscription<void>? _remote;
@ -39,15 +32,12 @@ class DayRepository(
/// Fires when the days changed, here or on another device. /// Fires when the days changed, here or on another device.
Stream<void> get changes => _changes.stream; 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) => String _chunkId(DateTime date, int index) =>
_id('day/$person/${dayKey(date)}/heart/$index'); _keys.rowId('train/day/${dayKey(date)}/heart/$index');
String _id(String path) =>
const Uuid().v5(Namespace.url.value, 'https://halkyon.app/train/$path');
/// Every date with a day or a night that opens here, newest first. /// Every date with a day or a night that opens here, newest first.
Future<List<StoredDay>> all() async { Future<List<StoredDay>> all() async {
@ -80,12 +70,18 @@ class DayRepository(
/// The day of [date], where one opens here. /// The day of [date], where one opens here.
Future<DayContent?> day(DateTime date) async { Future<DayContent?> day(DateTime date) async {
await _keys.refresh(); await _keys.refresh();
if (!_keys.unlocked) {
return null;
}
return await _day(date); return await _day(date);
} }
/// The night that ended [date], where one opens here. /// The night that ended [date], where one opens here.
Future<NightContent?> night(DateTime date) async { Future<NightContent?> night(DateTime date) async {
await _keys.refresh(); await _keys.refresh();
if (!_keys.unlocked) {
return null;
}
final id = _nightId(date); final id = _nightId(date);
return switch (await _gateway.night(id)) { return switch (await _gateway.night(id)) {
final envelope? => switch (_open(envelope, table: 'night', id: 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. /// The 24/7 heart rate of [date] that opens here, in time order.
Future<List<HeartRun>> heart(DateTime date) async { Future<List<HeartRun>> heart(DateTime date) async {
await _keys.refresh(); await _keys.refresh();
if (!_keys.unlocked) {
return [];
}
return await _heart(date); return await _heart(date);
} }

View file

@ -185,7 +185,6 @@ void main() {
FakeContentCipher(), FakeContentCipher(),
() async => null, () async => null,
), ),
person: person.authUserId,
), ),
plans: plans:
plans ?? plans ??
@ -538,7 +537,7 @@ void main() {
keys, keys,
person: 'alice', person: 'alice',
); );
days = DayRepository(MemoryDayGateway(), keys, person: 'alice'); days = DayRepository(MemoryDayGateway(), keys);
platform = FakeLoopPlatform() platform = FakeLoopPlatform()
..nearby.add(band) ..nearby.add(band)
..inReach.add('A1'); ..inReach.add('A1');
@ -1731,7 +1730,7 @@ void main() {
FakeLoopPlatform(), FakeLoopPlatform(),
LoopStore(File('${folder.path}/loop.json')), LoopStore(File('${folder.path}/loop.json')),
ProfileRepository(MemoryProfileGateway(), keys, person: 'alice'), ProfileRepository(MemoryProfileGateway(), keys, person: 'alice'),
days: DayRepository(MemoryDayGateway(), keys, person: 'alice'), days: DayRepository(MemoryDayGateway(), keys),
workouts: workouts, workouts: workouts,
backgroundLines: File('${folder.path}/loop_days.txt'), backgroundLines: File('${folder.path}/loop_days.txt'),
); );
@ -1845,7 +1844,7 @@ void main() {
FakeContentCipher(), FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1), () async => FakeAccountCrypto().keysNumbered(1),
); );
days = DayRepository(MemoryDayGateway(), keys, person: 'alice'); days = DayRepository(MemoryDayGateway(), keys);
}); });
tearDown(() => keys.dispose()); tearDown(() => keys.dispose());

View file

@ -3,6 +3,8 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.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/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/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.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); final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys); opened.add(keys);
await keys.refresh(); await keys.refresh();
return DayRepository(rows, keys, person: 'alice'); return DayRepository(rows, keys);
} }
setUpAll(() async { 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( test(
"a device without the account's keys sees no day and keeps none", "a device without the account's keys sees no day and keeps none",
() async { () async {

View file

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

View file

@ -63,7 +63,7 @@ void main() {
() async => alice, () async => alice,
); );
await keys.refresh(); await keys.refresh();
days = DayRepository(SyncDayGateway(db, sync), keys, person: 'alice'); days = DayRepository(SyncDayGateway(db, sync), keys);
}); });
tearDown(() async { tearDown(() async {

View file

@ -101,7 +101,7 @@ void main() {
person: 'person', person: 'person',
); );
await profile.save(body); await profile.save(body);
days = DayRepository(MemoryDayGateway(), keys, person: 'person'); days = DayRepository(MemoryDayGateway(), keys);
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys); workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
now = DateTime.utc(2026, 10, 28, 9); now = DateTime.utc(2026, 10, 28, 9);
day = today; day = today;
@ -344,7 +344,7 @@ void main() {
); );
addTearDown(keyless.dispose); addTearDown(keyless.dispose);
await keyless.refresh(); await keyless.refresh();
days = DayRepository(MemoryDayGateway(), keyless, person: 'person'); days = DayRepository(MemoryDayGateway(), keyless);
platform.days.add([summary('2026-10-27', 9876)]); platform.days.add([summary('2026-10-27', 9876)]);
final loop = await setUpBand(); final loop = await setUpBand();
await settled(loop); await settled(loop);

View file

@ -676,8 +676,8 @@ Strava. What was found, 27 Oct:
half hour, and when the person pulls the list; a later read replaces what it brought half hour, and when the person pulls the list; a later read replaces what it brought
of a date (its summary, Recharge, skin temperature, heart rate, night) and keeps the of a date (its summary, Recharge, skin temperature, heart rate, night) and keeps the
rest, and rewrites nothing unchanged; a day's heart rate goes in chunks by the three rest, and rewrites nothing unchanged; a day's heart rate goes in chunks by the three
hours each measurement began in, every row's ID made from the person and the date hours each measurement began in, every row's ID made from the date under the
(`https://halkyon.app/train/day/<person>/<date>`, `…/night/…`, `…/heart/<index>`); a account's key (D57: `train/day/<date>`, `train/night/<date>`, `…/heart/<index>`); a
row that doesn't open on a device is left out of *Days*, as its date is sealed with row that doesn't open on a device is left out of *Days*, as its date is sealed with
it; what is kept goes up to the day before the latest read's (`keptUntil`, in it; what is kept goes up to the day before the latest read's (`keptUntil`, in
`loop.json` with the last read's time), kept over a new setup of the same band and `loop.json` with the last read's time), kept over a new setup of the same band and
@ -1424,6 +1424,23 @@ Strava. What was found, 27 Oct:
learnt from the 2105 mm a sensor starts with. Its quotient may fall between 1 and 3 m, learnt from the 2105 mm a sensor starts with. Its quotient may fall between 1 and 3 m,
as one typed may (`measuredWheelMm`, `_gpsCovered`). A tunnel's straight line still as one typed may (`measuredWheelMm`, `_gpsCovered`). A tunnel's straight line still
measures nothing. The distance still follows `usesWheel`, against the wheel as learnt. measures nothing. The distance still follows `usesWheel`, against the wheel as learnt.
- **D57 Row IDs that tell the instance no date** (recommended, the developer's; 30 Oct;
revises D37's IDs): a `day`, `night` and `day_series` row's ID was a UUID of version
5 of `https://halkyon.app/train/day/<person>/<date>`, so that every phone named a
day alike offline; but the instance knows the person and can hash every date, so
each ID told it which days the person wore the band, their sealing aside. The ID is
now the account's keyed hash of the name (halkyon_keys' `keyedRowId`: BLAKE2b keyed
with the account's X25519 secret key over a label and `train/day/<date>`, a UUID of
version 8; `ContentCipher.rowId`, `TrainKeys.rowId`), the same on every device of
the account, which all hold its keys, and nothing of the date read from it without
them. Over a key of Train's own (its data keys multiply offline, D15, so two
phones would name one day two ways until they met), random IDs with a sealed index
from date to ID (two phones offline each making a day's row, merged later: more
code for the same), and coarser rows (a week or month each, rewriting more with
every day). What the instance still sees: how many rows there are, their sizes and
when they are written; a reset of the account's keys (ADR 0017) renames every row,
as it leaves what was sealed unreadable anyway. Train had no release, so no row
needed moving.
## Consequences ## Consequences

View file

@ -69,6 +69,12 @@ abstract interface class ContentCipher() {
/// [unlock]. /// [unlock].
bool verify(List<int> message, List<int> signature); bool verify(List<int> message, List<int> signature);
/// The ID of the row named [name], keyed by the account (halkyon_keys'
/// `keyedRowId`, Halkyon Train's ADR 0030, D57): the same on each of its
/// devices, and nothing of [name] read from it by the instance. Throws a
/// [StateError] before [unlock].
String rowId(String name);
/// Wipes every key kept open here. /// Wipes every key kept open here.
void dispose(); void dispose();
} }
@ -213,6 +219,12 @@ class SodiumContentCipher() implements ContentCipher {
); );
} }
@override
String rowId(String name) {
final (sodium, account) = _unlocked();
return keyedRowId(sodium, account, name);
}
@override @override
void dispose() { void dispose() {
for (final key in _keys.values) { for (final key in _keys.values) {

View file

@ -96,6 +96,25 @@ class FakeContentCipher() implements ContentCipher {
signature.length == 64 && signature.length == 64 &&
_same(Uint8List.fromList(signature), sign(message)); _same(Uint8List.fromList(signature), sign(message));
/// A UUID of version 8 made from the account and [name], as the real
/// one's form; but nothing here keys it.
@override
String rowId(String name) {
final account = _unlocked();
final bytes = Uint8List(16);
final tag = utf8.encode('row|$account|$name');
for (var i = 0; i < tag.length; i++) {
bytes[i % 16] = (bytes[i % 16] * 31 + tag[i]) & 0xff;
}
bytes[6] = bytes[6] & 0x0f | 0x80;
bytes[8] = bytes[8] & 0x3f | 0x80;
final hex = [
for (final byte in bytes) byte.toRadixString(16).padLeft(2, '0'),
].join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}
@override @override
void dispose() => opened.clear(); void dispose() => opened.clear();

View file

@ -92,6 +92,26 @@ void main() {
expect(device.unseal(sealed, context: context), plain); expect(device.unseal(sealed, context: context), plain);
}); });
test(
"a row's keyed ID (ADR 0030, D57) is the same on every device of the "
"account, another on another account's, and none before unlocking",
() async {
final other = SodiumContentCipher();
await other.unlock(alice);
addTearDown(other.dispose);
final bobs = SodiumContentCipher();
await bobs.unlock(bob);
addTearDown(bobs.dispose);
final locked = SodiumContentCipher();
final id = device.rowId('train/day/2026-10-30');
expect(other.rowId('train/day/2026-10-30'), id);
expect(bobs.rowId('train/day/2026-10-30'), isNot(id));
expect(device.rowId('train/day/2026-10-31'), isNot(id));
expect(() => locked.rowId('train/day/2026-10-30'), throwsStateError);
},
);
test('without its key nothing opens, nor is sealed', () { test('without its key nothing opens, nor is sealed', () {
final sealed = SealedPayload( final sealed = SealedPayload(
keyId: 'unknown', keyId: 'unknown',

View file

@ -20,6 +20,7 @@ builds or tests a package depending on this one needs a C compiler and `make`.
| The recovery key | `recoveryKeyOf` | libsodium's KDF over the phrase's 256 bits (context `hlkrcvy1`, subkey 1); a 12- to 21-word BIP-39 phrase is refused | | The recovery key | `recoveryKeyOf` | libsodium's KDF over the phrase's 256 bits (context `hlkrcvy1`, subkey 1); a 12- to 21-word BIP-39 phrase is refused |
| Data keys | `DataKey.generate`, `sealDataKey`, `openDataKey` | a random XChaCha20-Poly1305 key per store with an ID (1 to 255 bytes of UTF-8), sealed with its ID to the account's X25519 key (`crypto_box_seal`); it opens only under the ID it was sealed with | | Data keys | `DataKey.generate`, `sealDataKey`, `openDataKey` | a random XChaCha20-Poly1305 key per store with an ID (1 to 255 bytes of UTF-8), sealed with its ID to the account's X25519 key (`crypto_box_seal`); it opens only under the ID it was sealed with |
| Content | `sealContent`, `openContent` → `SealedContent` (`keyId`, `nonce`, `payload`, `signature`) | ADR 0002's envelope: XChaCha20-Poly1305 under the data key, the key's ID and a caller's `context` (its table and row) as additional data; the author's Ed25519 signature, detached, over the ID, nonce, payload and context, each framed by its length; the signature is checked before anything is decrypted | | Content | `sealContent`, `openContent` → `SealedContent` (`keyId`, `nonce`, `payload`, `signature`) | ADR 0002's envelope: XChaCha20-Poly1305 under the data key, the key's ID and a caller's `context` (its table and row) as additional data; the author's Ed25519 signature, detached, over the ID, nonce, payload and context, each framed by its length; the signature is checked before anything is decrypted |
| Row IDs keyed by the account | `keyedRowId` | BLAKE2b (`crypto_generichash`) keyed with the account's X25519 secret key, over a version label, a zero byte and the row's name, 16 bytes as a UUID of version 8 (RFC 9562): a row every device of the account names alike without asking each other (Halkyon Train's day by its date, its ADR 0030, D57), its name unreadable to the instance, which could hash a name in the open itself |
| Refusals | `KeyRefused` with a `KeyRefusal` | `wrongKeyOrTampered`, `notTheAccountsKeys`, `wrongDataKey`, `forgedSignature`, `malformed` (bytes from elsewhere of another length, checked before libsodium sees them), `weakPasswordKdf`, `excessivePasswordKdf`, `phraseLength`, `phraseWord`, `phraseChecksum` | | Refusals | `KeyRefused` with a `KeyRefusal` | `wrongKeyOrTampered`, `notTheAccountsKeys`, `wrongDataKey`, `forgedSignature`, `malformed` (bytes from elsewhere of another length, checked before libsodium sees them), `weakPasswordKdf`, `excessivePasswordKdf`, `phraseLength`, `phraseWord`, `phraseChecksum` |
Secrets stay in libsodium's guarded memory (`SecureKey`) where its API takes one: Secrets stay in libsodium's guarded memory (`SecureKey`) where its API takes one:

View file

@ -30,4 +30,5 @@ export 'src/recovery_phrase.dart'
recoveryPhraseOf, recoveryPhraseOf,
recoveryProofKeysOf, recoveryProofKeysOf,
recoveryProofPublicKeyOf; recoveryProofPublicKeyOf;
export 'src/row_ids.dart' show keyedRowId;
export 'src/sealed_content.dart' show SealedContent, openContent, sealContent; export 'src/sealed_content.dart' show SealedContent, openContent, sealContent;

View file

@ -0,0 +1,41 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_keys/src/account_keys.dart';
import 'package:sodium/sodium_sumo.dart';
/// Row IDs keyed by the account (Halkyon Train's ADR 0030, D57): a row that
/// every device of the account must name alike without asking each other
/// (a day's, by its date) takes its ID from its name under a key only the
/// account holds, so that two devices offline still write the same row, and
/// the instance, which sees the ID, learns nothing of the name. A name
/// hashed in the open (a UUID of version 5) would tell it: the instance can
/// hash every date itself.
/// What the ID's hash binds itself to before the name: a label naming the
/// format, so that no other use of the key meets it.
final _rowIdLabel = utf8.encode('halkyon row id v1');
/// The ID of the row named [name] for the account of [keys]: BLAKE2b keyed
/// with the account's X25519 secret key (16 bytes, over the label and the
/// name), as a UUID of version 8 (RFC 9562, for IDs of one's own making).
/// The same on every device of the account, another for any other name or
/// account, and nothing of [name] read from it without the key. Throws an
/// [ArgumentError] for an empty name.
String keyedRowId(SodiumSumo sodium, AccountKeys keys, String name) {
if (name.isEmpty) {
throw ArgumentError.value(name, 'name', 'A row ID needs a name');
}
final hash = sodium.crypto.genericHash(
message: Uint8List.fromList([..._rowIdLabel, 0, ...utf8.encode(name)]),
key: keys.encryption.secretKey,
outLen: 16,
);
hash[6] = hash[6] & 0x0f | 0x80;
hash[8] = hash[8] & 0x3f | 0x80;
final hex = [
for (final byte in hash) byte.toRadixString(16).padLeft(2, '0'),
].join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}

View file

@ -0,0 +1,55 @@
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:sodium/sodium_sumo.dart';
import 'package:test/test.dart';
Future<void> main() async {
final sodium = await SodiumSumoInit.init();
group('row IDs keyed by the account', () {
final account = AccountKeys.generate(sodium);
final secrets = (
encryption: account.encryption.secretKey.extractBytes(),
signing: account.signing.secretKey.extractBytes(),
);
test('every device of the account names a row alike: its keys, unlocked '
'anew, give the same ID', () {
final elsewhere = AccountKeys.fromSecretKeys(
sodium,
encryptionSecretKey: secrets.encryption,
signingSecretKey: secrets.signing,
);
expect(
keyedRowId(sodium, elsewhere, 'train/day/2026-10-30'),
keyedRowId(sodium, account, 'train/day/2026-10-30'),
);
});
test('another name, or the same name of another account, gives another '
'ID', () {
final other = AccountKeys.generate(sodium);
final id = keyedRowId(sodium, account, 'train/day/2026-10-30');
expect(keyedRowId(sodium, account, 'train/day/2026-10-31'), isNot(id));
expect(keyedRowId(sodium, account, 'train/night/2026-10-30'), isNot(id));
expect(keyedRowId(sodium, other, 'train/day/2026-10-30'), isNot(id));
});
test("the ID is a UUID of version 8, RFC 9562's for IDs of one's own "
'making, and not a name hashed in the open (version 5)', () {
final id = keyedRowId(sodium, account, 'train/day/2026-10-30');
expect(
id,
matches(
RegExp(
'^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-'
r'[0-9a-f]{12}$',
),
),
);
});
test('a row without a name has no ID', () {
expect(() => keyedRowId(sodium, account, ''), throwsArgumentError);
});
});
}