fix(keys): no copy of a key left on the way, and the README says what is wiped

Wrapping the account's keys and sealing a data key spread extractBytes()
copies into the buffer libsodium encrypts: the buffer was wiped, the
copies on the way weren't. The keys now go from guarded memory straight
into it (runUnlockedSync), and the seed derived to check a signing key is
disposed. The README said key bytes copied to the Dart heap were wiped
afterwards; it now says exactly which are, and which Dart can't wipe (x as
a BigInt, Strings, what isolates copy, the keyring's base64).

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-21 15:07:59 +02:00
commit 3ca01915e2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 39 additions and 18 deletions

View file

@ -50,9 +50,9 @@ final class AccountKeys._(
signingSecretKey.length - sodium.crypto.sign.publicKeyBytes,
),
);
final derived = sodium.crypto.sign.seedKeyPair(
sodium.crypto.sign.skToSeed(signingSecret),
);
final seed = sodium.crypto.sign.skToSeed(signingSecret);
final derived = sodium.crypto.sign.seedKeyPair(seed);
seed.dispose();
final matches = sodium.memcmp(derived.publicKey, signingPublic);
derived.secretKey.dispose();
if (!matches) {
@ -120,11 +120,18 @@ WrappedAccountKeys wrapAccountKeys(
final aead = sodium.crypto.aeadXChaCha20Poly1305IETF;
final nonce = sodium.randombytes.buf(aead.nonceBytes);
// libsodium encrypts a plain buffer: the private keys are copied out of
// guarded memory for this moment, and the copy wiped right after.
final plainText = Uint8List.fromList([
...keys.encryption.secretKey.extractBytes(),
...keys.signing.secretKey.extractBytes(),
]);
// guarded memory straight into it for this moment (no copy of each on
// the way), and it is wiped right after.
final encryptionBytes = sodium.crypto.box.secretKeyBytes;
final plainText = Uint8List(
encryptionBytes + sodium.crypto.sign.secretKeyBytes,
);
keys.encryption.secretKey.runUnlockedSync(
(bytes) => plainText.setAll(0, bytes),
);
keys.signing.secretKey.runUnlockedSync(
(bytes) => plainText.setAll(encryptionBytes, bytes),
);
try {
return WrappedAccountKeys(
nonce: nonce,

View file

@ -49,12 +49,13 @@ Uint8List sealDataKey(
}) {
final id = utf8.encode(dataKey.keyId);
// As account keys are wrapped, the key leaves guarded memory for this
// moment only.
final message = Uint8List.fromList([
id.length,
...id,
...dataKey.key.extractBytes(),
]);
// moment only, straight into the message (no copy of it on the way).
final message = Uint8List(1 + id.length + dataKey.key.length)
..[0] = id.length
..setAll(1, id);
dataKey.key.runUnlockedSync(
(bytes) => message.setAll(1 + id.length, bytes),
);
final Uint8List box;
try {
box = sodium.crypto.box.seal(message: message, publicKey: to);