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

View file

@ -92,6 +92,26 @@ void main() {
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', () {
final sealed = SealedPayload(
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 |
| 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 |
| 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` |
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,
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)}';
}

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