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:
parent
b33b99a50a
commit
3ded5b932f
1 changed files with 127 additions and 43 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue