Keys are encrypted with an empty password, so the ciphertext protects nothing
What happens
sage-keychain encrypts every secret key properly — Argon2 to derive the key,
AES-256-GCM to seal it, a fresh 32-byte salt and a fresh nonce per key. But the
password handed to it is the empty string, at all nine call sites in
crates/sage:
// crates/sage/src/endpoints/keys.rs
self.keychain.add_mnemonic(&mnemonic, b"")?
self.keychain.add_secret_key(&master_sk, b"")?
// crates/sage/src/utils/spends.rs, endpoints/{actions,offers,wallet_connect}.rs
self.keychain.extract_secrets(wallet.fingerprint, b"")?
Anyone who can read keys.bin can recover every mnemonic, because deriving the
key needs nothing the file does not already carry. The encryption is obfuscation
rather than protection, and the format looks like it protects more than it does.
Why it is worth changing
On the desktop this is bounded by file permissions: reading keys.bin already
means running as the user. It still means a backup, a sync folder or a stray
copy carries usable keys, and it rules out a passphrase-locked wallet, which is
what every other extension wallet offers (MetaMask, Goby, Rabby all derive the
vault key from the passphrase and hold it in memory only while unlocked).
It matters more for the browser build in this repo's chrome-extension work,
where the same bytes live in chrome.storage.local. A lock screen there would
be purely cosmetic while the password stays empty: the seed is two lines away in
the console.
What a fix involves
Not just a prompt — the passphrase has to become the KDF input:
- Thread a passphrase through the nine call sites, rather than
b"".
- Hold the derived key in memory for the session, never at rest.
- Migrate existing keychains: keys sealed under
b"" have to be re-sealed
under the new passphrase the first time it is set.
- Keep an empty passphrase working for wallets that never set one, so nobody is
forced into a password they did not ask for.
Two things worth measuring before committing to a design:
- Argon2 in wasm. The extension runs the same keychain compiled to wasm.
With the default parameters an unlock may be slow enough to need tuning.
- MV3 worker lifetime. A service worker is stopped after roughly 30 seconds
idle, so an in-memory key does not survive. chrome.storage.session is the
usual answer, since it is memory-backed and never written to disk.
Happy to work on this if the approach sounds right — it touches shared code, so
worth agreeing on the shape first.
Keys are encrypted with an empty password, so the ciphertext protects nothing
What happens
sage-keychainencrypts every secret key properly — Argon2 to derive the key,AES-256-GCM to seal it, a fresh 32-byte salt and a fresh nonce per key. But the
password handed to it is the empty string, at all nine call sites in
crates/sage:Anyone who can read
keys.bincan recover every mnemonic, because deriving thekey needs nothing the file does not already carry. The encryption is obfuscation
rather than protection, and the format looks like it protects more than it does.
Why it is worth changing
On the desktop this is bounded by file permissions: reading
keys.binalreadymeans running as the user. It still means a backup, a sync folder or a stray
copy carries usable keys, and it rules out a passphrase-locked wallet, which is
what every other extension wallet offers (MetaMask, Goby, Rabby all derive the
vault key from the passphrase and hold it in memory only while unlocked).
It matters more for the browser build in this repo's
chrome-extensionwork,where the same bytes live in
chrome.storage.local. A lock screen there wouldbe purely cosmetic while the password stays empty: the seed is two lines away in
the console.
What a fix involves
Not just a prompt — the passphrase has to become the KDF input:
b"".b""have to be re-sealedunder the new passphrase the first time it is set.
forced into a password they did not ask for.
Two things worth measuring before committing to a design:
With the default parameters an unlock may be slow enough to need tuning.
idle, so an in-memory key does not survive.
chrome.storage.sessionis theusual answer, since it is memory-backed and never written to disk.
Happy to work on this if the approach sounds right — it touches shared code, so
worth agreeing on the shape first.