Reject SSH key ciphers with null or empty required fields - #7584
Reject SSH key ciphers with null or empty required fields#7584xhon-pelushi wants to merge 2 commits into
Conversation
Validate privateKey, publicKey, and keyFingerprint before saving type-5 ciphers. Bitwarden cloud rejects these payloads; accepting them caused a successful write followed by silent sshKey loss on read-back. Fixes dani-garcia#7514
ffc567d to
a483a3b
Compare
BlackDex
left a comment
There was a problem hiding this comment.
I think the validation function can be a bit optimized.
The tests i find a bit too much t.b.h.
It might also be an option to just create one test function and have a vec of cases to check and then just run those?
Something like this maybe?
let cases = [
// missing field
(json!({"publicKey":"pub","keyFingerprint":"fp"}), false),
// null field
(json!({"privateKey":null,"publicKey":"pub","keyFingerprint":"fp"}), false),
]And then loop over those cases and use the false or true as an assert outcome?
That would make it easier to add other tests if needed in the future and thinking about different function names. The failed test is shown in detail if i'm correct, so we should see what would failed (but i might be wrong here).
| /// Ensure SSH key type-data has the required non-empty string fields. | ||
| fn validate_ssh_key_data(type_data: &Value) -> EmptyResult { | ||
| for field in ["privateKey", "publicKey", "keyFingerprint"] { | ||
| match type_data.get(field).and_then(Value::as_str) { | ||
| Some(value) if !value.is_empty() => {} | ||
| _ => err!(format!("SSH key field '{field}' must be a non-empty string")), | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
There was a problem hiding this comment.
This function is located at a wrong place, it cuts into the comment of an other function.
I also think it could be a bit more optimized maybe?
| /// Ensure SSH key type-data has the required non-empty string fields. | |
| fn validate_ssh_key_data(type_data: &Value) -> EmptyResult { | |
| for field in ["privateKey", "publicKey", "keyFingerprint"] { | |
| match type_data.get(field).and_then(Value::as_str) { | |
| Some(value) if !value.is_empty() => {} | |
| _ => err!(format!("SSH key field '{field}' must be a non-empty string")), | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn validate_ssh_key_data(type_data: &Value) -> EmptyResult { | |
| for field in ["privateKey", "publicKey", "keyFingerprint"] { | |
| type_data | |
| .get(field) | |
| .and_then(Value::as_str) | |
| .filter(|v| !v.is_empty()) | |
| .ok_or_else(|| err!(format!("SSH Key field '{field}' is required!")))?; | |
| } | |
| } |
The function had been inserted between the third and fourth lines of the doc comment on enforce_personal_ownership_policy, splitting it in two. Moved it below that function so the comment reads as one block again. Simplified the check to the same idiom Cipher::to_json already uses for these exact three fields (as_str().is_none_or(str::is_empty)), and noted the relationship between the two in the doc comment: to_json discards the type-data of stored SSH ciphers whose fields are missing or empty, while this rejects them before they are written. Collapsed the four test functions into one table of cases, and added two the originals did not cover: a non-string field, and type-data with no fields at all. Each case carries a label that is printed on failure, so a broken case still says which one it was.
|
Thanks — all three addressed in Placement. You were right, and it was worse than misplaced: the function had landed between the third and fourth lines of the The function. I went with the idiom already in the tree rather than my own — if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))so this now reads: fn validate_ssh_key_data(type_data: &Value) -> EmptyResult {
for field in ["privateKey", "publicKey", "keyFingerprint"] {
if type_data[field].as_str().is_none_or(str::is_empty) {
err!(format!("SSH key field '{field}' must be a non-empty string"))
}
}
Ok(())
}I added a doc comment noting how the two relate — One note on your suggested snippet: Tests. Collapsed to one table as you suggested, and added two cases the originals missed — a non-string field, and type-data with no fields at all: let cases = [
("all fields present", json!({...}), true),
("null private key", json!({...}), false),
...
];
for (case, type_data, expected_ok) in cases {
assert_eq!(validate_ssh_key_data(&type_data).is_ok(), expected_ok, "case: {case}");
}To your question about failure output — with a bare Verified locally on the pinned 1.97.1 toolchain: Unrelated, but you may want to know: assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);Nothing to do with this PR (it only touches |
Summary
type: 5) payloads before save.privateKey,publicKey, andkeyFingerprint.sshKeyon read-back.Fixes #7514
Test plan
cargo test --profile ci --features sqlite ssh_key_validation_testsnullreturns an error.