lib

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

commit 75488feb83ce93cc97121da52c60b332af8b3f71
parent e528bd0be26bdaa25266859ce139f59903b43f3f
Author: triesap <tyson@radroots.org>
Date:   Mon, 14 Sep 2026 07:18:43 +0000

signing: retain verified authored signature evidence

- Separate late authored facts from active signing and upload receipts
- Add an optional signer hook with exact request revalidation
- Preserve portable profiles and repair the no_std test import
- Verify misuse, coverage, public API and workspace consumers

Diffstat:
Mcontracts/api_baselines/radroots_signing.txt | 24++++++++++++++++++++++++
Acontracts/architecture/decisions/authored_signing_evidence.v1.json | 25+++++++++++++++++++++++++
Mcrates/signing/README.md | 24+++++++++++++++++++++++-
Mcrates/signing/src/identity.rs | 2++
Mcrates/signing/src/lib.rs | 2+-
Mcrates/signing/src/receipt.rs | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/signing/src/signer.rs | 46++++++++++++++++++++++++++++++++++++++++++----
Mcrates/signing/tests/authored_signing.rs | 23+++++++++++++++++------
Acrates/signing/tests/authored_signing/evidence.rs | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/tests/package_boundary.rs | 4++--
10 files changed, 577 insertions(+), 19 deletions(-)

