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:
+14
-33
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
-1
@@ -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
@@ -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="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user