Overview
KeyManager::from_password derives the 32-byte AES-256 master key by feeding the user's password through a single round of SHA-256 with no salt and no work factor (crates/tools/src/key_manager.rs lines 52–62). This is a known-broken key-derivation function: an attacker who obtains an encrypted vault file can recover the password from a candidate list at the cost of a single SHA-256 per guess, with no per-user salt to slow rainbow-table attacks. Open issue #12 covers a DIFFERENT SHA-256 misuse (server-side transaction signing) — this issue is specific to the vault's master-key derivation.
Evidence
// crates/tools/src/key_manager.rs (KeyManager::from_password)
pub fn from_password(password: &str) -> Result<Self> {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
let key_bytes = hasher.finalize();
let mut master_key = [0u8; 32];
master_key.copy_from_slice(&key_bytes);
Ok(Self { master_key })
}
The downstream call site EncryptedVault::with_password and the keymanager encrypt / decrypt / init-vault CLI subcommands (main.rs) all use this as the password-to-key path, meaning every secret in the encrypted vault (admin secret key, issuing secret key, etc.) is protected by a single SHA-256 hash of the operator password.
Impact
- An operator who chooses
hunter2 (or any word from a 100k-word dictionary) is vulnerable to an offline dictionary attack that costs roughly one sha256() invocation per guess — on the order of microseconds on commodity hardware, and trivially parallelisable on GPUs (≈10^10 guesses/s).
- Two operators with the same password derive identical master keys (no salt), so a vault for one operator is brute-forceable against any cracker's existing dictionary.
- This contradicts the AES-256-GCM choice upstream: the encryption itself is sound, but the key derivation has effectively no security.
Recommended Approach
Replace SHA-256 with a memory-hard password-based KDF that is the de-facto standard in 2026 Rust crates:
use argon2::{Algorithm, Argon2, Params, Version};
pub fn from_password(password: &str) -> Result<Self> {
// Random per-vault salt: callers persist it alongside the vault file
// (e.g. <VAULT_MASTER_SALT> in .env, or a leading line in the vault file).
let salt: [u8; 16] = rand::random();
let params = Params::new(19_456 /* KiB */, 2 /* iterations */, 1 /* lanes */, Some(32))
.map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon.hash_password_into(password.as_bytes(), &salt, &mut key)
.map_err(|e| anyhow::anyhow!("argon2 derive: {e}"))?;
Ok(Self { master_key: key })
// `salt` should be returned alongside the key or persisted by the caller.
}
Then:
- Plumb the salt through
EncryptedVault::with_password so it is stored next to the encrypted file (e.g. as a leading SALT=… line, parsed by EncryptedVault::load_from_file).
- Bump the
Cargo.toml of crates/tools to depend on argon2 = "0.5".
- Document the migration: existing vault files encrypted under SHA-256 must be re-encrypted under Argon2id (one-shot script or auto-detect), otherwise
decrypt will fail.
Acceptance Criteria
Affected Files
crates/tools/src/key_manager.rs
crates/tools/src/encrypted_vault.rs
crates/tools/src/main.rs
crates/tools/Cargo.toml
Overview
KeyManager::from_passwordderives the 32-byte AES-256 master key by feeding the user's password through a single round of SHA-256 with no salt and no work factor (crates/tools/src/key_manager.rs lines 52–62). This is a known-broken key-derivation function: an attacker who obtains an encrypted vault file can recover the password from a candidate list at the cost of a single SHA-256 per guess, with no per-user salt to slow rainbow-table attacks. Open issue #12 covers a DIFFERENT SHA-256 misuse (server-side transaction signing) — this issue is specific to the vault's master-key derivation.Evidence
The downstream call site
EncryptedVault::with_passwordand thekeymanager encrypt / decrypt / init-vaultCLI subcommands (main.rs) all use this as the password-to-key path, meaning every secret in the encrypted vault (admin secret key, issuing secret key, etc.) is protected by a single SHA-256 hash of the operator password.Impact
hunter2(or any word from a 100k-word dictionary) is vulnerable to an offline dictionary attack that costs roughly onesha256()invocation per guess — on the order of microseconds on commodity hardware, and trivially parallelisable on GPUs (≈10^10 guesses/s).Recommended Approach
Replace SHA-256 with a memory-hard password-based KDF that is the de-facto standard in 2026 Rust crates:
Then:
EncryptedVault::with_passwordso it is stored next to the encrypted file (e.g. as a leadingSALT=…line, parsed byEncryptedVault::load_from_file).Cargo.tomlofcrates/toolsto depend onargon2 = "0.5".decryptwill fail.Acceptance Criteria
KeyManager::from_passwordderives the master key via Argon2id (or scrypt) with a random per-instance saltEncryptedVault::with_passwordandEncryptedVault::load_from_fileround-trip the saltencrypt_key/decrypt_keystill passes after the swapkeymanager encrypt/decrypt/init-vault) produces working vaults under Argon2idAffected Files
crates/tools/src/key_manager.rscrates/tools/src/encrypted_vault.rscrates/tools/src/main.rscrates/tools/Cargo.toml