fix: refuse vaults without a verifier

Unlock now always checks the verifier, so an empty vault can never
accept a wrong master password and the 'verify against the first entry'
fallback for Go/older vaults is gone. A vault without a verifier is
refused with a message naming the file to remove (SEC-20261012-016).
The Go vault fixture and its test go with it.
This commit is contained in:
Andy Dixon
2026-10-12 10:08:29 +01:00
parent 88c5cca021
commit 9ebfe57301
3 changed files with 14 additions and 42 deletions
+14 -33
View File
@@ -111,8 +111,8 @@ struct VaultFile {
salt: String, salt: String,
secrets: BTreeMap<String, String>, secrets: BTreeMap<String, String>,
/// A known value sealed under the key, so even an empty vault can tell a wrong master /// A known value sealed under the key, so even an empty vault can tell a wrong master
/// password. Absent in vaults written by Go or older builds; added on the next write. /// password.
#[serde(default, skip_serializing_if = "String::is_empty")] #[serde(default)]
verifier: String, verifier: String,
} }
@@ -171,13 +171,15 @@ impl Vault {
let vault: VaultFile = serde_json::from_slice(&std::fs::read(self.path())?)?; let vault: VaultFile = serde_json::from_slice(&std::fs::read(self.path())?)?;
let salt = B64.decode(&vault.salt).map_err(|e| format!("secrets: corrupt vault salt: {e}"))?; let salt = B64.decode(&vault.salt).map_err(|e| format!("secrets: corrupt vault salt: {e}"))?;
let u = Unlocked { key: derive_key(password, &salt)?, vault }; let u = Unlocked { key: derive_key(password, &salt)?, vault };
// The verifier must open; without one (older vaults) one entry must decrypt, and an empty if u.vault.verifier.is_empty() {
// vault accepts any password, as in Go. return Err(format!(
let check = if u.vault.verifier.is_empty() { u.vault.secrets.values().next() } else { Some(&u.vault.verifier) }; "secrets: {} was made by an older version and can't be verified; remove it to create a new secret store",
if let Some(v) = check { self.path().display()
let data = B64.decode(v).unwrap_or_default(); )
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?; .into());
} }
let data = B64.decode(&u.vault.verifier).unwrap_or_default();
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?;
*self.state.lock() = Some(u); *self.state.lock() = Some(u);
Ok(()) Ok(())
} }
@@ -204,9 +206,6 @@ impl Vault {
let u = st.as_mut().ok_or("secrets: vault is locked")?; let u = st.as_mut().ok_or("secrets: vault is locked")?;
let enc = B64.encode(seal(&u.key, value.as_bytes())); let enc = B64.encode(seal(&u.key, value.as_bytes()));
u.vault.secrets.insert(key.to_string(), enc); u.vault.secrets.insert(key.to_string(), enc);
if u.vault.verifier.is_empty() {
u.vault.verifier = verifier(&u.key);
}
self.save(u) self.save(u)
} }
@@ -352,17 +351,6 @@ impl Secrets {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn reads_go_vault_fixture() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/vault");
let v = Vault::new(dir);
assert!(v.is_initialised());
assert!(v.unlock("wrong").is_err());
v.unlock("correct horse battery staple").unwrap();
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:password").unwrap(), "s3cret-ssh");
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:totp").unwrap(), "JBSWY3DPEHPK3PXP");
}
#[test] #[test]
fn vault_round_trip() { fn vault_round_trip() {
let dir = std::env::temp_dir().join(format!("nexus-vault-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("nexus-vault-{}", std::process::id()));
@@ -378,7 +366,7 @@ mod tests {
} }
#[test] #[test]
fn empty_vault_rejects_a_wrong_password_and_old_vaults_gain_a_verifier() { fn empty_vault_rejects_a_wrong_password_and_unverifiable_vaults_are_refused() {
let dir = std::env::temp_dir().join(format!("nexus-vault-verifier-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("nexus-vault-verifier-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
let v = Vault::new(dir.clone()); let v = Vault::new(dir.clone());
@@ -386,20 +374,13 @@ mod tests {
v.lock(); v.lock();
assert!(v.unlock("typo").is_err(), "an empty vault must not accept any password"); assert!(v.unlock("typo").is_err(), "an empty vault must not accept any password");
v.unlock("pw").unwrap(); v.unlock("pw").unwrap();
// A vault from Go/older builds (no verifier) keeps working and gains one on write. v.lock();
// A vault without a verifier (older format) is refused rather than trusted.
let path = dir.join(VAULT_FILE); let path = dir.join(VAULT_FILE);
let mut f: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); let mut f: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
f.as_object_mut().unwrap().remove("verifier"); f.as_object_mut().unwrap().remove("verifier");
std::fs::write(&path, f.to_string()).unwrap(); std::fs::write(&path, f.to_string()).unwrap();
v.lock(); assert!(v.unlock("pw").unwrap_err().to_string().contains("older version"));
v.unlock("anything").unwrap();
v.lock();
v.unlock("pw").unwrap();
v.set("a", "1").unwrap();
v.lock();
assert!(std::fs::read_to_string(&path).unwrap().contains("verifier"));
assert!(v.unlock("typo").is_err());
v.unlock("pw").unwrap();
std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(dir).unwrap();
} }
-1
View File
@@ -5,7 +5,6 @@ Generated by the last Go build of Alchementary Link with `cmd/genfixtures` (see
- `link.db` — a database through migration 006 with every protocol, a known host, a snippet, - `link.db` — a database through migration 006 with every protocol, a known host, a snippet,
IRC channel state and preferences. IRC channel state and preferences.
- `vault/vault.json` — a vault whose master password is `correct horse battery staple`.
- `profiles.link` — an encrypted export with password `export-pass`. - `profiles.link` — an encrypted export with password `export-pass`.
- `recordings/rec1.cast` — an asciicast v2 recording. - `recordings/rec1.cast` — an asciicast v2 recording.
-8
View File
@@ -1,8 +0,0 @@
{
"version": 1,
"salt": "uiC19v2mCz7XglOtlQwJgDe80p37X5XueedOqm2Av+w=",
"secrets": {
"profile:00000000-0000-0000-0000-000000000001:password": "MknPyD86GxFdJZipFyhRL8YiqkywXMP7JxD5PuVjgblss2zuloQ=",
"profile:00000000-0000-0000-0000-000000000001:totp": "DDeIJ7scepaK9ctzh46JDRuB9XRfMG7BMfDnqkwsMEuGdW6blJTddiJVVi4="
}
}