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:
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())