fix(keys): a sealed data key is signed by whoever sealed it

Data keys were sealed to the account as anonymous sealed boxes, and the
register knows every account's public key: it could have sealed a key of
its own under an existing key ID, and a device would have encrypted new
content with a key the register holds (the second reading's M7, latent
until step 4 encrypts anything). sealDataKey now signs the box, with the
account it is sealed to and a label, by the sealer's Ed25519 key, and
openDataKey checks that signature against the sealer it expects (the
account itself, or whoever shared the store) before opening anything.

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-20 21:27:15 +02:00
commit 3ded5b932f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 127 additions and 43 deletions

View file

@ -9,7 +9,14 @@ import 'package:sodium/sodium_sumo.dart';
/// studyset), which encrypts its content (`sealContent`) and is itself
/// sealed to the account's public key, so every device that opens the
/// account opens the store. Sharing a store later seals its key to the other
/// person's key too.
/// person's key too. A sealed box is anonymous, and the register knows every
/// account's public key, so a sealed key is also signed by whoever sealed it
/// (the second reading's M7): a key the register made and sealed itself
/// never opens, and no device encrypts with a key the register holds.
/// What a sealed key's signature binds itself to besides the box: a label
/// naming the format.
final _sealLabel = utf8.encode('halkyon data key v1');
/// A store's key, in libsodium's guarded memory until [dispose].
final class DataKey._(
@ -29,14 +36,17 @@ final class DataKey._(
void dispose() => key.dispose();
}
/// [dataKey] sealed to the account whose [encryptionPublicKey] it is
/// (libsodium's sealed box: X25519 and XSalsa20-Poly1305, anonymous), its ID
/// sealed with it, so it opens only as the key it was.
/// [dataKey] sealed to the account whose encryption public key is [to]
/// (libsodium's sealed box: X25519 and XSalsa20-Poly1305), its ID sealed
/// with it, so it opens only as the key it was; and signed by [sealer]
/// (Ed25519, over a label, [to] and the box), so it opens only as sealed
/// by them: the signature's 64 bytes, then the box.
Uint8List sealDataKey(
SodiumSumo sodium,
DataKey dataKey,
Uint8List encryptionPublicKey,
) {
DataKey dataKey, {
required Uint8List to,
required AccountKeys sealer,
}) {
final id = utf8.encode(dataKey.keyId);
// As account keys are wrapped, the key leaves guarded memory for this
// moment only.
@ -45,29 +55,50 @@ Uint8List sealDataKey(
...id,
...dataKey.key.extractBytes(),
]);
final Uint8List box;
try {
return sodium.crypto.box.seal(
message: message,
publicKey: encryptionPublicKey,
);
box = sodium.crypto.box.seal(message: message, publicKey: to);
} finally {
message.fillRange(0, message.length, 0);
}
final signature = sodium.crypto.sign.detached(
message: _signed(to, box),
secretKey: sealer.signing.secretKey,
);
return Uint8List.fromList([...signature, ...box]);
}
/// The data key named [keyId] that [sealed] holds, opened with the
/// account's [keys]; refused ([KeyRefused]) for another account's keys,
/// anything changed, or a key sealed under another ID.
/// account's [keys], sealed by the account whose signing public key is
/// [sealedBy] (the account itself, or whoever shared the store); refused
/// ([KeyRefused]) for a signature that isn't theirs over this box for this
/// account, or anything changed ([KeyRefusal.forgedSignature]), another
/// account's keys ([KeyRefusal.wrongKeyOrTampered]), or a key sealed under
/// another ID ([KeyRefusal.wrongDataKey]).
DataKey openDataKey(
SodiumSumo sodium,
Uint8List sealed, {
required String keyId,
required AccountKeys keys,
required Uint8List sealedBy,
}) {
final signatureBytes = sodium.crypto.sign.bytes;
if (sealed.length <= signatureBytes ||
sealedBy.length != sodium.crypto.sign.publicKeyBytes) {
throw const KeyRefused(KeyRefusal.forgedSignature);
}
final box = Uint8List.sublistView(sealed, signatureBytes);
if (!sodium.crypto.sign.verifyDetached(
message: _signed(keys.encryptionPublicKey, box),
signature: Uint8List.sublistView(sealed, 0, signatureBytes),
publicKey: sealedBy,
)) {
throw const KeyRefused(KeyRefusal.forgedSignature);
}
final Uint8List message;
try {
message = sodium.crypto.box.sealOpen(
cipherText: sealed,
cipherText: box,
publicKey: keys.encryptionPublicKey,
secretKey: keys.encryption.secretKey,
);
@ -95,6 +126,23 @@ DataKey openDataKey(
}
}
/// What a sealed key's signature covers: the label, the account it is
/// sealed to and the box, each after its length.
Uint8List _signed(Uint8List to, Uint8List box) {
final bytes = BytesBuilder(copy: false);
for (final part in [_sealLabel, to, box]) {
bytes
..add([
(part.length >> 24) & 0xff,
(part.length >> 16) & 0xff,
(part.length >> 8) & 0xff,
part.length & 0xff,
])
..add(part);
}
return bytes.takeBytes();
}
/// [keyId] if it is one: 1 to 255 bytes of UTF-8, as a sealed key carries
/// its ID's length in one byte.
String _checkedId(String keyId) {

View file

@ -15,53 +15,89 @@ Future<void> main() async {
group('data keys, sealed to the account', () {
final account = AccountKeys.generate(sodium);
final dataKey = DataKey.generate(sodium, 'agenda-2026');
final sealed = sealDataKey(sodium, dataKey, account.encryptionPublicKey);
final sealed = sealDataKey(
sodium,
dataKey,
to: account.encryptionPublicKey,
sealer: account,
);
DataKey open(
Uint8List sealed, {
String keyId = 'agenda-2026',
AccountKeys? keys,
Uint8List? sealedBy,
}) => openDataKey(
sodium,
sealed,
keyId: keyId,
keys: keys ?? account,
sealedBy: sealedBy ?? account.signingPublicKey,
);
test("any device that opens the account opens the store's key", () {
final opened = openDataKey(
sodium,
sealed,
keyId: 'agenda-2026',
keys: account,
);
final opened = open(sealed);
expect(opened.keyId, 'agenda-2026');
expect(opened.key.extractBytes(), dataKey.key.extractBytes());
});
test("another account's keys don't open it", () {
test("another account's keys don't open it: it was sealed, and signed, "
'for this one', () {
expect(
() => openDataKey(
sodium,
sealed,
keyId: 'agenda-2026',
keys: AccountKeys.generate(sodium),
),
refusedFor(KeyRefusal.wrongKeyOrTampered),
() => open(sealed, keys: AccountKeys.generate(sodium)),
refusedFor(KeyRefusal.forgedSignature),
);
});
test('a sealed key changed is refused', () {
final tampered = Uint8List.fromList(sealed)..[40] ^= 1;
for (final at in [10, 80, sealed.length - 1]) {
final tampered = Uint8List.fromList(sealed)..[at] ^= 1;
expect(() => open(tampered), refusedFor(KeyRefusal.forgedSignature));
}
});
test("a key the register seals itself doesn't open: it knows the "
"account's public key, but can't sign as the account, so new content "
'is never encrypted with a key it holds', () {
final registers = DataKey.generate(sodium, 'agenda-2026');
// Sealed to the account as an anonymous box, signed by another key.
final planted = sealDataKey(
sodium,
registers,
to: account.encryptionPublicKey,
sealer: AccountKeys.generate(sodium),
);
expect(() => open(planted), refusedFor(KeyRefusal.forgedSignature));
// Or with the account's signature of another key moved onto it.
final moved = Uint8List.fromList([
...sealed.sublist(0, sodium.crypto.sign.bytes),
...planted.sublist(sodium.crypto.sign.bytes),
]);
expect(() => open(moved), refusedFor(KeyRefusal.forgedSignature));
});
test('shared, it opens as sealed by whoever shared it, and only so', () {
final ben = AccountKeys.generate(sodium);
final shared = sealDataKey(
sodium,
dataKey,
to: ben.encryptionPublicKey,
sealer: account,
);
expect(
() => openDataKey(
sodium,
tampered,
keyId: 'agenda-2026',
keys: account,
),
refusedFor(KeyRefusal.wrongKeyOrTampered),
open(shared, keys: ben).key.extractBytes(),
dataKey.key.extractBytes(),
);
expect(
() => open(shared, keys: ben, sealedBy: ben.signingPublicKey),
refusedFor(KeyRefusal.forgedSignature),
);
});
test("a key sealed under one ID doesn't open as another's: a server "
"can't swap stores' keys", () {
expect(
() => openDataKey(
sodium,
sealed,
keyId: 'studyset-7',
keys: account,
),
() => open(sealed, keyId: 'studyset-7'),
refusedFor(KeyRefusal.wrongDataKey),
);
});