You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
396 lines
13 KiB
396 lines
13 KiB
//! Field-level encryption functions for the Seeder. |
|
//! |
|
//! This module provides FFI functions for encrypting and decrypting individual string |
|
//! values and JSON fields using AES-256-CBC-HMAC-SHA256 via bitwarden_crypto. |
|
//! No dependency on bitwarden_vault types — the caller drives which fields to encrypt. |
|
|
|
use std::ffi::{c_char, CStr, CString}; |
|
|
|
use base64::{engine::general_purpose::STANDARD, Engine}; |
|
|
|
use bitwarden_crypto::{ |
|
BitwardenLegacyKeyBytes, EncString, KeyDecryptable, KeyEncryptable, SymmetricCryptoKey, |
|
}; |
|
|
|
/// Create an error JSON response and return it as a C string pointer. |
|
fn error_response(message: &str) -> *const c_char { |
|
let error_json = serde_json::json!({ "error": message }).to_string(); |
|
CString::new(error_json).unwrap().into_raw() |
|
} |
|
|
|
/// Encrypt a plaintext string with a symmetric key, returning an EncString. |
|
/// |
|
/// # Arguments |
|
/// * `plaintext` - The plaintext string to encrypt |
|
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) |
|
/// |
|
/// # Returns |
|
/// EncString in format "2.{iv}|{data}|{mac}" |
|
/// |
|
/// # Safety |
|
/// Both pointers must be valid null-terminated strings. |
|
#[no_mangle] |
|
pub unsafe extern "C" fn encrypt_string( |
|
plaintext: *const c_char, |
|
symmetric_key_b64: *const c_char, |
|
) -> *const c_char { |
|
let Ok(plaintext) = CStr::from_ptr(plaintext).to_str() else { |
|
return error_response("Invalid UTF-8 in plaintext"); |
|
}; |
|
|
|
let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { |
|
return error_response("Invalid UTF-8 in symmetric_key_b64"); |
|
}; |
|
|
|
let Ok(key_bytes) = STANDARD.decode(key_b64) else { |
|
return error_response("Failed to decode base64 key"); |
|
}; |
|
|
|
let Ok(key) = |
|
SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) |
|
else { |
|
return error_response("Failed to create symmetric key: invalid key format or length"); |
|
}; |
|
|
|
let Ok(encrypted) = plaintext.to_string().encrypt_with_key(&key) else { |
|
return error_response("Failed to encrypt string"); |
|
}; |
|
|
|
CString::new(encrypted.to_string()).unwrap().into_raw() |
|
} |
|
|
|
/// Decrypt an EncString with a symmetric key, returning the plaintext. |
|
/// |
|
/// # Arguments |
|
/// * `enc_string` - EncString in format "2.{iv}|{data}|{mac}" |
|
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) |
|
/// |
|
/// # Returns |
|
/// The decrypted plaintext string |
|
/// |
|
/// # Safety |
|
/// Both pointers must be valid null-terminated strings. |
|
#[no_mangle] |
|
pub unsafe extern "C" fn decrypt_string( |
|
enc_string: *const c_char, |
|
symmetric_key_b64: *const c_char, |
|
) -> *const c_char { |
|
let Ok(enc_str) = CStr::from_ptr(enc_string).to_str() else { |
|
return error_response("Invalid UTF-8 in enc_string"); |
|
}; |
|
|
|
let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { |
|
return error_response("Invalid UTF-8 in symmetric_key_b64"); |
|
}; |
|
|
|
let Ok(parsed): Result<EncString, _> = enc_str.parse() else { |
|
return error_response("Failed to parse EncString"); |
|
}; |
|
|
|
let Ok(key_bytes) = STANDARD.decode(key_b64) else { |
|
return error_response("Failed to decode base64 key"); |
|
}; |
|
|
|
let Ok(key) = |
|
SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) |
|
else { |
|
return error_response("Failed to create symmetric key: invalid key format or length"); |
|
}; |
|
|
|
let Ok(plaintext): Result<String, _> = parsed.decrypt_with_key(&key) else { |
|
return error_response("Failed to decrypt string"); |
|
}; |
|
|
|
CString::new(plaintext).unwrap().into_raw() |
|
} |
|
|
|
/// Encrypt specified fields in a JSON object, returning the modified JSON. |
|
/// |
|
/// Takes a JSON object, a JSON array of dot-notation field paths (with `[*]` for |
|
/// array elements), and a symmetric key. Walks the JSON tree and encrypts string |
|
/// values at matching paths. Non-string values and unmatched paths are left unchanged. |
|
/// |
|
/// # Arguments |
|
/// * `json` - JSON object string |
|
/// * `field_paths_json` - JSON array of path strings, e.g. `["name","login.username","login.uris[*].uri"]` |
|
/// * `symmetric_key_b64` - Base64-encoded symmetric key |
|
/// |
|
/// # Returns |
|
/// Modified JSON with matching string fields encrypted as EncStrings |
|
/// |
|
/// # Safety |
|
/// All pointers must be valid null-terminated strings. |
|
#[no_mangle] |
|
pub unsafe extern "C" fn encrypt_fields( |
|
json: *const c_char, |
|
field_paths_json: *const c_char, |
|
symmetric_key_b64: *const c_char, |
|
) -> *const c_char { |
|
let Ok(json_str) = CStr::from_ptr(json).to_str() else { |
|
return error_response("Invalid UTF-8 in json"); |
|
}; |
|
|
|
let Ok(paths_str) = CStr::from_ptr(field_paths_json).to_str() else { |
|
return error_response("Invalid UTF-8 in field_paths_json"); |
|
}; |
|
|
|
let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { |
|
return error_response("Invalid UTF-8 in symmetric_key_b64"); |
|
}; |
|
|
|
let Ok(mut value): Result<serde_json::Value, _> = serde_json::from_str(json_str) else { |
|
return error_response("Failed to parse JSON"); |
|
}; |
|
|
|
let Ok(paths): Result<Vec<String>, _> = serde_json::from_str(paths_str) else { |
|
return error_response("Failed to parse field paths JSON"); |
|
}; |
|
|
|
let Ok(key_bytes) = STANDARD.decode(key_b64) else { |
|
return error_response("Failed to decode base64 key"); |
|
}; |
|
|
|
let Ok(key) = |
|
SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) |
|
else { |
|
return error_response("Failed to create symmetric key: invalid key format or length"); |
|
}; |
|
|
|
for path in &paths { |
|
if let Err(msg) = encrypt_at_path(&mut value, path, &key) { |
|
return error_response(&msg); |
|
} |
|
} |
|
|
|
match serde_json::to_string(&value) { |
|
Ok(result) => CString::new(result).unwrap().into_raw(), |
|
Err(_) => error_response("Failed to serialize result JSON"), |
|
} |
|
} |
|
|
|
/// Walks a JSON value tree and encrypts string values at the given dot-path. |
|
/// Supports `[*]` segments for iterating array elements. |
|
fn encrypt_at_path( |
|
value: &mut serde_json::Value, |
|
path: &str, |
|
key: &SymmetricCryptoKey, |
|
) -> Result<(), String> { |
|
let segments: Vec<&str> = path.split('.').collect(); |
|
encrypt_segments(value, &segments, key) |
|
} |
|
|
|
fn encrypt_segments( |
|
value: &mut serde_json::Value, |
|
segments: &[&str], |
|
key: &SymmetricCryptoKey, |
|
) -> Result<(), String> { |
|
if segments.is_empty() { |
|
return Ok(()); |
|
} |
|
|
|
let segment = segments[0]; |
|
let rest = &segments[1..]; |
|
|
|
// Handle array wildcard: "uris[*]" means iterate all elements of the "uris" array |
|
if let Some(field_name) = segment.strip_suffix("[*]") { |
|
let Some(arr) = value.get_mut(field_name).and_then(|v| v.as_array_mut()) else { |
|
return Ok(()); // Field missing or not an array — skip |
|
}; |
|
|
|
for element in arr.iter_mut() { |
|
encrypt_segments(element, rest, key)?; |
|
} |
|
|
|
return Ok(()); |
|
} |
|
|
|
// Last segment — encrypt the value if it's a string |
|
if rest.is_empty() { |
|
if let Some(s) = value.get(segment).and_then(|v| v.as_str()) { |
|
let encrypted = s |
|
.to_string() |
|
.encrypt_with_key(key) |
|
.map_err(|_| format!("Failed to encrypt field '{segment}'"))?; |
|
value[segment] = serde_json::Value::String(encrypted.to_string()); |
|
} |
|
// null or missing — leave unchanged |
|
return Ok(()); |
|
} |
|
|
|
// Intermediate segment — recurse into nested object |
|
let Some(nested) = value.get_mut(segment) else { |
|
return Ok(()); // Field missing — skip |
|
}; |
|
|
|
encrypt_segments(nested, rest, key) |
|
} |
|
|
|
#[cfg(test)] |
|
mod tests { |
|
use super::*; |
|
use crate::free_c_string; |
|
|
|
fn make_test_key() -> SymmetricCryptoKey { |
|
SymmetricCryptoKey::make_aes256_cbc_hmac_key() |
|
} |
|
|
|
fn call_ffi_string( |
|
func: unsafe extern "C" fn(*const c_char, *const c_char) -> *const c_char, |
|
a: &str, |
|
b: &str, |
|
) -> String { |
|
let a_cstr = CString::new(a).unwrap(); |
|
let b_cstr = CString::new(b).unwrap(); |
|
let ptr = unsafe { func(a_cstr.as_ptr(), b_cstr.as_ptr()) }; |
|
let result = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned(); |
|
unsafe { free_c_string(ptr as *mut c_char) }; |
|
result |
|
} |
|
|
|
#[test] |
|
fn encrypt_string_decrypt_string_roundtrip() { |
|
let key = make_test_key(); |
|
let key_b64: String = key.to_base64().into(); |
|
|
|
let encrypted = call_ffi_string(encrypt_string, "hello world", &key_b64); |
|
assert!( |
|
encrypted.starts_with("2."), |
|
"Expected EncString, got: {encrypted}" |
|
); |
|
|
|
let decrypted = call_ffi_string(decrypt_string, &encrypted, &key_b64); |
|
assert_eq!(decrypted, "hello world"); |
|
} |
|
|
|
#[test] |
|
fn encrypt_at_path_encrypts_top_level_string() { |
|
let key = make_test_key(); |
|
let mut value: serde_json::Value = serde_json::json!({"name": "Test", "type": 1}); |
|
|
|
encrypt_at_path(&mut value, "name", &key).unwrap(); |
|
|
|
let name = value["name"].as_str().unwrap(); |
|
assert!(name.starts_with("2."), "Expected encrypted, got: {name}"); |
|
assert_ne!(name, "Test"); |
|
} |
|
|
|
#[test] |
|
fn encrypt_at_path_encrypts_nested_field() { |
|
let key = make_test_key(); |
|
let mut value: serde_json::Value = serde_json::json!({ |
|
"login": {"username": "user@test.com", "password": "secret"} |
|
}); |
|
|
|
encrypt_at_path(&mut value, "login.username", &key).unwrap(); |
|
|
|
let username = value["login"]["username"].as_str().unwrap(); |
|
assert!( |
|
username.starts_with("2."), |
|
"Expected encrypted, got: {username}" |
|
); |
|
|
|
// password should be unchanged |
|
assert_eq!(value["login"]["password"].as_str().unwrap(), "secret"); |
|
} |
|
|
|
#[test] |
|
fn encrypt_at_path_encrypts_array_wildcard() { |
|
let key = make_test_key(); |
|
let mut value: serde_json::Value = serde_json::json!({ |
|
"login": { |
|
"uris": [ |
|
{"uri": "https://example.com", "match": 0}, |
|
{"uri": "https://test.com", "match": 1} |
|
] |
|
} |
|
}); |
|
|
|
encrypt_at_path(&mut value, "login.uris[*].uri", &key).unwrap(); |
|
|
|
let uris = value["login"]["uris"].as_array().unwrap(); |
|
for uri_obj in uris { |
|
let uri = uri_obj["uri"].as_str().unwrap(); |
|
assert!(uri.starts_with("2."), "Expected encrypted URI, got: {uri}"); |
|
} |
|
// match should be unchanged |
|
assert_eq!(uris[0]["match"].as_i64().unwrap(), 0); |
|
} |
|
|
|
#[test] |
|
fn encrypt_fields_ffi_encrypts_specified_paths() { |
|
let key = make_test_key(); |
|
let key_b64: String = key.to_base64().into(); |
|
|
|
let input_json = serde_json::json!({ |
|
"name": "Test Login", |
|
"type": 1, |
|
"login": {"username": "user@test.com", "password": "secret"} |
|
}) |
|
.to_string(); |
|
|
|
let paths_json = r#"["name","login.username","login.password"]"#; |
|
|
|
let json_cstr = CString::new(input_json).unwrap(); |
|
let paths_cstr = CString::new(paths_json).unwrap(); |
|
let key_cstr = CString::new(key_b64.as_str()).unwrap(); |
|
|
|
let ptr = |
|
unsafe { encrypt_fields(json_cstr.as_ptr(), paths_cstr.as_ptr(), key_cstr.as_ptr()) }; |
|
let result = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned(); |
|
unsafe { free_c_string(ptr as *mut c_char) }; |
|
|
|
assert!(!result.contains("\"error\""), "Got error: {result}"); |
|
|
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); |
|
let name = parsed["name"].as_str().unwrap(); |
|
assert!( |
|
name.starts_with("2."), |
|
"name should be encrypted, got: {name}" |
|
); |
|
|
|
let username = parsed["login"]["username"].as_str().unwrap(); |
|
assert!( |
|
username.starts_with("2."), |
|
"username should be encrypted, got: {username}" |
|
); |
|
|
|
// type should be unchanged |
|
assert_eq!(parsed["type"].as_i64().unwrap(), 1); |
|
} |
|
|
|
#[test] |
|
fn decrypt_string_with_wrong_key_fails() { |
|
let key1 = make_test_key(); |
|
let key2 = make_test_key(); |
|
let key1_b64: String = key1.to_base64().into(); |
|
let key2_b64: String = key2.to_base64().into(); |
|
|
|
let encrypted = call_ffi_string(encrypt_string, "secret", &key1_b64); |
|
let result = call_ffi_string(decrypt_string, &encrypted, &key2_b64); |
|
|
|
assert!( |
|
result.contains("\"error\""), |
|
"Should fail with wrong key, got: {result}" |
|
); |
|
} |
|
|
|
#[test] |
|
fn encrypt_at_path_skips_null_values() { |
|
let key = make_test_key(); |
|
let mut value: serde_json::Value = serde_json::json!({"name": null, "type": 1}); |
|
|
|
encrypt_at_path(&mut value, "name", &key).unwrap(); |
|
|
|
assert!(value["name"].is_null(), "Null should remain null"); |
|
} |
|
|
|
#[test] |
|
fn encrypt_at_path_skips_missing_fields() { |
|
let key = make_test_key(); |
|
let mut value: serde_json::Value = serde_json::json!({"type": 1}); |
|
|
|
// Should not error on missing "name" |
|
encrypt_at_path(&mut value, "name", &key).unwrap(); |
|
encrypt_at_path(&mut value, "login.username", &key).unwrap(); |
|
} |
|
}
|
|
|