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

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) {