lib

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

commit 23ee816e25f36062535c6ef33b0f772c6c8a9a69
parent 0a594be27573167f6022716b12684d4c611badea
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Jul 2026 11:13:49 +0000

signing: move authorization and exact-draft verification

- authorize draft validity actor role and expected key in fixed order
- make invalid requests unconstructible before signer invocation
- validate every successful signer event through the request-bound receipt
- prove counting-signer rejection and exact output drift coverage

Diffstat:
Mcrates/signing/src/receipt.rs | 175++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/signing/src/request.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/signing/src/signer.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 358 insertions(+), 21 deletions(-)

diff --git a/crates/signing/src/receipt.rs b/crates/signing/src/receipt.rs @@ -1,12 +1,14 @@ //! Signing receipts. use core::fmt; -use radroots_event::SignedEvent; +use radroots_event::{SignedEvent, draft::validate_signed_nostr_event_matches_draft}; use radroots_protocol::runtime::v1::OperationId; +use crate::{Error, SignRequest}; + /// Successful signer output with portable operation provenance. #[non_exhaustive] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, PartialEq, Eq)] pub struct SignReceipt { @@ -27,18 +29,20 @@ impl fmt::Debug for SignReceipt { } impl SignReceipt { - /// Creates a receipt from an invariant-checked signed event. - #[must_use] - pub const fn new( - operation_id: OperationId, + /// Validates signer output against the exact request draft and creates its + /// receipt. This is the only public receipt constructor. + pub fn from_signed_event( + request: &SignRequest, signed_event: SignedEvent, completed_at_unix: u64, - ) -> Self { - Self { - operation_id, + ) -> Result<Self, Error> { + validate_signed_nostr_event_matches_draft(&signed_event, request.draft()) + .map_err(|_| Error)?; + Ok(Self { + operation_id: request.operation_id(), signed_event, completed_at_unix, - } + }) } /// Returns the originating runtime operation identity. @@ -59,3 +63,154 @@ impl SignReceipt { self.completed_at_unix } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Actor, + actor::ActorSource, + request::{CancellationPolicy, SignPolicy}, + }; + use radroots_event::{EventDraft, contract::AuthorRole, wire::Nip01EventWire}; + use radroots_identity::PublicKey; + + #[cfg(not(feature = "std"))] + use alloc::{borrow::ToOwned, string::String, vec, vec::Vec}; + #[cfg(feature = "std")] + use std::{borrow::ToOwned, string::String, vec, vec::Vec}; + + const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER_PUBLIC_KEY: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + fn request() -> SignRequest { + let actor = Actor::new( + PublicKey::from_hex(PUBLIC_KEY).expect("public key"), + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .expect("actor"); + let draft = EventDraft::new( + "radroots.social.geochat.v1", + 20_000, + 1_700_000_000, + Vec::new(), + "frozen-content", + PUBLIC_KEY, + ) + .expect("draft"); + SignRequest::new( + OperationId::SyncPush, + actor, + draft, + SignPolicy::new(1_700_000_100, CancellationPolicy::PreservePublishedRequest) + .expect("policy"), + ) + .expect("request") + } + + fn signed_event( + pubkey: &str, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: &str, + ) -> SignedEvent { + let mut wire = Nip01EventWire { + id: String::new(), + pubkey: pubkey.to_owned(), + created_at, + kind, + tags, + content: content.to_owned(), + sig: core::iter::repeat_n('f', 128).collect(), + extra: Default::default(), + }; + wire.id = wire.computed_event_id().expect("event id").into_string(); + let raw_json = serde_json::json!({ + "id": wire.id, + "pubkey": wire.pubkey, + "created_at": wire.created_at, + "kind": wire.kind, + "tags": wire.tags, + "content": wire.content, + "sig": wire.sig, + }) + .to_string(); + SignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") + } + + fn matching_event(request: &SignRequest) -> SignedEvent { + let draft = request.draft(); + signed_event( + PUBLIC_KEY, + draft.created_at_u64(), + draft.kind_u32(), + draft.tags_as_vec(), + draft.content(), + ) + } + + #[test] + fn exact_signed_event_creates_receipt_with_request_operation() { + let request = request(); + let receipt = SignReceipt::from_signed_event(&request, matching_event(&request), 42) + .expect("receipt"); + + assert_eq!(receipt.operation_id(), OperationId::SyncPush); + assert_eq!(receipt.completed_at_unix(), 42); + assert_eq!(receipt.signed_event().content(), "frozen-content"); + assert!(!format!("{receipt:?}").contains("frozen-content")); + } + + #[test] + fn every_publicly_constructible_signed_event_drift_is_rejected() { + let request = request(); + let draft = request.draft(); + let cases = [ + signed_event( + OTHER_PUBLIC_KEY, + draft.created_at_u64(), + draft.kind_u32(), + draft.tags_as_vec(), + draft.content(), + ), + signed_event( + PUBLIC_KEY, + draft.created_at_u64() + 1, + draft.kind_u32(), + draft.tags_as_vec(), + draft.content(), + ), + signed_event( + PUBLIC_KEY, + draft.created_at_u64(), + draft.kind_u32() + 1, + draft.tags_as_vec(), + draft.content(), + ), + signed_event( + PUBLIC_KEY, + draft.created_at_u64(), + draft.kind_u32(), + vec![vec!["changed".to_owned()]], + draft.content(), + ), + signed_event( + PUBLIC_KEY, + draft.created_at_u64(), + draft.kind_u32(), + draft.tags_as_vec(), + "changed-content", + ), + ]; + + for event in cases { + assert_eq!( + SignReceipt::from_signed_event(&request, event, 42), + Err(Error) + ); + } + } +} diff --git a/crates/signing/src/request.rs b/crates/signing/src/request.rs @@ -1,7 +1,7 @@ //! Validated signing requests. use core::fmt; -use radroots_event::EventDraft; +use radroots_event::{EventDraft, contract::event_contract}; use radroots_protocol::runtime::v1::OperationId; #[cfg(not(feature = "std"))] @@ -9,7 +9,7 @@ use alloc::sync::Arc; #[cfg(feature = "std")] use std::sync::Arc; -use crate::{Actor, status::SignProgress}; +use crate::{Actor, Error, status::SignProgress}; /// How a signer must interpret cancellation around remote publication. #[non_exhaustive] @@ -97,21 +97,22 @@ pub struct SignRequest { } impl SignRequest { - /// Creates a request without installing a progress observer. - #[must_use] + /// Validates the current draft, then the actor role, then the expected + /// public key, and creates a request without a progress observer. pub fn new( operation_id: OperationId, actor: Actor, draft: EventDraft, policy: SignPolicy, - ) -> Self { - Self { + ) -> Result<Self, Error> { + authorize_actor_for_draft(&actor, &draft).map_err(|_| Error)?; + Ok(Self { operation_id, actor, draft, policy, progress_observer: None, - } + }) } /// Installs a runtime-local progress observer. @@ -153,6 +154,32 @@ impl SignRequest { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AuthorizationFailure { + InvalidDraft, + ActorRoleUnsatisfied, + ActorPublicKeyMismatch, +} + +fn authorize_actor_for_draft( + actor: &Actor, + draft: &EventDraft, +) -> Result<(), AuthorizationFailure> { + // This order is part of the authorization contract: no actor decision is + // made for an invalid/stale draft, and role rejection precedes key drift. + draft + .validate_for_signing() + .map_err(|_| AuthorizationFailure::InvalidDraft)?; + let contract = event_contract(draft.contract_id()).ok_or(AuthorizationFailure::InvalidDraft)?; + if !actor.satisfies(contract.required_author_role()) { + return Err(AuthorizationFailure::ActorRoleUnsatisfied); + } + if actor.public_key() != *draft.expected_pubkey() { + return Err(AuthorizationFailure::ActorPublicKeyMismatch); + } + Ok(()) +} + impl fmt::Debug for SignRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -178,14 +205,17 @@ mod tests { }; use core::sync::atomic::{AtomicUsize, Ordering}; use radroots_event::contract::AuthorRole; + use radroots_event::envelope::kind::KIND_TRADE_PROPOSAL; use radroots_identity::PublicKey; #[cfg(not(feature = "std"))] - use alloc::{string::String, sync::Arc, vec::Vec}; + use alloc::{string::String, sync::Arc, vec, vec::Vec}; #[cfg(feature = "std")] - use std::{string::String, sync::Arc, vec::Vec}; + use std::{string::String, sync::Arc, vec, vec::Vec}; const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER_PUBLIC_KEY: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; struct CountingObserver(AtomicUsize); @@ -219,6 +249,7 @@ mod tests { SignPolicy::new(1_700_000_100, CancellationPolicy::PreservePublishedRequest) .expect("policy"), ) + .expect("authorized request") } #[test] @@ -246,6 +277,54 @@ mod tests { assert!(debug.contains("redacted frozen event draft")); } + #[test] + fn authorization_rejects_role_before_public_key_drift() { + let draft = EventDraft::new( + "radroots.trade.proposal.v1", + KIND_TRADE_PROPOSAL, + 1_700_000_000, + vec![ + vec![ + "contract".to_owned(), + "radroots.trade.proposal.v1".to_owned(), + ], + vec![ + "d".to_owned(), + "11111111111111111111111111111111".to_owned(), + ], + vec!["p".to_owned(), PUBLIC_KEY.to_owned()], + ], + r#"{"contract_id":"radroots.trade.proposal.v1"}"#, + PUBLIC_KEY, + ) + .expect("draft"); + let wrong_key = PublicKey::from_hex(OTHER_PUBLIC_KEY).expect("public key"); + let actor = Actor::new( + wrong_key, + ActorSource::ExplicitPublicKey, + [AuthorRole::Seller], + ) + .expect("actor"); + + assert_eq!( + authorize_actor_for_draft(&actor, &draft), + Err(AuthorizationFailure::ActorRoleUnsatisfied) + ); + } + + #[test] + fn authorization_rejects_actor_public_key_drift() { + let draft = request().draft().clone(); + let wrong_key = PublicKey::from_hex(OTHER_PUBLIC_KEY).expect("public key"); + let actor = Actor::new(wrong_key, ActorSource::ExplicitPublicKey, [AuthorRole::Any]) + .expect("actor"); + + assert_eq!( + authorize_actor_for_draft(&actor, &draft), + Err(AuthorizationFailure::ActorPublicKeyMismatch) + ); + } + fn alloc_or_std_format(value: &SignRequest) -> String { value_to_string(format_args!("{value:?}")) } diff --git a/crates/signing/src/signer.rs b/crates/signing/src/signer.rs @@ -27,17 +27,39 @@ pub trait Signer: Send + Sync { /// Signs one already-authorized request. /// /// The request's deadline and cancellation policy remain authoritative - /// throughout the operation. Implementations must not install an executor, - /// spawn hidden workers, or convert cancellation into silent success. + /// throughout the operation. Implementations must create successful output + /// with [`SignReceipt::from_signed_event`], which rejects any drift from the + /// frozen draft. They must not install an executor, spawn hidden workers, + /// or convert cancellation into silent success. fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>>; } #[cfg(test)] mod tests { use super::*; + use crate::{ + Actor, + actor::ActorSource, + request::{CancellationPolicy, SignPolicy}, + }; + use core::sync::atomic::{AtomicUsize, Ordering}; + use radroots_event::envelope::kind::KIND_TRADE_PROPOSAL; + use radroots_event::{EventDraft, contract::AuthorRole}; + use radroots_identity::PublicKey; + use radroots_protocol::runtime::v1::OperationId; + + #[cfg(not(feature = "std"))] + use alloc::{borrow::ToOwned, vec, vec::Vec}; + #[cfg(feature = "std")] + use std::{borrow::ToOwned, vec, vec::Vec}; + + const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER_PUBLIC_KEY: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; struct LocalSigner; struct RemoteSigner; + struct CountingSigner(AtomicUsize); impl Signer for LocalSigner { fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { @@ -59,6 +81,17 @@ mod tests { } } + impl Signer for CountingSigner { + fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { + Box::pin(async { Ok(SignerStatus::unavailable()) }) + } + + fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { + self.0.fetch_add(1, Ordering::Relaxed); + Box::pin(async { Err(Error) }) + } + } + fn assert_dyn_signer(signer: &dyn Signer) { drop(signer.status()); } @@ -74,4 +107,74 @@ mod tests { fn assert_send_sync<T: Send + Sync + ?Sized>() {} assert_send_sync::<dyn Signer>(); } + + #[test] + fn rejected_request_cannot_invoke_counting_signer() { + let key_drift_draft = EventDraft::new( + "radroots.social.geochat.v1", + 20_000, + 1_700_000_000, + Vec::new(), + "frozen-content", + PUBLIC_KEY, + ) + .expect("draft"); + let key_drift_actor = Actor::new( + PublicKey::from_hex(OTHER_PUBLIC_KEY).expect("public key"), + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .expect("actor"); + let role_drift_draft = EventDraft::new( + "radroots.trade.proposal.v1", + KIND_TRADE_PROPOSAL, + 1_700_000_000, + vec![ + vec![ + "contract".to_owned(), + "radroots.trade.proposal.v1".to_owned(), + ], + vec![ + "d".to_owned(), + "11111111111111111111111111111111".to_owned(), + ], + vec!["p".to_owned(), PUBLIC_KEY.to_owned()], + ], + r#"{"contract_id":"radroots.trade.proposal.v1"}"#, + PUBLIC_KEY, + ) + .expect("draft"); + let role_drift_actor = Actor::new( + PublicKey::from_hex(PUBLIC_KEY).expect("public key"), + ActorSource::ExplicitPublicKey, + [AuthorRole::Seller], + ) + .expect("actor"); + let policy = SignPolicy::new(1_700_000_100, CancellationPolicy::PreservePublishedRequest) + .expect("policy"); + let signer = CountingSigner(AtomicUsize::new(0)); + + let requests = [ + SignRequest::new( + OperationId::SyncPush, + key_drift_actor, + key_drift_draft, + policy, + ), + SignRequest::new( + OperationId::SyncPush, + role_drift_actor, + role_drift_draft, + policy, + ), + ]; + for request in requests { + assert!(request.is_err()); + if let Ok(request) = request { + drop(signer.sign(request)); + } + } + + assert_eq!(signer.0.load(Ordering::Relaxed), 0); + } }