feat(vault): KDF upgrade to 64 MiB / t=3 with transparent migration on unlock
New vaults derive their key with Argon2id m=64 MiB, t=3 (OWASP 2024) instead of the RFC 9106 19 MiB / t=2 defaults. The vault header already records per-vault KDF parameters, so unlocking an old vault keeps working unchanged; when it carries exactly the legacy parameters the unlock transparently re-wraps every secret (profile keys, NIP-46 connection secrets, client keys) under a fresh salt + stronger key and reports the upgrade so the caller persists it. Also fixes a real gap surfaced by the new tests: set_password previously re-encrypted only profile keys — NIP-46 connection secrets and client keys stayed plaintext in the JSON after 'encrypt my vault'. All three secret stores now go through one rewrap_secrets() helper shared by set_password and the KDF upgrade path. Wrong passwords never touch the header; the upgrade runs only after the old verifier accepts. Tests: legacy-params upgrade + no-op on current params, wrong-password leaves header untouched, rewrap covers connection secrets/client keys for both upgrade and set_password.
This commit is contained in:
parent
5e91914ad6
commit
c553dd5e17
4 changed files with 250 additions and 25 deletions
241
src/app.rs
241
src/app.rs
|
|
@ -169,8 +169,16 @@ impl App {
|
||||||
self.signing_for(&npub).await
|
self.signing_for(&npub).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a password and keep the derived key in memory for the session.
|
/// Verify a password, keep the derived key in memory for the session,
|
||||||
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
|
/// and transparently upgrade the vault's KDF if it is still encrypted
|
||||||
|
/// under the legacy parameters (19 MiB / t=2).
|
||||||
|
///
|
||||||
|
/// Returns `true` when the vault was re-wrapped under the current
|
||||||
|
/// [`crate::crypto::KDF_M_COST`]/[`crate::crypto::KDF_T_COST`] (the caller
|
||||||
|
/// should persist the vault), `false` for a plain unlock. A wrong password
|
||||||
|
/// never touches the vault header: the upgrade only runs after the old
|
||||||
|
/// verifier has accepted the supplied password.
|
||||||
|
pub fn unlock(&mut self, password: &str) -> Result<bool, AppError> {
|
||||||
let crypto = self
|
let crypto = self
|
||||||
.vault
|
.vault
|
||||||
.crypto
|
.crypto
|
||||||
|
|
@ -183,8 +191,41 @@ impl App {
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
return Err(AppError::wrong_password());
|
return Err(AppError::wrong_password());
|
||||||
}
|
}
|
||||||
self.unlock_key = Some(key);
|
|
||||||
Ok(())
|
let is_legacy = crypto.kdf.m_cost == crypto::LEGACY_KDF_M_COST
|
||||||
|
&& crypto.kdf.t_cost == crypto::LEGACY_KDF_T_COST;
|
||||||
|
if !is_legacy {
|
||||||
|
self.unlock_key = Some(key);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password accepted under the legacy parameters: re-derive with the
|
||||||
|
// current ones and re-wrap every stored secret under the new key.
|
||||||
|
let upgraded = {
|
||||||
|
let salt = crypto::generate_salt()?;
|
||||||
|
let new_key = crypto::derive_key(
|
||||||
|
password,
|
||||||
|
&salt,
|
||||||
|
crypto::KDF_M_COST,
|
||||||
|
crypto::KDF_T_COST,
|
||||||
|
crypto::KDF_P_COST,
|
||||||
|
)?;
|
||||||
|
rewrap_secrets(&mut self.vault, Some(&key), &new_key)?;
|
||||||
|
key.zeroize();
|
||||||
|
self.vault.crypto = Some(VaultCrypto {
|
||||||
|
kdf: KdfParams {
|
||||||
|
algorithm: "argon2id".to_string(),
|
||||||
|
salt: B64.encode(salt),
|
||||||
|
m_cost: crypto::KDF_M_COST,
|
||||||
|
t_cost: crypto::KDF_T_COST,
|
||||||
|
p_cost: crypto::KDF_P_COST,
|
||||||
|
},
|
||||||
|
verifier: crypto::make_verifier(&new_key)?,
|
||||||
|
});
|
||||||
|
new_key
|
||||||
|
};
|
||||||
|
self.unlock_key = Some(upgraded);
|
||||||
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the derived key, re-locking the vault for the session.
|
/// Drop the derived key, re-locking the vault for the session.
|
||||||
|
|
@ -348,25 +389,14 @@ impl App {
|
||||||
crypto::KDF_P_COST,
|
crypto::KDF_P_COST,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut encrypted = Vec::with_capacity(self.vault.profiles.len());
|
// Re-wrap every secret store (profiles, connection secrets, client
|
||||||
for profile in &self.vault.profiles {
|
// keys) so no credential keeps its old wrapping.
|
||||||
// Decrypted plaintexts are Zeroizing: each wipes itself once the
|
rewrap_secrets(&mut self.vault, previous_key.as_ref(), &new_key)?;
|
||||||
// re-encrypted replacement has been produced.
|
// Wipe the old vault key now that everything is re-encrypted.
|
||||||
let plaintext = match &previous_key {
|
|
||||||
Some(key) => crypto::decrypt_secret(key, &profile.secret_key)?,
|
|
||||||
None => Zeroizing::new(profile.secret_key.clone()),
|
|
||||||
};
|
|
||||||
encrypted.push(StoredProfile {
|
|
||||||
secret_key: crypto::encrypt_secret(&new_key, &plaintext)?,
|
|
||||||
..profile.clone()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Wipe the old vault key now that every profile is re-encrypted.
|
|
||||||
if let Some(mut key) = previous_key {
|
if let Some(mut key) = previous_key {
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.vault.profiles = encrypted;
|
|
||||||
self.vault.crypto = Some(VaultCrypto {
|
self.vault.crypto = Some(VaultCrypto {
|
||||||
kdf: KdfParams {
|
kdf: KdfParams {
|
||||||
algorithm: "argon2id".to_string(),
|
algorithm: "argon2id".to_string(),
|
||||||
|
|
@ -467,6 +497,47 @@ fn derive_with(crypto: &VaultCrypto, password: &str) -> Result<VaultKey, AppErro
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-encrypt every stored secret in the vault from `old_key` to `new_key`.
|
||||||
|
///
|
||||||
|
/// Covers all three secret stores — profile keys, NIP-46 connection secrets,
|
||||||
|
/// and NIP-46 client keys — so a password change or KDF upgrade can never
|
||||||
|
/// leave one collection wrapped under the previous key. `old_key` is `None`
|
||||||
|
/// only when the vault was unencrypted (secrets are plaintext on the way in).
|
||||||
|
/// Each plaintext is [`Zeroizing`]: shredded as soon as its replacement
|
||||||
|
/// ciphertext exists.
|
||||||
|
fn rewrap_secrets(
|
||||||
|
vault: &mut Vault,
|
||||||
|
old_key: Option<&VaultKey>,
|
||||||
|
new_key: &VaultKey,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let decrypt_one = |blob: &str| -> Result<Zeroizing<String>, AppError> {
|
||||||
|
match old_key {
|
||||||
|
Some(key) => crypto::decrypt_secret(key, blob),
|
||||||
|
None => Ok(Zeroizing::new(blob.to_string())),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut profiles = Vec::with_capacity(vault.profiles.len());
|
||||||
|
for profile in &vault.profiles {
|
||||||
|
let plaintext = decrypt_one(&profile.secret_key)?;
|
||||||
|
profiles.push(StoredProfile {
|
||||||
|
secret_key: crypto::encrypt_secret(new_key, &plaintext)?,
|
||||||
|
..profile.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vault.profiles = profiles;
|
||||||
|
|
||||||
|
for entry in &mut vault.connection_secrets {
|
||||||
|
let plaintext = decrypt_one(&entry.secret)?;
|
||||||
|
entry.secret = crypto::encrypt_secret(new_key, &plaintext)?;
|
||||||
|
}
|
||||||
|
for entry in &mut vault.connection_client_keys {
|
||||||
|
let plaintext = decrypt_one(&entry.secret_hex)?;
|
||||||
|
entry.secret_hex = crypto::encrypt_secret(new_key, &plaintext)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -607,6 +678,138 @@ mod tests {
|
||||||
assert_eq!(err.kind(), ErrorKind::Config);
|
assert_eq!(err.kind(), ErrorKind::Config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a vault encrypted under explicit (legacy) KDF parameters,
|
||||||
|
/// exactly as an older app version would have written it, and return the
|
||||||
|
/// matching password. Also stores one connection secret and one client
|
||||||
|
/// key encrypted under the same legacy key.
|
||||||
|
fn legacy_kdf_app(m_cost: u32, t_cost: u32) -> App {
|
||||||
|
use crate::crypto;
|
||||||
|
let password = "correct horse battery staple";
|
||||||
|
let salt = crypto::generate_salt().unwrap();
|
||||||
|
let key = crypto::derive_key(password, &salt, m_cost, t_cost, crypto::KDF_P_COST).unwrap();
|
||||||
|
let mut app = sample_app();
|
||||||
|
let ref_ = crate::signer::VaultRef::new(Some("npub1test".to_string()), "aabb".to_string());
|
||||||
|
app.vault.crypto = Some(VaultCrypto {
|
||||||
|
kdf: KdfParams {
|
||||||
|
algorithm: "argon2id".to_string(),
|
||||||
|
salt: B64.encode(salt),
|
||||||
|
m_cost,
|
||||||
|
t_cost,
|
||||||
|
p_cost: crypto::KDF_P_COST,
|
||||||
|
},
|
||||||
|
verifier: crypto::make_verifier(&key).unwrap(),
|
||||||
|
});
|
||||||
|
// crypto is set first so these store ENCRYPTED under the legacy key,
|
||||||
|
// exactly as a running pre-upgrade app would have written them.
|
||||||
|
for profile in &mut app.vault.profiles {
|
||||||
|
profile.secret_key = crypto::encrypt_secret(&key, &profile.secret_key).unwrap();
|
||||||
|
}
|
||||||
|
vault::store_connection_secret(&mut app.vault, Some(&key), &ref_, "pairing-secret")
|
||||||
|
.unwrap();
|
||||||
|
vault::store_connection_client_key(
|
||||||
|
&mut app.vault,
|
||||||
|
Some(&key),
|
||||||
|
&ref_,
|
||||||
|
"cd".repeat(32).as_str(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlock_upgrades_legacy_kdf_params() {
|
||||||
|
let mut app = legacy_kdf_app(crypto::LEGACY_KDF_M_COST, crypto::LEGACY_KDF_T_COST);
|
||||||
|
let legacy_m = app.vault.crypto.as_ref().unwrap().kdf.m_cost;
|
||||||
|
assert_ne!(legacy_m, crypto::KDF_M_COST, "test vault must be legacy");
|
||||||
|
|
||||||
|
let upgraded = app
|
||||||
|
.unlock("correct horse battery staple")
|
||||||
|
.expect("legacy vault must unlock");
|
||||||
|
assert!(upgraded, "unlock must report a KDF upgrade happened");
|
||||||
|
|
||||||
|
let kdf = &app.vault.crypto.as_ref().unwrap().kdf;
|
||||||
|
assert_eq!(kdf.m_cost, crypto::KDF_M_COST);
|
||||||
|
assert_eq!(kdf.t_cost, crypto::KDF_T_COST);
|
||||||
|
|
||||||
|
// The new key must decrypt every profile secret re-wrapped under it,
|
||||||
|
// and the new verifier must accept it.
|
||||||
|
let key = app.vault_key().expect("unlocked");
|
||||||
|
assert!(crypto::verify(
|
||||||
|
key,
|
||||||
|
&app.vault.crypto.as_ref().unwrap().verifier
|
||||||
|
));
|
||||||
|
let secret = profiles::resolve_secret_key(
|
||||||
|
&app.vault,
|
||||||
|
&app.vault.profiles[0].public_key.clone(),
|
||||||
|
app.vault_key(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!secret.is_empty());
|
||||||
|
|
||||||
|
// Unlocking again is a no-op: already at current parameters.
|
||||||
|
app.lock();
|
||||||
|
assert!(
|
||||||
|
!app.unlock("correct horse battery staple").unwrap(),
|
||||||
|
"current-params vault must not re-upgrade"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kdf_upgrade_rewraps_connection_secrets_and_client_keys() {
|
||||||
|
let mut app = legacy_kdf_app(crypto::LEGACY_KDF_M_COST, crypto::LEGACY_KDF_T_COST);
|
||||||
|
let ref_ = crate::signer::VaultRef::new(Some("npub1test".to_string()), "aabb".to_string());
|
||||||
|
app.unlock("correct horse battery staple").unwrap();
|
||||||
|
|
||||||
|
let secret = vault::resolve_connection_secret(&app.vault, app.vault_key(), &ref_)
|
||||||
|
.unwrap()
|
||||||
|
.expect("connection secret survives the upgrade");
|
||||||
|
assert_eq!(secret.as_str(), "pairing-secret");
|
||||||
|
let client_key = vault::resolve_connection_client_key(&app.vault, app.vault_key(), &ref_)
|
||||||
|
.unwrap()
|
||||||
|
.expect("client key survives the upgrade");
|
||||||
|
assert_eq!(client_key.as_str(), "cd".repeat(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_password_does_not_upgrade_kdf() {
|
||||||
|
let mut app = legacy_kdf_app(crypto::LEGACY_KDF_M_COST, crypto::LEGACY_KDF_T_COST);
|
||||||
|
let err = app
|
||||||
|
.unlock("not the password")
|
||||||
|
.expect_err("wrong password must fail");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongPassword);
|
||||||
|
let kdf = &app.vault.crypto.as_ref().unwrap().kdf;
|
||||||
|
assert_eq!(kdf.m_cost, crypto::LEGACY_KDF_M_COST, "header untouched");
|
||||||
|
assert!(app.is_locked());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_password_rewraps_connection_secrets_and_client_keys() {
|
||||||
|
// Plaintext vault with connection credentials: setting a password
|
||||||
|
// must encrypt EVERY secret, not just profile keys.
|
||||||
|
let mut app = sample_app();
|
||||||
|
let ref_ = crate::signer::VaultRef::new(Some("npub1test".to_string()), "aabb".to_string());
|
||||||
|
vault::store_connection_secret(&mut app.vault, None, &ref_, "pairing-secret").unwrap();
|
||||||
|
vault::store_connection_client_key(&mut app.vault, None, &ref_, "ef".repeat(32).as_str())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&app.vault).unwrap();
|
||||||
|
assert!(
|
||||||
|
!serialized.contains("pairing-secret"),
|
||||||
|
"pairing secret must not survive in plaintext"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!serialized.contains(&"ef".repeat(32)),
|
||||||
|
"client key must not survive in plaintext"
|
||||||
|
);
|
||||||
|
let secret = vault::resolve_connection_secret(&app.vault, app.vault_key(), &ref_)
|
||||||
|
.unwrap()
|
||||||
|
.expect("secret still resolvable after encryption");
|
||||||
|
assert_eq!(secret.as_str(), "pairing-secret");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unlock_roundtrip_with_wrong_then_right_password() {
|
fn unlock_roundtrip_with_wrong_then_right_password() {
|
||||||
let mut app = sample_app();
|
let mut app = sample_app();
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,22 @@ pub const SALT_LEN: usize = 16;
|
||||||
/// AES-GCM nonce length in bytes.
|
/// AES-GCM nonce length in bytes.
|
||||||
pub const NONCE_LEN: usize = 12;
|
pub const NONCE_LEN: usize = 12;
|
||||||
|
|
||||||
/// Argon2id memory cost in KiB (RFC 9106 recommendation).
|
/// Argon2id memory cost in KiB (64 MiB — OWASP 2024 recommendation).
|
||||||
pub const KDF_M_COST: u32 = 19 * 1024;
|
pub const KDF_M_COST: u32 = 64 * 1024;
|
||||||
/// Argon2id time cost (iterations).
|
/// Argon2id time cost (iterations).
|
||||||
pub const KDF_T_COST: u32 = 2;
|
pub const KDF_T_COST: u32 = 3;
|
||||||
/// Argon2id parallelism.
|
/// Argon2id parallelism.
|
||||||
pub const KDF_P_COST: u32 = 1;
|
pub const KDF_P_COST: u32 = 1;
|
||||||
|
|
||||||
|
/// Memory cost used by app versions before the Step 5 upgrade (RFC 9106's
|
||||||
|
/// 19 MiB / t=2). Vaults carrying exactly these parameters are transparently
|
||||||
|
/// re-wrapped under [`KDF_M_COST`]/[`KDF_T_COST`] on next unlock; vaults with
|
||||||
|
/// any OTHER parameters keep them — the header is authoritative, and these
|
||||||
|
/// constants are only the definition of "legacy".
|
||||||
|
pub const LEGACY_KDF_M_COST: u32 = 19 * 1024;
|
||||||
|
/// Time cost used by pre-upgrade versions (see [`LEGACY_KDF_M_COST`]).
|
||||||
|
pub const LEGACY_KDF_T_COST: u32 = 2;
|
||||||
|
|
||||||
/// The in-memory key that unlocks an encrypted vault.
|
/// The in-memory key that unlocks an encrypted vault.
|
||||||
pub type VaultKey = [u8; KEY_LEN];
|
pub type VaultKey = [u8; KEY_LEN];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1024,7 +1024,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::UnlockVault { password } => {
|
Request::UnlockVault { password } => {
|
||||||
app.unlock(&password)?;
|
let upgraded = app.unlock(&password)?;
|
||||||
|
if upgraded {
|
||||||
|
// The vault was transparently re-wrapped under stronger KDF
|
||||||
|
// parameters: persist immediately so the upgrade sticks.
|
||||||
|
app.save_vault()?;
|
||||||
|
}
|
||||||
// Re-initialize signers with unlocked vault
|
// Re-initialize signers with unlocked vault
|
||||||
if app.signer_mode == SignerMode::Embedded {
|
if app.signer_mode == SignerMode::Embedded {
|
||||||
if let Some(signer) = &app.embedded_signer {
|
if let Some(signer) = &app.embedded_signer {
|
||||||
|
|
|
||||||
12
src/main.rs
12
src/main.rs
|
|
@ -483,7 +483,10 @@ fn load_app_with_unlock() -> Result<App, AppError> {
|
||||||
let mut app = App::load()?;
|
let mut app = App::load()?;
|
||||||
if app.is_locked() {
|
if app.is_locked() {
|
||||||
let password = prompt_password("Vault password: ")?;
|
let password = prompt_password("Vault password: ")?;
|
||||||
app.unlock(&password)?;
|
if app.unlock(&password)? {
|
||||||
|
// Transparent KDF upgrade: persist the re-wrapped vault.
|
||||||
|
app.save_vault()?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
@ -531,7 +534,12 @@ fn cli_unlock() -> Result<String, AppError> {
|
||||||
return Err(AppError::config("Your vault is not encrypted."));
|
return Err(AppError::config("Your vault is not encrypted."));
|
||||||
}
|
}
|
||||||
let password = prompt_password("Vault password: ")?;
|
let password = prompt_password("Vault password: ")?;
|
||||||
app.unlock(&password)?;
|
if app.unlock(&password)? {
|
||||||
|
app.save_vault()?;
|
||||||
|
return Ok(
|
||||||
|
"Vault unlocked and upgraded to stronger key-derivation parameters.".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok("Vault unlocked.".to_string())
|
Ok("Vault unlocked.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue