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:
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);
+ }
}