commit b7e95ddf9d9f660d506d1bcdcf77028099385b46
parent 161b879ad9be2b63eef42cfffeb08599d7d57fb5
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 01:20:08 +0000
simplex: protect agent store secrets
- split SimpleX agent persistence into public snapshots and protected-store sidecar secrets
- seal private keys, shared secrets, ratchet keys, and skipped message keys with protected-store envelopes
- expose redacted protected-store diagnostics and cover reopen plus sidecar cleanup in store tests
- restore no-default-features SimpleX store checks with alloc imports in dependent crypto code
Diffstat:
6 files changed, 805 insertions(+), 11 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4599,6 +4599,7 @@ dependencies = [
name = "radroots_simplex_agent_store"
version = "0.1.0-alpha.2"
dependencies = [
+ "radroots_protected_store",
"radroots_simplex_agent_proto",
"radroots_simplex_smp_crypto",
"radroots_simplex_smp_proto",
diff --git a/crates/simplex_agent_store/Cargo.toml b/crates/simplex_agent_store/Cargo.toml
@@ -15,6 +15,8 @@ readme = "README"
[features]
default = ["std"]
std = [
+ "dep:radroots_protected_store",
+ "radroots_protected_store/std",
"radroots_simplex_agent_proto/std",
"radroots_simplex_smp_crypto/std",
"radroots_simplex_smp_proto/std",
@@ -24,6 +26,7 @@ std = [
]
[dependencies]
+radroots_protected_store = { 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 }
diff --git a/crates/simplex_agent_store/src/lib.rs b/crates/simplex_agent_store/src/lib.rs
@@ -8,6 +8,8 @@ pub mod store;
pub mod prelude {
pub use crate::error::RadrootsSimplexAgentStoreError;
+ #[cfg(feature = "std")]
+ pub use crate::store::RadrootsSimplexAgentStoreProtectedSecretsDiagnostics;
pub use crate::store::{
RadrootsSimplexAgentConnectionRecord, RadrootsSimplexAgentDeliveryCursor,
RadrootsSimplexAgentOutboundMessage, RadrootsSimplexAgentPendingCommand,
diff --git a/crates/simplex_agent_store/src/store.rs b/crates/simplex_agent_store/src/store.rs
@@ -1,29 +1,63 @@
use crate::error::RadrootsSimplexAgentStoreError;
use alloc::collections::BTreeMap;
-use alloc::string::{String, ToString};
+use alloc::format;
+use alloc::string::String;
+#[cfg(feature = "std")]
+use alloc::string::ToString;
use alloc::vec::Vec;
+#[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::{
+ RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope, sidecar_path,
+};
use radroots_simplex_agent_proto::prelude::{
RadrootsSimplexAgentConnectionLink, RadrootsSimplexAgentConnectionMode,
RadrootsSimplexAgentConnectionStatus, RadrootsSimplexAgentEnvelope,
RadrootsSimplexAgentMessageId, RadrootsSimplexAgentMessageReceipt,
RadrootsSimplexAgentQueueAddress, RadrootsSimplexAgentQueueDescriptor,
- RadrootsSimplexSmpRatchetState, decode_connection_link, decode_envelope,
- encode_connection_link, encode_envelope,
+ RadrootsSimplexSmpRatchetState,
};
-use radroots_simplex_smp_crypto::prelude::{
- RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH, RadrootsSimplexSmpEd25519Keypair,
- RadrootsSimplexSmpSkippedMessageKey,
+#[cfg(feature = "std")]
+use radroots_simplex_agent_proto::prelude::{
+ decode_connection_link, decode_envelope, encode_connection_link, encode_envelope,
};
-use radroots_simplex_smp_proto::prelude::{
- RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpServerAddress,
+use radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpEd25519Keypair;
+#[cfg(feature = "std")]
+use radroots_simplex_smp_crypto::prelude::{
+ RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH, RadrootsSimplexSmpSkippedMessageKey,
};
#[cfg(feature = "std")]
+use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpQueueUri;
+use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpServerAddress;
+#[cfg(feature = "std")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "std")]
use std::fs;
#[cfg(feature = "std")]
use std::path::{Path, PathBuf};
+#[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")]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsSimplexAgentStoreProtectedSecretsDiagnostics {
+ pub store_path: PathBuf,
+ pub protected_secrets_path: PathBuf,
+ pub wrapping_key_path: PathBuf,
+ pub public_snapshot_exists: bool,
+ pub protected_secrets_configured: bool,
+ pub protected_secrets_exists: bool,
+ pub wrapping_key_exists: bool,
+ pub protected_connection_count: usize,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
pub enum RadrootsSimplexAgentQueueRole {
@@ -166,12 +200,24 @@ pub struct RadrootsSimplexAgentConnectionRecord {
struct RadrootsSimplexAgentStoreSnapshot {
next_connection_sequence: u64,
next_command_sequence: u64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ protected_secrets: Option<RadrootsSimplexAgentStoreProtectedSecretsRef>,
connections: Vec<RadrootsSimplexAgentConnectionSnapshot>,
pending_commands: Vec<RadrootsSimplexAgentPendingCommandSnapshot>,
}
#[cfg(feature = "std")]
#[derive(Debug, Clone, Serialize, Deserialize)]
+struct RadrootsSimplexAgentStoreProtectedSecretsRef {
+ version: u8,
+ envelope_suffix: String,
+ wrapping_key_suffix: String,
+ key_slot: String,
+ connection_count: usize,
+}
+
+#[cfg(feature = "std")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
struct RadrootsSimplexAgentConnectionSnapshot {
id: String,
mode: String,
@@ -317,6 +363,52 @@ struct RadrootsSimplexAgentMessageReceiptSnapshot {
receipt_info: Vec<u8>,
}
+#[cfg(feature = "std")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct RadrootsSimplexAgentStoreSecretsSnapshot {
+ version: u8,
+ connections: Vec<RadrootsSimplexAgentConnectionSecretsSnapshot>,
+}
+
+#[cfg(feature = "std")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct RadrootsSimplexAgentConnectionSecretsSnapshot {
+ id: String,
+ queues: Vec<RadrootsSimplexAgentQueueSecretsSnapshot>,
+ ratchet_state: Option<RadrootsSimplexAgentRatchetSecretsSnapshot>,
+ local_e2e_private_key: Option<Vec<u8>>,
+ local_x3dh_key_1_private_key: Option<Vec<u8>>,
+ local_x3dh_key_2_private_key: Option<Vec<u8>>,
+ local_pq_private_key: Option<Vec<u8>>,
+ shared_secret: Option<Vec<u8>>,
+}
+
+#[cfg(feature = "std")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct RadrootsSimplexAgentQueueSecretsSnapshot {
+ entity_id: Vec<u8>,
+ role: String,
+ auth_private_key: Option<Vec<u8>>,
+ delivery_private_key: Option<Vec<u8>>,
+ delivery_shared_secret: Option<Vec<u8>>,
+}
+
+#[cfg(feature = "std")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct RadrootsSimplexAgentRatchetSecretsSnapshot {
+ current_pq_shared_secret: Option<Vec<u8>>,
+ local_pq_private_key: Option<Vec<u8>>,
+ local_dh_private_key: Option<Vec<u8>>,
+ official_root_key: Option<Vec<u8>>,
+ official_sending_chain_key: Option<Vec<u8>>,
+ official_receiving_chain_key: Option<Vec<u8>>,
+ official_sending_header_key: Option<Vec<u8>>,
+ official_receiving_header_key: Option<Vec<u8>>,
+ official_next_sending_header_key: Option<Vec<u8>>,
+ official_next_receiving_header_key: Option<Vec<u8>>,
+ official_skipped_message_keys: Vec<RadrootsSimplexAgentSkippedMessageKeySnapshot>,
+}
+
#[derive(Debug, Clone, Default)]
pub struct RadrootsSimplexAgentStore {
next_connection_sequence: u64,
@@ -349,13 +441,17 @@ impl RadrootsSimplexAgentStore {
))
})?;
- let snapshot: RadrootsSimplexAgentStoreSnapshot =
- serde_json::from_slice(&raw).map_err(|error| {
+ let mut snapshot: RadrootsSimplexAgentStoreSnapshot = serde_json::from_slice(&raw)
+ .map_err(|error| {
RadrootsSimplexAgentStoreError::Persistence(format!(
"failed to parse SimpleX agent store snapshot `{}`: {error}",
path.display()
))
})?;
+ if snapshot.protected_secrets.is_some() {
+ let protected = read_protected_secrets_snapshot(&path, &snapshot)?;
+ merge_protected_secrets(&mut snapshot, protected)?;
+ }
let mut store = Self::from_snapshot(snapshot)?;
store.persistence_path = Some(path);
@@ -380,7 +476,14 @@ impl RadrootsSimplexAgentStore {
))
})?;
}
- let snapshot = self.snapshot()?;
+ let mut snapshot = self.snapshot()?;
+ let secrets = redact_snapshot_secrets(&mut snapshot);
+ if secrets.has_secret_material() {
+ snapshot.protected_secrets = Some(write_protected_secrets_snapshot(path, &secrets)?);
+ } else {
+ snapshot.protected_secrets = None;
+ remove_protected_secrets_files(path)?;
+ }
let mut encoded = serde_json::to_vec_pretty(&snapshot).map_err(|error| {
RadrootsSimplexAgentStoreError::Persistence(format!(
"failed to serialize SimpleX agent store snapshot `{}`: {error}",
@@ -396,6 +499,24 @@ impl RadrootsSimplexAgentStore {
})
}
+ #[cfg(feature = "std")]
+ pub fn protected_secrets_path(path: impl AsRef<Path>) -> PathBuf {
+ protected_secrets_path(path.as_ref())
+ }
+
+ #[cfg(feature = "std")]
+ pub fn protected_secrets_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf {
+ protected_secrets_wrapping_key_path(path.as_ref())
+ }
+
+ #[cfg(feature = "std")]
+ pub fn protected_secrets_diagnostics(
+ path: impl AsRef<Path>,
+ ) -> Result<RadrootsSimplexAgentStoreProtectedSecretsDiagnostics, RadrootsSimplexAgentStoreError>
+ {
+ protected_secrets_diagnostics(path.as_ref())
+ }
+
pub fn create_connection(
&mut self,
mode: RadrootsSimplexAgentConnectionMode,
@@ -837,6 +958,7 @@ impl RadrootsSimplexAgentStore {
Ok(RadrootsSimplexAgentStoreSnapshot {
next_connection_sequence: self.next_connection_sequence,
next_command_sequence: self.next_command_sequence,
+ protected_secrets: None,
connections,
pending_commands,
})
@@ -867,6 +989,521 @@ impl RadrootsSimplexAgentStore {
}
#[cfg(feature = "std")]
+impl RadrootsSimplexAgentStoreSecretsSnapshot {
+ fn has_secret_material(&self) -> bool {
+ self.connections
+ .iter()
+ .any(RadrootsSimplexAgentConnectionSecretsSnapshot::has_secret_material)
+ }
+}
+
+#[cfg(feature = "std")]
+impl RadrootsSimplexAgentConnectionSecretsSnapshot {
+ fn has_secret_material(&self) -> bool {
+ self.local_e2e_private_key.is_some()
+ || self.local_x3dh_key_1_private_key.is_some()
+ || self.local_x3dh_key_2_private_key.is_some()
+ || self.local_pq_private_key.is_some()
+ || self.shared_secret.is_some()
+ || self
+ .queues
+ .iter()
+ .any(RadrootsSimplexAgentQueueSecretsSnapshot::has_secret_material)
+ || self
+ .ratchet_state
+ .as_ref()
+ .is_some_and(RadrootsSimplexAgentRatchetSecretsSnapshot::has_secret_material)
+ }
+}
+
+#[cfg(feature = "std")]
+impl RadrootsSimplexAgentQueueSecretsSnapshot {
+ fn has_secret_material(&self) -> bool {
+ self.auth_private_key.is_some()
+ || self.delivery_private_key.is_some()
+ || self.delivery_shared_secret.is_some()
+ }
+}
+
+#[cfg(feature = "std")]
+impl RadrootsSimplexAgentRatchetSecretsSnapshot {
+ fn has_secret_material(&self) -> bool {
+ self.current_pq_shared_secret.is_some()
+ || self.local_pq_private_key.is_some()
+ || self.local_dh_private_key.is_some()
+ || self.official_root_key.is_some()
+ || self.official_sending_chain_key.is_some()
+ || self.official_receiving_chain_key.is_some()
+ || self.official_sending_header_key.is_some()
+ || self.official_receiving_header_key.is_some()
+ || self.official_next_sending_header_key.is_some()
+ || self.official_next_receiving_header_key.is_some()
+ || !self.official_skipped_message_keys.is_empty()
+ }
+}
+
+#[cfg(feature = "std")]
+fn protected_secrets_path(path: &Path) -> PathBuf {
+ sidecar_path(path, RADROOTS_PROTECTED_FILE_SECRET_SUFFIX)
+}
+
+#[cfg(feature = "std")]
+fn protected_secrets_wrapping_key_path(path: &Path) -> PathBuf {
+ sidecar_path(path, RADROOTS_PROTECTED_FILE_WRAPPING_KEY_FILE)
+}
+
+#[cfg(feature = "std")]
+fn protected_secrets_diagnostics(
+ path: &Path,
+) -> Result<RadrootsSimplexAgentStoreProtectedSecretsDiagnostics, RadrootsSimplexAgentStoreError> {
+ let store_path = path.to_path_buf();
+ let protected_secrets_path = protected_secrets_path(path);
+ let wrapping_key_path = protected_secrets_wrapping_key_path(path);
+ let public_snapshot_exists = path.exists();
+ let mut protected_secrets_configured = false;
+ let mut protected_connection_count = 0;
+
+ if public_snapshot_exists {
+ let raw = fs::read(path).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to read SimpleX agent store snapshot `{}`: {error}",
+ path.display()
+ ))
+ })?;
+ let snapshot: RadrootsSimplexAgentStoreSnapshot =
+ serde_json::from_slice(&raw).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to parse SimpleX agent store snapshot `{}`: {error}",
+ path.display()
+ ))
+ })?;
+ if let Some(protected) = snapshot.protected_secrets {
+ protected_secrets_configured = true;
+ protected_connection_count = protected.connection_count;
+ }
+ }
+
+ Ok(RadrootsSimplexAgentStoreProtectedSecretsDiagnostics {
+ store_path,
+ protected_secrets_path: protected_secrets_path.clone(),
+ wrapping_key_path: wrapping_key_path.clone(),
+ public_snapshot_exists,
+ protected_secrets_configured,
+ protected_secrets_exists: protected_secrets_path.exists(),
+ wrapping_key_exists: wrapping_key_path.exists(),
+ protected_connection_count,
+ })
+}
+
+#[cfg(feature = "std")]
+fn redact_snapshot_secrets(
+ snapshot: &mut RadrootsSimplexAgentStoreSnapshot,
+) -> RadrootsSimplexAgentStoreSecretsSnapshot {
+ let connections = snapshot
+ .connections
+ .iter_mut()
+ .map(redact_connection_secrets)
+ .collect();
+ RadrootsSimplexAgentStoreSecretsSnapshot {
+ version: RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_VERSION,
+ connections,
+ }
+}
+
+#[cfg(feature = "std")]
+fn redact_connection_secrets(
+ connection: &mut RadrootsSimplexAgentConnectionSnapshot,
+) -> RadrootsSimplexAgentConnectionSecretsSnapshot {
+ RadrootsSimplexAgentConnectionSecretsSnapshot {
+ id: connection.id.clone(),
+ queues: connection
+ .queues
+ .iter_mut()
+ .map(redact_queue_secrets)
+ .collect(),
+ ratchet_state: connection
+ .ratchet_state
+ .as_mut()
+ .map(redact_ratchet_secrets),
+ local_e2e_private_key: connection.local_e2e_private_key.take(),
+ local_x3dh_key_1_private_key: redact_x3dh_keypair_private(&mut connection.local_x3dh_key_1),
+ local_x3dh_key_2_private_key: redact_x3dh_keypair_private(&mut connection.local_x3dh_key_2),
+ local_pq_private_key: redact_pq_keypair_private(&mut connection.local_pq_keypair),
+ shared_secret: connection.shared_secret.take(),
+ }
+}
+
+#[cfg(feature = "std")]
+fn redact_queue_secrets(
+ queue: &mut RadrootsSimplexAgentQueueRecordSnapshot,
+) -> RadrootsSimplexAgentQueueSecretsSnapshot {
+ RadrootsSimplexAgentQueueSecretsSnapshot {
+ entity_id: queue.entity_id.clone(),
+ role: queue.role.clone(),
+ auth_private_key: queue
+ .auth_state
+ .as_mut()
+ .and_then(|auth| take_non_empty_vec(&mut auth.private_key)),
+ delivery_private_key: queue.delivery_private_key.take(),
+ delivery_shared_secret: queue.delivery_shared_secret.take(),
+ }
+}
+
+#[cfg(feature = "std")]
+fn redact_ratchet_secrets(
+ ratchet: &mut RadrootsSimplexAgentRatchetStateSnapshot,
+) -> RadrootsSimplexAgentRatchetSecretsSnapshot {
+ RadrootsSimplexAgentRatchetSecretsSnapshot {
+ current_pq_shared_secret: ratchet.current_pq_shared_secret.take(),
+ local_pq_private_key: ratchet.local_pq_private_key.take(),
+ local_dh_private_key: ratchet.local_dh_private_key.take(),
+ official_root_key: ratchet.official_root_key.take(),
+ official_sending_chain_key: ratchet.official_sending_chain_key.take(),
+ official_receiving_chain_key: ratchet.official_receiving_chain_key.take(),
+ official_sending_header_key: ratchet.official_sending_header_key.take(),
+ official_receiving_header_key: ratchet.official_receiving_header_key.take(),
+ official_next_sending_header_key: ratchet.official_next_sending_header_key.take(),
+ official_next_receiving_header_key: ratchet.official_next_receiving_header_key.take(),
+ official_skipped_message_keys: core::mem::take(&mut ratchet.official_skipped_message_keys),
+ }
+}
+
+#[cfg(feature = "std")]
+fn redact_x3dh_keypair_private(
+ keypair: &mut Option<RadrootsSimplexAgentX3dhKeypair>,
+) -> Option<Vec<u8>> {
+ keypair
+ .as_mut()
+ .and_then(|keypair| take_non_empty_vec(&mut keypair.private_key))
+}
+
+#[cfg(feature = "std")]
+fn redact_pq_keypair_private(
+ keypair: &mut Option<RadrootsSimplexAgentPqKeypair>,
+) -> Option<Vec<u8>> {
+ keypair
+ .as_mut()
+ .and_then(|keypair| take_non_empty_vec(&mut keypair.private_key))
+}
+
+#[cfg(feature = "std")]
+fn take_non_empty_vec(value: &mut Vec<u8>) -> Option<Vec<u8>> {
+ if value.is_empty() {
+ None
+ } else {
+ Some(core::mem::take(value))
+ }
+}
+
+#[cfg(feature = "std")]
+fn write_protected_secrets_snapshot(
+ path: &Path,
+ secrets: &RadrootsSimplexAgentStoreSecretsSnapshot,
+) -> Result<RadrootsSimplexAgentStoreProtectedSecretsRef, RadrootsSimplexAgentStoreError> {
+ let protected_path = protected_secrets_path(path);
+ if let Some(parent) = protected_path.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ fs::create_dir_all(parent).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to create SimpleX agent protected store directory `{}`: {error}",
+ parent.display()
+ ))
+ })?;
+ }
+
+ let payload = serde_json::to_vec(secrets).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to serialize SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ let key_source = RadrootsProtectedFileKeySource::new(protected_secrets_wrapping_key_path(path));
+ let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key(
+ &key_source,
+ RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_KEY_SLOT,
+ &payload,
+ )
+ .map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to seal SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ let encoded = envelope.encode_json().map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to encode SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ fs::write(&protected_path, encoded).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to write SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ set_secret_permissions(&protected_path)?;
+
+ Ok(RadrootsSimplexAgentStoreProtectedSecretsRef {
+ version: RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_VERSION,
+ envelope_suffix: RADROOTS_PROTECTED_FILE_SECRET_SUFFIX.into(),
+ wrapping_key_suffix: RADROOTS_PROTECTED_FILE_WRAPPING_KEY_FILE.into(),
+ key_slot: RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_KEY_SLOT.into(),
+ connection_count: secrets.connections.len(),
+ })
+}
+
+#[cfg(feature = "std")]
+fn read_protected_secrets_snapshot(
+ path: &Path,
+ snapshot: &RadrootsSimplexAgentStoreSnapshot,
+) -> Result<RadrootsSimplexAgentStoreSecretsSnapshot, RadrootsSimplexAgentStoreError> {
+ let protected_ref = snapshot.protected_secrets.as_ref().ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(
+ "SimpleX agent store snapshot does not reference protected secrets".into(),
+ )
+ })?;
+ validate_protected_secrets_ref(protected_ref)?;
+
+ let protected_path = protected_secrets_path(path);
+ let encoded = fs::read(&protected_path).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to read SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to decode SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ if envelope.header.key_slot != RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_KEY_SLOT {
+ return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets snapshot `{}` uses key slot `{}`",
+ protected_path.display(),
+ envelope.header.key_slot
+ )));
+ }
+
+ let key_source = RadrootsProtectedFileKeySource::new(protected_secrets_wrapping_key_path(path));
+ let plaintext = envelope
+ .open_with_wrapped_key(&key_source)
+ .map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to open SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ let secrets: RadrootsSimplexAgentStoreSecretsSnapshot = serde_json::from_slice(&plaintext)
+ .map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to parse SimpleX agent protected secrets snapshot `{}`: {error}",
+ protected_path.display()
+ ))
+ })?;
+ if secrets.version != RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_VERSION {
+ return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "unsupported SimpleX agent protected secrets version `{}`",
+ secrets.version
+ )));
+ }
+ Ok(secrets)
+}
+
+#[cfg(feature = "std")]
+fn validate_protected_secrets_ref(
+ protected_ref: &RadrootsSimplexAgentStoreProtectedSecretsRef,
+) -> Result<(), RadrootsSimplexAgentStoreError> {
+ if protected_ref.version != RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_VERSION {
+ return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "unsupported SimpleX agent protected secrets reference version `{}`",
+ protected_ref.version
+ )));
+ }
+ if protected_ref.envelope_suffix != RADROOTS_PROTECTED_FILE_SECRET_SUFFIX {
+ return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "unsupported SimpleX agent protected secrets envelope suffix `{}`",
+ protected_ref.envelope_suffix
+ )));
+ }
+ if protected_ref.wrapping_key_suffix != RADROOTS_PROTECTED_FILE_WRAPPING_KEY_FILE {
+ return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "unsupported SimpleX agent protected secrets wrapping key suffix `{}`",
+ protected_ref.wrapping_key_suffix
+ )));
+ }
+ if protected_ref.key_slot != RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_KEY_SLOT {
+ return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "unsupported SimpleX agent protected secrets key slot `{}`",
+ protected_ref.key_slot
+ )));
+ }
+ Ok(())
+}
+
+#[cfg(feature = "std")]
+fn merge_protected_secrets(
+ snapshot: &mut RadrootsSimplexAgentStoreSnapshot,
+ secrets: RadrootsSimplexAgentStoreSecretsSnapshot,
+) -> Result<(), RadrootsSimplexAgentStoreError> {
+ for secret_connection in secrets.connections {
+ let connection = snapshot
+ .connections
+ .iter_mut()
+ .find(|connection| connection.id == secret_connection.id)
+ .ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference unknown connection `{}`",
+ secret_connection.id
+ ))
+ })?;
+ merge_connection_secrets(connection, secret_connection)?;
+ }
+ Ok(())
+}
+
+#[cfg(feature = "std")]
+fn merge_connection_secrets(
+ connection: &mut RadrootsSimplexAgentConnectionSnapshot,
+ secrets: RadrootsSimplexAgentConnectionSecretsSnapshot,
+) -> Result<(), RadrootsSimplexAgentStoreError> {
+ for queue_secrets in secrets.queues {
+ let queue = connection
+ .queues
+ .iter_mut()
+ .find(|queue| {
+ queue.entity_id == queue_secrets.entity_id && queue.role == queue_secrets.role
+ })
+ .ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference unknown queue on `{}`",
+ connection.id
+ ))
+ })?;
+ merge_queue_secrets(queue, queue_secrets, &connection.id)?;
+ }
+
+ if let Some(ratchet_secrets) = secrets.ratchet_state {
+ let ratchet = connection.ratchet_state.as_mut().ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference missing ratchet state on `{}`",
+ connection.id
+ ))
+ })?;
+ merge_ratchet_secrets(ratchet, ratchet_secrets);
+ }
+
+ connection.local_e2e_private_key = secrets.local_e2e_private_key;
+ if let Some(private_key) = secrets.local_x3dh_key_1_private_key {
+ let keypair = connection.local_x3dh_key_1.as_mut().ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference missing first X3DH keypair on `{}`",
+ connection.id
+ ))
+ })?;
+ keypair.private_key = private_key;
+ }
+ if let Some(private_key) = secrets.local_x3dh_key_2_private_key {
+ let keypair = connection.local_x3dh_key_2.as_mut().ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference missing second X3DH keypair on `{}`",
+ connection.id
+ ))
+ })?;
+ keypair.private_key = private_key;
+ }
+ if let Some(private_key) = secrets.local_pq_private_key {
+ let keypair = connection.local_pq_keypair.as_mut().ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference missing PQ keypair on `{}`",
+ connection.id
+ ))
+ })?;
+ keypair.private_key = private_key;
+ }
+ connection.shared_secret = secrets.shared_secret;
+ Ok(())
+}
+
+#[cfg(feature = "std")]
+fn merge_queue_secrets(
+ queue: &mut RadrootsSimplexAgentQueueRecordSnapshot,
+ secrets: RadrootsSimplexAgentQueueSecretsSnapshot,
+ connection_id: &str,
+) -> Result<(), RadrootsSimplexAgentStoreError> {
+ if let Some(private_key) = secrets.auth_private_key {
+ let auth = queue.auth_state.as_mut().ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference missing queue auth state on `{connection_id}`"
+ ))
+ })?;
+ auth.private_key = private_key;
+ }
+ queue.delivery_private_key = secrets.delivery_private_key;
+ queue.delivery_shared_secret = secrets.delivery_shared_secret;
+ Ok(())
+}
+
+#[cfg(feature = "std")]
+fn merge_ratchet_secrets(
+ ratchet: &mut RadrootsSimplexAgentRatchetStateSnapshot,
+ secrets: RadrootsSimplexAgentRatchetSecretsSnapshot,
+) {
+ ratchet.current_pq_shared_secret = secrets.current_pq_shared_secret;
+ ratchet.local_pq_private_key = secrets.local_pq_private_key;
+ ratchet.local_dh_private_key = secrets.local_dh_private_key;
+ ratchet.official_root_key = secrets.official_root_key;
+ ratchet.official_sending_chain_key = secrets.official_sending_chain_key;
+ ratchet.official_receiving_chain_key = secrets.official_receiving_chain_key;
+ ratchet.official_sending_header_key = secrets.official_sending_header_key;
+ ratchet.official_receiving_header_key = secrets.official_receiving_header_key;
+ ratchet.official_next_sending_header_key = secrets.official_next_sending_header_key;
+ ratchet.official_next_receiving_header_key = secrets.official_next_receiving_header_key;
+ ratchet.official_skipped_message_keys = secrets.official_skipped_message_keys;
+}
+
+#[cfg(feature = "std")]
+fn remove_protected_secrets_files(path: &Path) -> Result<(), RadrootsSimplexAgentStoreError> {
+ remove_file_if_exists(&protected_secrets_path(path))?;
+ remove_file_if_exists(&protected_secrets_wrapping_key_path(path))
+}
+
+#[cfg(feature = "std")]
+fn remove_file_if_exists(path: &Path) -> Result<(), RadrootsSimplexAgentStoreError> {
+ match fs::remove_file(path) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to remove SimpleX agent protected store file `{}`: {error}",
+ path.display()
+ ))),
+ }
+}
+
+#[cfg(feature = "std")]
+fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSimplexAgentStoreError> {
+ set_secret_permissions_inner(path).map_err(|error| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "failed to set SimpleX agent protected store permissions `{}`: {error}",
+ path.display()
+ ))
+ })
+}
+
+#[cfg(all(feature = "std", unix))]
+fn set_secret_permissions_inner(path: &Path) -> std::io::Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+
+ fs::set_permissions(path, fs::Permissions::from_mode(0o600))
+}
+
+#[cfg(all(feature = "std", not(unix)))]
+fn set_secret_permissions_inner(_path: &Path) -> std::io::Result<()> {
+ Ok(())
+}
+
+#[cfg(feature = "std")]
fn connection_to_snapshot(
record: RadrootsSimplexAgentConnectionRecord,
) -> Result<RadrootsSimplexAgentConnectionSnapshot, RadrootsSimplexAgentStoreError> {
@@ -1578,6 +2215,13 @@ mod tests {
let connection = store.connection_mut(&connection.id).unwrap();
connection.hello_sent = true;
connection.hello_received = true;
+ connection.local_e2e_public_key = Some(b"e2e-public".to_vec());
+ connection.local_e2e_private_key = Some(b"e2e-private".to_vec());
+ connection.shared_secret = Some(b"connection-shared-secret".to_vec());
+ let queue = connection.queues.first_mut().unwrap();
+ queue.auth_state.as_mut().unwrap().private_key = b"queue-auth-private".to_vec();
+ queue.delivery_private_key = Some(b"queue-delivery-private".to_vec());
+ queue.delivery_shared_secret = Some(b"queue-delivery-shared-secret".to_vec());
let mut ratchet =
RadrootsSimplexSmpRatchetState::initiator(vec![1_u8; 56], vec![2_u8; 56], None)
.unwrap();
@@ -1616,6 +2260,93 @@ mod tests {
});
}
store.flush().unwrap();
+ let raw_public = fs::read_to_string(&path).unwrap();
+ let public_json: serde_json::Value = serde_json::from_str(&raw_public).unwrap();
+ let public_connection = &public_json["connections"][0];
+ assert!(public_connection["local_e2e_public_key"].is_array());
+ assert!(public_connection["local_e2e_private_key"].is_null());
+ assert!(public_connection["shared_secret"].is_null());
+ assert!(public_connection["local_x3dh_key_1"]["public_key"].is_array());
+ assert_eq!(
+ public_connection["local_x3dh_key_1"]["private_key"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 0
+ );
+ assert_eq!(
+ public_connection["local_x3dh_key_2"]["private_key"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 0
+ );
+ assert!(public_connection["local_pq_keypair"]["public_key"].is_array());
+ assert_eq!(
+ public_connection["local_pq_keypair"]["private_key"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 0
+ );
+ let public_queue = &public_connection["queues"][0];
+ assert_eq!(
+ public_queue["auth_state"]["private_key"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 0
+ );
+ assert!(public_queue["delivery_private_key"].is_null());
+ assert!(public_queue["delivery_shared_secret"].is_null());
+ let public_ratchet = &public_connection["ratchet_state"];
+ for field in [
+ "current_pq_shared_secret",
+ "local_pq_private_key",
+ "local_dh_private_key",
+ "official_root_key",
+ "official_sending_chain_key",
+ "official_receiving_chain_key",
+ "official_sending_header_key",
+ "official_receiving_header_key",
+ "official_next_sending_header_key",
+ "official_next_receiving_header_key",
+ ] {
+ assert!(
+ public_ratchet[field].is_null(),
+ "public ratchet leaked {field}"
+ );
+ }
+ assert_eq!(
+ public_ratchet["official_skipped_message_keys"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 0
+ );
+ assert!(raw_public.contains("protected_secrets"));
+ let protected_path = RadrootsSimplexAgentStore::protected_secrets_path(&path);
+ let protected_raw = fs::read_to_string(&protected_path).unwrap();
+ for secret in [
+ "e2e-private",
+ "queue-auth-private",
+ "connection-shared-secret",
+ "official-root",
+ "x3dh-private-1",
+ "pq-private",
+ ] {
+ assert!(
+ !protected_raw.contains(secret),
+ "protected envelope leaked {secret}"
+ );
+ }
+ assert!(RadrootsSimplexAgentStore::protected_secrets_wrapping_key_path(&path).is_file());
+ let diagnostics = RadrootsSimplexAgentStore::protected_secrets_diagnostics(&path).unwrap();
+ assert!(diagnostics.public_snapshot_exists);
+ assert!(diagnostics.protected_secrets_configured);
+ assert!(diagnostics.protected_secrets_exists);
+ assert!(diagnostics.wrapping_key_exists);
+ assert_eq!(diagnostics.protected_connection_count, 1);
let loaded = RadrootsSimplexAgentStore::open(&path).unwrap();
let loaded_connection = loaded.connection(&connection.id).unwrap();
@@ -1628,6 +2359,30 @@ mod tests {
);
assert!(loaded_connection.hello_sent);
assert!(loaded_connection.hello_received);
+ assert_eq!(
+ loaded_connection.local_e2e_private_key.as_deref(),
+ Some(&b"e2e-private"[..])
+ );
+ assert_eq!(
+ loaded_connection.shared_secret.as_deref(),
+ Some(&b"connection-shared-secret"[..])
+ );
+ let loaded_queue = loaded.primary_send_queue(&connection.id).unwrap();
+ assert_eq!(
+ loaded_queue
+ .auth_state
+ .as_ref()
+ .map(|auth| auth.private_key.as_slice()),
+ Some(&b"queue-auth-private"[..])
+ );
+ assert_eq!(
+ loaded_queue.delivery_private_key.as_deref(),
+ Some(&b"queue-delivery-private"[..])
+ );
+ assert_eq!(
+ loaded_queue.delivery_shared_secret.as_deref(),
+ Some(&b"queue-delivery-shared-secret"[..])
+ );
let loaded_ratchet = loaded_connection.ratchet_state.as_ref().unwrap();
assert_eq!(
loaded_ratchet.official_associated_data.as_deref(),
@@ -1689,4 +2444,35 @@ mod tests {
.is_some()
);
}
+
+ #[cfg(feature = "std")]
+ #[test]
+ fn flush_without_secrets_removes_stale_protected_sidecars() {
+ let tempdir = tempfile::tempdir().unwrap();
+ let path = tempdir.path().join("agent-store.json");
+ fs::write(
+ RadrootsSimplexAgentStore::protected_secrets_path(&path),
+ b"stale",
+ )
+ .unwrap();
+ fs::write(
+ RadrootsSimplexAgentStore::protected_secrets_wrapping_key_path(&path),
+ b"stale",
+ )
+ .unwrap();
+
+ let mut store = RadrootsSimplexAgentStore::open(&path).unwrap();
+ store.create_connection(
+ RadrootsSimplexAgentConnectionMode::Direct,
+ RadrootsSimplexAgentConnectionStatus::Connected,
+ None,
+ None,
+ );
+ store.flush().unwrap();
+
+ let raw_public = fs::read_to_string(&path).unwrap();
+ assert!(!raw_public.contains("protected_secrets"));
+ assert!(!RadrootsSimplexAgentStore::protected_secrets_path(&path).exists());
+ assert!(!RadrootsSimplexAgentStore::protected_secrets_wrapping_key_path(&path).exists());
+ }
}
diff --git a/crates/simplex_smp_crypto/src/message.rs b/crates/simplex_smp_crypto/src/message.rs
@@ -1,4 +1,5 @@
use crate::error::RadrootsSimplexSmpCryptoError;
+use alloc::vec;
use alloc::vec::Vec;
use getrandom::getrandom;
use hkdf::Hkdf;
diff --git a/crates/simplex_smp_crypto/src/official_ratchet.rs b/crates/simplex_smp_crypto/src/official_ratchet.rs
@@ -2,6 +2,7 @@ use crate::error::RadrootsSimplexSmpCryptoError;
use aes_gcm::aead::consts::U16;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{AesGcm, Nonce, aes::Aes256};
+use alloc::borrow::ToOwned;
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;