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:
parent
67b26e0ea0
commit
3ca01915e2
3 changed files with 39 additions and 18 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue