commit 5139f6169764a01a0c4ec149f9af6a8726afa3b9
parent e4517b061898a6d2651e0d584bbd633ccef6c43f
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 07:06:48 +0000
simplex: protect pending short-link command keys
- move pending short invitation link keys into protected agent-store payloads
- reject public pending command link keys without matching protected metadata
- restore protected pending link keys during snapshot open
- cover redaction, fail-closed open, no-default, check, and tests
Diffstat:
1 file changed, 220 insertions(+), 0 deletions(-)
diff --git a/crates/simplex_agent_store/src/store.rs b/crates/simplex_agent_store/src/store.rs
@@ -65,6 +65,7 @@ pub struct RadrootsSimplexAgentStoreProtectedSecretsDiagnostics {
pub protected_secrets_exists: bool,
pub wrapping_key_exists: bool,
pub protected_connection_count: usize,
+ pub protected_pending_command_count: usize,
pub protected_generation: Option<String>,
pub protected_envelope_suffix: Option<String>,
pub protected_wrapping_key_suffix: Option<String>,
@@ -268,6 +269,7 @@ struct RadrootsSimplexAgentStoreProtectedSecretsRef {
wrapping_key_suffix: String,
key_slot: String,
connection_count: usize,
+ pending_command_count: usize,
}
#[cfg(feature = "std")]
@@ -470,6 +472,7 @@ struct RadrootsSimplexAgentStoreSecretsSnapshot {
version: u8,
generation: String,
connections: Vec<RadrootsSimplexAgentConnectionSecretsSnapshot>,
+ pending_commands: Vec<RadrootsSimplexAgentPendingCommandSecretsSnapshot>,
}
#[cfg(feature = "std")]
@@ -515,6 +518,14 @@ struct RadrootsSimplexAgentRatchetSecretsSnapshot {
official_skipped_message_keys: Vec<RadrootsSimplexAgentSkippedMessageKeySnapshot>,
}
+#[cfg(feature = "std")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct RadrootsSimplexAgentPendingCommandSecretsSnapshot {
+ id: u64,
+ connection_id: String,
+ short_invitation_link_key: Option<Vec<u8>>,
+}
+
#[derive(Debug, Clone, Default)]
pub struct RadrootsSimplexAgentStore {
next_connection_sequence: u64,
@@ -1096,6 +1107,10 @@ impl RadrootsSimplexAgentStoreSecretsSnapshot {
self.connections
.iter()
.any(RadrootsSimplexAgentConnectionSecretsSnapshot::has_secret_material)
+ || self
+ .pending_commands
+ .iter()
+ .any(RadrootsSimplexAgentPendingCommandSecretsSnapshot::has_secret_material)
}
}
@@ -1147,6 +1162,13 @@ impl RadrootsSimplexAgentRatchetSecretsSnapshot {
}
#[cfg(feature = "std")]
+impl RadrootsSimplexAgentPendingCommandSecretsSnapshot {
+ fn has_secret_material(&self) -> bool {
+ self.short_invitation_link_key.is_some()
+ }
+}
+
+#[cfg(feature = "std")]
fn protected_secrets_path(path: &Path) -> PathBuf {
sidecar_path(path, RADROOTS_PROTECTED_FILE_SECRET_SUFFIX)
}
@@ -1166,6 +1188,7 @@ fn protected_secrets_diagnostics(
let public_snapshot_exists = path.exists();
let mut protected_secrets_configured = false;
let mut protected_connection_count = 0;
+ let mut protected_pending_command_count = 0;
let mut protected_generation = None;
let mut protected_envelope_suffix = None;
let mut protected_wrapping_key_suffix = None;
@@ -1190,6 +1213,7 @@ fn protected_secrets_diagnostics(
protected_secrets_configured = true;
let secrets = read_protected_secrets_snapshot(path, &snapshot)?;
protected_connection_count = secrets.connections.len();
+ protected_pending_command_count = secrets.pending_commands.len();
protected_generation = Some(protected.generation.clone());
protected_envelope_suffix = Some(protected.envelope_suffix.clone());
protected_wrapping_key_suffix = Some(protected.wrapping_key_suffix.clone());
@@ -1205,6 +1229,7 @@ fn protected_secrets_diagnostics(
protected_secrets_exists: protected_secrets_path.exists(),
wrapping_key_exists: wrapping_key_path.exists(),
protected_connection_count,
+ protected_pending_command_count,
protected_generation,
protected_envelope_suffix,
protected_wrapping_key_suffix,
@@ -1220,10 +1245,17 @@ fn redact_snapshot_secrets(
.iter_mut()
.map(redact_connection_secrets)
.collect::<Result<Vec<_>, _>>()?;
+ let pending_commands = snapshot
+ .pending_commands
+ .iter_mut()
+ .map(redact_pending_command_secrets)
+ .filter(RadrootsSimplexAgentPendingCommandSecretsSnapshot::has_secret_material)
+ .collect::<Vec<_>>();
Ok(RadrootsSimplexAgentStoreSecretsSnapshot {
version: RADROOTS_SIMPLEX_AGENT_STORE_PROTECTED_SECRETS_VERSION,
generation: String::new(),
connections,
+ pending_commands,
})
}
@@ -1297,6 +1329,35 @@ fn redact_ratchet_secrets(
}
#[cfg(feature = "std")]
+fn redact_pending_command_secrets(
+ command: &mut RadrootsSimplexAgentPendingCommandSnapshot,
+) -> RadrootsSimplexAgentPendingCommandSecretsSnapshot {
+ RadrootsSimplexAgentPendingCommandSecretsSnapshot {
+ id: command.id,
+ connection_id: command.connection_id.clone(),
+ short_invitation_link_key: redact_pending_command_short_invitation_link_key(
+ &mut command.kind,
+ ),
+ }
+}
+
+#[cfg(feature = "std")]
+fn redact_pending_command_short_invitation_link_key(
+ kind: &mut RadrootsSimplexAgentPendingCommandKindSnapshot,
+) -> Option<Vec<u8>> {
+ match kind {
+ RadrootsSimplexAgentPendingCommandKindSnapshot::SecureGetQueueLinkData {
+ invitation,
+ ..
+ }
+ | RadrootsSimplexAgentPendingCommandKindSnapshot::GetQueueLinkData { invitation, .. } => {
+ take_non_empty_vec(&mut invitation.link_key)
+ }
+ _ => None,
+ }
+}
+
+#[cfg(feature = "std")]
fn redact_x3dh_keypair_private(
keypair: &mut Option<RadrootsSimplexAgentX3dhKeypair>,
) -> Option<Vec<u8>> {
@@ -1494,6 +1555,7 @@ fn write_protected_secrets_snapshot(
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(),
+ pending_command_count: secrets.pending_commands.len(),
})
}
@@ -1565,6 +1627,13 @@ fn read_protected_secrets_snapshot(
protected_ref.connection_count
)));
}
+ if secrets.pending_commands.len() != protected_ref.pending_command_count {
+ return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets pending command count `{}` does not match public snapshot count `{}`",
+ secrets.pending_commands.len(),
+ protected_ref.pending_command_count
+ )));
+ }
let expected_generation = compute_protected_generation(snapshot, &secrets)?;
if expected_generation != protected_ref.generation {
return Err(RadrootsSimplexAgentStoreError::Persistence(format!(
@@ -1625,6 +1694,9 @@ fn validate_public_snapshot_secret_posture(
for connection in &snapshot.connections {
validate_public_connection_secret_posture(connection, protected_secrets_configured)?;
}
+ for command in &snapshot.pending_commands {
+ validate_public_pending_command_secret_posture(command, protected_secrets_configured)?;
+ }
Ok(())
}
@@ -1776,6 +1848,28 @@ fn reject_public_ratchet_secret_posture(
}
#[cfg(feature = "std")]
+fn validate_public_pending_command_secret_posture(
+ command: &RadrootsSimplexAgentPendingCommandSnapshot,
+ protected_secrets_configured: bool,
+) -> Result<(), RadrootsSimplexAgentStoreError> {
+ match &command.kind {
+ RadrootsSimplexAgentPendingCommandKindSnapshot::SecureGetQueueLinkData {
+ invitation,
+ ..
+ }
+ | RadrootsSimplexAgentPendingCommandKindSnapshot::GetQueueLinkData { invitation, .. } => {
+ reject_public_secret_vec(
+ invitation.link_key.as_slice(),
+ protected_secrets_configured,
+ "pending short-link invitation link key",
+ &command.connection_id,
+ )
+ }
+ _ => Ok(()),
+ }
+}
+
+#[cfg(feature = "std")]
fn reject_public_keypair_private(
keypair: Option<&RadrootsSimplexAgentX3dhKeypair>,
protected_secrets_configured: bool,
@@ -1880,6 +1974,9 @@ fn merge_protected_secrets(
})?;
merge_connection_secrets(connection, secret_connection)?;
}
+ for command_secrets in secrets.pending_commands {
+ merge_pending_command_secrets(snapshot, command_secrets)?;
+ }
Ok(())
}
@@ -2028,6 +2125,42 @@ fn merge_ratchet_secrets(
}
#[cfg(feature = "std")]
+fn merge_pending_command_secrets(
+ snapshot: &mut RadrootsSimplexAgentStoreSnapshot,
+ secrets: RadrootsSimplexAgentPendingCommandSecretsSnapshot,
+) -> Result<(), RadrootsSimplexAgentStoreError> {
+ let command = snapshot
+ .pending_commands
+ .iter_mut()
+ .find(|command| command.id == secrets.id && command.connection_id == secrets.connection_id)
+ .ok_or_else(|| {
+ RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference unknown pending command `{}`",
+ secrets.id
+ ))
+ })?;
+
+ let Some(link_key) = secrets.short_invitation_link_key else {
+ return Ok(());
+ };
+
+ match &mut command.kind {
+ RadrootsSimplexAgentPendingCommandKindSnapshot::SecureGetQueueLinkData {
+ invitation,
+ ..
+ }
+ | RadrootsSimplexAgentPendingCommandKindSnapshot::GetQueueLinkData { invitation, .. } => {
+ invitation.link_key = link_key;
+ Ok(())
+ }
+ _ => Err(RadrootsSimplexAgentStoreError::Persistence(format!(
+ "SimpleX agent protected secrets reference pending command `{}` without short invitation link data",
+ secrets.id
+ ))),
+ }
+}
+
+#[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))
@@ -3180,6 +3313,26 @@ mod tests {
0
);
assert!(raw_public.contains("protected_secrets"));
+ let public_pending_commands = public_json["pending_commands"].as_array().unwrap();
+ let redacted_pending_short_links = public_pending_commands
+ .iter()
+ .filter(|command| {
+ matches!(
+ command["kind"]["kind"].as_str(),
+ Some("secure_get_queue_link_data") | Some("get_queue_link_data")
+ )
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(redacted_pending_short_links.len(), 2);
+ for command in redacted_pending_short_links {
+ assert_eq!(
+ command["kind"]["invitation"]["link_key"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 0
+ );
+ }
let protected_path = RadrootsSimplexAgentStore::protected_secrets_path(&path);
let protected_raw = fs::read_to_string(&protected_path).unwrap();
for secret in [
@@ -3188,6 +3341,7 @@ mod tests {
"connection-shared-secret",
"short-link-key-must-be-secret",
"short-link-private-signature-key",
+ "short-link-key-must-be-secret!!",
"official-root",
"x3dh-private-1",
"pq-private",
@@ -3204,6 +3358,7 @@ mod tests {
assert!(diagnostics.protected_secrets_exists);
assert!(diagnostics.wrapping_key_exists);
assert_eq!(diagnostics.protected_connection_count, 1);
+ assert_eq!(diagnostics.protected_pending_command_count, 2);
let loaded = RadrootsSimplexAgentStore::open(&path).unwrap();
let loaded_connection = loaded.connection(&connection.id).unwrap();
@@ -3483,6 +3638,71 @@ mod tests {
#[cfg(feature = "std")]
#[test]
+ fn pending_short_invitation_link_key_without_protected_metadata_is_rejected() {
+ let tempdir = tempfile::tempdir().unwrap();
+ let path = tempdir.path().join("agent-store.json");
+ let mut store = RadrootsSimplexAgentStore::new();
+ let connection = store.create_connection(
+ RadrootsSimplexAgentConnectionMode::Direct,
+ RadrootsSimplexAgentConnectionStatus::JoinPending,
+ None,
+ None,
+ );
+ store
+ .enqueue_command(
+ &connection.id,
+ RadrootsSimplexAgentPendingCommandKind::GetQueueLinkData {
+ invitation: sample_short_invitation_link(vec![4_u8; 24]),
+ reply_queue: sample_descriptor(true).queue_uri,
+ },
+ 10,
+ )
+ .unwrap();
+ let snapshot = store.snapshot().unwrap();
+ fs::write(
+ &path,
+ format!("{}\n", serde_json::to_string_pretty(&snapshot).unwrap()),
+ )
+ .unwrap();
+
+ let error = RadrootsSimplexAgentStore::open(&path).unwrap_err();
+ assert!(error.to_string().contains("without protected metadata"));
+ }
+
+ #[cfg(feature = "std")]
+ #[test]
+ fn pending_short_invitation_plaintext_link_key_with_protected_metadata_is_rejected() {
+ let tempdir = tempfile::tempdir().unwrap();
+ let path = tempdir.path().join("agent-store.json");
+ let mut store = RadrootsSimplexAgentStore::open(&path).unwrap();
+ let connection = store.create_connection(
+ RadrootsSimplexAgentConnectionMode::Direct,
+ RadrootsSimplexAgentConnectionStatus::JoinPending,
+ None,
+ None,
+ );
+ store
+ .enqueue_command(
+ &connection.id,
+ RadrootsSimplexAgentPendingCommandKind::SecureGetQueueLinkData {
+ invitation: sample_short_invitation_link(vec![5_u8; 24]),
+ reply_queue: sample_descriptor(true).queue_uri,
+ },
+ 10,
+ )
+ .unwrap();
+ store.flush().unwrap();
+ let mut public_json = read_public_snapshot(&path);
+ public_json["pending_commands"][0]["kind"]["invitation"]["link_key"] =
+ serde_json::Value::Array(vec![serde_json::Value::from(7)]);
+ write_public_snapshot(&path, &public_json);
+
+ let error = RadrootsSimplexAgentStore::open(&path).unwrap_err();
+ assert!(error.to_string().contains("plaintext secret material"));
+ }
+
+ #[cfg(feature = "std")]
+ #[test]
fn redacted_markers_without_protected_metadata_are_rejected() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("agent-store.json");