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

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