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

View file

@ -69,6 +69,12 @@ abstract interface class ContentCipher() {
/// [unlock].
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.
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
void dispose() {
for (final key in _keys.values) {

View file

@ -96,6 +96,25 @@ class FakeContentCipher() implements ContentCipher {
signature.length == 64 &&
_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
void dispose() => opened.clear();