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:
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;",