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

@ -30,4 +30,5 @@ export 'src/recovery_phrase.dart'
recoveryPhraseOf,
recoveryProofKeysOf,
recoveryProofPublicKeyOf;
export 'src/row_ids.dart' show keyedRowId;
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)}';
}