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:
parent
9b9b8d08ca
commit
839d49c4cc
7 changed files with 249 additions and 36 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
41
packages/halkyon_keys/lib/src/row_ids.dart
Normal file
41
packages/halkyon_keys/lib/src/row_ids.dart
Normal 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)}';
|
||||
}
|
||||
55
packages/halkyon_keys/test/row_ids_test.dart
Normal file
55
packages/halkyon_keys/test/row_ids_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue