lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit fa4da70b2cfc81f04d02e65aa9adb78e5ab9cb86
parent c10b5663af892b09e0f0fd49d7cf4d37153a4905
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 07:33:57 +0000

simplex: add protected agent snapshot store

- add secret-vault-backed protected snapshot persistence
- keep public JSON snapshot persistence as an explicit lower-level mode
- add Keychain-backed and injected-vault protected open APIs
- cover protected snapshot round-trip and fail-closed open errors

Diffstat:
MCargo.lock | 4++++
Mcrates/simplex_agent_store/Cargo.toml | 11+++++++++++
Mcrates/simplex_agent_store/src/store.rs | 609+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
3 files changed, 597 insertions(+), 27 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4610,7 +4610,10 @@ dependencies = [ name = "radroots_simplex_agent_store" version = "0.1.0-alpha.2" dependencies = [ + "chacha20poly1305", + "getrandom 0.2.17", "radroots_protected_store", + "radroots_secret_vault", "radroots_simplex_agent_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", @@ -4618,6 +4621,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "zeroize", ] [[package]] diff --git a/crates/simplex_agent_store/Cargo.toml b/crates/simplex_agent_store/Cargo.toml @@ -14,9 +14,16 @@ readme = "README" [features] default = ["std"] +memory-vault = ["std", "radroots_secret_vault/memory-vault"] +os-keyring = ["std", "radroots_secret_vault/os-keyring"] std = [ + "dep:chacha20poly1305", + "dep:getrandom", "dep:radroots_protected_store", + "dep:radroots_secret_vault", + "dep:zeroize", "radroots_protected_store/std", + "radroots_secret_vault/std", "radroots_simplex_agent_proto/std", "radroots_simplex_smp_crypto/std", "radroots_simplex_smp_proto/std", @@ -26,13 +33,17 @@ std = [ ] [dependencies] +chacha20poly1305 = { workspace = true, optional = true } +getrandom = { workspace = true, optional = true } radroots_protected_store = { workspace = true, optional = true, default-features = false } +radroots_secret_vault = { workspace = true, optional = true, default-features = false } radroots_simplex_agent_proto = { workspace = true, default-features = false } radroots_simplex_smp_crypto = { workspace = true, default-features = false } radroots_simplex_smp_proto = { workspace = true, default-features = false } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true, default-features = false } +zeroize = { workspace = true, optional = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/simplex_agent_store/src/store.rs b/crates/simplex_agent_store/src/store.rs @@ -4,15 +4,30 @@ use alloc::format; use alloc::string::String; #[cfg(feature = "std")] use alloc::string::ToString; +#[cfg(feature = "std")] +use alloc::sync::Arc; use alloc::vec::Vec; #[cfg(feature = "std")] +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +#[cfg(feature = "std")] +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +#[cfg(feature = "std")] +use getrandom::getrandom; +#[cfg(feature = "std")] use radroots_protected_store::file::{ RADROOTS_PROTECTED_FILE_SECRET_SUFFIX, RADROOTS_PROTECTED_FILE_WRAPPING_KEY_FILE, }; #[cfg(feature = "std")] use radroots_protected_store::{ + RADROOTS_PROTECTED_STORE_KEY_LENGTH, RADROOTS_PROTECTED_STORE_NONCE_LENGTH, RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope, sidecar_path, }; +#[cfg(all(feature = "std", feature = "os-keyring"))] +use radroots_secret_vault::RadrootsSecretVaultOsKeyring; +#[cfg(feature = "std")] +use radroots_secret_vault::{ + RadrootsSecretKeyWrapping, RadrootsSecretVault, RadrootsSecretVaultAccessError, +}; use radroots_simplex_agent_proto::prelude::{ RadrootsSimplexAgentConnectionLink, RadrootsSimplexAgentConnectionMode, RadrootsSimplexAgentConnectionStatus, RadrootsSimplexAgentEnvelope, @@ -47,12 +62,24 @@ use std::io::Write; use std::path::{Path, PathBuf}; #[cfg(feature = "std")] use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(feature = "std")] +use zeroize::Zeroize; #[cfg(feature = "std")] const RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_VERSION: u8 = 1; #[cfg(feature = "std")] const RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_KEY_SLOT: &str = "radroots_simplex_agent_store_secrets"; +#[cfg(feature = "std")] +const RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SNAPSHOT_KEY_SLOT: &str = + "radroots_simplex_agent_store_snapshot"; +#[cfg(feature = "std")] +const RADROOTS_SIMPLEX_AGENT_STORE_VAULT_MASTER_KEY_BYTES: usize = + RADROOTS_PROTECTED_STORE_KEY_LENGTH; +#[cfg(feature = "std")] +const RADROOTS_SIMPLEX_AGENT_STORE_WRAPPED_KEY_VERSION: u8 = 1; +#[cfg(all(feature = "std", feature = "os-keyring"))] +const RADROOTS_SIMPLEX_AGENT_STORE_KEYCHAIN_SERVICE: &str = "org.radroots.simplex.agent-store"; #[cfg(feature = "std")] #[derive(Debug, Clone, PartialEq, Eq)] @@ -526,6 +553,51 @@ struct RadrootsSimplexAgentPendingCommandSecretsSnapshot { short_invitation_link_key: Option<Vec<u8>>, } +#[cfg(feature = "std")] +#[derive(Clone)] +enum RadrootsSimplexAgentStorePersistence { + PublicSnapshot { + path: PathBuf, + }, + ProtectedSnapshot { + path: PathBuf, + key_source: RadrootsSimplexAgentStoreVaultKeySource, + }, +} + +#[cfg(feature = "std")] +impl core::fmt::Debug for RadrootsSimplexAgentStorePersistence { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::PublicSnapshot { path } => f + .debug_struct("PublicSnapshot") + .field("path", path) + .finish(), + Self::ProtectedSnapshot { path, key_source } => f + .debug_struct("ProtectedSnapshot") + .field("path", path) + .field("key_source", key_source) + .finish(), + } + } +} + +#[cfg(feature = "std")] +#[derive(Clone)] +struct RadrootsSimplexAgentStoreVaultKeySource { + vault: Arc<dyn RadrootsSecretVault>, + master_key_slot: String, +} + +#[cfg(feature = "std")] +impl core::fmt::Debug for RadrootsSimplexAgentStoreVaultKeySource { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RadrootsSimplexAgentStoreVaultKeySource") + .field("master_key_slot", &self.master_key_slot) + .finish_non_exhaustive() + } +} + #[derive(Debug, Clone, Default)] pub struct RadrootsSimplexAgentStore { next_connection_sequence: u64, @@ -533,7 +605,7 @@ pub struct RadrootsSimplexAgentStore { connections: BTreeMap<String, RadrootsSimplexAgentConnectionRecord>, pending_commands: BTreeMap<u64, RadrootsSimplexAgentPendingCommand>, #[cfg(feature = "std")] - persistence_path: Option<PathBuf>, + persistence: Option<RadrootsSimplexAgentStorePersistence>, } impl RadrootsSimplexAgentStore { @@ -546,7 +618,7 @@ impl RadrootsSimplexAgentStore { let path = path.as_ref().to_path_buf(); if !path.exists() { return Ok(Self { - persistence_path: Some(path), + persistence: Some(RadrootsSimplexAgentStorePersistence::PublicSnapshot { path }), ..Default::default() }); } @@ -573,41 +645,103 @@ impl RadrootsSimplexAgentStore { } let mut store = Self::from_snapshot(snapshot)?; - store.persistence_path = Some(path); + store.persistence = Some(RadrootsSimplexAgentStorePersistence::PublicSnapshot { path }); + Ok(store) + } + + #[cfg(all(feature = "std", feature = "os-keyring"))] + pub fn open_keychain_protected( + path: impl AsRef<Path>, + ) -> Result<Self, RadrootsSimplexAgentStoreError> { + let path = path.as_ref(); + Self::open_protected_with_vault( + path, + Arc::new(RadrootsSecretVaultOsKeyring::new( + RADROOTS_SIMPLEX_AGENT_STORE_KEYCHAIN_SERVICE, + )), + protected_snapshot_master_key_slot(path), + ) + } + + #[cfg(feature = "std")] + pub fn open_protected_with_vault( + path: impl AsRef<Path>, + vault: Arc<dyn RadrootsSecretVault>, + master_key_slot: impl Into<String>, + ) -> Result<Self, RadrootsSimplexAgentStoreError> { + let path = path.as_ref().to_path_buf(); + let key_source = RadrootsSimplexAgentStoreVaultKeySource { + vault, + master_key_slot: master_key_slot.into(), + }; + if !path.exists() { + return Ok(Self { + persistence: Some(RadrootsSimplexAgentStorePersistence::ProtectedSnapshot { + path, + key_source, + }), + ..Default::default() + }); + } + + let snapshot = read_protected_snapshot(&path, &key_source)?; + let mut store = Self::from_snapshot(snapshot)?; + store.persistence = + Some(RadrootsSimplexAgentStorePersistence::ProtectedSnapshot { path, key_source }); Ok(store) } #[cfg(feature = "std")] pub fn set_persistence_path(&mut self, path: impl AsRef<Path>) { - self.persistence_path = Some(path.as_ref().to_path_buf()); + self.persistence = Some(RadrootsSimplexAgentStorePersistence::PublicSnapshot { + path: path.as_ref().to_path_buf(), + }); + } + + #[cfg(feature = "std")] + pub fn set_protected_persistence( + &mut self, + path: impl AsRef<Path>, + vault: Arc<dyn RadrootsSecretVault>, + master_key_slot: impl Into<String>, + ) { + self.persistence = Some(RadrootsSimplexAgentStorePersistence::ProtectedSnapshot { + path: path.as_ref().to_path_buf(), + key_source: RadrootsSimplexAgentStoreVaultKeySource { + vault, + master_key_slot: master_key_slot.into(), + }, + }); } #[cfg(feature = "std")] pub fn flush(&self) -> Result<(), RadrootsSimplexAgentStoreError> { - let Some(path) = self.persistence_path.as_ref() else { + let Some(persistence) = self.persistence.as_ref() else { return Ok(()); }; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - RadrootsSimplexAgentStoreError::Persistence(format!( - "failed to create SimpleX agent store directory `{}`: {error}", - parent.display() - )) - })?; - } - let mut snapshot = self.snapshot()?; - let mut secrets = redact_snapshot_secrets(&mut snapshot)?; - if secrets.has_secret_material() { - let generation = compute_protected_generation(&snapshot, &secrets)?; - secrets.generation = generation.clone(); - snapshot.protected_secrets = Some(write_protected_secrets_snapshot( - path, &secrets, generation, - )?); - atomic_write_public_snapshot(path, &snapshot) - } else { - snapshot.protected_secrets = None; - atomic_write_public_snapshot(path, &snapshot)?; - remove_protected_secrets_files(path) + match persistence { + RadrootsSimplexAgentStorePersistence::PublicSnapshot { path } => { + ensure_parent_dir(path)?; + let mut snapshot = self.snapshot()?; + let mut secrets = redact_snapshot_secrets(&mut snapshot)?; + if secrets.has_secret_material() { + let generation = compute_protected_generation(&snapshot, &secrets)?; + secrets.generation = generation.clone(); + snapshot.protected_secrets = Some(write_protected_secrets_snapshot( + path, &secrets, generation, + )?); + atomic_write_public_snapshot(path, &snapshot) + } else { + snapshot.protected_secrets = None; + atomic_write_public_snapshot(path, &snapshot)?; + remove_protected_secrets_files(path) + } + } + RadrootsSimplexAgentStorePersistence::ProtectedSnapshot { path, key_source } => { + ensure_parent_dir(path)?; + write_protected_snapshot(path, key_source, &self.snapshot()?)?; + remove_protected_secrets_files(path) + } } } @@ -1096,7 +1230,7 @@ impl RadrootsSimplexAgentStore { next_command_sequence: snapshot.next_command_sequence, connections, pending_commands, - persistence_path: None, + persistence: None, }) } } @@ -1115,6 +1249,172 @@ impl RadrootsSimplexAgentStoreSecretsSnapshot { } #[cfg(feature = "std")] +impl RadrootsSecretKeyWrapping for RadrootsSimplexAgentStoreVaultKeySource { + type Error = RadrootsSimplexAgentStoreError; + + fn wrap_data_key(&self, key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + let mut master_key = load_or_create_vault_master_key(self)?; + let mut nonce = [0_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH]; + getrandom(&mut nonce).map_err(|_| { + RadrootsSimplexAgentStoreError::Persistence( + "entropy unavailable for SimpleX agent protected snapshot key wrapping".into(), + ) + })?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext_key, + aad: key_slot.as_bytes(), + }, + ) + .map_err(|_| { + RadrootsSimplexAgentStoreError::Persistence( + "failed to wrap SimpleX agent protected snapshot data key".into(), + ) + })?; + master_key.zeroize(); + let mut encoded = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); + encoded.push(RADROOTS_SIMPLEX_AGENT_STORE_WRAPPED_KEY_VERSION); + encoded.extend_from_slice(&nonce); + encoded.extend_from_slice(ciphertext.as_slice()); + Ok(encoded) + } + + fn unwrap_data_key(&self, key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + if wrapped_key.len() <= 1 + RADROOTS_PROTECTED_STORE_NONCE_LENGTH { + return Err(RadrootsSimplexAgentStoreError::Persistence( + "SimpleX agent protected snapshot wrapped key is truncated".into(), + )); + } + if wrapped_key[0] != RADROOTS_SIMPLEX_AGENT_STORE_WRAPPED_KEY_VERSION { + return Err(RadrootsSimplexAgentStoreError::Persistence(format!( + "unsupported SimpleX agent protected snapshot wrapped key version `{}`", + wrapped_key[0] + ))); + } + + let mut master_key = load_vault_master_key(self)?; + let nonce_offset = 1; + let ciphertext_offset = nonce_offset + RADROOTS_PROTECTED_STORE_NONCE_LENGTH; + let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); + let plaintext = cipher + .decrypt( + XNonce::from_slice(&wrapped_key[nonce_offset..ciphertext_offset]), + Payload { + msg: &wrapped_key[ciphertext_offset..], + aad: key_slot.as_bytes(), + }, + ) + .map_err(|_| { + RadrootsSimplexAgentStoreError::Persistence( + "failed to unwrap SimpleX agent protected snapshot data key".into(), + ) + })?; + master_key.zeroize(); + Ok(plaintext) + } +} + +#[cfg(all(feature = "std", feature = "os-keyring"))] +fn protected_snapshot_master_key_slot(path: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(path.as_os_str().as_encoded_bytes()); + format!( + "radroots_simplex_agent_store_snapshot_{}", + encode_digest_hex(hasher.finalize().as_slice()) + ) +} + +#[cfg(feature = "std")] +fn load_or_create_vault_master_key( + source: &RadrootsSimplexAgentStoreVaultKeySource, +) -> Result<[u8; RADROOTS_SIMPLEX_AGENT_STORE_VAULT_MASTER_KEY_BYTES], RadrootsSimplexAgentStoreError> +{ + if let Some(encoded) = source + .vault + .load_secret(&source.master_key_slot) + .map_err(|error| vault_access_error("load", error))? + { + return decode_vault_master_key(&encoded); + } + + let mut key = [0_u8; RADROOTS_SIMPLEX_AGENT_STORE_VAULT_MASTER_KEY_BYTES]; + getrandom(&mut key).map_err(|_| { + RadrootsSimplexAgentStoreError::Persistence( + "entropy unavailable for SimpleX agent protected snapshot master key".into(), + ) + })?; + let encoded = encode_digest_hex(key.as_slice()); + let store_result = source + .vault + .store_secret(&source.master_key_slot, &encoded) + .map_err(|error| vault_access_error("store", error)); + if let Err(error) = store_result { + key.zeroize(); + return Err(error); + } + Ok(key) +} + +#[cfg(feature = "std")] +fn load_vault_master_key( + source: &RadrootsSimplexAgentStoreVaultKeySource, +) -> Result<[u8; RADROOTS_SIMPLEX_AGENT_STORE_VAULT_MASTER_KEY_BYTES], RadrootsSimplexAgentStoreError> +{ + let encoded = source + .vault + .load_secret(&source.master_key_slot) + .map_err(|error| vault_access_error("load", error))? + .ok_or_else(|| { + RadrootsSimplexAgentStoreError::Persistence( + "SimpleX agent protected snapshot master key is missing".into(), + ) + })?; + decode_vault_master_key(&encoded) +} + +#[cfg(feature = "std")] +fn decode_vault_master_key( + encoded: &str, +) -> Result<[u8; RADROOTS_SIMPLEX_AGENT_STORE_VAULT_MASTER_KEY_BYTES], RadrootsSimplexAgentStoreError> +{ + if encoded.len() != RADROOTS_SIMPLEX_AGENT_STORE_VAULT_MASTER_KEY_BYTES * 2 { + return Err(RadrootsSimplexAgentStoreError::Persistence( + "SimpleX agent protected snapshot master key has invalid length".into(), + )); + } + let mut key = [0_u8; RADROOTS_SIMPLEX_AGENT_STORE_VAULT_MASTER_KEY_BYTES]; + for (index, chunk) in encoded.as_bytes().chunks_exact(2).enumerate() { + key[index] = (decode_ascii_hex_nibble(chunk[0])? << 4) | decode_ascii_hex_nibble(chunk[1])?; + } + Ok(key) +} + +#[cfg(feature = "std")] +fn decode_ascii_hex_nibble(value: u8) -> Result<u8, RadrootsSimplexAgentStoreError> { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + b'A'..=b'F' => Ok(value - b'A' + 10), + _ => Err(RadrootsSimplexAgentStoreError::Persistence( + "SimpleX agent protected snapshot master key is not hex encoded".into(), + )), + } +} + +#[cfg(feature = "std")] +fn vault_access_error( + action: &str, + source: RadrootsSecretVaultAccessError, +) -> RadrootsSimplexAgentStoreError { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to {action} SimpleX agent protected snapshot key: {source}" + )) +} + +#[cfg(feature = "std")] impl RadrootsSimplexAgentConnectionSecretsSnapshot { fn has_secret_material(&self) -> bool { self.short_link_link_key.is_some() @@ -1437,6 +1737,102 @@ fn atomic_write_public_snapshot( } #[cfg(feature = "std")] +fn write_protected_snapshot( + path: &Path, + key_source: &RadrootsSimplexAgentStoreVaultKeySource, + snapshot: &RadrootsSimplexAgentStoreSnapshot, +) -> Result<(), RadrootsSimplexAgentStoreError> { + let mut protected_snapshot = snapshot.clone(); + protected_snapshot.protected_secrets = None; + let plaintext = serde_json::to_vec(&protected_snapshot).map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to serialize SimpleX agent protected snapshot `{}`: {error}", + path.display() + )) + })?; + let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( + key_source, + RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SNAPSHOT_KEY_SLOT, + &plaintext, + ) + .map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to seal SimpleX agent protected snapshot `{}`: {error}", + path.display() + )) + })?; + let encoded = envelope.encode_json().map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to encode SimpleX agent protected snapshot `{}`: {error}", + path.display() + )) + })?; + atomic_write_bytes(path, encoded.as_slice(), true) +} + +#[cfg(feature = "std")] +fn read_protected_snapshot( + path: &Path, + key_source: &RadrootsSimplexAgentStoreVaultKeySource, +) -> Result<RadrootsSimplexAgentStoreSnapshot, RadrootsSimplexAgentStoreError> { + let encoded = fs::read(path).map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to read SimpleX agent protected snapshot `{}`: {error}", + path.display() + )) + })?; + let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded).map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to decode SimpleX agent protected snapshot `{}`: {error}", + path.display() + )) + })?; + if envelope.header.key_slot != RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SNAPSHOT_KEY_SLOT { + return Err(RadrootsSimplexAgentStoreError::Persistence(format!( + "SimpleX agent protected snapshot `{}` uses key slot `{}`", + path.display(), + envelope.header.key_slot + ))); + } + let plaintext = envelope + .open_with_wrapped_key(key_source) + .map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to open SimpleX agent protected snapshot `{}`: {error}", + path.display() + )) + })?; + let snapshot: RadrootsSimplexAgentStoreSnapshot = + serde_json::from_slice(&plaintext).map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to parse SimpleX agent protected snapshot `{}`: {error}", + path.display() + )) + })?; + if snapshot.protected_secrets.is_some() { + return Err(RadrootsSimplexAgentStoreError::Persistence( + "SimpleX agent protected snapshot must not reference protected sidecar secrets".into(), + )); + } + Ok(snapshot) +} + +#[cfg(feature = "std")] +fn ensure_parent_dir(path: &Path) -> Result<(), RadrootsSimplexAgentStoreError> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).map_err(|error| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "failed to create SimpleX agent store directory `{}`: {error}", + parent.display() + )) + })?; + } + Ok(()) +} + +#[cfg(feature = "std")] fn atomic_write_bytes( path: &Path, bytes: &[u8], @@ -2919,9 +3315,61 @@ fn decode_short_link_scheme( #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "std")] + use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultAccessError}; use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpQueueUri; #[cfg(feature = "std")] + use std::collections::HashMap; + #[cfg(feature = "std")] use std::path::Path; + #[cfg(feature = "std")] + use std::sync::{Arc, RwLock}; + + #[cfg(feature = "std")] + #[derive(Clone, Default)] + struct TestSecretVault { + entries: Arc<RwLock<HashMap<String, String>>>, + } + + #[cfg(feature = "std")] + impl TestSecretVault { + fn new() -> Self { + Self::default() + } + } + + #[cfg(feature = "std")] + impl RadrootsSecretVault for TestSecretVault { + fn store_secret( + &self, + slot: &str, + secret: &str, + ) -> Result<(), RadrootsSecretVaultAccessError> { + let mut entries = self.entries.write().map_err(|_| { + RadrootsSecretVaultAccessError::Backend("test vault poisoned".into()) + })?; + entries.insert(slot.to_owned(), secret.to_owned()); + Ok(()) + } + + fn load_secret( + &self, + slot: &str, + ) -> Result<Option<String>, RadrootsSecretVaultAccessError> { + let entries = self.entries.read().map_err(|_| { + RadrootsSecretVaultAccessError::Backend("test vault poisoned".into()) + })?; + Ok(entries.get(slot).cloned()) + } + + fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { + let mut entries = self.entries.write().map_err(|_| { + RadrootsSecretVaultAccessError::Backend("test vault poisoned".into()) + })?; + entries.remove(slot); + Ok(()) + } + } fn sample_descriptor(primary: bool) -> RadrootsSimplexAgentQueueDescriptor { sample_descriptor_with_uri( @@ -3497,6 +3945,113 @@ mod tests { #[cfg(feature = "std")] #[test] + fn protected_snapshot_persists_full_agent_state_without_plaintext_json() { + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("agent-store.protected.json"); + let vault = Arc::new(TestSecretVault::new()); + let mut store = RadrootsSimplexAgentStore::open_protected_with_vault( + &path, + vault.clone(), + "test-agent-master", + ) + .unwrap(); + let connection = store.create_connection( + RadrootsSimplexAgentConnectionMode::Direct, + RadrootsSimplexAgentConnectionStatus::Connected, + None, + None, + ); + store + .add_queue( + &connection.id, + sample_descriptor(true), + RadrootsSimplexAgentQueueRole::Send, + true, + sample_auth_state(), + ) + .unwrap(); + store.connection_mut(&connection.id).unwrap().shared_secret = + Some(b"connection-shared-secret".to_vec()); + store.flush().unwrap(); + + let raw = fs::read_to_string(&path).unwrap(); + assert!(!raw.contains("connections")); + assert!(!raw.contains("relay.example")); + assert!(!raw.contains("connection-shared-secret")); + assert!(serde_json::from_str::<RadrootsSimplexAgentStoreSnapshot>(&raw).is_err()); + assert!(!RadrootsSimplexAgentStore::protected_secrets_path(&path).exists()); + assert!(!RadrootsSimplexAgentStore::protected_secrets_wrapping_key_path(&path).exists()); + + let loaded = RadrootsSimplexAgentStore::open_protected_with_vault( + &path, + vault.clone(), + "test-agent-master", + ) + .unwrap(); + let loaded_connection = loaded.connection(&connection.id).unwrap(); + assert_eq!( + loaded_connection.shared_secret.as_deref(), + Some(&b"connection-shared-secret"[..]) + ); + assert!( + loaded + .primary_send_queue(&connection.id) + .unwrap() + .auth_state + .is_some() + ); + } + + #[cfg(feature = "std")] + #[test] + fn protected_snapshot_wrong_vault_fails_open() { + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("agent-store.protected.json"); + let vault = Arc::new(TestSecretVault::new()); + let mut store = RadrootsSimplexAgentStore::open_protected_with_vault( + &path, + vault.clone(), + "test-agent-master", + ) + .unwrap(); + store.create_connection( + RadrootsSimplexAgentConnectionMode::Direct, + RadrootsSimplexAgentConnectionStatus::Connected, + None, + None, + ); + store.flush().unwrap(); + + let error = RadrootsSimplexAgentStore::open_protected_with_vault( + &path, + Arc::new(TestSecretVault::new()), + "test-agent-master", + ) + .unwrap_err(); + + assert!(error.to_string().contains("failed to open")); + } + + #[cfg(feature = "std")] + #[test] + fn corrupt_protected_snapshot_fails_open() { + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("agent-store.protected.json"); + let vault = Arc::new(TestSecretVault::new()); + fs::write(&path, b"not-json").unwrap(); + + let error = RadrootsSimplexAgentStore::open_protected_with_vault( + &path, + vault.clone(), + "test-agent-master", + ) + .unwrap_err(); + + assert!(error.to_string().contains("failed to decode")); + } + + #[cfg(feature = "std")] + #[test] fn corrupt_protected_sidecar_fails_open_and_diagnostics() { let tempdir = tempfile::tempdir().unwrap(); let path = tempdir.path().join("agent-store.json");