#[cfg(feature = "openpgp-card")]
use crate::config::TokenInteractions;
use crate::{
config::{Config, KeyBackend, KeyTypes, UnlockCode},
errors::OpenVTCError,
};
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use hkdf::Hkdf;
use keyring_core::Entry;
use rand::{RngCore, rngs::OsRng};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use tracing::{error, info, warn};
use zeroize::{Zeroize, ZeroizeOnDrop};
const SERVICE: &str = "openvtc";
#[must_use]
pub fn new_protected_key() -> SecretString {
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
let encoded = BASE64_URL_SAFE_NO_PAD.encode(key);
key.zeroize();
SecretString::new(encoded.into())
}
pub fn require_encrypted_blob(bytes: &[u8]) -> Result<(), String> {
match serde_json::from_slice::<SecuredConfigFormat>(bytes) {
Ok(SecuredConfigFormat::PasswordEncrypted { .. })
| Ok(SecuredConfigFormat::TokenEncrypted { .. }) => Ok(()),
Ok(SecuredConfigFormat::PlainText { .. }) => Err(
"this profile has no passphrase, so its secret would be written to disk \
unencrypted; set one under Settings -> Config Protection to store it durably"
.to_string(),
),
Err(e) => Err(format!(
"refusing to store a blob that is not a SecuredConfig envelope: {e}"
)),
}
}
fn encode_blob(format: &SecuredConfigFormat) -> Result<Vec<u8>, OpenVTCError> {
let bytes = serde_json::to_vec(format)?;
if let Some(pos) = bytes
.iter()
.position(|b| !b.is_ascii() || b.is_ascii_control() || *b == b'\\')
{
return Err(OpenVTCError::SecureStore {
fault: crate::errors::SecureStoreFault::Corrupt,
profile: String::new(),
detail: format!(
"refusing to write a secret containing a byte the OS credential store \
cannot round-trip (0x{:02x} at offset {pos}); storing it would corrupt \
the keyring",
bytes[pos]
),
});
}
Ok(bytes)
}
#[must_use]
pub(crate) fn service_name() -> &'static str {
SERVICE
}
mod serde_secret_str {
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &SecretString, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(v.expose_secret())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
Ok(SecretString::new(String::deserialize(d)?.into()))
}
}
mod serde_opt_secret_str {
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(secret) => s.serialize_some(secret.expose_secret()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
Ok(Option::<String>::deserialize(d)?.map(|s| SecretString::new(s.into())))
}
}
#[derive(Clone, Debug, Default)]
pub enum ProtectionMethod {
TokenEncrypted,
PasswordEncrypted,
PlainText,
#[default]
Unknown,
}
impl From<SecuredConfigFormat> for ProtectionMethod {
fn from(format: SecuredConfigFormat) -> Self {
match format {
SecuredConfigFormat::TokenEncrypted { .. } => ProtectionMethod::TokenEncrypted,
SecuredConfigFormat::PasswordEncrypted { .. } => ProtectionMethod::PasswordEncrypted,
SecuredConfigFormat::PlainText { .. } => ProtectionMethod::PlainText,
}
}
}
#[derive(Serialize, Deserialize, Debug, Zeroize)]
#[serde(tag = "format")]
enum SecuredConfigFormat {
TokenEncrypted {
esk: String,
data: String,
},
PasswordEncrypted {
data: String,
},
PlainText {
text: String,
},
}
#[derive(Deserialize, Zeroize)]
#[serde(untagged)]
enum LegacySecuredConfigFormat {
TokenEncrypted { esk: String, data: String },
PasswordEncrypted { data: String },
PlainText { text: String },
}
impl From<LegacySecuredConfigFormat> for SecuredConfigFormat {
fn from(legacy: LegacySecuredConfigFormat) -> Self {
match legacy {
LegacySecuredConfigFormat::TokenEncrypted { esk, data } => {
SecuredConfigFormat::TokenEncrypted { esk, data }
}
LegacySecuredConfigFormat::PasswordEncrypted { data } => {
SecuredConfigFormat::PasswordEncrypted { data }
}
LegacySecuredConfigFormat::PlainText { text } => {
SecuredConfigFormat::PlainText { text }
}
}
}
}
fn assert_format_matches_intent(
format: &SecuredConfigFormat,
has_token: bool,
has_unlock: bool,
) -> Result<(), OpenVTCError> {
if matches!(
(format, has_token, has_unlock),
(SecuredConfigFormat::TokenEncrypted { .. }, true, _)
| (SecuredConfigFormat::PasswordEncrypted { .. }, false, true)
| (SecuredConfigFormat::PlainText { .. }, false, false)
) {
return Ok(());
}
let stored = match format {
SecuredConfigFormat::TokenEncrypted { .. } => "token-encrypted",
SecuredConfigFormat::PasswordEncrypted { .. } => "password-encrypted",
SecuredConfigFormat::PlainText { .. } => "plaintext",
};
let expected = if has_token {
"token-encrypted"
} else if has_unlock {
"password-encrypted"
} else {
"plaintext"
};
error!(
"SECURITY ALERT: stored config format ({stored}) does not match expected \
protection level ({expected}). Possible downgrade attack or config corruption."
);
Err(OpenVTCError::Config(format!(
"Security violation: stored config format '{stored}' does not match \
expected protection level '{expected}'. Refusing to load."
)))
}
impl SecuredConfigFormat {
#[cfg_attr(not(feature = "openpgp-card"), allow(unused_variables))]
pub fn unlock(
&self,
#[cfg(feature = "openpgp-card")] user_pin: &SecretString,
token: Option<&String>,
unlock: Option<&UnlockCode>,
#[cfg(feature = "openpgp-card")] touch_prompt: &impl TokenInteractions,
) -> Result<SecuredConfig, OpenVTCError> {
let raw_bytes = match self {
SecuredConfigFormat::TokenEncrypted { esk, data } => {
if let Some(token) = token {
#[cfg(feature = "openpgp-card")]
{
use crate::openpgp_card::crypt::token_decrypt;
token_decrypt(
user_pin,
token,
&BASE64_URL_SAFE_NO_PAD.decode(esk)?,
&BASE64_URL_SAFE_NO_PAD.decode(data)?,
touch_prompt,
)?
}
#[cfg(not(feature = "openpgp-card"))]
{
warn!(
"Token has been configured, but no openpgp-card feature-flag has been enabled! exiting..."
);
return Err(OpenVTCError::Config("Token has been configured, but no openpgp-card feature-flag has been enabled! exiting.".to_string()));
}
} else {
warn!(
"Secured Config is Token Encrypted, but no token identifier has been provided!"
);
return Err(OpenVTCError::Config("Secured Config is Token Encrypted, but no token identifier has been provided!".to_string()));
}
}
SecuredConfigFormat::PasswordEncrypted { data } => {
if let Some(unlock) = unlock {
let decoded = BASE64_URL_SAFE_NO_PAD.decode(data)?;
let key = unlock
.0
.expose_secret()
.first_chunk::<32>()
.ok_or_else(|| {
OpenVTCError::Decrypt("Unlock code is not 32 bytes".to_string())
})?;
unlock_code_decrypt(key, &decoded).map_err(|e| {
OpenVTCError::Decrypt(format!(
"Couldn't decrypt password encrypted SecuredConfig. Reason: {e}"
))
})?
} else {
return Err(OpenVTCError::Config(
"Secured Config is Password Encrypted, but no unlock code has been provided!".to_string()
));
}
}
SecuredConfigFormat::PlainText { text } => {
BASE64_URL_SAFE_NO_PAD.decode(text)?
}
};
Ok(serde_json::from_slice(raw_bytes.as_slice())?)
}
}
#[derive(Serialize, Deserialize, Debug, Zeroize, ZeroizeOnDrop)]
pub struct SecuredConfig {
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serde_opt_secret_str::serialize",
deserialize_with = "serde_opt_secret_str::deserialize"
)]
#[zeroize(skip)]
pub bip32_seed: Option<SecretString>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serde_opt_secret_str::serialize",
deserialize_with = "serde_opt_secret_str::deserialize"
)]
#[zeroize(skip)]
pub credential_bundle: Option<SecretString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vta_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vta_did: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mediator_did: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serde_opt_secret_str::serialize",
deserialize_with = "serde_opt_secret_str::deserialize"
)]
#[zeroize(skip)]
pub protected_key: Option<SecretString>,
#[zeroize(skip)] pub key_info: HashMap<String, KeyInfoConfig>,
#[serde(skip, default)]
#[zeroize(skip)]
pub protection_method: ProtectionMethod,
}
impl From<&Config> for SecuredConfig {
fn from(cfg: &Config) -> Self {
match &cfg.key_backend {
KeyBackend::Bip32 { seed, .. } => SecuredConfig {
bip32_seed: Some(seed.clone()),
credential_bundle: None,
protected_key: cfg.protected_key.clone(),
vta_url: None,
vta_did: None,
mediator_did: None,
key_info: cfg.key_info.clone(),
protection_method: cfg.protection_method.clone(),
},
KeyBackend::Vta {
credential_bundle,
mediator_did,
..
} => {
let (vta_url, vta_did) = cfg.persisted_vta_anchor().unwrap_or_default();
SecuredConfig {
bip32_seed: None,
credential_bundle: Some(credential_bundle.clone()),
protected_key: cfg.protected_key.clone(),
vta_url: if vta_url.is_empty() {
None
} else {
Some(vta_url.to_string())
},
vta_did: Some(vta_did.to_string()),
mediator_did: mediator_did.clone(),
key_info: cfg.key_info.clone(),
protection_method: cfg.protection_method.clone(),
}
}
}
}
}
impl SecuredConfig {
#[cfg_attr(not(feature = "openpgp-card"), allow(unused_variables))]
pub fn save(
&self,
profile: &str,
token: Option<&String>,
unlock: Option<&Vec<u8>>,
#[cfg(feature = "openpgp-card")] touch_prompt: &(dyn Fn() + Send + Sync),
) -> Result<(), OpenVTCError> {
let entry =
Entry::new(SERVICE, profile).map_err(|e| OpenVTCError::from_keyring(&e, profile))?;
let input = serde_json::to_vec(&self)?;
let formatted = if let Some(token) = token {
#[cfg(feature = "openpgp-card")]
{
use crate::openpgp_card::crypt::token_encrypt;
let (esk, data) = token_encrypt(token, &input, touch_prompt)?;
SecuredConfigFormat::TokenEncrypted {
esk: BASE64_URL_SAFE_NO_PAD.encode(&esk),
data: BASE64_URL_SAFE_NO_PAD.encode(&data),
}
}
#[cfg(not(feature = "openpgp-card"))]
return Err(OpenVTCError::Config( "Token has been configured, but no openpgp-card feature-flag has been enabled! exiting...".to_string()));
} else if let Some(unlock) = unlock {
SecuredConfigFormat::PasswordEncrypted {
data: BASE64_URL_SAFE_NO_PAD.encode(unlock_code_encrypt(
unlock.first_chunk::<32>().ok_or_else(|| {
OpenVTCError::Encrypt("Unlock code is not 32 bytes".to_string())
})?,
&input,
)?),
}
} else {
SecuredConfigFormat::PlainText {
text: BASE64_URL_SAFE_NO_PAD.encode(input),
}
};
entry
.set_secret(&encode_blob(&formatted)?)
.map_err(|e| OpenVTCError::from_keyring(&e, profile))?;
Ok(())
}
fn parse_format(
secret: &[u8],
profile: &str,
) -> Result<(SecuredConfigFormat, bool), OpenVTCError> {
match serde_json::from_slice::<SecuredConfigFormat>(secret) {
Ok(format) => Ok((format, false)),
Err(tagged_err) => match serde_json::from_slice::<LegacySecuredConfigFormat>(secret) {
Ok(legacy) => {
warn!(
"Tagged SecuredConfig parse failed ({tagged_err}); migrating legacy untagged blob"
);
Ok((SecuredConfigFormat::from(legacy), true))
}
Err(legacy_err) => {
error!(
"Format of SecuredConfig in OS Secure store is invalid! \
Tagged: {tagged_err}; legacy: {legacy_err}"
);
Err(OpenVTCError::SecureStore {
fault: crate::errors::SecureStoreFault::Corrupt,
profile: profile.to_string(),
detail: format!(
"stored blob parses as neither the current nor the legacy \
format: {tagged_err}"
),
})
}
},
}
}
pub fn parse(bytes: &[u8]) -> Result<(), OpenVTCError> {
Self::parse_format(bytes, "").map(|_| ())
}
pub fn load(
profile: &str,
#[cfg(feature = "openpgp-card")] user_pin: &SecretString,
token: Option<&String>,
unlock: Option<&UnlockCode>,
#[cfg(feature = "openpgp-card")] touch_prompt: &impl TokenInteractions,
) -> Result<Self, OpenVTCError> {
let entry =
Entry::new(SERVICE, profile).map_err(|e| OpenVTCError::from_keyring(&e, profile))?;
let secret = match entry.get_secret() {
Ok(s) => s,
Err(e) => {
error!("Couldn't read the SecuredConfig from the OS secure store: {e}");
return Err(OpenVTCError::from_keyring(&e, profile));
}
};
let (raw_secured_config, needs_migration) = Self::parse_format(secret.as_slice(), profile)?;
assert_format_matches_intent(&raw_secured_config, token.is_some(), unlock.is_some())?;
let sc = raw_secured_config.unlock(
#[cfg(feature = "openpgp-card")]
user_pin,
token,
unlock,
#[cfg(feature = "openpgp-card")]
touch_prompt,
)?;
if needs_migration {
let unlock_vec = unlock.map(|uc| uc.0.expose_secret().clone());
if let Err(e) = sc.save(
profile,
token,
unlock_vec.as_ref(),
#[cfg(feature = "openpgp-card")]
&|| {},
) {
warn!("Auto-migration: failed to re-save SecuredConfig in tagged format: {e}");
} else {
info!("Migrated legacy SecuredConfig blob to tagged format");
}
}
Ok(sc)
}
}
#[derive(Clone, Serialize, Deserialize, Debug, Zeroize, ZeroizeOnDrop)]
pub struct KeyInfoConfig {
pub path: KeySourceMaterial,
#[zeroize(skip)] pub create_time: DateTime<Utc>,
#[zeroize(skip)]
#[serde(default)]
pub purpose: KeyTypes,
}
#[derive(Clone, Serialize, Deserialize, Debug, Zeroize, ZeroizeOnDrop)]
pub enum KeySourceMaterial {
Derived { path: String },
Imported {
#[serde(with = "serde_secret_str")]
#[zeroize(skip)]
seed: SecretString,
},
VtaManaged { key_id: String },
}
const NONCE_SIZE: usize = 12;
const HKDF_INFO: &[u8] = b"openvtc-key-v2";
fn derive_key(unlock: &[u8; 32], nonce: &[u8]) -> Result<Aes256Gcm, OpenVTCError> {
let hk = Hkdf::<Sha256>::new(Some(nonce), unlock);
let mut key_bytes = [0u8; 32];
hk.expand(HKDF_INFO, &mut key_bytes)
.map_err(|e| OpenVTCError::Encrypt(format!("HKDF key derivation failed: {e}")))?;
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.map_err(|e| OpenVTCError::Encrypt(format!("Invalid AES key: {e}")))?;
key_bytes.zeroize();
Ok(cipher)
}
pub fn unlock_code_encrypt(unlock: &[u8; 32], input: &[u8]) -> Result<Vec<u8>, OpenVTCError> {
let mut nonce_bytes = [0u8; NONCE_SIZE];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = aes_gcm::Nonce::from(nonce_bytes);
let cipher = derive_key(unlock, &nonce)?;
match cipher.encrypt(&nonce, input) {
Ok(ciphertext) => {
let mut result = nonce.to_vec();
result.extend_from_slice(&ciphertext);
Ok(result)
}
Err(e) => {
error!("Couldn't encrypt data. Reason: {e}");
Err(OpenVTCError::Encrypt(format!(
"Couldn't encrypt data. Reason: {e}"
)))
}
}
}
pub fn unlock_code_decrypt(unlock: &[u8; 32], input: &[u8]) -> Result<Vec<u8>, OpenVTCError> {
if input.len() <= NONCE_SIZE {
return Err(OpenVTCError::Decrypt(
"Ciphertext too short (missing nonce)".to_string(),
));
}
let (nonce_bytes, ciphertext) = input.split_at(NONCE_SIZE);
let nonce_arr: &[u8; NONCE_SIZE] = nonce_bytes
.try_into()
.map_err(|_| OpenVTCError::Decrypt("Nonce is not 12 bytes".to_string()))?;
let nonce: &aes_gcm::Nonce<_> = nonce_arr.into();
let cipher = derive_key(unlock, nonce_bytes)?;
cipher.decrypt(nonce, ciphertext).map_err(|e| {
error!("Couldn't decrypt data. Likely due to incorrect unlock code! Reason: {e}");
OpenVTCError::Decrypt(format!(
"Couldn't decrypt data, likely due to incorrect unlock code! Reason: {e}"
))
})
}
const V2_MAGIC: &[u8; 4] = b"OPV2";
const V2_SALT_SIZE: usize = 16;
const V2_HEADER_SIZE: usize = V2_MAGIC.len() + V2_SALT_SIZE;
pub fn passphrase_encrypt_v2(
passphrase: &[u8],
_info: &[u8],
plaintext: &[u8],
) -> Result<Vec<u8>, OpenVTCError> {
use rand::RngCore;
let mut salt = [0u8; V2_SALT_SIZE];
OsRng.fill_bytes(&mut salt);
let key = crate::config::derive_passphrase_key_v2(passphrase, &salt)?;
let inner = unlock_code_encrypt(&key, plaintext)?;
let mut out = Vec::with_capacity(V2_HEADER_SIZE + inner.len());
out.extend_from_slice(V2_MAGIC);
out.extend_from_slice(&salt);
out.extend_from_slice(&inner);
Ok(out)
}
pub async fn passphrase_encrypt_v2_blocking(
passphrase: Vec<u8>,
info: Vec<u8>,
plaintext: Vec<u8>,
) -> Result<Vec<u8>, OpenVTCError> {
let passphrase = zeroize::Zeroizing::new(passphrase);
let plaintext = zeroize::Zeroizing::new(plaintext);
tokio::task::spawn_blocking(move || passphrase_encrypt_v2(&passphrase, &info, &plaintext))
.await
.map_err(|e| OpenVTCError::Encrypt(format!("Argon2 encrypt task panicked: {e}")))?
}
pub fn passphrase_decrypt(
passphrase: &[u8],
info: &[u8],
blob: &[u8],
) -> Result<Vec<u8>, OpenVTCError> {
if blob.len() >= V2_HEADER_SIZE && &blob[..V2_MAGIC.len()] == V2_MAGIC {
let salt = &blob[V2_MAGIC.len()..V2_HEADER_SIZE];
let inner = &blob[V2_HEADER_SIZE..];
let key = crate::config::derive_passphrase_key_v2(passphrase, salt)?;
return unlock_code_decrypt(&key, inner);
}
let key = crate::config::derive_passphrase_key(passphrase, info)?;
unlock_code_decrypt(&key, blob)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_tagged_blob_and_rejects_garbage() {
let bytes =
serde_json::to_vec(&SecuredConfigFormat::PlainText { text: "p".into() }).unwrap();
assert!(SecuredConfig::parse(&bytes).is_ok());
assert!(SecuredConfig::parse(b"{ not valid json").is_err());
assert!(SecuredConfig::parse(&[]).is_err());
}
#[test]
fn tagged_format_writes_explicit_discriminator() {
let token_enc = SecuredConfigFormat::TokenEncrypted {
esk: "abc".into(),
data: "xyz".into(),
};
let pass_enc = SecuredConfigFormat::PasswordEncrypted { data: "xyz".into() };
let plain = SecuredConfigFormat::PlainText { text: "xyz".into() };
assert!(
serde_json::to_string(&token_enc)
.unwrap()
.contains(r#""format":"TokenEncrypted""#)
);
assert!(
serde_json::to_string(&pass_enc)
.unwrap()
.contains(r#""format":"PasswordEncrypted""#)
);
assert!(
serde_json::to_string(&plain)
.unwrap()
.contains(r#""format":"PlainText""#)
);
}
#[test]
fn legacy_untagged_blobs_round_trip_through_legacy_enum() {
let plain = r#"{"text":"dGVzdA"}"#;
let pass = r#"{"data":"dGVzdA"}"#;
let token = r#"{"esk":"e","data":"d"}"#;
for blob in [plain, pass, token] {
assert!(serde_json::from_str::<SecuredConfigFormat>(blob).is_err());
assert!(serde_json::from_str::<LegacySecuredConfigFormat>(blob).is_ok());
}
}
#[test]
fn encoded_blob_is_a_single_line() {
let formats = [
SecuredConfigFormat::PlainText {
text: BASE64_URL_SAFE_NO_PAD.encode(vec![0xffu8; 512]),
},
SecuredConfigFormat::PasswordEncrypted {
data: BASE64_URL_SAFE_NO_PAD.encode(vec![0x00u8; 512]),
},
SecuredConfigFormat::TokenEncrypted {
esk: BASE64_URL_SAFE_NO_PAD.encode([7u8; 32]),
data: BASE64_URL_SAFE_NO_PAD.encode(vec![0x0au8; 512]),
},
];
for format in &formats {
let bytes = encode_blob(format).unwrap();
assert!(
!bytes.contains(&b'\n') && !bytes.contains(&b'\r'),
"stored secret must not contain a line break"
);
assert!(
bytes.iter().all(|b| b.is_ascii() && !b.is_ascii_control()),
"stored secret must be printable ASCII"
);
assert!(!bytes.contains(&b'\\'), "stored secret must not escape");
assert!(SecuredConfig::parse(&bytes).is_ok());
}
}
#[test]
fn encode_blob_refuses_a_non_round_trippable_secret() {
let bad = SecuredConfigFormat::PlainText {
text: "line one\nline two".to_string(),
};
let err = encode_blob(&bad).unwrap_err();
assert!(matches!(
err,
OpenVTCError::SecureStore {
fault: crate::errors::SecureStoreFault::Corrupt,
..
}
));
}
#[test]
fn intent_gate_rejects_plaintext_when_password_expected() {
let plain = SecuredConfigFormat::PlainText {
text: BASE64_URL_SAFE_NO_PAD.encode(b"{}"),
};
let err = assert_format_matches_intent(&plain, false, true).unwrap_err();
assert!(err.to_string().contains("Security violation"));
}
#[test]
fn intent_gate_accepts_matching_combinations() {
let token = SecuredConfigFormat::TokenEncrypted {
esk: "e".into(),
data: "d".into(),
};
let pass = SecuredConfigFormat::PasswordEncrypted { data: "d".into() };
let plain = SecuredConfigFormat::PlainText { text: "p".into() };
assert!(assert_format_matches_intent(&token, true, false).is_ok());
assert!(assert_format_matches_intent(&pass, false, true).is_ok());
assert!(assert_format_matches_intent(&plain, false, false).is_ok());
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let unlock = [42u8; 32];
let plaintext = b"hello world - this is sensitive config data";
let encrypted = unlock_code_encrypt(&unlock, plaintext).unwrap();
assert_ne!(encrypted, plaintext);
let decrypted = unlock_code_decrypt(&unlock, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encryption_is_non_deterministic() {
let unlock = [42u8; 32];
let plaintext = b"same data";
let cipher1 = unlock_code_encrypt(&unlock, plaintext).unwrap();
let cipher2 = unlock_code_encrypt(&unlock, plaintext).unwrap();
assert_ne!(cipher1, cipher2, "Encryption must be non-deterministic");
}
#[test]
fn test_decrypt_wrong_key_fails() {
let unlock = [42u8; 32];
let wrong_unlock = [99u8; 32];
let plaintext = b"secret data";
let encrypted = unlock_code_encrypt(&unlock, plaintext).unwrap();
assert!(unlock_code_decrypt(&wrong_unlock, &encrypted).is_err());
}
#[test]
fn test_encrypt_empty_data() {
let unlock = [42u8; 32];
let encrypted = unlock_code_encrypt(&unlock, b"").unwrap();
let decrypted = unlock_code_decrypt(&unlock, &encrypted).unwrap();
assert!(decrypted.is_empty());
}
#[test]
fn test_encrypt_large_data() {
let unlock = [42u8; 32];
let plaintext = vec![0xABu8; 10_000];
let encrypted = unlock_code_encrypt(&unlock, &plaintext).unwrap();
let decrypted = unlock_code_decrypt(&unlock, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_decrypt_too_short_input_fails() {
let unlock = [42u8; 32];
assert!(unlock_code_decrypt(&unlock, &[0u8; 5]).is_err());
assert!(unlock_code_decrypt(&unlock, &[]).is_err());
}
#[test]
fn test_different_unlocks_produce_different_ciphertext() {
let plaintext = b"same data";
let encrypted1 = unlock_code_encrypt(&[1u8; 32], plaintext).unwrap();
let encrypted2 = unlock_code_encrypt(&[2u8; 32], plaintext).unwrap();
assert_ne!(encrypted1, encrypted2);
}
#[test]
fn test_output_contains_nonce_prefix() {
let unlock = [42u8; 32];
let plaintext = b"test";
let encrypted = unlock_code_encrypt(&unlock, plaintext).unwrap();
assert_eq!(encrypted.len(), NONCE_SIZE + plaintext.len() + 16);
}
#[test]
fn test_decrypt_corrupted_data_fails() {
let unlock = [42u8; 32];
let plaintext = b"important data";
let mut encrypted = unlock_code_encrypt(&unlock, plaintext).unwrap();
if let Some(byte) = encrypted.last_mut() {
*byte ^= 0xFF;
}
assert!(unlock_code_decrypt(&unlock, &encrypted).is_err());
}
#[test]
fn test_key_source_material_zeroize() {
let source = KeySourceMaterial::Imported {
seed: SecretString::new("z6MkTestSeed123456789".into()),
};
match &source {
KeySourceMaterial::Imported { seed } => {
assert!(!seed.expose_secret().is_empty())
}
_ => panic!("expected Imported variant"),
}
}
#[test]
fn test_bip32_seed_is_secret_string() {
let config = SecuredConfig {
protected_key: None,
bip32_seed: Some(SecretString::new("super-secret-seed-value".into())),
credential_bundle: None,
vta_url: None,
vta_did: None,
mediator_did: None,
key_info: std::collections::HashMap::new(),
protection_method: ProtectionMethod::default(),
};
let debug = format!("{:?}", config);
assert!(
!debug.contains("super-secret-seed-value"),
"SecretString must not leak through Debug formatting"
);
}
#[test]
fn test_imported_seed_requires_expose() {
let material = KeySourceMaterial::Imported {
seed: SecretString::new("z6MkSensitiveKeyData".into()),
};
let json = serde_json::to_string(&material).unwrap();
assert!(json.contains("z6MkSensitiveKeyData"));
if let KeySourceMaterial::Imported { seed } = &material {
assert_eq!(seed.expose_secret(), "z6MkSensitiveKeyData");
}
}
}
#[cfg(test)]
mod protected_key_tests {
use super::*;
#[test]
fn a_minted_key_is_32_bytes_and_unique() {
let a = new_protected_key();
let b = new_protected_key();
assert_ne!(
a.expose_secret(),
b.expose_secret(),
"two profiles must not share a config key"
);
let bytes = BASE64_URL_SAFE_NO_PAD
.decode(a.expose_secret())
.expect("base64url");
assert_eq!(bytes.len(), 32);
}
#[test]
fn the_key_is_independent_of_the_credential() {
let key = new_protected_key();
let before = key.expose_secret().to_string();
let after = key.expose_secret().to_string();
assert_eq!(before, after);
}
#[test]
fn an_absent_key_is_omitted_from_the_wire() {
let sc = SecuredConfig {
bip32_seed: None,
credential_bundle: Some(SecretString::new("bundle".into())),
protected_key: None,
vta_url: None,
vta_did: None,
mediator_did: None,
key_info: HashMap::new(),
protection_method: ProtectionMethod::default(),
};
let json = serde_json::to_string(&sc).expect("serialize");
assert!(!json.contains("protected_key"), "{json}");
assert!(!json.contains("null"), "{json}");
let back: SecuredConfig = serde_json::from_str(&json).expect("deserialize");
assert!(back.protected_key.is_none());
}
#[test]
fn a_stored_key_round_trips() {
let key = new_protected_key();
let expected = key.expose_secret().to_string();
let sc = SecuredConfig {
bip32_seed: None,
credential_bundle: Some(SecretString::new("bundle".into())),
protected_key: Some(key),
vta_url: None,
vta_did: None,
mediator_did: None,
key_info: HashMap::new(),
protection_method: ProtectionMethod::default(),
};
let json = serde_json::to_string(&sc).expect("serialize");
let back: SecuredConfig = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
back.protected_key
.as_ref()
.expect("key present")
.expose_secret(),
&expected
);
}
}