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
3 changed files with 39 additions and 18 deletions

View file

@ -20,13 +20,26 @@ builds or tests a package depending on this one needs a C compiler and `make`.
| The recovery key | `recoveryKeyOf` | libsodium's KDF over the phrase's 256 bits (context `hlkrcvy1`, subkey 1); a 12- to 21-word BIP-39 phrase is refused |
| Data keys | `DataKey.generate`, `sealDataKey`, `openDataKey` | a random XChaCha20-Poly1305 key per store with an ID (1 to 255 bytes of UTF-8), sealed with its ID to the account's X25519 key (`crypto_box_seal`); it opens only under the ID it was sealed with |
| Content | `sealContent`, `openContent` → `SealedContent` (`keyId`, `nonce`, `payload`, `signature`) | ADR 0002's envelope: XChaCha20-Poly1305 under the data key, the key's ID and a caller's `context` (its table and row) as additional data; the author's Ed25519 signature, detached, over the ID, nonce, payload and context, each framed by its length; the signature is checked before anything is decrypted |
| Refusals | `KeyRefused` with a `KeyRefusal` | `wrongKeyOrTampered`, `notTheAccountsKeys`, `wrongDataKey`, `forgedSignature`, `weakPasswordKdf`, `phraseLength`, `phraseWord`, `phraseChecksum` |
| Refusals | `KeyRefused` with a `KeyRefusal` | `wrongKeyOrTampered`, `notTheAccountsKeys`, `wrongDataKey`, `forgedSignature`, `malformed` (bytes from elsewhere of another length, checked before libsodium sees them), `weakPasswordKdf`, `excessivePasswordKdf`, `phraseLength`, `phraseWord`, `phraseChecksum` |
Secrets stay in libsodium's guarded memory (`SecureKey`) where its API takes one:
the master key, both password subkeys, the private halves of the key pairs, the
recovery key and data keys. Wrapping, sealing a data key and reading x copy key bytes
to the Dart heap for a moment, wiped afterwards. `dispose` wipes and frees a holder's
keys, which may not be read afterwards.
recovery key, the phrase's proof key and data keys. `dispose` wipes and frees a
holder's keys, which may not be read afterwards.
What leaves guarded memory, exactly:
- **Wiped after use:** wrapping the account's keys and sealing a data key copy the
keys straight from guarded memory into the one buffer libsodium encrypts, wiped
right after; opening them, the decrypted buffer, wiped once its keys are copied
into guarded memory; a phrase's entropy once its key is derived; the seed checked
against a signing key (disposed).
- **Not wiped, as Dart can't:** SRP's x as a `BigInt` (immutable, gone when the
garbage collector takes it); the password and the phrase as typed (`String`s);
and on the device (halkyon_app) the bytes of x and the key-encryption key a
sign-in carries between its isolates while a code is typed, and the unlocked
keys as the keyring returns them (base64 `String`s). Isolates copy what they
send each other, so each holds a copy of its own.
## What never leaves the device

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