lib

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

commit d4ffe3cac0fdcd95830098427b42d3c89df30f1f
parent d10f2335f2e4dab6e346591d14525078d9283216
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 04:02:03 -0700

event_store: verify nostr events at ingest

- add a radroots_nostr helper for NIP-01 event ID and signature verification
- make event-store ingest compute verification status instead of accepting caller labels
- report verification and projection eligibility from relay fetch receipts
- replace hex-shaped event-store tests with deterministic signed and tampered fixtures

Diffstat:
MCargo.lock | 1+
Mcrates/event_store/Cargo.toml | 4++++
Mcrates/event_store/src/model.rs | 25++++++++++++++++---------
Mcrates/event_store/src/store.rs | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Acrates/nostr/src/event_verify.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/lib.rs | 7+++++++
Mcrates/outbox/src/store.rs | 2+-
Mcrates/relay_transport/src/fetch.rs | 12+++++++++++-
Mcrates/relay_transport/src/outbox.rs | 2+-
Mcrates/relay_transport/tests/transport.rs | 41++++++++++++++++++++++++++++++++++++++---
10 files changed, 392 insertions(+), 71 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3999,6 +3999,7 @@ name = "radroots_event_store" version = "0.1.0-alpha.2" dependencies = [ "radroots_events", + "radroots_nostr", "serde", "serde_json", "sqlx", diff --git a/crates/event_store/Cargo.toml b/crates/event_store/Cargo.toml @@ -22,6 +22,10 @@ radroots_events = { workspace = true, default-features = false, features = [ "std", "serde", ] } +radroots_nostr = { workspace = true, default-features = false, features = [ + "std", + "events", +] } serde = { workspace = true, features = ["std"] } serde_json = { workspace = true, features = ["std"] } sqlx = { workspace = true, optional = true, features = ["derive"] } diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs @@ -7,25 +7,34 @@ use radroots_events::event_head::RadrootsEventHeadDecision; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsEventVerificationStatus { + NotChecked, + IdVerified, Verified, - Unverified, - Invalid, + IdMismatch, + SignatureInvalid, + MalformedEnvelope, } impl RadrootsEventVerificationStatus { pub fn as_str(self) -> &'static str { match self { + Self::NotChecked => "not_checked", + Self::IdVerified => "id_verified", Self::Verified => "verified", - Self::Unverified => "unverified", - Self::Invalid => "invalid", + Self::IdMismatch => "id_mismatch", + Self::SignatureInvalid => "signature_invalid", + Self::MalformedEnvelope => "malformed_envelope", } } pub fn parse(value: &str) -> Result<Self, RadrootsEventStoreError> { match value { + "not_checked" => Ok(Self::NotChecked), + "id_verified" => Ok(Self::IdVerified), "verified" => Ok(Self::Verified), - "unverified" => Ok(Self::Unverified), - "invalid" => Ok(Self::Invalid), + "id_mismatch" => Ok(Self::IdMismatch), + "signature_invalid" => Ok(Self::SignatureInvalid), + "malformed_envelope" => Ok(Self::MalformedEnvelope), _ => Err(RadrootsEventStoreError::InvalidStoredEnum { field: "verification_status", value: value.to_owned(), @@ -166,17 +175,15 @@ impl RadrootsRelayObservation { pub struct RadrootsEventIngest { pub event: RadrootsNostrEvent, pub raw_json: Option<String>, - pub verification_status: RadrootsEventVerificationStatus, pub observed_at_ms: i64, pub relay_observation: Option<RadrootsRelayObservation>, } impl RadrootsEventIngest { - pub fn verified(event: RadrootsNostrEvent, observed_at_ms: i64) -> Self { + pub fn new(event: RadrootsNostrEvent, observed_at_ms: i64) -> Self { Self { event, raw_json: None, - verification_status: RadrootsEventVerificationStatus::Verified, observed_at_ms, relay_observation: None, } diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -16,6 +16,7 @@ use radroots_events::event_head::{ select_event_head, }; use radroots_events::ids::{RadrootsEventId, RadrootsEventSignature, RadrootsPublicKey}; +use radroots_nostr::prelude::{RadrootsNostrEventVerification, radroots_nostr_verify_event}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Row, SqlitePool}; use std::path::Path; @@ -76,6 +77,7 @@ impl RadrootsEventStore { ingest: RadrootsEventIngest, ) -> Result<RadrootsEventIngestReceipt, RadrootsEventStoreError> { validate_event_identity(&ingest.event)?; + let verification_status = verify_event(&ingest.event); let classification = classify_event(&ingest.event); let raw_json = ingest .raw_json @@ -88,14 +90,14 @@ impl RadrootsEventStore { &mut tx, &ingest, &classification, + verification_status, raw_json.as_str(), tags_json.as_str(), ) .await?; let inserted = insert.inserted; let mut head_decision = RadrootsEventHeadStoreDecision::Unsupported; - let mut projection_eligible = - classification.base_projection_eligible(ingest.verification_status); + let mut projection_eligible = classification.base_projection_eligible(verification_status); if inserted { insert_tags(&mut tx, &ingest.event, classification.contract).await?; @@ -129,7 +131,7 @@ impl RadrootsEventStore { seq: insert.seq, event_id: ingest.event.id, inserted, - verification_status: ingest.verification_status, + verification_status, contract_status: classification.contract_status, contract_id: classification .contract @@ -358,10 +360,25 @@ fn classify_event(event: &RadrootsNostrEvent) -> EventClassification { } } +fn verify_event(event: &RadrootsNostrEvent) -> RadrootsEventVerificationStatus { + match radroots_nostr_verify_event(event) { + RadrootsNostrEventVerification::Verified => RadrootsEventVerificationStatus::Verified, + RadrootsNostrEventVerification::IdVerified => RadrootsEventVerificationStatus::IdVerified, + RadrootsNostrEventVerification::IdMismatch => RadrootsEventVerificationStatus::IdMismatch, + RadrootsNostrEventVerification::SignatureInvalid => { + RadrootsEventVerificationStatus::SignatureInvalid + } + RadrootsNostrEventVerification::MalformedEnvelope => { + RadrootsEventVerificationStatus::MalformedEnvelope + } + } +} + async fn insert_raw_event( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, ingest: &RadrootsEventIngest, classification: &EventClassification, + verification_status: RadrootsEventVerificationStatus, raw_json: &str, tags_json: &str, ) -> Result<InsertRawEventResult, RadrootsEventStoreError> { @@ -370,7 +387,7 @@ async fn insert_raw_event( let event_class = classification .contract .map(|contract| StoredEventClass::from_event_class(contract.class).as_str()); - let projection_eligible = classification.base_projection_eligible(ingest.verification_status); + let projection_eligible = classification.base_projection_eligible(verification_status); let result = sqlx::query( "INSERT OR IGNORE INTO nostr_event(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) @@ -382,7 +399,7 @@ async fn insert_raw_event( .bind(event.content.as_str()) .bind(event.sig.as_str()) .bind(raw_json) - .bind(ingest.verification_status.as_str()) + .bind(verification_status.as_str()) .bind(classification.contract_status.as_str()) .bind(contract_id) .bind(event_class) @@ -700,32 +717,57 @@ mod tests { use super::*; use radroots_events::event_head::event_head_candidate_for_event; use radroots_events::kinds::{KIND_POST, KIND_PROFILE}; + use radroots_nostr::prelude::{ + RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, + radroots_event_from_nostr, radroots_nostr_build_event, + }; - fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() - } + const FIXTURE_ALICE_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - fn sig() -> String { - "f".repeat(128) + fn fixture_keys() -> RadrootsNostrKeys { + let secret_key = + RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); + RadrootsNostrKeys::new(secret_key) } - fn event( + fn signed_event( kind: u32, - id: char, - author: char, created_at: u32, tags: Vec<Vec<String>>, content: &str, ) -> RadrootsNostrEvent { - RadrootsNostrEvent { - id: hex_64(id), - author: hex_64(author), - created_at, - kind, - tags, - content: content.to_owned(), - sig: sig(), + let raw_event = radroots_nostr_build_event(kind, content, tags) + .expect("builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at))) + .sign_with_keys(&fixture_keys()) + .expect("signed event"); + radroots_event_from_nostr(&raw_event) + } + + fn tamper_signature(event: &mut RadrootsNostrEvent) { + let replacement = if event.sig.starts_with('0') { "1" } else { "0" }; + event.sig.replace_range(0..1, replacement); + } + + #[test] + fn verification_status_values_round_trip() { + for status in [ + RadrootsEventVerificationStatus::NotChecked, + RadrootsEventVerificationStatus::IdVerified, + RadrootsEventVerificationStatus::Verified, + RadrootsEventVerificationStatus::IdMismatch, + RadrootsEventVerificationStatus::SignatureInvalid, + RadrootsEventVerificationStatus::MalformedEnvelope, + ] { + assert_eq!( + RadrootsEventVerificationStatus::parse(status.as_str()).expect("status"), + status + ); } + assert!(RadrootsEventVerificationStatus::parse("invalid").is_err()); } #[tokio::test] @@ -757,16 +799,14 @@ mod tests { #[tokio::test] async fn ingest_retains_raw_event_and_ignores_duplicate_rows() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let event = event( + let event = signed_event( KIND_POST, - '1', - '2', 10, vec![vec!["t".to_owned(), "soil".to_owned()]], "hello", ); let ingest = - RadrootsEventIngest::verified(event.clone(), 1_000).with_raw_json("{\"fixture\":true}"); + RadrootsEventIngest::new(event.clone(), 1_000).with_raw_json("{\"fixture\":true}"); let first = store .ingest_event(ingest.clone()) @@ -782,6 +822,10 @@ mod tests { assert!(first.inserted); assert!(!second.inserted); assert_eq!(first.seq, second.seq); + assert_eq!( + first.verification_status, + RadrootsEventVerificationStatus::Verified + ); assert_eq!(stored.seq, first.seq); assert_eq!(stored.raw_json, "{\"fixture\":true}"); assert_eq!(stored.content, "hello"); @@ -802,46 +846,115 @@ mod tests { } #[tokio::test] - async fn unsupported_and_invalid_events_are_stored_but_not_projected() { + async fn unsupported_verified_events_are_stored_but_not_projected() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let event = signed_event(999, 11, Vec::new(), "unsupported"); + let receipt = store + .ingest_event(RadrootsEventIngest::new(event.clone(), 2_000)) + .await + .expect("ingest"); + let stored = store + .get_event(event.id.as_str()) + .await + .expect("get") + .expect("stored"); + + assert_eq!( + receipt.contract_status, + RadrootsEventContractStatus::UnsupportedKind(999) + ); + assert_eq!( + stored.verification_status, + RadrootsEventVerificationStatus::Verified + ); + assert!(!stored.projection_eligible); + } + + #[tokio::test] + async fn id_mismatch_events_are_stored_but_not_projected() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let mut ingest = - RadrootsEventIngest::verified(event(999_999, '3', '4', 11, Vec::new(), ""), 2_000); - ingest.verification_status = RadrootsEventVerificationStatus::Invalid; - let receipt = store.ingest_event(ingest).await.expect("ingest"); + let mut event = signed_event(KIND_POST, 12, Vec::new(), "hello"); + event.content = "tampered".to_owned(); + let receipt = store + .ingest_event(RadrootsEventIngest::new(event.clone(), 2_100)) + .await + .expect("ingest"); let stored = store - .get_event(hex_64('3').as_str()) + .get_event(event.id.as_str()) .await .expect("get") .expect("stored"); assert_eq!( receipt.contract_status, - RadrootsEventContractStatus::UnsupportedKind(999_999) + RadrootsEventContractStatus::Supported + ); + assert_eq!( + receipt.verification_status, + RadrootsEventVerificationStatus::IdMismatch + ); + assert_eq!( + stored.verification_status, + RadrootsEventVerificationStatus::IdMismatch + ); + assert!(!stored.projection_eligible); + assert!( + store + .events_since_cursor("social", 10) + .await + .expect("events") + .is_empty() + ); + } + + #[tokio::test] + async fn signature_invalid_events_are_stored_but_not_projected() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let mut event = signed_event(KIND_POST, 13, Vec::new(), "hello"); + tamper_signature(&mut event); + let receipt = store + .ingest_event(RadrootsEventIngest::new(event.clone(), 2_200)) + .await + .expect("ingest"); + let stored = store + .get_event(event.id.as_str()) + .await + .expect("get") + .expect("stored"); + + assert_eq!( + receipt.verification_status, + RadrootsEventVerificationStatus::SignatureInvalid ); assert_eq!( stored.verification_status, - RadrootsEventVerificationStatus::Invalid + RadrootsEventVerificationStatus::SignatureInvalid ); assert!(!stored.projection_eligible); + assert!( + store + .events_since_cursor("social", 10) + .await + .expect("events") + .is_empty() + ); } #[tokio::test] async fn tag_rows_preserve_order_and_contract_metadata() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let event = event( + let event = signed_event( KIND_PROFILE, - '5', - '6', - 12, + 14, vec![ - vec!["p".to_owned(), hex_64('7')], + vec!["p".to_owned(), FIXTURE_ALICE_PUBLIC_KEY_HEX.to_owned()], vec!["t".to_owned(), "harvest".to_owned()], ], "{}", ); store - .ingest_event(RadrootsEventIngest::verified(event.clone(), 3_000)) + .ingest_event(RadrootsEventIngest::new(event.clone(), 3_000)) .await .expect("ingest"); let tags = store.tags_for_event(event.id.as_str()).await.expect("tags"); @@ -857,14 +970,13 @@ mod tests { #[tokio::test] async fn relay_observations_upsert_separately_from_event_identity() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let event = event(KIND_POST, '8', '9', 13, Vec::new(), "hello"); + let event = signed_event(KIND_POST, 15, Vec::new(), "hello"); let observation = RadrootsRelayObservation::new( "wss://relay.local", crate::RadrootsRelayObservationType::Subscription, 4_000, ); - let ingest = - RadrootsEventIngest::verified(event.clone(), 4_000).with_observation(observation); + let ingest = RadrootsEventIngest::new(event.clone(), 4_000).with_observation(observation); store.ingest_event(ingest).await.expect("first"); let observation = RadrootsRelayObservation::new( "wss://relay.local", @@ -872,8 +984,7 @@ mod tests { 4_100, ) .with_message("duplicate accepted"); - let ingest = - RadrootsEventIngest::verified(event.clone(), 4_100).with_observation(observation); + let ingest = RadrootsEventIngest::new(event.clone(), 4_100).with_observation(observation); store.ingest_event(ingest).await.expect("second"); let observations = store @@ -892,15 +1003,15 @@ mod tests { #[tokio::test] async fn event_heads_use_protocol_tie_breaks() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let high = event(KIND_PROFILE, 'b', 'a', 20, Vec::new(), "{}"); - let low = event(KIND_PROFILE, 'a', 'a', 20, Vec::new(), "{}"); + let high = signed_event(KIND_PROFILE, 20, Vec::new(), "{\"name\":\"high\"}"); + let low = signed_event(KIND_PROFILE, 20, Vec::new(), "{\"name\":\"low\"}"); let first = store - .ingest_event(RadrootsEventIngest::verified(high.clone(), 5_000)) + .ingest_event(RadrootsEventIngest::new(high.clone(), 5_000)) .await .expect("first"); let second = store - .ingest_event(RadrootsEventIngest::verified(low.clone(), 5_100)) + .ingest_event(RadrootsEventIngest::new(low.clone(), 5_100)) .await .expect("second"); let RadrootsEventHeadCandidateResult::Candidate(candidate) = @@ -915,24 +1026,27 @@ mod tests { .expect("stored head"); assert_eq!(first.head_decision, RadrootsEventHeadStoreDecision::Applied); - assert_eq!( - second.head_decision, + let expected_id = if low.id < high.id { &low.id } else { &high.id }; + let expected_second_decision = if low.id < high.id { RadrootsEventHeadStoreDecision::Applied - ); - assert_eq!(head.event_id, low.id); + } else { + RadrootsEventHeadStoreDecision::SkippedSameTimestampHigherEventId + }; + assert_eq!(second.head_decision, expected_second_decision); + assert_eq!(&head.event_id, expected_id); } #[tokio::test] async fn projection_cursors_replay_by_store_sequence() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let first = event(KIND_POST, 'e', 'd', 30, Vec::new(), "one"); - let second = event(KIND_POST, 'd', 'd', 30, Vec::new(), "two"); + let first = signed_event(KIND_POST, 30, Vec::new(), "one"); + let second = signed_event(KIND_POST, 30, Vec::new(), "two"); let first_receipt = store - .ingest_event(RadrootsEventIngest::verified(first.clone(), 6_000)) + .ingest_event(RadrootsEventIngest::new(first.clone(), 6_000)) .await .expect("first"); let second_receipt = store - .ingest_event(RadrootsEventIngest::verified(second.clone(), 6_100)) + .ingest_event(RadrootsEventIngest::new(second.clone(), 6_100)) .await .expect("second"); assert!(first_receipt.seq < second_receipt.seq); diff --git a/crates/nostr/src/event_verify.rs b/crates/nostr/src/event_verify.rs @@ -0,0 +1,143 @@ +#![forbid(unsafe_code)] + +use alloc::vec::Vec; +use core::str::FromStr; + +use crate::types::{ + RadrootsNostrEvent as RadrootsNostrRawEvent, RadrootsNostrEventId, RadrootsNostrKind, + RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTimestamp, +}; +use nostr::secp256k1::schnorr::Signature; +use radroots_events::RadrootsNostrEvent; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsNostrEventVerification { + Verified, + IdVerified, + IdMismatch, + SignatureInvalid, + MalformedEnvelope, +} + +pub fn radroots_nostr_verify_event(event: &RadrootsNostrEvent) -> RadrootsNostrEventVerification { + let Some(raw_event) = raw_event_from_radroots(event) else { + return RadrootsNostrEventVerification::MalformedEnvelope; + }; + if !raw_event.verify_id() { + return RadrootsNostrEventVerification::IdMismatch; + } + if !raw_event.verify_signature() { + return RadrootsNostrEventVerification::SignatureInvalid; + } + RadrootsNostrEventVerification::Verified +} + +pub fn radroots_nostr_verify_event_id( + event: &RadrootsNostrEvent, +) -> RadrootsNostrEventVerification { + let Some(raw_event) = raw_event_from_radroots(event) else { + return RadrootsNostrEventVerification::MalformedEnvelope; + }; + if raw_event.verify_id() { + RadrootsNostrEventVerification::IdVerified + } else { + RadrootsNostrEventVerification::IdMismatch + } +} + +fn raw_event_from_radroots(event: &RadrootsNostrEvent) -> Option<RadrootsNostrRawEvent> { + let id = RadrootsNostrEventId::from_hex(event.id.as_str()).ok()?; + let public_key = RadrootsNostrPublicKey::from_hex(event.author.as_str()).ok()?; + let kind_u16 = u16::try_from(event.kind).ok()?; + let mut tags = Vec::with_capacity(event.tags.len()); + for tag in event.tags.iter().cloned() { + tags.push(RadrootsNostrTag::parse(tag).ok()?); + } + let sig = Signature::from_str(event.sig.as_str()).ok()?; + Some(RadrootsNostrRawEvent::new( + id, + public_key, + RadrootsNostrTimestamp::from_secs(u64::from(event.created_at)), + RadrootsNostrKind::Custom(kind_u16), + tags, + event.content.clone(), + sig, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event_convert::radroots_event_from_nostr; + use crate::events::radroots_nostr_build_event; + use crate::test_fixtures::FIXTURE_ALICE; + use crate::types::{RadrootsNostrKeys, RadrootsNostrSecretKey}; + use radroots_events::kinds::KIND_POST; + + fn fixture_keys() -> RadrootsNostrKeys { + let secret_key = + RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE.secret_key_hex).expect("secret key"); + RadrootsNostrKeys::new(secret_key) + } + + fn signed_event() -> RadrootsNostrEvent { + let raw_event = radroots_nostr_build_event( + KIND_POST, + "hello", + vec![vec!["t".to_owned(), "soil".to_owned()]], + ) + .expect("builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(&fixture_keys()) + .expect("signed event"); + radroots_event_from_nostr(&raw_event) + } + + #[test] + fn verifies_signed_event_id_and_signature() { + let event = signed_event(); + + assert_eq!( + radroots_nostr_verify_event(&event), + RadrootsNostrEventVerification::Verified + ); + assert_eq!( + radroots_nostr_verify_event_id(&event), + RadrootsNostrEventVerification::IdVerified + ); + } + + #[test] + fn reports_id_mismatch_before_signature_checks() { + let mut event = signed_event(); + event.content = "tampered".to_owned(); + + assert_eq!( + radroots_nostr_verify_event(&event), + RadrootsNostrEventVerification::IdMismatch + ); + } + + #[test] + fn reports_signature_invalid_for_valid_id_with_wrong_signature() { + let mut event = signed_event(); + let replacement = if event.sig.starts_with('0') { "1" } else { "0" }; + event.sig.replace_range(0..1, replacement); + + assert_eq!( + radroots_nostr_verify_event(&event), + RadrootsNostrEventVerification::SignatureInvalid + ); + } + + #[test] + fn reports_malformed_envelope_for_unparseable_wire_fields() { + let mut event = signed_event(); + event.kind = u32::from(u16::MAX) + 1; + + assert_eq!( + radroots_nostr_verify_event(&event), + RadrootsNostrEventVerification::MalformedEnvelope + ); + } +} diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -37,6 +37,8 @@ pub mod event_adapters; pub mod draft_signing; #[cfg(feature = "events")] pub mod event_convert; +#[cfg(feature = "events")] +pub mod event_verify; #[cfg(all(feature = "client", feature = "codec"))] pub mod identity_profile; @@ -131,6 +133,11 @@ pub mod prelude { #[cfg(feature = "events")] pub use crate::event_convert::{radroots_event_from_nostr, radroots_event_ptr_from_nostr}; + #[cfg(feature = "events")] + pub use crate::event_verify::{ + RadrootsNostrEventVerification, radroots_nostr_verify_event, radroots_nostr_verify_event_id, + }; + #[cfg(feature = "codec")] pub use crate::job_adapter::RadrootsNostrEventAdapter; } diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -381,7 +381,7 @@ impl RadrootsOutbox { .signed_event .ok_or(RadrootsOutboxError::MissingSignedEvent(outbox_event_id))?; let event = event_from_signed(&signed_event); - let ingest = RadrootsEventIngest::verified(event, observed_at_ms) + let ingest = RadrootsEventIngest::new(event, observed_at_ms) .with_raw_json(signed_event.raw_json.clone()); let receipt = event_store.ingest_event(ingest).await?; sqlx::query( diff --git a/crates/relay_transport/src/fetch.rs b/crates/relay_transport/src/fetch.rs @@ -70,6 +70,8 @@ pub struct RadrootsRelayFetchEventReceipt { pub duplicate: bool, pub unsupported: bool, pub malformed: bool, + pub projection_eligible: bool, + pub verification_status: Option<String>, pub message: Option<String>, } @@ -135,6 +137,8 @@ where duplicate: false, unsupported: false, malformed: true, + projection_eligible: false, + verification_status: None, message: Some("event JSON parse failed".to_owned()), }); continue; @@ -146,7 +150,7 @@ where RadrootsRelayObservationType::Subscription } }; - let ingest = RadrootsEventIngest::verified(event, observed_at_ms) + let ingest = RadrootsEventIngest::new(event, observed_at_ms) .with_raw_json(raw_json) .with_observation(RadrootsRelayObservation::new( relay_url.clone(), @@ -172,6 +176,10 @@ where duplicate: !store_receipt.inserted, unsupported, malformed: false, + projection_eligible: store_receipt.projection_eligible, + verification_status: Some( + store_receipt.verification_status.as_str().to_owned(), + ), message: None, }); } @@ -184,6 +192,8 @@ where duplicate: false, unsupported: false, malformed: true, + projection_eligible: false, + verification_status: None, message: Some(error.to_string()), }); } diff --git a/crates/relay_transport/src/outbox.rs b/crates/relay_transport/src/outbox.rs @@ -219,7 +219,7 @@ async fn ingest_publish_observation( if let Some(message) = message { observation = observation.with_message(message); } - let ingest = RadrootsEventIngest::verified(event_from_signed(signed_event), observed_at_ms) + let ingest = RadrootsEventIngest::new(event_from_signed(signed_event), observed_at_ms) .with_raw_json(signed_event.raw_json.clone()) .with_observation(observation); event_store.ingest_event(ingest).await?; diff --git a/crates/relay_transport/tests/transport.rs b/crates/relay_transport/tests/transport.rs @@ -1,5 +1,5 @@ use nostr::JsonUtil; -use radroots_event_store::RadrootsEventStore; +use radroots_event_store::{RadrootsEventStore, RadrootsEventVerificationStatus}; use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}; use radroots_events::kinds::KIND_POST; use radroots_nostr::prelude::{ @@ -53,6 +53,14 @@ fn unsupported_raw_event() -> String { event.as_json() } +fn tampered_raw_event() -> String { + let signed = signed_post("trusted"); + let mut value = + serde_json::from_str::<serde_json::Value>(signed.raw_json.as_str()).expect("raw json"); + value["content"] = serde_json::Value::String("tampered".to_owned()); + serde_json::to_string(&value).expect("tampered json") +} + #[test] fn relay_url_validation_and_target_normalization() { let relay = RadrootsRelayUrl::parse("wss://Relay.Example.com", RadrootsRelayUrlPolicy::Public) @@ -198,9 +206,14 @@ async fn fetch_ingests_events_and_records_relay_observations() { observed_at_ms: 1_002, }, RadrootsRelayFetchItem::Event { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + raw_json: tampered_raw_event(), + observed_at_ms: 1_003, + }, + RadrootsRelayFetchItem::Event { relay_url: RELAY_TERTIARY_WSS.to_owned(), raw_json: "{not json".to_owned(), - observed_at_ms: 1_003, + observed_at_ms: 1_004, }, RadrootsRelayFetchItem::Eose { relay_url: RELAY_PRIMARY_WSS.to_owned(), @@ -223,13 +236,35 @@ async fn fetch_ingests_events_and_records_relay_observations() { .await .expect("fetch ingest"); - assert_eq!(receipt.inserted_count, 2); + assert_eq!(receipt.inserted_count, 3); assert_eq!(receipt.duplicate_count, 1); assert_eq!(receipt.unsupported_count, 1); assert_eq!(receipt.malformed_count, 1); assert_eq!(receipt.eose_count, 1); assert_eq!(receipt.closed_count, 1); assert_eq!(receipt.notice_count, 1); + assert_eq!( + receipt.events[0].verification_status.as_deref(), + Some(RadrootsEventVerificationStatus::Verified.as_str()) + ); + assert!(receipt.events[0].projection_eligible); + assert_eq!( + receipt.events[1].verification_status.as_deref(), + Some(RadrootsEventVerificationStatus::Verified.as_str()) + ); + assert!(!receipt.events[1].projection_eligible); + assert_eq!( + receipt.events[2].verification_status.as_deref(), + Some(RadrootsEventVerificationStatus::Verified.as_str()) + ); + assert!(!receipt.events[2].projection_eligible); + assert_eq!( + receipt.events[3].verification_status.as_deref(), + Some(RadrootsEventVerificationStatus::IdMismatch.as_str()) + ); + assert!(!receipt.events[3].projection_eligible); + assert_eq!(receipt.events[4].verification_status, None); + assert!(!receipt.events[4].projection_eligible); let observations = store .observations_for_event(signed.id.as_str())