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 | | 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 | | 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 | | 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: 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 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 recovery key, the phrase's proof key and data keys. `dispose` wipes and frees a
to the Dart heap for a moment, wiped afterwards. `dispose` wipes and frees a holder's holder's keys, which may not be read afterwards.
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 ## What never leaves the device

View file

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

View file

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