commit c57445a0c95945e80637eac70ff3df0b712b82f3
parent 0724cfa2b10fe91d507f6567995909a5d88b20bf
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 13:57:52 -0700
security: add commerce privacy guards
- reject private commerce plaintext fields before relay storage
- cover JSON payloads and private-data tags in core validation
- add live relay conformance for rejected private order data
- verify public listings still persist through direct SurrealDB checks
Diffstat:
3 files changed, 304 insertions(+), 3 deletions(-)
diff --git a/crates/tangle/tests/commerce_privacy_conformance.rs b/crates/tangle/tests/commerce_privacy_conformance.rs
@@ -0,0 +1,168 @@
+#![forbid(unsafe_code)]
+
+mod support;
+
+use std::fs;
+use support::{
+ RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store,
+ request_event_by_id, send_auth, send_event,
+};
+use tangle_protocol::Event;
+use tangle_test_support::{
+ FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
+ valid_public_listing_spec,
+};
+
+const PRIVATE_VALUES: &[&str] = &[
+ "fixture-order-001",
+ "buyer.contact@privacy.test",
+ "100 Privacy Fixture Way",
+ "fixture-payment-token",
+ "fixture-refund-token",
+ "fixture-dispute-evidence",
+ "private order note fixture",
+ "5550100",
+];
+
+#[tokio::test]
+async fn commerce_privacy_conformance_rejects_private_order_plaintext() {
+ let seller = FixtureKey::Seller.public_key();
+ let harness = RelayHarness::start(
+ "commerce_privacy_conformance",
+ serde_json::json!({
+ "approved_sellers": [seller.as_str()]
+ }),
+ );
+ let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+ let auth = build_fixture_event(&auth_event_spec()).expect("auth");
+ let private_events = private_commerce_events();
+ let mut client = connect_client(harness.port).await;
+
+ assert_ok(&send_auth(&mut client, &auth).await, true);
+ for event in &private_events {
+ let rejection = send_event(&mut client, event).await;
+ assert_ok(&rejection, false);
+ assert_eq!(rejection[1], event.id().as_str());
+ assert!(
+ rejection[3]
+ .as_str()
+ .expect("privacy rejection")
+ .contains("privacy: private commerce plaintext field")
+ );
+ }
+ assert_ok(&send_event(&mut client, &listing).await, true);
+
+ let rejected_lookup =
+ request_event_by_id(&mut client, "private-order-rejected", &private_events[0]).await;
+ assert_eq!(rejected_lookup[0], "EOSE");
+ assert_eq!(rejected_lookup[1], "private-order-rejected");
+ let accepted_lookup =
+ request_event_by_id(&mut client, "public-listing-accepted", &listing).await;
+ assert_eq!(accepted_lookup[0], "EVENT");
+ assert_eq!(accepted_lookup[1], "public-listing-accepted");
+ assert_eq!(accepted_lookup[2]["id"], listing.id().as_str());
+ assert_eq!(next_label(&mut client).await, "EOSE");
+
+ let listings = http_get(harness.port, "/api/listings?limit=5");
+ assert!(listings.contains("200 OK"));
+ assert!(listings.contains(listing.id().as_str()));
+ for value in PRIVATE_VALUES {
+ assert!(!listings.contains(value));
+ }
+ let detail = http_get(
+ harness.port,
+ &format!("/api/listings/{}/listing-a", seller.as_str()),
+ );
+ assert!(detail.contains("200 OK"));
+ assert!(detail.contains(listing.id().as_str()));
+ for value in PRIVATE_VALUES {
+ assert!(!detail.contains(value));
+ }
+
+ let store_config = harness.store_config();
+ let root = harness.root.clone();
+ drop(client);
+ harness.stop();
+ let store = reopen_store(&store_config).await;
+ for event in &private_events {
+ assert!(
+ store
+ .raw_event_row(event.id())
+ .await
+ .expect("private raw row")
+ .is_none()
+ );
+ }
+ assert!(
+ store
+ .raw_event_row(listing.id())
+ .await
+ .expect("listing raw row")
+ .is_some()
+ );
+ let listing_key = format!("30402:{}:listing-a", seller.as_str());
+ assert!(
+ store
+ .listing_current_row(&listing_key)
+ .await
+ .expect("listing row")
+ .is_some()
+ );
+ assert!(
+ store
+ .search_document_row(&listing_key)
+ .await
+ .expect("search row")
+ .is_some()
+ );
+ drop(store);
+ fs::remove_dir_all(root).expect("remove runtime root");
+}
+
+fn private_commerce_events() -> Vec<Event> {
+ let mut events = PRIVATE_FIELDS
+ .iter()
+ .enumerate()
+ .map(|(index, (field, value))| {
+ let mut private = serde_json::Map::new();
+ private.insert(
+ (*field).to_owned(),
+ serde_json::Value::String((*value).to_owned()),
+ );
+ let mut root = serde_json::Map::new();
+ root.insert(
+ "private_commerce".to_owned(),
+ serde_json::Value::Object(private),
+ );
+ build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ 1_714_124_450 + index as u64,
+ 1,
+ vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]],
+ &serde_json::Value::Object(root).to_string(),
+ )
+ .expect("private commerce event")
+ })
+ .collect::<Vec<_>>();
+ events.push(
+ build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ 1_714_124_470,
+ 1,
+ vec![vec!["phone".to_owned(), "5550100".to_owned()]],
+ "private phone detail",
+ )
+ .expect("phone tag event"),
+ );
+ events
+}
+
+const PRIVATE_FIELDS: &[(&str, &str)] = &[
+ ("order_id", "fixture-order-001"),
+ ("buyer_contact", "buyer.contact@privacy.test"),
+ ("delivery_address", "100 Privacy Fixture Way"),
+ ("payment_details", "fixture-payment-token"),
+ ("refund_details", "fixture-refund-token"),
+ ("dispute_evidence", "fixture-dispute-evidence"),
+ ("private_note", "private order note fixture"),
+];
diff --git a/crates/tangle_core/Cargo.toml b/crates/tangle_core/Cargo.toml
@@ -8,13 +8,13 @@ license.workspace = true
description = "Transport-independent relay core policy for tangle"
[dependencies]
+serde_json = "1"
tangle_crypto = { path = "../tangle_crypto" }
tangle_nips = { path = "../tangle_nips" }
tangle_protocol = { path = "../tangle_protocol" }
tangle_store = { path = "../tangle_store" }
[dev-dependencies]
-serde_json = "1"
tangle_test_support = { path = "../tangle_test_support" }
[lints]
diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs
@@ -781,6 +781,7 @@ impl EventValidator {
.validate_event_timestamp(event, now)
.map_err(EventValidationRejection::RuntimeLimit)?;
verify_event_signature(event).map_err(EventValidationRejection::Crypto)?;
+ validate_private_commerce_plaintext(event)?;
let payload = validation_payload(event)?;
let admission_event =
AdmissionEvent::new(event.unsigned().pubkey().clone(), payload.admission_kind());
@@ -883,6 +884,7 @@ impl ValidatedEventPayload {
pub enum EventValidationRejection {
RuntimeLimit(RuntimeLimitViolation),
Crypto(String),
+ Privacy(String),
Parser(EventParserRejection),
Admission(AdmissionRejection),
}
@@ -892,6 +894,7 @@ impl EventValidationRejection {
match self {
Self::RuntimeLimit(_) => EventValidationRejectionKind::RuntimeLimit,
Self::Crypto(_) => EventValidationRejectionKind::Crypto,
+ Self::Privacy(_) => EventValidationRejectionKind::Privacy,
Self::Parser(_) => EventValidationRejectionKind::Parser,
Self::Admission(_) => EventValidationRejectionKind::Admission,
}
@@ -903,6 +906,10 @@ impl fmt::Display for EventValidationRejection {
match self {
Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"),
Self::Crypto(message) => write!(formatter, "crypto: {message}"),
+ Self::Privacy(field) => write!(
+ formatter,
+ "privacy: private commerce plaintext field `{field}` is not allowed"
+ ),
Self::Parser(rejection) => write!(formatter, "parser: {rejection}"),
Self::Admission(rejection) => write!(formatter, "admission: {rejection}"),
}
@@ -915,6 +922,7 @@ impl std::error::Error for EventValidationRejection {}
pub enum EventValidationRejectionKind {
RuntimeLimit,
Crypto,
+ Privacy,
Parser,
Admission,
}
@@ -968,6 +976,69 @@ impl fmt::Display for EventParser {
}
}
+fn validate_private_commerce_plaintext(event: &Event) -> Result<(), EventValidationRejection> {
+ for tag in event.unsigned().tags() {
+ let field = tag.name().as_str().to_owned();
+ if private_commerce_plaintext_field(&field) {
+ return Err(EventValidationRejection::Privacy(field));
+ }
+ }
+ if let Ok(content) = serde_json::from_str::<serde_json::Value>(event.unsigned().content())
+ && let Some(field) = private_commerce_plaintext_json_field(&content)
+ {
+ return Err(EventValidationRejection::Privacy(field));
+ }
+ Ok(())
+}
+
+fn private_commerce_plaintext_json_field(value: &serde_json::Value) -> Option<String> {
+ match value {
+ serde_json::Value::Object(fields) => {
+ for (field, value) in fields {
+ if private_commerce_plaintext_field(field) {
+ return Some(field.clone());
+ }
+ if let Some(field) = private_commerce_plaintext_json_field(value) {
+ return Some(field);
+ }
+ }
+ None
+ }
+ serde_json::Value::Array(values) => values
+ .iter()
+ .find_map(private_commerce_plaintext_json_field),
+ _ => None,
+ }
+}
+
+fn private_commerce_plaintext_field(field: &str) -> bool {
+ matches!(
+ normalized_privacy_field(field).as_str(),
+ "buyercontact"
+ | "contact"
+ | "deliveryaddress"
+ | "dispute"
+ | "disputeevidence"
+ | "order"
+ | "orderid"
+ | "ordernote"
+ | "payment"
+ | "paymentdetails"
+ | "phone"
+ | "privatenote"
+ | "refund"
+ | "refunddetails"
+ )
+}
+
+fn normalized_privacy_field(field: &str) -> String {
+ field
+ .chars()
+ .filter(|character| character.is_ascii_alphanumeric())
+ .flat_map(char::to_lowercase)
+ .collect()
+}
+
fn validation_payload(event: &Event) -> Result<ValidatedEventPayload, EventValidationRejection> {
if event.unsigned().kind().as_u32() == 22_242 {
let auth = parse_relay_auth_event(event)
@@ -3142,8 +3213,9 @@ mod tests {
RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent,
};
use tangle_test_support::{
- FixtureKey, InMemoryRepository, auth_event_spec, build_fixture_event, deletion_event_spec,
- fixture_spec_from_json, projection_ineligible_listing_spec, valid_public_listing_spec,
+ FixtureKey, InMemoryRepository, auth_event_spec, build_fixture_event,
+ build_fixture_event_from_parts, deletion_event_spec, fixture_spec_from_json,
+ projection_ineligible_listing_spec, valid_public_listing_spec,
};
#[test]
@@ -3868,6 +3940,67 @@ mod tests {
}
#[test]
+ fn event_validator_rejects_private_commerce_plaintext_before_storage() {
+ let seller = FixtureKey::Seller.public_key();
+ let validator = EventValidator::new(
+ RuntimeLimits::default(),
+ AdmissionPolicy::new().approve_seller(seller.clone()),
+ );
+ let delivery_payload = build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ 1_714_124_437,
+ 1,
+ vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]],
+ r#"{"private_commerce":{"delivery_address":"100 Privacy Fixture Way","payment_details":"fixture-payment-token"}}"#,
+ )
+ .expect("delivery payload");
+ let phone_tag = build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ 1_714_124_438,
+ 1,
+ vec![vec!["phone".to_owned(), "5550100".to_owned()]],
+ "private phone detail",
+ )
+ .expect("phone tag");
+ let public_listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+
+ let delivery_rejection = validator
+ .validate(
+ &delivery_payload,
+ &AdmissionContext::authenticated(seller.clone()),
+ UnixTimestamp::new(1_714_124_500),
+ )
+ .expect_err("delivery privacy rejection");
+ let tag_rejection = validator
+ .validate(
+ &phone_tag,
+ &AdmissionContext::authenticated(seller.clone()),
+ UnixTimestamp::new(1_714_124_500),
+ )
+ .expect_err("phone privacy rejection");
+
+ assert_eq!(
+ delivery_rejection.kind(),
+ EventValidationRejectionKind::Privacy
+ );
+ assert_eq!(
+ delivery_rejection.to_string(),
+ "privacy: private commerce plaintext field `delivery_address` is not allowed"
+ );
+ assert_eq!(
+ tag_rejection,
+ EventValidationRejection::Privacy("phone".to_owned())
+ );
+ validator
+ .validate(
+ &public_listing,
+ &AdmissionContext::authenticated(seller),
+ UnixTimestamp::new(1_714_124_500),
+ )
+ .expect("public listing remains valid");
+ }
+
+ #[test]
fn event_validator_rejects_limits_crypto_parser_and_admission_failures() {
let seller = FixtureKey::Seller.public_key();
let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");