diff --git a/contracts/api_baselines/radroots_signing.txt b/contracts/api_baselines/radroots_signing.txt @@ -142,6 +142,17 @@ pub fn radroots_signing::identity::SigningOperationId::to_hex(self) -> alloc::st impl core::fmt::Debug for radroots_signing::identity::SigningOperationId pub fn radroots_signing::identity::SigningOperationId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub mod radroots_signing::receipt +#[non_exhaustive] pub struct radroots_signing::receipt::AuthoredSignEvidence +impl radroots_signing::receipt::AuthoredSignEvidence +pub fn radroots_signing::receipt::AuthoredSignEvidence::from_signed_event(&radroots_signing::request::SignRequest, radroots_event::draft::SignedEvent, u64) -> core::result::Result<Self, radroots_signing::error::Error> +pub const fn radroots_signing::receipt::AuthoredSignEvidence::intent_id(&self) -> radroots_signing::identity::SigningIntentId +pub const fn radroots_signing::receipt::AuthoredSignEvidence::observed_at_unix_ms(&self) -> u64 +pub const fn radroots_signing::receipt::AuthoredSignEvidence::operation_kind(&self) -> radroots_protocol::runtime::v1::OperationId +pub fn radroots_signing::receipt::AuthoredSignEvidence::revalidate(&self, &radroots_signing::request::SignRequest, u64) -> core::result::Result<Self, radroots_signing::error::Error> +pub const fn radroots_signing::receipt::AuthoredSignEvidence::signed_event(&self) -> &radroots_event::draft::SignedEvent +pub const fn radroots_signing::receipt::AuthoredSignEvidence::signer_request_id(&self) -> radroots_signing::identity::SignerRequestId +impl core::fmt::Debug for radroots_signing::receipt::AuthoredSignEvidence +pub fn radroots_signing::receipt::AuthoredSignEvidence::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result #[non_exhaustive] pub struct radroots_signing::receipt::SignReceipt impl radroots_signing::receipt::SignReceipt pub const fn radroots_signing::receipt::SignReceipt::completed_at_unix_ms(&self) -> u64 @@ -222,6 +233,7 @@ pub fn radroots_signing::request::ProgressObserver::on_progress(&self, &radroots pub mod radroots_signing::signer pub trait radroots_signing::signer::Signer: core::marker::Send + core::marker::Sync pub fn radroots_signing::signer::Signer::sign(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::SignReceipt, radroots_signing::error::Error>> +pub fn radroots_signing::signer::Signer::sign_authored_evidence(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::AuthoredSignEvidence, radroots_signing::error::Error>> pub fn radroots_signing::signer::Signer::status(&self) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::status::SignerStatus, radroots_signing::error::Error>> pub type radroots_signing::signer::BoxFuture<'a, T> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = T> + core::marker::Send + 'a)>> pub type radroots_signing::signer::DynSigner = alloc::sync::Arc<dyn radroots_signing::signer::Signer> @@ -291,6 +303,17 @@ pub fn radroots_signing::identity::AuthoredArtifactId::new([u8; 16]) -> core::re pub fn radroots_signing::identity::AuthoredArtifactId::to_hex(self) -> alloc::string::String impl core::fmt::Debug for radroots_signing::identity::AuthoredArtifactId pub fn radroots_signing::identity::AuthoredArtifactId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +#[non_exhaustive] pub struct radroots_signing::AuthoredSignEvidence +impl radroots_signing::receipt::AuthoredSignEvidence +pub fn radroots_signing::receipt::AuthoredSignEvidence::from_signed_event(&radroots_signing::request::SignRequest, radroots_event::draft::SignedEvent, u64) -> core::result::Result<Self, radroots_signing::error::Error> +pub const fn radroots_signing::receipt::AuthoredSignEvidence::intent_id(&self) -> radroots_signing::identity::SigningIntentId +pub const fn radroots_signing::receipt::AuthoredSignEvidence::observed_at_unix_ms(&self) -> u64 +pub const fn radroots_signing::receipt::AuthoredSignEvidence::operation_kind(&self) -> radroots_protocol::runtime::v1::OperationId +pub fn radroots_signing::receipt::AuthoredSignEvidence::revalidate(&self, &radroots_signing::request::SignRequest, u64) -> core::result::Result<Self, radroots_signing::error::Error> +pub const fn radroots_signing::receipt::AuthoredSignEvidence::signed_event(&self) -> &radroots_event::draft::SignedEvent +pub const fn radroots_signing::receipt::AuthoredSignEvidence::signer_request_id(&self) -> radroots_signing::identity::SignerRequestId +impl core::fmt::Debug for radroots_signing::receipt::AuthoredSignEvidence +pub fn radroots_signing::receipt::AuthoredSignEvidence::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_signing::Error impl radroots_signing::error::Error pub const fn radroots_signing::error::Error::class(&self) -> radroots_protocol::error::v1::Class @@ -382,4 +405,5 @@ impl radroots_signing::authorization::CurrentAuthoringAuthority for radroots_sig pub fn radroots_signing::authorization::CurrentRegistryAuthority::evaluate(&self, &radroots_event_codec::authoring::AuthoredEventPlan) -> radroots_signing::authorization::CurrentAuthoringDecision pub trait radroots_signing::Signer: core::marker::Send + core::marker::Sync pub fn radroots_signing::Signer::sign(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::SignReceipt, radroots_signing::error::Error>> +pub fn radroots_signing::Signer::sign_authored_evidence(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::AuthoredSignEvidence, radroots_signing::error::Error>> pub fn radroots_signing::Signer::status(&self) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::status::SignerStatus, radroots_signing::error::Error>> diff --git a/contracts/architecture/decisions/authored_signing_evidence.v1.json b/contracts/architecture/decisions/authored_signing_evidence.v1.json @@ -0,0 +1,25 @@ +{ + "schema": "radroots.authored-signing-evidence.v1", + "status": "approved", + "owner": "radroots_signing", + "purpose": "Retain verified authored signature facts independently of caller deadline and cancellation. Evidence does not authorize new work.", + "public_api": { + "type": "receipt::AuthoredSignEvidence (also exported at crate root)", + "constructor": "from_signed_event(&SignRequest, SignedEvent, observed_at_unix_ms) verifies authored purpose, positive injected observation time, all exact plan fields, event ID and Schnorr signature", + "revalidation": "revalidate(&SignRequest, observed_at_unix_ms) additionally verifies operation kind, intent and signer request ID before constructing locally observed evidence", + "signer_hook": "Signer::sign_authored_evidence(SignRequest) defaults to the existing sign method, rejects non-authored and already-cancelled requests before invocation and verifies returned receipt identity before promotion" + }, + "scheduling": "An adapter may retain the result of work already started before deadline/cancellation. It must not start new work after either signal. The composing host owns polling, request lifecycle and durable reconciliation.", + "compatibility": "SignReceipt and Signer::sign keep their strict active-request semantics. Existing adapters compile unchanged. The default cannot recover evidence discarded by an existing adapter; an adapter returning late facts must override the optional hook.", + "boundary": "Only AuthoredEvent requests can produce this type. BlossomUploadAuthorization still requires an active receipt and independent HTTP expiry verification. No evidence result is a credential or scheduling capability.", + "serialization": "Serialize only under the existing serde feature; no Deserialize and no unchecked constructor. Debug contains identifiers and time but no raw event, content or signature.", + "time": "The timestamp is an injected observation, not proof of actual signing time. Persistence callers revalidate with their own clock. The SPI reads no clock and adds no runtime or dependency.", + "persistence": "Shared storage and orchestration must bind evidence to their retained immutable operation and plan; this SPI checkpoint adds no storage mutation, retry or transport behavior.", + "verification": [ + "active receipt and Blossom expiry/cancellation remain rejected", + "late authored evidence verifies exact raw event and signature", + "wrong operation/artifact/request binding rejected on revalidation", + "default hook preserves existing adapter behavior and rejects credential purpose without invoking sign", + "no_std, serde-only, std-only, default feature, object safety and public API baseline checks" + ] +} diff --git a/crates/signing/README.md b/crates/signing/README.md @@ -122,10 +122,32 @@ The SPI defines those rules but performs no I/O itself. Commit points belong to the concrete adapter and must be visible in that adapter's documentation and status/progress behavior. +## Retained authored evidence + +`AuthoredSignEvidence` records a cryptographically verified authored event even +when an already-started signer finishes after the caller's deadline or +cancellation. It binds exact event bytes, operation, artifact and signer request +identity to a positive injected observation time. That time records observation, +not proof of the instant of signing. `revalidate` checks the retained request +identity and all cryptographic fields again with the caller's own clock. + +`Signer::sign_authored_evidence` is an optional hook. Its default delegates to +`sign`, verifies receipt identity and promotes the receipt; already-cancelled +requests are rejected before invoking the adapter. Existing adapters +compile unchanged. Adapters that discard late output need an override to retain +it. Overrides must still prevent new work after deadline or cancellation. The +host owns polling, lifecycle and durable reconciliation; the SPI adds no worker. + +Evidence is a fact, not an active success receipt or authority to schedule new +signing, admission or delivery. `SignReceipt` remains deadline/cancellation +checked. The evidence constructor and default hook reject Blossom requests; +expiring BUD-11 authorization continues to use the strict active receipt path. + ## Serialization contract Native `Actor` and `SignRequest` values are runtime-local and are not -serializable. `SignReceipt` can be serialized with `serde` but cannot be +serializable. `SignReceipt` and `AuthoredSignEvidence` can be serialized with +`serde` but cannot be deserialized without the originating request; this prevents callers from bypassing authorization and exact-draft verification. diff --git a/crates/signing/src/identity.rs b/crates/signing/src/identity.rs @@ -119,6 +119,8 @@ impl fmt::Debug for SignerRequestId { #[cfg(test)] mod tests { use super::*; + #[cfg(not(feature = "std"))] + use alloc::format; #[test] fn stable_identities_reject_zero_and_expose_exact_bytes() { diff --git a/crates/signing/src/lib.rs b/crates/signing/src/lib.rs @@ -20,7 +20,7 @@ pub use actor::Actor; pub use authorization::{CurrentAuthoringAuthority, CurrentAuthoringDecision}; pub use error::Error; pub use identity::{AuthoredArtifactId, SignerRequestId, SigningIntentId, SigningOperationId}; -pub use receipt::SignReceipt; +pub use receipt::{AuthoredSignEvidence, SignReceipt}; pub use request::{SignRequest, SigningPurpose}; pub use signer::Signer; pub use status::SignerStatus; diff --git a/crates/signing/src/receipt.rs b/crates/signing/src/receipt.rs @@ -42,11 +42,7 @@ impl SignReceipt { completed_at_unix_ms: u64, ) -> Result<Self, Error> { request.ensure_active(completed_at_unix_ms)?; - verify_exact_plan(&signed_event, request)?; - let id_verified = verify::id(RawEvent::new(signed_event.envelope().clone())) - .map_err(|_| Error::new(Kind::SignerOutputInvalid))?; - verify::signature(id_verified, &Nip01SignatureVerifier) - .map_err(|_| Error::new(Kind::SignerOutputInvalid))?; + verify_signed_event(&signed_event, request)?; Ok(Self { operation_kind: request.operation_kind(), intent_id: request.intent_id(), @@ -96,3 +92,131 @@ fn verify_exact_plan(event: &SignedEvent, request: &SignRequest) -> Result<(), E // exact wire value; the checks above bind that wire to the request plan. Ok(()) } + +/// Verified authored signature facts, independent of whether a caller is waiting. +/// +/// This is neither an active success receipt nor authority to sign, admit or +/// deliver an event. It cannot represent an expiring Blossom credential. +/// Persistence callers must use [`Self::revalidate`] with their retained request +/// and locally observed time before recording the evidence. +/// +/// Evidence cannot be deserialized without cryptographic verification: +/// ```compile_fail +/// fn decode<'a, T: serde::Deserialize<'a>>() {} +/// decode::<radroots_signing::AuthoredSignEvidence>(); +/// ``` +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[derive(Clone, PartialEq, Eq)] +pub struct AuthoredSignEvidence { + operation_kind: OperationId, + intent_id: SigningIntentId, + signer_request_id: SignerRequestId, + signed_event: SignedEvent, + observed_at_unix_ms: u64, +} + +impl AuthoredSignEvidence { + /// Verifies an already-created authored event against its originating request. + /// + /// Deadline and cancellation affect scheduling, not the truth of retained + /// signature facts. Observation time must be positive and is not proof of + /// when the signature was created. This constructor performs no signing. + pub fn from_signed_event( + request: &SignRequest, + signed_event: SignedEvent, + observed_at_unix_ms: u64, + ) -> Result<Self, Error> { + if request.authored_plan().is_none() || observed_at_unix_ms == 0 { + return Err(Error::new(Kind::InvalidArgument)); + } + verify_signed_event(&signed_event, request)?; + Ok(Self { + operation_kind: request.operation_kind(), + intent_id: request.intent_id(), + signer_request_id: request.signer_request_id(), + signed_event, + observed_at_unix_ms, + }) + } + + /// Rebinds verified evidence to a caller's exact retained request and clock. + /// + /// A signature over the same event under a different operation or artifact + /// is not evidence for this request. The original raw event bytes survive. + pub fn revalidate( + &self, + request: &SignRequest, + observed_at_unix_ms: u64, + ) -> Result<Self, Error> { + verify_identity( + self.operation_kind, + self.intent_id, + self.signer_request_id, + request, + )?; + Self::from_signed_event(request, self.signed_event.clone(), observed_at_unix_ms) + } + + #[must_use] + pub const fn operation_kind(&self) -> OperationId { + self.operation_kind + } + + #[must_use] + pub const fn intent_id(&self) -> SigningIntentId { + self.intent_id + } + + #[must_use] + pub const fn signer_request_id(&self) -> SignerRequestId { + self.signer_request_id + } + + #[must_use] + pub const fn signed_event(&self) -> &SignedEvent { + &self.signed_event + } + + #[must_use] + pub const fn observed_at_unix_ms(&self) -> u64 { + self.observed_at_unix_ms + } +} + +impl fmt::Debug for AuthoredSignEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthoredSignEvidence") + .field("operation_kind", &self.operation_kind) + .field("intent_id", &self.intent_id) + .field("signer_request_id", &self.signer_request_id) + .field("signed_event_id", &self.signed_event.id_str()) + .field("observed_at_unix_ms", &self.observed_at_unix_ms) + .finish() + } +} + +pub(crate) fn verify_identity( + operation_kind: OperationId, + intent_id: SigningIntentId, + signer_request_id: SignerRequestId, + request: &SignRequest, +) -> Result<(), Error> { + if operation_kind != request.operation_kind() + || intent_id != request.intent_id() + || signer_request_id != request.signer_request_id() + { + return Err(Error::new(Kind::SignerOutputInvalid)); + } + Ok(()) +} + +fn verify_signed_event(event: &SignedEvent, request: &SignRequest) -> Result<(), Error> { + verify_exact_plan(event, request)?; + let id_verified = verify::id(RawEvent::new(event.envelope().clone())) + .map_err(|_| Error::new(Kind::SignerOutputInvalid))?; + verify::signature(id_verified, &Nip01SignatureVerifier) + .map_err(|_| Error::new(Kind::SignerOutputInvalid))?; + Ok(()) +} diff --git a/crates/signing/src/signer.rs b/crates/signing/src/signer.rs @@ -7,7 +7,10 @@ use alloc::{boxed::Box, sync::Arc}; #[cfg(feature = "std")] use std::{boxed::Box, sync::Arc}; -use crate::{Error, SignReceipt, SignRequest, SignerStatus}; +use crate::{ + AuthoredSignEvidence, Error, SignReceipt, SignRequest, SignerStatus, error::Kind, + receipt::verify_identity, +}; pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; @@ -15,8 +18,8 @@ pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; /// /// Implementations must document their durable remote-effect point. Dropping /// a future after that point does not imply rollback. Replay behavior is -/// advertised through signer status and every successful result must use the -/// verified receipt constructor. +/// advertised through signer status and every successful result must use a +/// verified receipt or authored-evidence constructor. pub trait Signer: Send + Sync { fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>>; @@ -27,6 +30,42 @@ pub trait Signer: Send + Sync { /// signer request ID for remote replay, and create success only through /// [`SignReceipt::from_signed_event`]. fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>>; + + /// Signs an authored plan while retaining any verified result of started work. + /// + /// An override must honor deadline and cancellation before starting work, + /// but may return exact evidence received afterward. Such evidence is not + /// permission to resume stopped work. The composing host owns polling and + /// durable reconciliation; dropping this future does not undo a signature. + /// + /// The default delegates to [`Self::sign`] and preserves existing adapters. + /// It cannot recover evidence that the adapter discards. Blossom requests + /// and already-cancelled requests are rejected before invoking that adapter. + fn sign_authored_evidence( + &self, + request: SignRequest, + ) -> BoxFuture<'_, Result<AuthoredSignEvidence, Error>> { + Box::pin(async move { + if request.authored_plan().is_none() { + return Err(Error::new(Kind::InvalidArgument)); + } + if request.cancellation_signal().is_cancelled() { + return Err(Error::new(Kind::SignerCancelled)); + } + let receipt = self.sign(request.clone()).await?; + verify_identity( + receipt.operation_kind(), + receipt.intent_id(), + receipt.signer_request_id(), + &request, + )?; + AuthoredSignEvidence::from_signed_event( + &request, + receipt.signed_event().clone(), + receipt.completed_at_unix_ms(), + ) + }) + } } /// Shared signer handle used by composing hosts without selecting a runtime. @@ -35,7 +74,6 @@ pub type DynSigner = Arc<dyn Signer>; #[cfg(test)] mod tests { use super::*; - use crate::error::Kind; struct Stub; diff --git a/crates/signing/tests/authored_signing.rs b/crates/signing/tests/authored_signing.rs @@ -477,12 +477,7 @@ fn receipt_requires_exact_fields_and_a_valid_schnorr_signature() { ); } - let valid = signed_event(); - let mut wire = valid.wire().clone(); - wire.sig = "f".repeat(128); - let raw = serde_json::to_string(&wire).expect("raw event"); - let invalid = radroots_event::SignedEvent::from_wire_verified_id(wire, raw) - .expect("ID-valid event with hostile signature"); + let invalid = invalid_signature_event(); assert_eq!( SignReceipt::from_signed_event(&request, invalid, DEADLINE_MS - 1) .expect_err("invalid signature") @@ -516,3 +511,19 @@ fn uncertain_remote_effects_never_become_unsafe_automatic_retries() { RecoveryDisposition::RetryLocal ); } + +#[path = "authored_signing/evidence.rs"] +mod evidence; + +fn invalid_signature_event() -> radroots_event::SignedEvent { + let valid = signed_event(); + let mut wire = valid.wire().clone(); + wire.sig = "f".repeat(128); + let mut raw: serde_json::Value = serde_json::from_str(valid.raw_json()).expect("raw event"); + raw["sig"] = serde_json::Value::String(wire.sig.clone()); + radroots_event::SignedEvent::from_wire_verified_id( + wire, + serde_json::to_string(&raw).expect("hostile raw event"), + ) + .expect("ID-valid event with hostile signature") +} diff --git a/crates/signing/tests/authored_signing/evidence.rs b/crates/signing/tests/authored_signing/evidence.rs @@ -0,0 +1,312 @@ +use super::*; +use radroots_signing::{AuthoredSignEvidence, Signer, SignerStatus, signer::BoxFuture}; +use std::{ + sync::atomic::{AtomicUsize, Ordering}, + task::{Context, Poll, Waker}, +}; + +fn ready<T>(mut future: BoxFuture<'_, T>) -> T { + match future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + { + Poll::Ready(value) => value, + Poll::Pending => panic!("fixture must complete without an executor"), + } +} + +#[test] +fn late_and_cancelled_evidence_preserves_exact_bytes_without_active_success() { + let request = request(); + let event = signed_event(); + let evidence = AuthoredSignEvidence::from_signed_event(&request, event.clone(), DEADLINE_MS) + .expect("late signature remains a fact"); + assert_eq!(evidence.operation_kind(), request.operation_kind()); + assert_eq!(evidence.intent_id(), request.intent_id()); + assert_eq!(evidence.signer_request_id(), request.signer_request_id()); + assert_eq!(evidence.observed_at_unix_ms(), DEADLINE_MS); + assert_eq!(evidence.signed_event(), &event); + assert_eq!( + SignReceipt::from_signed_event(&request, event.clone(), DEADLINE_MS) + .unwrap_err() + .kind(), + Kind::DeadlineExceeded + ); + request.cancellation_signal().cancel(); + let later = evidence + .revalidate(&request, DEADLINE_MS + 1) + .expect("retain after cancellation"); + assert_eq!(later.signed_event(), &event); + assert_eq!(later.observed_at_unix_ms(), DEADLINE_MS + 1); + assert_eq!( + SignReceipt::from_signed_event(&request, event.clone(), DEADLINE_MS - 1) + .unwrap_err() + .kind(), + Kind::SignerCancelled + ); + assert_eq!( + AuthoredSignEvidence::from_signed_event(&request, event, 0) + .unwrap_err() + .kind(), + Kind::InvalidArgument + ); + assert_eq!( + later.revalidate(&request, 0).unwrap_err().kind(), + Kind::InvalidArgument + ); + let debug = format!("{later:?}"); + assert!(debug.contains(later.signed_event().id_str())); + assert!(!debug.contains("exact signing plan")); + assert!(!debug.contains(&later.signed_event().wire().sig)); + assert_eq!(later, later.clone()); + #[cfg(feature = "serde")] + { + let json = serde_json::to_value(&later).expect("serialize evidence"); + assert_eq!(json["observed_at_unix_ms"], DEADLINE_MS + 1); + assert!(json.get("signed_event").is_some()); + } +} + +#[test] +fn evidence_rejects_wrong_intent_even_for_the_identical_signed_event() { + let expected = request(); + let evidence = + AuthoredSignEvidence::from_signed_event(&expected, signed_event(), DEADLINE_MS).unwrap(); + for identity in [intent(3, 2), intent(1, 3)] { + let other = SignRequest::new( + OperationId::SyncPush, + identity, + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]), + plan(), + policy(), + ) + .unwrap(); + assert_eq!(other.expected_event_id(), expected.expected_event_id()); + assert_eq!( + evidence.revalidate(&other, DEADLINE_MS).unwrap_err().kind(), + Kind::SignerOutputInvalid + ); + } + let other_kind = SignRequest::new( + OperationId::SyncPull, + intent(1, 2), + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]), + plan(), + policy(), + ) + .unwrap(); + assert_eq!( + evidence + .revalidate(&other_kind, DEADLINE_MS) + .unwrap_err() + .kind(), + Kind::SignerOutputInvalid + ); + let other_plan = AuthoredEventPlan::from_generic( + GenericEventDraft::new( + "radroots.social.geochat.v1", + 20_000, + CREATED_AT, + Vec::new(), + "different plan", + public_key().to_hex(), + ) + .unwrap(), + ) + .unwrap(); + let other_request = SignRequest::new( + OperationId::SyncPush, + intent(1, 2), + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]), + other_plan, + policy(), + ) + .unwrap(); + assert_eq!( + evidence + .revalidate(&other_request, DEADLINE_MS) + .unwrap_err() + .kind(), + Kind::SignerOutputInvalid + ); +} + +#[test] +fn late_evidence_keeps_cryptographic_and_exact_plan_checks() { + let request = request(); + let other_keys = Keys::new(SecretKey::from_hex(&"11".repeat(32)).unwrap()); + for event in [ + signed_event_with( + &other_keys, + 20_000, + "exact signing plan", + CREATED_AT, + Vec::new(), + ), + signed_event_with( + &keys(), + 20_000, + "exact signing plan", + CREATED_AT + 1, + Vec::new(), + ), + signed_event_with( + &keys(), + 20_001, + "exact signing plan", + CREATED_AT, + Vec::new(), + ), + signed_event_with( + &keys(), + 20_000, + "exact signing plan", + CREATED_AT, + vec![nostr::Tag::parse(["t", "wrong"]).unwrap()], + ), + signed_event_with(&keys(), 20_000, "wrong content", CREATED_AT, Vec::new()), + ] { + assert_eq!( + AuthoredSignEvidence::from_signed_event(&request, event, DEADLINE_MS + 1) + .unwrap_err() + .kind(), + Kind::SignerOutputInvalid + ); + } + let invalid = invalid_signature_event(); + assert_eq!( + AuthoredSignEvidence::from_signed_event(&request, invalid, DEADLINE_MS + 1) + .unwrap_err() + .kind(), + Kind::SignerOutputInvalid + ); +} + +struct LegacySigner { + receipt_request: SignRequest, + calls: AtomicUsize, + fail: bool, +} +impl Signer for LegacySigner { + fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { + Box::pin(async { Ok(SignerStatus::unavailable()) }) + } + fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { + if self.fail { + return Err(Error::new(Kind::SignerUnavailable)); + } + SignReceipt::from_signed_event(&self.receipt_request, signed_event(), DEADLINE_MS - 1) + }) + } +} + +#[test] +fn default_hook_preserves_legacy_adapters_and_rejects_wrong_receipt_binding() { + let signer = LegacySigner { + receipt_request: request(), + calls: AtomicUsize::new(0), + fail: false, + }; + let object: &dyn Signer = &signer; + let result = ready(object.sign_authored_evidence(request())).unwrap(); + assert_eq!(result.intent_id(), request().intent_id()); + assert_eq!(result.observed_at_unix_ms(), DEADLINE_MS - 1); + assert_eq!(signer.calls.load(Ordering::SeqCst), 1); + let wrong = SignRequest::new( + OperationId::SyncPush, + intent(4, 2), + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]), + plan(), + policy(), + ) + .unwrap(); + assert_eq!( + ready(object.sign_authored_evidence(wrong)) + .unwrap_err() + .kind(), + Kind::SignerOutputInvalid + ); + let unavailable = LegacySigner { + receipt_request: request(), + calls: AtomicUsize::new(0), + fail: true, + }; + assert_eq!( + ready(unavailable.sign_authored_evidence(request())) + .unwrap_err() + .kind(), + Kind::SignerUnavailable + ); +} + +#[test] +fn blossom_credentials_cannot_use_authored_evidence_or_invoke_default_signer() { + let request = blossom_request(); + let event = signed_event_with( + &keys(), + 24_242, + request.content(), + request.created_at(), + request + .tags() + .iter() + .cloned() + .map(nostr::Tag::parse) + .collect::<Result<Vec<_>, _>>() + .unwrap(), + ); + assert!(SignReceipt::from_signed_event(&request, event.clone(), DEADLINE_MS - 1).is_ok()); + for time in [DEADLINE_MS - 1, DEADLINE_MS, DEADLINE_MS + 1] { + assert_eq!( + AuthoredSignEvidence::from_signed_event(&request, event.clone(), time) + .unwrap_err() + .kind(), + Kind::InvalidArgument + ); + } + assert_eq!( + SignReceipt::from_signed_event(&request, event.clone(), DEADLINE_MS) + .unwrap_err() + .kind(), + Kind::DeadlineExceeded + ); + request.cancellation_signal().cancel(); + assert_eq!( + SignReceipt::from_signed_event(&request, event, DEADLINE_MS - 1) + .unwrap_err() + .kind(), + Kind::SignerCancelled + ); + let signer = LegacySigner { + receipt_request: super::request(), + calls: AtomicUsize::new(0), + fail: false, + }; + assert_eq!( + ready(signer.sign_authored_evidence(request)) + .unwrap_err() + .kind(), + Kind::InvalidArgument + ); + assert_eq!(signer.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn default_hook_does_not_invoke_an_adapter_for_already_cancelled_work() { + let request = request(); + request.cancellation_signal().cancel(); + let signer = LegacySigner { + receipt_request: super::request(), + calls: AtomicUsize::new(0), + fail: false, + }; + assert_eq!( + ready(signer.sign_authored_evidence(request)) + .unwrap_err() + .kind(), + Kind::SignerCancelled + ); + assert_eq!(signer.calls.load(Ordering::SeqCst), 0); +} diff --git a/crates/signing/tests/package_boundary.rs b/crates/signing/tests/package_boundary.rs @@ -114,7 +114,7 @@ fn crate_root_declares_the_approved_module_skeleton() { "pub use authorization::{CurrentAuthoringAuthority, CurrentAuthoringDecision};", "pub use error::Error;", "pub use identity::{AuthoredArtifactId, SignerRequestId, SigningIntentId, SigningOperationId};", - "pub use receipt::SignReceipt;", + "pub use receipt::{AuthoredSignEvidence, SignReceipt};", "pub use request::{SignRequest, SigningPurpose};", "pub use signer::Signer;", "pub use status::SignerStatus;", @@ -131,7 +131,7 @@ fn crate_root_declares_the_approved_module_skeleton() { "pub use authorization::{CurrentAuthoringAuthority, CurrentAuthoringDecision};", "pub use error::Error;", "pub use identity::{AuthoredArtifactId, SignerRequestId, SigningIntentId, SigningOperationId};", - "pub use receipt::SignReceipt;", + "pub use receipt::{AuthoredSignEvidence, SignReceipt};", "pub use request::{SignRequest, SigningPurpose};", "pub use signer::Signer;", "pub use status::SignerStatus;",