tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

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:
Acrates/tangle/tests/commerce_privacy_conformance.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_core/Cargo.toml | 2+-
Mcrates/tangle_core/src/lib.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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");