lib

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

commit 035c3b551a4a8f4b407873710b80487207b94cff
parent cb8d1c740c8f2fea8a87c76da87dc6e63f7c4a61
Author: triesap <tyson@radroots.org>
Date:   Wed,  5 Aug 2026 00:57:03 +0000

refactor(signing): verify and recover plan signing

- authorize exact plans against current contract, actor, and host provenance policy
- derive stable artifact-bound signer request IDs with explicit replay recovery
- enforce millisecond deadlines, cooperative cancellation, and verified receipts
- migrate the local Nostr signer to exact authored-plan signing


Diffstat:
MCargo.lock | 4++++
Mcrates/nostr/src/plan_signing.rs | 17+++++++++++++++++
Mcrates/nostr/src/signing.rs | 249++++++++++++++++++++++++++++++-------------------------------------------------
Mcrates/nostr/tests/conformance.rs | 20+++++++++++++-------
Mcrates/signing/Cargo.toml | 6++++++
Mcrates/signing/README.md | 38++++++++++++++++++++++----------------
Mcrates/signing/examples/host_signer.rs | 26++++++++++++++++++--------
Mcrates/signing/src/actor.rs | 8++++----
Acrates/signing/src/authorization.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/src/capability.rs | 12++++++++++++
Mcrates/signing/src/error.rs | 18++++++++++++++++++
Acrates/signing/src/identity.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/src/lib.rs | 5+++++
Mcrates/signing/src/receipt.rs | 247+++++++++++++++++--------------------------------------------------------------
Acrates/signing/src/recovery.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/src/request.rs | 431+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcrates/signing/src/signer.rs | 169+++++++++++--------------------------------------------------------------------
Mcrates/signing/src/status.rs | 3+++
Acrates/signing/tests/authored_signing.rs | 300+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/tests/conformance.rs | 12+++++++++++-
Mcrates/signing/tests/package_boundary.rs | 26++++++++++++++++++++++----
21 files changed, 1073 insertions(+), 766 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2691,11 +2691,15 @@ dependencies = [ name = "radroots_signing" version = "0.1.0-alpha" dependencies = [ + "hex", + "nostr", "radroots_event", + "radroots_event_codec", "radroots_identity", "radroots_protocol", "serde", "serde_json", + "sha2", ] [[package]] diff --git a/crates/nostr/src/plan_signing.rs b/crates/nostr/src/plan_signing.rs @@ -11,6 +11,11 @@ use crate::{ }; use radroots_event_codec::authoring::AuthoredEventPlan; +#[cfg(feature = "signing")] +use nostr::JsonUtil; +#[cfg(feature = "signing")] +use radroots_event::{SignedEvent, wire::Nip01EventWire}; + pub(crate) fn unsigned_event_from_plan( plan: &AuthoredEventPlan, ) -> Result<nostr::UnsignedEvent, Error> { @@ -45,6 +50,18 @@ pub(crate) fn unsigned_event_from_plan( Ok(unsigned) } +#[cfg(feature = "signing")] +pub(crate) fn sign_authored_plan( + keys: &nostr::Keys, + plan: &AuthoredEventPlan, +) -> Result<SignedEvent, Error> { + let event = unsigned_event_from_plan(plan)?.sign_with_keys(keys)?; + validate_signed_event_matches_plan(&event, plan)?; + let raw_json = event.as_json(); + let wire = Nip01EventWire::parse_json(&raw_json)?; + SignedEvent::from_wire_verified_id(wire, raw_json).map_err(Into::into) +} + pub(crate) fn validate_signed_event_matches_plan( event: &RadrootsNostrEvent, plan: &AuthoredEventPlan, diff --git a/crates/nostr/src/signing.rs b/crates/nostr/src/signing.rs @@ -1,7 +1,7 @@ //! Concrete local Nostr implementation of the generic signing SPI. //! -//! Signing is local and in-memory. Success returns a verified receipt without -//! persisting or publishing it; dropping the future creates no durable effect. +//! Signing is local and in-memory. Success returns a cryptographically verified +//! exact-plan receipt without persistence or publication. use core::fmt; use std::{ @@ -9,25 +9,23 @@ use std::{ vec, }; +use radroots_identity::PublicKey; use radroots_signing::{ Error as SigningError, SignReceipt, SignRequest, Signer, SignerStatus, capability::{CancellationSupport, SignerCapability, SignerKind}, error::Kind, + recovery::ReplayCapability, signer::BoxFuture, status::{SignProgress, SignProgressStage, SignerAvailability}, }; use crate::{Error as NostrError, key::SecretKey}; -use radroots_identity::PublicKey; pub use crate::draft_signing::sign_frozen_draft; type Clock = fn() -> Result<u64, SigningError>; /// A local Nostr key-backed signer adapter. -/// -/// Key material remains private to this adapter. Debug output reports only the -/// public key and never delegates to the upstream key container. pub struct LocalSigner { keys: nostr::Keys, public_key: PublicKey, @@ -35,17 +33,14 @@ pub struct LocalSigner { } impl LocalSigner { - /// Consumes one opaque local secret and creates its signer adapter. pub fn new(secret_key: SecretKey) -> Result<Self, crate::Error> { - Self::with_clock(secret_key, system_time_unix) + Self::with_clock(secret_key, system_time_unix_ms) } - /// Generates a fresh opaque secret and creates its signer adapter. pub fn generate() -> Result<Self, crate::Error> { Self::new(SecretKey::generate()) } - /// Returns the canonical public identity controlled by this signer. #[must_use] pub const fn public_key(&self) -> PublicKey { self.public_key @@ -78,6 +73,7 @@ impl Signer for LocalSigner { SignerAvailability::Ready, vec![SignerCapability::new( SignerKind::Local, + ReplayCapability::LocalReplaySafe, CancellationSupport::BeforePublication, true, false, @@ -89,46 +85,51 @@ impl Signer for LocalSigner { fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, SigningError>> { Box::pin(async move { - let started_at_unix = (self.clock)()?; - if started_at_unix >= request.policy().deadline_unix() { - return Err(SigningError::new(Kind::DeadlineExceeded)); - } + request.ensure_active((self.clock)()?)?; request.report_progress( &SignProgress::stage(SignProgressStage::Validating) - .expect("validating progress never requires a challenge"), + .expect("validating has no challenge"), ); - let signed_event = - sign_frozen_draft(&self.keys, request.draft()).map_err(normalize_nostr_error)?; + if request.plan().author() != &self.public_key { + return Err(SigningError::new(Kind::AuthorizationDenied)); + } + let signed_event = crate::plan_signing::sign_authored_plan(&self.keys, request.plan()) + .map_err(normalize_nostr_error)?; request.report_progress( &SignProgress::stage(SignProgressStage::VerifyingOutput) - .expect("verification progress never requires a challenge"), + .expect("verification has no challenge"), ); - let completed_at_unix = (self.clock)()?; - if completed_at_unix >= request.policy().deadline_unix() { - return Err(SigningError::new(Kind::DeadlineExceeded)); - } + let completed_at_unix_ms = (self.clock)()?; + request.ensure_active(completed_at_unix_ms)?; let receipt = - SignReceipt::from_signed_event(&request, signed_event, completed_at_unix)?; + SignReceipt::from_signed_event(&request, signed_event, completed_at_unix_ms)?; request.report_progress( &SignProgress::stage(SignProgressStage::Complete) - .expect("completion progress never requires a challenge"), + .expect("completion has no challenge"), ); Ok(receipt) }) } } -fn system_time_unix() -> Result<u64, SigningError> { +fn system_time_unix_ms() -> Result<u64, SigningError> { SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) .map_err(|source| SigningError::with_source(Kind::InternalError, source)) + .and_then(|duration| { + u64::try_from(duration.as_millis()) + .map_err(|source| SigningError::with_source(Kind::InternalError, source)) + }) } fn normalize_nostr_error(source: NostrError) -> SigningError { let kind = match &source { - NostrError::FrozenDraftPubkeyMismatch { .. } => Kind::AuthorizationDenied, - NostrError::FrozenDraftEventIdMismatch { .. } => Kind::SignerOutputInvalid, + NostrError::FrozenDraftPubkeyMismatch { .. } + | NostrError::ExternalSigningAuthorMismatch { .. } => Kind::AuthorizationDenied, + NostrError::FrozenDraftEventIdMismatch { .. } + | NostrError::ExternalSigningEventIdMismatch { .. } + | NostrError::ExternalSigningEventInvalid(_) + | NostrError::ExternalSigningPlanMismatch { .. } => Kind::SignerOutputInvalid, _ => Kind::InternalError, }; SigningError::with_source(kind, source) @@ -137,16 +138,13 @@ fn normalize_nostr_error(source: NostrError) -> SigningError { #[cfg(test)] mod tests { use super::*; - use radroots_event::{EventDraft, contract::AuthorRole, envelope::kind::KIND_GEOCHAT}; + use radroots_event::{GenericEventDraft, contract::AuthorRole}; + use radroots_event_codec::authoring::AuthoredEventPlan; use radroots_protocol::runtime::v1::OperationId; use radroots_signing::{ - Actor, + Actor, AuthoredArtifactId, SigningIntentId, SigningOperationId, actor::ActorSource, - request::{CancellationPolicy, ProgressObserver, SignPolicy}, - }; - use std::sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, + request::{CancellationPolicy, CancellationSignal, SignPolicy}, }; use crate::{ @@ -154,38 +152,19 @@ mod tests { test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB}, }; - const DEADLINE: u64 = 1_700_000_100; - static CROSSING_DEADLINE_CALLS: AtomicUsize = AtomicUsize::new(0); - - struct RecordingObserver(Mutex<Vec<SignProgressStage>>); - - impl ProgressObserver for RecordingObserver { - fn on_progress(&self, progress: &SignProgress) { - self.0 - .lock() - .expect("progress lock") - .push(progress.stage_value()); - } - } + const CREATED_AT: u64 = 1_700_000_000; + const DEADLINE_MS: u64 = 1_700_000_100_000; fn before_deadline() -> Result<u64, SigningError> { - Ok(1_700_000_050) + Ok(DEADLINE_MS - 1) } fn at_deadline() -> Result<u64, SigningError> { - Ok(DEADLINE) + Ok(DEADLINE_MS) } - fn crossing_deadline() -> Result<u64, SigningError> { - if CROSSING_DEADLINE_CALLS.fetch_add(1, Ordering::SeqCst) == 0 { - before_deadline() - } else { - at_deadline() - } - } - - fn fixture_secret(secret_key_hex: &str) -> SecretKey { - parse_secret_key(secret_key_hex).expect("secret key fixture") + fn fixture_secret(value: &str) -> SecretKey { + parse_secret_key(value).expect("secret fixture") } fn request() -> SignRequest { @@ -194,130 +173,90 @@ mod tests { ActorSource::ExplicitPublicKey, [AuthorRole::Any], ) - .expect("actor"); - let draft = EventDraft::new( + .unwrap(); + let draft = GenericEventDraft::new( "radroots.social.geochat.v1", - KIND_GEOCHAT, - 1_700_000_000, + 20_000, + CREATED_AT, Vec::new(), "private-fixture-content", FIXTURE_ALICE.public_key_hex, ) - .expect("draft"); + .unwrap(); SignRequest::new( OperationId::SyncPush, + SigningIntentId::new( + SigningOperationId::new([1; 16]).unwrap(), + AuthoredArtifactId::new([2; 16]).unwrap(), + ), actor, - draft, - SignPolicy::new(DEADLINE, CancellationPolicy::LocalCooperative).expect("policy"), + AuthoredEventPlan::from_generic(draft).unwrap(), + SignPolicy::new(DEADLINE_MS, CancellationPolicy::LocalCooperative).unwrap(), ) - .expect("request") + .unwrap() } #[tokio::test] - async fn local_adapter_reports_capability_and_signs_the_exact_draft() { + async fn local_signer_reports_safe_replay_and_returns_verified_exact_plan() { let signer = LocalSigner::with_clock( fixture_secret(FIXTURE_ALICE.secret_key_hex), before_deadline, ) - .expect("local signer"); - let status = signer.status().await.expect("status"); - assert_eq!(status.availability(), SignerAvailability::Ready); - assert_eq!(status.capabilities().len(), 1); - let capability = status.capabilities()[0]; - assert_eq!(capability.kind(), SignerKind::Local); + .unwrap(); + let status = signer.status().await.unwrap(); assert_eq!( - capability.cancellation(), - CancellationSupport::BeforePublication + status.capabilities()[0].replay(), + ReplayCapability::LocalReplaySafe ); - assert!(capability.reports_progress()); - assert!(!capability.may_require_authentication()); - assert_eq!(signer.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); - - let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); - let request = request().with_progress_observer(observer.clone()); - let expected_id = request.draft().expected_event_id_hex(); - let receipt = signer.sign(request).await.expect("receipt"); - - assert_eq!(receipt.operation_id(), OperationId::SyncPush); - assert_eq!(receipt.completed_at_unix(), 1_700_000_050); + let request = request(); + let expected_id = request.plan().expected_event_id().to_hex(); + let receipt = signer.sign(request).await.unwrap(); assert_eq!(receipt.signed_event().id_str(), expected_id); - assert_eq!( - receipt.signed_event().pubkey().to_hex(), - FIXTURE_ALICE.public_key_hex - ); - assert_eq!( - observer.0.lock().expect("progress lock").as_slice(), - &[ - SignProgressStage::Validating, - SignProgressStage::VerifyingOutput, - SignProgressStage::Complete, - ] - ); + assert_eq!(receipt.completed_at_unix_ms(), DEADLINE_MS - 1); } #[tokio::test] - async fn wrong_local_key_is_normalized_without_leaking_secret_material() { - let signer = + async fn wrong_key_deadline_and_cancellation_fail_closed() { + let wrong = LocalSigner::with_clock(fixture_secret(FIXTURE_BOB.secret_key_hex), before_deadline) - .expect("local signer"); - let error = signer - .sign(request()) - .await - .expect_err("wrong key must fail"); - - assert_eq!(error.kind(), Kind::AuthorizationDenied); - assert!(!error.to_string().contains(FIXTURE_BOB.secret_key_hex)); - assert!(!format!("{error:?}").contains(FIXTURE_BOB.secret_key_hex)); - assert!(!format!("{signer:?}").contains(FIXTURE_BOB.secret_key_hex)); - assert!(!format!("{signer:?}").contains(FIXTURE_BOB.nsec)); - } - - #[test] - fn generated_local_signer_exposes_only_its_public_identity() { - let signer = LocalSigner::generate().expect("generated local signer"); - let rendered = format!("{signer:?}"); - - assert_eq!(signer.public_key().to_hex().len(), 64); - assert!(rendered.contains("[redacted]")); - assert!(rendered.contains(&signer.public_key().to_hex())); - } - - #[tokio::test] - async fn expired_deadline_fails_before_local_signing() { - let signer = + .unwrap(); + assert_eq!( + wrong.sign(request()).await.unwrap_err().kind(), + Kind::AuthorizationDenied + ); + let expired = LocalSigner::with_clock(fixture_secret(FIXTURE_ALICE.secret_key_hex), at_deadline) - .expect("local signer"); - let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); - let error = signer - .sign(request().with_progress_observer(observer.clone())) + .unwrap(); + assert_eq!( + expired.sign(request()).await.unwrap_err().kind(), + Kind::DeadlineExceeded + ); + let signal = CancellationSignal::new(); + let cancelled = request().with_cancellation_signal(signal.clone()); + signal.cancel(); + assert_eq!( + LocalSigner::with_clock( + fixture_secret(FIXTURE_ALICE.secret_key_hex), + before_deadline, + ) + .unwrap() + .sign(cancelled) .await - .expect_err("deadline must fail"); - - assert_eq!(error.kind(), Kind::DeadlineExceeded); - assert!(observer.0.lock().expect("progress lock").is_empty()); + .unwrap_err() + .kind(), + Kind::SignerCancelled + ); } - #[tokio::test] - async fn deadline_crossing_discards_output_before_receipt_completion() { - CROSSING_DEADLINE_CALLS.store(0, Ordering::SeqCst); + #[test] + fn debug_never_exposes_local_secret_material() { let signer = LocalSigner::with_clock( fixture_secret(FIXTURE_ALICE.secret_key_hex), - crossing_deadline, + before_deadline, ) - .expect("local signer"); - let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); - let error = signer - .sign(request().with_progress_observer(observer.clone())) - .await - .expect_err("crossed deadline must fail"); - - assert_eq!(error.kind(), Kind::DeadlineExceeded); - assert_eq!( - observer.0.lock().expect("progress lock").as_slice(), - &[ - SignProgressStage::Validating, - SignProgressStage::VerifyingOutput, - ] - ); + .unwrap(); + let debug = format!("{signer:?}"); + assert!(!debug.contains(FIXTURE_ALICE.secret_key_hex)); + assert!(!debug.contains(FIXTURE_ALICE.nsec)); } } diff --git a/crates/nostr/tests/conformance.rs b/crates/nostr/tests/conformance.rs @@ -35,12 +35,13 @@ fn public_protocol_conversions_are_canonical_and_typed() { #[cfg(feature = "signing")] #[tokio::test] -async fn public_local_signer_signs_only_the_exact_authorized_draft() { - use radroots_event::{EventDraft, contract::AuthorRole, envelope::kind::KIND_GEOCHAT}; +async fn public_local_signer_signs_only_the_exact_authorized_plan() { + use radroots_event::{GenericEventDraft, contract::AuthorRole, envelope::kind::KIND_GEOCHAT}; + use radroots_event_codec::authoring::AuthoredEventPlan; use radroots_nostr::{key::SecretKey, signing::LocalSigner}; use radroots_protocol::runtime::v1::OperationId; use radroots_signing::{ - Actor, SignRequest, Signer, + Actor, AuthoredArtifactId, SignRequest, Signer, SigningIntentId, SigningOperationId, actor::ActorSource, request::{CancellationPolicy, SignPolicy}, }; @@ -51,7 +52,7 @@ async fn public_local_signer_signs_only_the_exact_authorized_draft() { let public_key = secret_key.public_key().expect("public key"); assert_eq!(public_key.to_hex(), PUBLIC_KEY); - let draft = EventDraft::new( + let draft = GenericEventDraft::new( "radroots.social.geochat.v1", KIND_GEOCHAT, 1_700_000_000, @@ -59,8 +60,9 @@ async fn public_local_signer_signs_only_the_exact_authorized_draft() { "package-conformance-message", PUBLIC_KEY, ) - .expect("frozen event draft"); - let expected_id = draft.expected_event_id_hex(); + .expect("generic event draft"); + let plan = AuthoredEventPlan::from_generic(draft).expect("authored plan"); + let expected_id = plan.expected_event_id().to_hex(); let actor = Actor::new( public_key, ActorSource::ExplicitPublicKey, @@ -69,8 +71,12 @@ async fn public_local_signer_signs_only_the_exact_authorized_draft() { .expect("authorized actor"); let request = SignRequest::new( OperationId::SyncPush, + SigningIntentId::new( + SigningOperationId::new([1; 16]).expect("operation ID"), + AuthoredArtifactId::new([2; 16]).expect("artifact ID"), + ), actor, - draft, + plan, SignPolicy::new(u64::MAX, CancellationPolicy::LocalCooperative) .expect("bounded signing policy"), ) diff --git a/crates/signing/Cargo.toml b/crates/signing/Cargo.toml @@ -25,20 +25,25 @@ name = "radroots_signing" default = ["std", "serde"] std = [ "radroots_event/std", + "radroots_event_codec/std", "radroots_identity/std", "radroots_protocol/std", ] serde = [ "dep:serde", "radroots_event/serde", + "radroots_event_codec/json", "radroots_identity/serde", "radroots_protocol/serde", ] [dependencies] radroots_event = { workspace = true, default-features = false } +radroots_event_codec = { workspace = true, default-features = false } radroots_identity = { workspace = true, default-features = false } radroots_protocol = { workspace = true, default-features = false } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +sha2 = { workspace = true, default-features = false } serde = { workspace = true, default-features = false, features = [ "alloc", "derive", @@ -46,6 +51,7 @@ serde = { workspace = true, default-features = false, features = [ [dev-dependencies] serde_json = { workspace = true, features = ["std"] } +nostr = { workspace = true, features = ["std"] } [lints] workspace = true diff --git a/crates/signing/README.md b/crates/signing/README.md @@ -1,8 +1,8 @@ # radroots_signing `radroots_signing` is the protocol-neutral host SPI for authorizing and signing -frozen Radroots event drafts. It owns actor provenance, signer capabilities and -status, bounded signing requests, exact-draft receipts, progress, and normalized +exact Radroots authored-event plans. It owns actor provenance, signer capabilities and +status, bounded signing requests, verified receipts, progress, and normalized secret-safe errors. The crate is `no_std` with `alloc`. It does not own secret keys, keyrings, @@ -17,14 +17,14 @@ The authoritative package charter is the ## Typical flow 1. A host resolves public actor provenance and roles into an [`Actor`]. -2. Event-domain code freezes a canonical `radroots_event::EventDraft`. -3. The host combines the actor, draft, typed operation ID, deadline, and +2. Event-codec code creates an immutable `AuthoredEventPlan`. +3. The host combines stable operation/artifact identity, actor, plan, deadline, and cancellation policy into a [`SignRequest`]. Construction validates the - draft, required author role, and expected public key before a signer runs. + current authorization, required author role, public key, and provenance. 4. A dyn-compatible [`Signer`] implementation signs locally, delegates to a remote device/service, or mediates explicit host interaction. 5. The implementation creates a [`SignReceipt`] from the originating request. - Receipt construction rejects any signed-event drift from the frozen draft. + Receipt construction rejects plan drift and invalid Schnorr signatures. [`Actor`]: crate::Actor [`Signer`]: crate::Signer @@ -32,11 +32,12 @@ The authoritative package charter is the [`SignReceipt`]: crate::SignReceipt ```rust -use radroots_event::{EventDraft, contract::AuthorRole}; +use radroots_event::{GenericEventDraft, contract::AuthorRole}; +use radroots_event_codec::authoring::AuthoredEventPlan; use radroots_identity::PublicKey; use radroots_protocol::runtime::v1::OperationId; use radroots_signing::{ - Actor, SignRequest, + Actor, AuthoredArtifactId, SignRequest, SigningIntentId, SigningOperationId, actor::ActorSource, request::{CancellationPolicy, SignPolicy}, }; @@ -51,7 +52,7 @@ let actor = Actor::new( [AuthorRole::Any], ) .expect("validated actor"); -let draft = EventDraft::new( +let draft = GenericEventDraft::new( "radroots.social.geochat.v1", 20_000, 1_700_000_000, @@ -59,16 +60,21 @@ let draft = EventDraft::new( "hello from Radroots", public_key.to_hex(), ) -.expect("frozen draft"); +.expect("validated generic draft"); +let plan = AuthoredEventPlan::from_generic(draft).expect("exact plan"); +let intent_id = SigningIntentId::new( + SigningOperationId::new([1; 16]).expect("operation ID"), + AuthoredArtifactId::new([2; 16]).expect("artifact ID"), +); let policy = SignPolicy::new( - 1_700_000_030, + 1_700_000_030_000, CancellationPolicy::PreservePublishedRequest, ) .expect("bounded policy"); -let request = SignRequest::new(OperationId::SyncPush, actor, draft, policy) +let request = SignRequest::new(OperationId::SyncPush, intent_id, actor, plan, policy) .expect("authorized signing request"); -assert_eq!(request.operation_id(), OperationId::SyncPush); +assert_eq!(request.operation_kind(), OperationId::SyncPush); ``` The complete externally implementable SPI example is @@ -96,7 +102,7 @@ select an async runtime or require an async-trait macro. ## Deadlines, cancellation, and commit points -`SignPolicy` carries an absolute Unix deadline. A signer must reject work once +`SignPolicy` carries an absolute Unix-millisecond deadline. A signer must reject work once that deadline is reached; request construction does not read a clock. `CancellationPolicy::LocalCooperative` is for local-only work that may stop @@ -126,10 +132,10 @@ secret material in it. ## Security and side effects -- Request construction validates the current draft before role and key +- Request construction separates current authorization from historical plan integrity before role, key, and provenance authorization and never invokes a signer on failure. - A successful receipt proves exact equality of author, timestamp, kind, tags, - content, and event ID with the frozen request draft. + content, and event ID with the exact request plan, plus a valid signature. - `Error` display/debug output and protocol reports are redacted. Under `std`, a caller may explicitly inspect a preserved native error source locally. - `AuthChallenge` debug output redacts its URI; the value accepts only bounded diff --git a/crates/signing/examples/host_signer.rs b/crates/signing/examples/host_signer.rs @@ -1,8 +1,10 @@ -use radroots_event::{EventDraft, contract::AuthorRole}; +use radroots_event::{GenericEventDraft, contract::AuthorRole}; +use radroots_event_codec::authoring::AuthoredEventPlan; use radroots_identity::PublicKey; use radroots_protocol::runtime::v1::OperationId; use radroots_signing::{ - Actor, Error, SignReceipt, SignRequest, Signer, SignerStatus, + Actor, AuthoredArtifactId, Error, SignReceipt, SignRequest, Signer, SignerStatus, + SigningIntentId, SigningOperationId, actor::ActorSource, error::Kind, request::{CancellationPolicy, SignPolicy}, @@ -31,7 +33,7 @@ fn main() { [AuthorRole::Any], ) .expect("validated actor"); - let draft = EventDraft::new( + let draft = GenericEventDraft::new( "radroots.social.geochat.v1", 20_000, 1_700_000_000, @@ -39,11 +41,19 @@ fn main() { "host-composed signing", public_key.to_hex(), ) - .expect("frozen draft"); - let policy = SignPolicy::new(1_700_000_030, CancellationPolicy::PreservePublishedRequest) - .expect("bounded policy"); - let request = - SignRequest::new(OperationId::SyncPush, actor, draft, policy).expect("authorized request"); + .expect("validated generic draft"); + let plan = AuthoredEventPlan::from_generic(draft).expect("exact authored plan"); + let intent_id = SigningIntentId::new( + SigningOperationId::new([1; 16]).expect("operation ID"), + AuthoredArtifactId::new([2; 16]).expect("artifact ID"), + ); + let policy = SignPolicy::new( + 1_700_000_030_000, + CancellationPolicy::PreservePublishedRequest, + ) + .expect("bounded policy"); + let request = SignRequest::new(OperationId::SyncPush, intent_id, actor, plan, policy) + .expect("authorized request"); let signer: &dyn Signer = &HostSigner; let future = signer.sign(request); diff --git a/crates/signing/src/actor.rs b/crates/signing/src/actor.rs @@ -57,11 +57,11 @@ pub enum ActorSelector { Account(AccountId), /// Use one explicit canonical public key. PublicKey(PublicKey), - /// Resolve the public key frozen into the event draft. - DraftExpectedPublicKey, + /// Resolve the public key frozen into the authored event plan. + PlanAuthorPublicKey, } -/// Inputs a host must satisfy when resolving an actor for a draft. +/// Inputs a host must satisfy when resolving an actor for an authored plan. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ActorResolutionRequest { selector: ActorSelector, @@ -96,7 +96,7 @@ impl ActorResolutionRequest { self.required_role } - /// Returns the exact public key frozen into the draft. + /// Returns the exact public key frozen into the plan. #[must_use] pub const fn expected_public_key(&self) -> PublicKey { self.expected_public_key diff --git a/crates/signing/src/authorization.rs b/crates/signing/src/authorization.rs @@ -0,0 +1,80 @@ +//! Current signing authorization, separate from historical plan integrity. + +use radroots_event::contract::{EventAuthoringPolicy, EventStability, event_contract}; +use radroots_event_codec::authoring::AuthoredEventPlan; + +use crate::{Actor, actor::ActorSource}; + +/// Current policy decision for one historically valid authored plan. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CurrentAuthoringDecision { + Allowed, + AllowedDeprecated { warning_code: &'static str }, + Blocked { code: &'static str }, + Revoked { code: &'static str }, +} + +/// Host or registry authority for current signing policy. +pub trait CurrentAuthoringAuthority: Send + Sync { + fn evaluate(&self, plan: &AuthoredEventPlan) -> CurrentAuthoringDecision; +} + +/// Current immutable registry policy. +#[derive(Clone, Copy, Debug, Default)] +pub struct CurrentRegistryAuthority; + +impl CurrentAuthoringAuthority for CurrentRegistryAuthority { + fn evaluate(&self, plan: &AuthoredEventPlan) -> CurrentAuthoringDecision { + let Some(contract) = event_contract(plan.body().contract().contract_id().as_str()) else { + return CurrentAuthoringDecision::Blocked { + code: "contract_not_current", + }; + }; + if contract.authoring_policy() == EventAuthoringPolicy::ReadOnly { + return CurrentAuthoringDecision::Revoked { + code: "contract_read_only", + }; + } + match contract.stability { + EventStability::Stable => CurrentAuthoringDecision::Allowed, + EventStability::Experimental => CurrentAuthoringDecision::AllowedDeprecated { + warning_code: "contract_experimental", + }, + } + } +} + +/// Whether a request explicitly accepts a currently deprecated contract. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DeprecatedPlanPolicy { + #[default] + Deny, + Allow, +} + +/// Host-owned policy limiting which validated actor provenance may sign. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ManagedSigningPolicy { + #[default] + AnyValidatedSource, + AccountBackedOnly, + LocalAccountOnly, +} + +impl ManagedSigningPolicy { + #[must_use] + pub const fn permits(self, actor: &Actor) -> bool { + match self { + Self::AnyValidatedSource => true, + Self::AccountBackedOnly => actor.source().account_id().is_some(), + Self::LocalAccountOnly => matches!(actor.source(), ActorSource::LocalAccount(_)), + } + } +} diff --git a/crates/signing/src/capability.rs b/crates/signing/src/capability.rs @@ -1,5 +1,7 @@ //! Signer capability declarations. +use crate::recovery::ReplayCapability; + /// How a signer implementation obtains signatures. #[non_exhaustive] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -34,6 +36,7 @@ pub enum CancellationSupport { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SignerCapability { kind: SignerKind, + replay: ReplayCapability, cancellation: CancellationSupport, reports_progress: bool, may_require_authentication: bool, @@ -44,12 +47,14 @@ impl SignerCapability { #[must_use] pub const fn new( kind: SignerKind, + replay: ReplayCapability, cancellation: CancellationSupport, reports_progress: bool, may_require_authentication: bool, ) -> Self { Self { kind, + replay, cancellation, reports_progress, may_require_authentication, @@ -62,6 +67,12 @@ impl SignerCapability { self.kind } + /// Returns the exact replay contract. + #[must_use] + pub const fn replay(self) -> ReplayCapability { + self.replay + } + /// Returns the advertised cancellation contract. #[must_use] pub const fn cancellation(self) -> CancellationSupport { @@ -89,6 +100,7 @@ mod tests { fn capability_round_trips_with_stable_wire_labels() { let capability = SignerCapability::new( SignerKind::Remote, + ReplayCapability::ExactReplayByRequestId, CancellationSupport::BeforeAndAfterPublication, true, true, diff --git a/crates/signing/src/error.rs b/crates/signing/src/error.rs @@ -7,6 +7,8 @@ use radroots_protocol::{ runtime::v1::OperationId, }; +use crate::recovery::RemoteEffect; + #[cfg(feature = "std")] use std::boxed::Box; @@ -129,6 +131,7 @@ signing_error_catalog! { /// diagnostics; protocol conversion always discards it. pub struct Error { kind: Kind, + remote_effect: RemoteEffect, #[cfg(feature = "std")] source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>, } @@ -139,6 +142,7 @@ impl Error { pub const fn new(kind: Kind) -> Self { Self { kind, + remote_effect: RemoteEffect::None, #[cfg(feature = "std")] source: None, } @@ -153,16 +157,29 @@ impl Error { { Self { kind, + remote_effect: RemoteEffect::None, source: Some(Box::new(source)), } } + /// Marks that a failed remote invocation may already have taken effect. + #[must_use] + pub const fn with_possible_remote_effect(mut self) -> Self { + self.remote_effect = RemoteEffect::MayHaveOccurred; + self + } + #[must_use] pub const fn kind(&self) -> Kind { self.kind } #[must_use] + pub const fn remote_effect(&self) -> RemoteEffect { + self.remote_effect + } + + #[must_use] pub const fn descriptor(&self) -> Descriptor { self.kind.descriptor() } @@ -198,6 +215,7 @@ impl fmt::Debug for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let mut value = formatter.debug_struct("Error"); value.field("kind", &self.kind); + value.field("remote_effect", &self.remote_effect); #[cfg(feature = "std")] value.field("source", &self.source.as_ref().map(|_| "[redacted]")); value.finish() diff --git a/crates/signing/src/identity.rs b/crates/signing/src/identity.rs @@ -0,0 +1,117 @@ +//! Stable signing operation, artifact, and signer-request identities. + +use core::fmt; +use radroots_event_codec::authoring::PlanDigest; +use sha2::{Digest, Sha256}; + +use crate::{Error, error::Kind}; + +const REQUEST_ID_DOMAIN: &[u8] = b"radroots.signer_request.v1"; + +macro_rules! nonzero_id { + ($name:ident, $label:literal) => { + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name([u8; 16]); + + impl $name { + pub fn new(bytes: [u8; 16]) -> Result<Self, Error> { + if bytes == [0; 16] { + return Err(Error::new(Kind::InvalidArgument)); + } + Ok(Self(bytes)) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } + + #[must_use] + pub fn to_hex(self) -> alloc_or_std::String { + hex::encode(self.0) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple($label).field(&self.to_hex()).finish() + } + } + }; +} + +#[cfg(not(feature = "std"))] +mod alloc_or_std { + pub use alloc::string::String; +} +#[cfg(feature = "std")] +mod alloc_or_std { + pub use std::string::String; +} + +nonzero_id!(SigningOperationId, "SigningOperationId"); +nonzero_id!(AuthoredArtifactId, "AuthoredArtifactId"); + +/// Stable parent/child identity for one authored artifact signing operation. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SigningIntentId { + operation_id: SigningOperationId, + artifact_id: AuthoredArtifactId, +} + +impl SigningIntentId { + #[must_use] + pub const fn new(operation_id: SigningOperationId, artifact_id: AuthoredArtifactId) -> Self { + Self { + operation_id, + artifact_id, + } + } + + #[must_use] + pub const fn operation_id(self) -> SigningOperationId { + self.operation_id + } + + #[must_use] + pub const fn artifact_id(self) -> AuthoredArtifactId { + self.artifact_id + } +} + +/// Deterministic identity a replay-capable remote signer must deduplicate. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SignerRequestId([u8; 32]); + +impl SignerRequestId { + #[must_use] + pub fn derive(artifact_id: AuthoredArtifactId, plan_digest: PlanDigest) -> Self { + let mut digest = Sha256::new(); + digest.update(REQUEST_ID_DOMAIN); + digest.update(artifact_id.as_bytes()); + digest.update(plan_digest.as_bytes()); + Self(digest.finalize().into()) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + #[must_use] + pub fn to_hex(self) -> alloc_or_std::String { + hex::encode(self.0) + } +} + +impl fmt::Debug for SignerRequestId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("SignerRequestId") + .field(&self.to_hex()) + .finish() + } +} diff --git a/crates/signing/src/lib.rs b/crates/signing/src/lib.rs @@ -6,15 +6,20 @@ extern crate alloc; pub mod actor; +pub mod authorization; pub mod capability; pub mod error; +pub mod identity; pub mod receipt; +pub mod recovery; pub mod request; pub mod signer; pub mod status; 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 request::SignRequest; pub use signer::Signer; diff --git a/crates/signing/src/receipt.rs b/crates/signing/src/receipt.rs @@ -1,242 +1,99 @@ -//! Signing receipts. +//! Cryptographically verified exact-plan signing receipts. use core::fmt; -use radroots_event::{SignedEvent, draft::validate_signed_nostr_event_matches_draft}; +use radroots_event::SignedEvent; +use radroots_event_codec::verify::{self, Nip01SignatureVerifier, RawEvent}; use radroots_protocol::runtime::v1::OperationId; -use crate::{Error, SignRequest, error::Kind}; +use crate::{Error, SignRequest, SignerRequestId, SigningIntentId, error::Kind}; -/// Successful signer output with portable operation provenance. -/// -/// Native receipts cannot be deserialized without the originating request; -/// adapters must use [`SignReceipt::from_signed_event`] so exact-draft -/// verification cannot be bypassed. -/// -/// ```compile_fail -/// use radroots_signing::SignReceipt; -/// -/// let _: SignReceipt = serde_json::from_str("{}").unwrap(); -/// ``` +/// Successful signer output with exact request and artifact provenance. #[non_exhaustive] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, PartialEq, Eq)] pub struct SignReceipt { - operation_id: OperationId, + operation_kind: OperationId, + intent_id: SigningIntentId, + signer_request_id: SignerRequestId, signed_event: SignedEvent, - completed_at_unix: u64, + completed_at_unix_ms: u64, } impl fmt::Debug for SignReceipt { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("SignReceipt") - .field("operation_id", &self.operation_id) + .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("completed_at_unix", &self.completed_at_unix) + .field("completed_at_unix_ms", &self.completed_at_unix_ms) .finish() } } impl SignReceipt { - /// Validates signer output against the exact request draft and creates its - /// receipt. This is the only public receipt constructor. + /// Verifies exact plan fields, raw/wire coherence, event ID, signature, + /// deadline, cancellation, and request identity before success exists. pub fn from_signed_event( request: &SignRequest, signed_event: SignedEvent, - completed_at_unix: u64, + completed_at_unix_ms: u64, ) -> Result<Self, Error> { - validate_signed_nostr_event_matches_draft(&signed_event, request.draft()).map_err( - |source| { - #[cfg(feature = "std")] - { - Error::with_source(Kind::SignerOutputInvalid, source) - } - #[cfg(not(feature = "std"))] - { - let _ = source; - Error::new(Kind::SignerOutputInvalid) - } - }, - )?; + 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))?; Ok(Self { - operation_id: request.operation_id(), + operation_kind: request.operation_kind(), + intent_id: request.intent_id(), + signer_request_id: request.signer_request_id(), signed_event, - completed_at_unix, + completed_at_unix_ms, }) } - /// Returns the originating runtime operation identity. #[must_use] - pub const fn operation_id(&self) -> OperationId { - self.operation_id + pub const fn operation_kind(&self) -> OperationId { + self.operation_kind } - /// Borrows the invariant-checked signed event. #[must_use] - pub const fn signed_event(&self) -> &SignedEvent { - &self.signed_event + pub const fn intent_id(&self) -> SigningIntentId { + self.intent_id } - /// Returns the host-supplied completion timestamp. #[must_use] - pub const fn completed_at_unix(&self) -> u64 { - 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, - format, - string::{String, ToString}, - 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") + pub const fn signer_request_id(&self) -> SignerRequestId { + self.signer_request_id } - 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(), - ) + #[must_use] + pub const fn signed_event(&self) -> &SignedEvent { + &self.signed_event } - #[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")); + #[must_use] + pub const fn completed_at_unix_ms(&self) -> u64 { + self.completed_at_unix_ms } +} - #[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 { - let error = - SignReceipt::from_signed_event(&request, event, 42).expect_err("drift must fail"); - assert_eq!(error.kind(), Kind::SignerOutputInvalid); - } +fn verify_exact_plan(event: &SignedEvent, request: &SignRequest) -> Result<(), Error> { + let plan = request.plan(); + if event.pubkey() != plan.author() + || event.created_at() != plan.created_at() + || event.kind() != plan.body().kind() + || event.tags_as_vec() != plan.body().tags() + || event.content() != plan.body().content() + || event.id() != plan.expected_event_id() + { + return Err(Error::new(Kind::SignerOutputInvalid)); } + // `SignedEvent` construction proves its retained raw JSON parses to this + // exact wire value; the checks above bind that wire to the request plan. + Ok(()) } diff --git a/crates/signing/src/recovery.rs b/crates/signing/src/recovery.rs @@ -0,0 +1,51 @@ +//! Explicit signer replay and uncertain remote-effect recovery contracts. + +/// What exact replay behavior a signer guarantees. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReplayCapability { + ExactReplayByRequestId, + LocalReplaySafe, + NonReplayable, +} + +/// Whether a failed call may already have created a durable remote effect. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RemoteEffect { + #[default] + None, + MayHaveOccurred, +} + +/// Required durable recovery treatment after a signing failure. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecoveryDisposition { + RetryExactRequest, + RetryLocal, + Indeterminate, + Failed, +} + +#[must_use] +pub const fn recovery_disposition( + replay: ReplayCapability, + remote_effect: RemoteEffect, + retryable: bool, +) -> RecoveryDisposition { + if !retryable { + return RecoveryDisposition::Failed; + } + match (replay, remote_effect) { + (ReplayCapability::ExactReplayByRequestId, _) => RecoveryDisposition::RetryExactRequest, + (ReplayCapability::LocalReplaySafe, RemoteEffect::None) => RecoveryDisposition::RetryLocal, + (ReplayCapability::NonReplayable, RemoteEffect::MayHaveOccurred) + | (ReplayCapability::LocalReplaySafe, RemoteEffect::MayHaveOccurred) => { + RecoveryDisposition::Indeterminate + } + (ReplayCapability::NonReplayable, RemoteEffect::None) => RecoveryDisposition::Failed, + } +} diff --git a/crates/signing/src/request.rs b/crates/signing/src/request.rs @@ -1,7 +1,11 @@ -//! Validated signing requests. - -use core::fmt; -use radroots_event::{EventDraft, contract::event_contract}; +//! Validated authored-plan signing requests. + +use core::{ + fmt, + sync::atomic::{AtomicBool, Ordering}, +}; +use radroots_event::contract::event_contract; +use radroots_event_codec::authoring::AuthoredEventPlan; use radroots_protocol::runtime::v1::OperationId; #[cfg(not(feature = "std"))] @@ -9,7 +13,15 @@ use alloc::sync::Arc; #[cfg(feature = "std")] use std::sync::Arc; -use crate::{Actor, Error, error::Kind, status::SignProgress}; +use crate::{ + Actor, Error, SignerRequestId, SigningIntentId, + authorization::{ + CurrentAuthoringAuthority, CurrentAuthoringDecision, CurrentRegistryAuthority, + DeprecatedPlanPolicy, ManagedSigningPolicy, + }, + error::Kind, + status::SignProgress, +}; /// How a signer must interpret cancellation around remote publication. #[non_exhaustive] @@ -17,129 +29,238 @@ use crate::{Actor, Error, error::Kind, status::SignProgress}; #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CancellationPolicy { - /// Stop if cancellation is observed before publication; report the final - /// remote state explicitly when observed after publication. PreservePublishedRequest, - /// A local-only operation may stop whenever cancellation is observed. LocalCooperative, } -/// Explicit deadline and cancellation policy for one signing operation. +/// Runtime-local cooperative cancellation shared by caller and signer. +#[derive(Clone, Debug, Default)] +pub struct CancellationSignal(Arc<AtomicBool>); + +impl CancellationSignal { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +/// Explicit millisecond deadline and authorization/cancellation policy. #[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, Copy, Debug, PartialEq, Eq)] pub struct SignPolicy { - deadline_unix: u64, + deadline_unix_ms: u64, cancellation: CancellationPolicy, + deprecated_plan: DeprecatedPlanPolicy, + managed_signing: ManagedSigningPolicy, } impl SignPolicy { - /// Creates a bounded policy. Unix timestamp zero is never a valid deadline. - pub const fn new(deadline_unix: u64, cancellation: CancellationPolicy) -> Result<Self, Error> { - if deadline_unix == 0 { + pub const fn new( + deadline_unix_ms: u64, + cancellation: CancellationPolicy, + ) -> Result<Self, Error> { + if deadline_unix_ms == 0 { return Err(Error::new(Kind::InvalidArgument)); } Ok(Self { - deadline_unix, + deadline_unix_ms, cancellation, + deprecated_plan: DeprecatedPlanPolicy::Deny, + managed_signing: ManagedSigningPolicy::AnyValidatedSource, }) } - /// Returns the absolute Unix deadline. #[must_use] - pub const fn deadline_unix(self) -> u64 { - self.deadline_unix + pub const fn allowing_deprecated(mut self) -> Self { + self.deprecated_plan = DeprecatedPlanPolicy::Allow; + self + } + + #[must_use] + pub const fn with_managed_signing_policy(mut self, policy: ManagedSigningPolicy) -> Self { + self.managed_signing = policy; + self + } + + #[must_use] + pub const fn deadline_unix_ms(self) -> u64 { + self.deadline_unix_ms } - /// Returns the explicit cancellation contract. #[must_use] pub const fn cancellation(self) -> CancellationPolicy { self.cancellation } + + #[must_use] + pub const fn deprecated_plan(self) -> DeprecatedPlanPolicy { + self.deprecated_plan + } + + #[must_use] + pub const fn managed_signing(self) -> ManagedSigningPolicy { + self.managed_signing + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for SignPolicy { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Repr { + deadline_unix_ms: u64, + cancellation: CancellationPolicy, + deprecated_plan: DeprecatedPlanPolicy, + managed_signing: ManagedSigningPolicy, + } + + let value = Repr::deserialize(deserializer)?; + let mut policy = Self::new(value.deadline_unix_ms, value.cancellation) + .map_err(serde::de::Error::custom)?; + policy.deprecated_plan = value.deprecated_plan; + policy.managed_signing = value.managed_signing; + Ok(policy) + } } /// Runtime-local observer for signing progress. -/// -/// Observers are not serialized, persisted, or invoked by hidden workers. -/// Implementations call them synchronously from the active signing future. pub trait ProgressObserver: Send + Sync { - /// Observes one immutable progress value. fn on_progress(&self, progress: &SignProgress); } -/// One authorized actor, frozen draft, and bounded signer invocation. -/// -/// Runtime-local observers intentionally prevent native requests from becoming -/// passive wire DTOs. Versioned protocol types own serialized boundaries. -/// -/// ```compile_fail -/// use radroots_signing::SignRequest; -/// -/// fn serialize(request: &SignRequest) { -/// let _ = serde_json::to_string(request).unwrap(); -/// } -/// ``` +/// One currently authorized exact plan and bounded signer invocation. #[derive(Clone)] pub struct SignRequest { - operation_id: OperationId, + operation_kind: OperationId, + intent_id: SigningIntentId, + signer_request_id: SignerRequestId, actor: Actor, - draft: EventDraft, + plan: AuthoredEventPlan, + authorization: CurrentAuthoringDecision, policy: SignPolicy, + cancellation_signal: CancellationSignal, progress_observer: Option<Arc<dyn ProgressObserver>>, } impl SignRequest { - /// 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, + operation_kind: OperationId, + intent_id: SigningIntentId, actor: Actor, - draft: EventDraft, + plan: AuthoredEventPlan, policy: SignPolicy, ) -> Result<Self, Error> { - authorize_actor_for_draft(&actor, &draft).map_err(authorization_error)?; + Self::new_with_authority( + operation_kind, + intent_id, + actor, + plan, + policy, + &CurrentRegistryAuthority, + ) + } + + pub fn new_with_authority( + operation_kind: OperationId, + intent_id: SigningIntentId, + actor: Actor, + plan: AuthoredEventPlan, + policy: SignPolicy, + authority: &dyn CurrentAuthoringAuthority, + ) -> Result<Self, Error> { + let authorization = authority.evaluate(&plan); + authorize(&actor, &plan, policy, authorization)?; + let signer_request_id = SignerRequestId::derive(intent_id.artifact_id(), plan.digest()); Ok(Self { - operation_id, + operation_kind, + intent_id, + signer_request_id, actor, - draft, + plan, + authorization, policy, + cancellation_signal: CancellationSignal::new(), progress_observer: None, }) } - /// Installs a runtime-local progress observer. + #[must_use] + pub fn with_cancellation_signal(mut self, signal: CancellationSignal) -> Self { + self.cancellation_signal = signal; + self + } + #[must_use] pub fn with_progress_observer(mut self, observer: Arc<dyn ProgressObserver>) -> Self { self.progress_observer = Some(observer); self } - /// Returns the versioned runtime operation identity. #[must_use] - pub const fn operation_id(&self) -> OperationId { - self.operation_id + 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 } - /// Borrows the actor provenance and role claim. #[must_use] pub const fn actor(&self) -> &Actor { &self.actor } - /// Borrows the exact canonical draft to sign. #[must_use] - pub const fn draft(&self) -> &EventDraft { - &self.draft + pub const fn plan(&self) -> &AuthoredEventPlan { + &self.plan + } + + #[must_use] + pub const fn authorization(&self) -> CurrentAuthoringDecision { + self.authorization } - /// Returns the deadline and cancellation policy. #[must_use] pub const fn policy(&self) -> SignPolicy { self.policy } - /// Reports progress to the request-local observer, when present. + #[must_use] + pub const fn cancellation_signal(&self) -> &CancellationSignal { + &self.cancellation_signal + } + + pub fn ensure_active(&self, now_unix_ms: u64) -> Result<(), Error> { + if self.cancellation_signal.is_cancelled() { + return Err(Error::new(Kind::SignerCancelled)); + } + if now_unix_ms >= self.policy.deadline_unix_ms { + return Err(Error::new(Kind::DeadlineExceeded)); + } + Ok(()) + } + pub fn report_progress(&self, progress: &SignProgress) { if let Some(observer) = &self.progress_observer { observer.on_progress(progress); @@ -147,192 +268,44 @@ impl SignRequest { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AuthorizationFailure { - InvalidDraft, - ActorRoleUnsatisfied, - ActorPublicKeyMismatch, -} - -fn authorize_actor_for_draft( +fn authorize( 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); + plan: &AuthoredEventPlan, + policy: SignPolicy, + decision: CurrentAuthoringDecision, +) -> Result<(), Error> { + match decision { + CurrentAuthoringDecision::Allowed => {} + CurrentAuthoringDecision::AllowedDeprecated { .. } + if policy.deprecated_plan() == DeprecatedPlanPolicy::Allow => {} + CurrentAuthoringDecision::AllowedDeprecated { .. } + | CurrentAuthoringDecision::Blocked { .. } + | CurrentAuthoringDecision::Revoked { .. } => { + return Err(Error::new(Kind::AuthorizationDenied)); + } } - if actor.public_key() != *draft.expected_pubkey() { - return Err(AuthorizationFailure::ActorPublicKeyMismatch); + let contract = event_contract(plan.body().contract().contract_id().as_str()) + .ok_or_else(|| Error::new(Kind::AuthorizationDenied))?; + if !actor.satisfies(contract.required_author_role()) + || actor.public_key() != *plan.author() + || !policy.managed_signing().permits(actor) + { + return Err(Error::new(Kind::AuthorizationDenied)); } Ok(()) } -fn authorization_error(failure: AuthorizationFailure) -> Error { - match failure { - AuthorizationFailure::InvalidDraft => Error::new(Kind::InvalidArgument), - AuthorizationFailure::ActorRoleUnsatisfied - | AuthorizationFailure::ActorPublicKeyMismatch => Error::new(Kind::AuthorizationDenied), - } -} - impl fmt::Debug for SignRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("SignRequest") - .field("operation_id", &self.operation_id) + .field("operation_kind", &self.operation_kind) + .field("intent_id", &self.intent_id) + .field("signer_request_id", &self.signer_request_id) .field("actor", &self.actor) - .field("draft", &"[redacted frozen event draft]") + .field("plan", &"[redacted authored event plan]") + .field("authorization", &self.authorization) .field("policy", &self.policy) - .field( - "progress_observer", - &self.progress_observer.as_ref().map(|_| "[installed]"), - ) - .finish() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - actor::ActorSource, - status::{SignProgress, SignProgressStage}, - }; - 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::{borrow::ToOwned, string::String, sync::Arc, vec, vec::Vec}; - #[cfg(feature = "std")] - use std::{string::String, sync::Arc, vec, vec::Vec}; - - const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const OTHER_PUBLIC_KEY: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; - - struct CountingObserver(AtomicUsize); - - impl ProgressObserver for CountingObserver { - fn on_progress(&self, _progress: &SignProgress) { - self.0.fetch_add(1, Ordering::Relaxed); - } - } - - fn request() -> SignRequest { - let public_key = PublicKey::from_hex(PUBLIC_KEY).expect("public key"); - let actor = Actor::new( - public_key, - ActorSource::ExplicitPublicKey, - [AuthorRole::Any], - ) - .expect("actor"); - let draft = EventDraft::new( - "radroots.social.geochat.v1", - 20_000, - 1_700_000_000, - Vec::new(), - "private-draft-content", - PUBLIC_KEY, - ) - .expect("draft"); - SignRequest::new( - OperationId::SyncPush, - actor, - draft, - SignPolicy::new(1_700_000_100, CancellationPolicy::PreservePublishedRequest) - .expect("policy"), - ) - .expect("authorized request") - } - - #[test] - fn policy_requires_a_real_deadline() { - let error = SignPolicy::new(0, CancellationPolicy::LocalCooperative) - .expect_err("zero deadline must fail"); - assert_eq!(error.kind(), Kind::InvalidArgument); - } - - #[test] - fn request_preserves_inputs_reports_progress_and_redacts_draft_debug() { - let observer = Arc::new(CountingObserver(AtomicUsize::new(0))); - let request = request().with_progress_observer(observer.clone()); - let progress = SignProgress::stage(SignProgressStage::Queued).expect("progress"); - - request.report_progress(&progress); - - assert_eq!(request.operation_id(), OperationId::SyncPush); - assert_eq!(request.policy().deadline_unix(), 1_700_000_100); - assert_eq!(request.draft().content(), "private-draft-content"); - assert_eq!(observer.0.load(Ordering::Relaxed), 1); - let debug = alloc_or_std_format(&request); - assert!(!debug.contains("private-draft-content")); - 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:?}")) - } - - fn value_to_string(arguments: fmt::Arguments<'_>) -> String { - use core::fmt::Write as _; - let mut output = String::new(); - output.write_fmt(arguments).expect("string formatting"); - output + .finish_non_exhaustive() } } diff --git a/crates/signing/src/signer.rs b/crates/signing/src/signer.rs @@ -3,182 +3,57 @@ use core::{future::Future, pin::Pin}; #[cfg(not(feature = "std"))] -use alloc::boxed::Box; +use alloc::{boxed::Box, sync::Arc}; #[cfg(feature = "std")] -use std::boxed::Box; +use std::{boxed::Box, sync::Arc}; use crate::{Error, SignReceipt, SignRequest, SignerStatus}; -/// A boxed, dynamically dispatched signer future. pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; -/// Protocol-neutral signing service-provider interface. +/// Protocol-neutral, caller-driven signing service-provider interface. /// -/// The owned request and boxed futures keep this trait dyn-compatible for -/// local, remote, and host-mediated implementations without choosing an async -/// runtime. Implementations must document the point at which a request creates -/// a durable remote side effect. Dropping the future before that point must -/// leave no durable effect; dropping it afterward does not imply rollback. +/// 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. pub trait Signer: Send + Sync { - /// Reports current capabilities and progress without creating a signing - /// request or another durable side effect. fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>>; - /// Signs one already-authorized request. + /// Signs one already-authorized exact plan. /// - /// The request's deadline and cancellation policy remain authoritative - /// 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. + /// Implementations must observe the request's millisecond deadline and + /// cancellation signal throughout the operation, preserve its stable + /// signer request ID for remote replay, and create success only through + /// [`SignReceipt::from_signed_event`]. fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>>; } +/// Shared signer handle used by composing hosts without selecting a runtime. +pub type DynSigner = Arc<dyn Signer>; + #[cfg(test)] mod tests { use super::*; - use crate::{ - Actor, - actor::ActorSource, - error::Kind, - 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>> { - Box::pin(async { Ok(SignerStatus::unavailable()) }) - } - - fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { - Box::pin(async { Err(Error::new(Kind::InternalError)) }) - } - } - - impl Signer for RemoteSigner { - fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { - Box::pin(async { Ok(SignerStatus::unavailable()) }) - } + use crate::error::Kind; - fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { - Box::pin(async { Err(Error::new(Kind::InternalError)) }) - } - } + struct Stub; - impl Signer for CountingSigner { + impl Signer for Stub { 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::new(Kind::InternalError)) }) + Box::pin(async { Err(Error::new(Kind::SignerUnavailable)) }) } } - fn assert_dyn_signer(signer: &dyn Signer) { - drop(signer.status()); - } - #[test] - fn local_and_remote_implementations_are_dyn_compatible() { - assert_dyn_signer(&LocalSigner); - assert_dyn_signer(&RemoteSigner); - } - - #[test] - fn trait_objects_remain_send_and_sync() { + fn signer_remains_dyn_send_and_sync() { + fn assert_dyn(_: &dyn Signer) {} fn assert_send_sync<T: Send + Sync + ?Sized>() {} + assert_dyn(&Stub); 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_eq!( - request.as_ref().expect_err("request must fail").kind(), - Kind::AuthorizationDenied - ); - if let Ok(request) = request { - drop(signer.sign(request)); - } - } - - assert_eq!(signer.0.load(Ordering::Relaxed), 0); - } } diff --git a/crates/signing/src/status.rs b/crates/signing/src/status.rs @@ -254,6 +254,8 @@ mod tests { use super::*; #[cfg(feature = "serde")] use crate::capability::{CancellationSupport, SignerKind}; + #[cfg(feature = "serde")] + use crate::recovery::ReplayCapability; #[cfg(not(feature = "std"))] use alloc::format; @@ -337,6 +339,7 @@ mod tests { fn status_round_trips_and_invalid_progress_fails_closed() { let capability = SignerCapability::new( SignerKind::Remote, + ReplayCapability::ExactReplayByRequestId, CancellationSupport::BeforePublication, true, true, diff --git a/crates/signing/tests/authored_signing.rs b/crates/signing/tests/authored_signing.rs @@ -0,0 +1,300 @@ +use nostr::{EventBuilder, JsonUtil, Keys, Kind as NostrKind, SecretKey, Timestamp}; +use radroots_event::{ + GenericEventDraft, + contract::AuthorRole, + food::availability::{ + FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, + FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit, + }, + wire::Nip01EventWire, +}; +use radroots_event_codec::authoring::AuthoredEventPlan; +use radroots_identity::{AccountId, PublicKey}; +use radroots_protocol::runtime::v1::OperationId; +use radroots_signing::{ + Actor, AuthoredArtifactId, CurrentAuthoringAuthority, CurrentAuthoringDecision, Error, + SignReceipt, SignRequest, SigningIntentId, SigningOperationId, + actor::ActorSource, + authorization::ManagedSigningPolicy, + error::Kind, + recovery::{RecoveryDisposition, RemoteEffect, ReplayCapability, recovery_disposition}, + request::{CancellationPolicy, CancellationSignal, SignPolicy}, +}; + +const SECRET: &str = "7e0112ad58b2d2d13fb80532625195dc169b86d72b0e1db48347837a785cae90"; +const CREATED_AT: u64 = 1_700_000_000; +const DEADLINE_MS: u64 = 1_700_000_100_000; + +#[derive(Clone, Copy)] +struct FixedAuthority(CurrentAuthoringDecision); + +impl CurrentAuthoringAuthority for FixedAuthority { + fn evaluate(&self, _plan: &AuthoredEventPlan) -> CurrentAuthoringDecision { + self.0 + } +} + +fn keys() -> Keys { + Keys::new(SecretKey::from_hex(SECRET).expect("secret fixture")) +} + +fn public_key() -> PublicKey { + PublicKey::from_hex(&keys().public_key().to_hex()).expect("public key") +} + +fn plan() -> AuthoredEventPlan { + AuthoredEventPlan::from_generic( + GenericEventDraft::new( + "radroots.social.geochat.v1", + 20_000, + CREATED_AT, + Vec::new(), + "exact signing plan", + public_key().to_hex(), + ) + .expect("generic draft"), + ) + .expect("authored plan") +} + +fn actor(source: ActorSource, roles: impl IntoIterator<Item = AuthorRole>) -> Actor { + Actor::new(public_key(), source, roles).expect("actor") +} + +fn intent(operation: u8, artifact: u8) -> SigningIntentId { + SigningIntentId::new( + SigningOperationId::new([operation; 16]).expect("operation ID"), + AuthoredArtifactId::new([artifact; 16]).expect("artifact ID"), + ) +} + +fn policy() -> SignPolicy { + SignPolicy::new(DEADLINE_MS, CancellationPolicy::LocalCooperative).expect("policy") +} + +fn request() -> SignRequest { + SignRequest::new( + OperationId::SyncPush, + intent(1, 2), + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]), + plan(), + policy(), + ) + .expect("request") +} + +fn signed_event() -> radroots_event::SignedEvent { + let event = EventBuilder::new(NostrKind::Custom(20_000), "exact signing plan") + .custom_created_at(Timestamp::from_secs(CREATED_AT)) + .sign_with_keys(&keys()) + .expect("signed fixture"); + let raw = event.as_json(); + let wire = Nip01EventWire::parse_json(&raw).expect("wire"); + radroots_event::SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") +} + +#[test] +fn authorization_decisions_are_current_and_explicit() { + let base_actor = actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]); + let base_plan = plan(); + for decision in [ + CurrentAuthoringDecision::Blocked { code: "blocked" }, + CurrentAuthoringDecision::Revoked { code: "revoked" }, + ] { + let error = SignRequest::new_with_authority( + OperationId::SyncPush, + intent(1, 2), + base_actor.clone(), + base_plan.clone(), + policy(), + &FixedAuthority(decision), + ) + .expect_err("denied decision"); + assert_eq!(error.kind(), Kind::AuthorizationDenied); + } + + let deprecated = FixedAuthority(CurrentAuthoringDecision::AllowedDeprecated { + warning_code: "deprecated", + }); + assert_eq!( + SignRequest::new_with_authority( + OperationId::SyncPush, + intent(1, 2), + base_actor.clone(), + base_plan.clone(), + policy(), + &deprecated, + ) + .expect_err("deprecated requires opt in") + .kind(), + Kind::AuthorizationDenied + ); + let allowed = SignRequest::new_with_authority( + OperationId::SyncPush, + intent(1, 2), + base_actor, + base_plan, + policy().allowing_deprecated(), + &deprecated, + ) + .expect("explicitly allowed deprecated plan"); + assert!(matches!( + allowed.authorization(), + CurrentAuthoringDecision::AllowedDeprecated { .. } + )); +} + +#[test] +fn authorization_enforces_key_role_and_host_provenance() { + let wrong_key = + PublicKey::from_hex("e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af") + .expect("other public key"); + let wrong_actor = + Actor::new(wrong_key, ActorSource::ExplicitPublicKey, [AuthorRole::Any]).expect("actor"); + assert_eq!( + SignRequest::new( + OperationId::SyncPush, + intent(1, 2), + wrong_actor, + plan(), + policy(), + ) + .expect_err("key drift") + .kind(), + Kind::AuthorizationDenied + ); + + let account = AccountId::from_hex(&public_key().to_hex()).expect("account ID"); + let local_actor = actor(ActorSource::LocalAccount(account), [AuthorRole::Any]); + SignRequest::new( + OperationId::SyncPush, + intent(1, 2), + local_actor, + plan(), + policy().with_managed_signing_policy(ManagedSigningPolicy::LocalAccountOnly), + ) + .expect("local account is allowed"); + assert_eq!( + SignRequest::new( + OperationId::SyncPush, + intent(1, 2), + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]), + plan(), + policy().with_managed_signing_policy(ManagedSigningPolicy::AccountBackedOnly), + ) + .expect_err("unmanaged explicit key") + .kind(), + Kind::AuthorizationDenied + ); + + let food = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { + content: FoodContent::new("Carrots available.").unwrap(), + identifier: FoodIdentifier::parse("carrots").unwrap(), + title: FoodText::new("Carrots").unwrap(), + summary: FoodText::new("Fresh bunches").unwrap(), + published_at: FoodPublishedAt::new(CREATED_AT).unwrap(), + location: FoodText::new("Saanich").unwrap(), + price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound).unwrap(), + quantity: None, + status: FoodAvailabilityStatus::Active, + images: Vec::new(), + }) + .unwrap(); + let seller_plan = + AuthoredEventPlan::from_food_availability(&food, CREATED_AT, public_key().to_hex()) + .unwrap(); + assert_eq!( + SignRequest::new( + OperationId::SyncPush, + intent(1, 3), + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Buyer]), + seller_plan, + policy(), + ) + .expect_err("role drift") + .kind(), + Kind::AuthorizationDenied + ); +} + +#[test] +fn request_identity_deadline_and_cancellation_are_exact() { + let request = request(); + let replay = request.clone(); + assert_eq!(request.signer_request_id(), replay.signer_request_id()); + let other_artifact = SignRequest::new( + OperationId::SyncPush, + intent(1, 3), + actor(ActorSource::ExplicitPublicKey, [AuthorRole::Any]), + plan(), + policy(), + ) + .unwrap(); + assert_ne!( + request.signer_request_id(), + other_artifact.signer_request_id() + ); + assert!(request.ensure_active(DEADLINE_MS - 1).is_ok()); + assert_eq!( + request.ensure_active(DEADLINE_MS).unwrap_err().kind(), + Kind::DeadlineExceeded + ); + + let signal = CancellationSignal::new(); + let cancelled = request.clone().with_cancellation_signal(signal.clone()); + signal.cancel(); + assert_eq!( + cancelled.ensure_active(DEADLINE_MS - 1).unwrap_err().kind(), + Kind::SignerCancelled + ); +} + +#[test] +fn receipt_requires_exact_fields_and_a_valid_schnorr_signature() { + let request = request(); + let receipt = SignReceipt::from_signed_event(&request, signed_event(), DEADLINE_MS - 1) + .expect("verified receipt"); + assert_eq!(receipt.intent_id(), request.intent_id()); + assert_eq!(receipt.signer_request_id(), request.signer_request_id()); + assert_eq!(receipt.operation_kind(), OperationId::SyncPush); + assert_eq!(receipt.completed_at_unix_ms(), DEADLINE_MS - 1); + + 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"); + assert_eq!( + SignReceipt::from_signed_event(&request, invalid, DEADLINE_MS - 1) + .expect_err("invalid signature") + .kind(), + Kind::SignerOutputInvalid + ); +} + +#[test] +fn uncertain_remote_effects_never_become_unsafe_automatic_retries() { + let uncertain = Error::new(Kind::SignerTimeout).with_possible_remote_effect(); + assert_eq!(uncertain.remote_effect(), RemoteEffect::MayHaveOccurred); + assert_eq!( + recovery_disposition( + ReplayCapability::ExactReplayByRequestId, + uncertain.remote_effect(), + uncertain.retryable(), + ), + RecoveryDisposition::RetryExactRequest + ); + assert_eq!( + recovery_disposition( + ReplayCapability::NonReplayable, + uncertain.remote_effect(), + uncertain.retryable(), + ), + RecoveryDisposition::Indeterminate + ); + assert_eq!( + recovery_disposition(ReplayCapability::LocalReplaySafe, RemoteEffect::None, true,), + RecoveryDisposition::RetryLocal + ); +} diff --git a/crates/signing/tests/conformance.rs b/crates/signing/tests/conformance.rs @@ -2,6 +2,7 @@ use radroots_signing::{ Error, Signer, capability::{CancellationSupport, SignerCapability, SignerKind}, error::{CATALOG, Kind}, + recovery::ReplayCapability, request::{CancellationPolicy, SignPolicy}, }; @@ -32,7 +33,7 @@ fn deadline_and_cancellation_contracts_are_explicit() { let local = SignPolicy::new(42, CancellationPolicy::LocalCooperative).expect("local policy"); let remote = SignPolicy::new(42, CancellationPolicy::PreservePublishedRequest).expect("remote policy"); - assert_eq!(local.deadline_unix(), 42); + assert_eq!(local.deadline_unix_ms(), 42); assert_eq!(local.cancellation(), CancellationPolicy::LocalCooperative); assert_eq!( remote.cancellation(), @@ -41,12 +42,17 @@ fn deadline_and_cancellation_contracts_are_explicit() { let capability = SignerCapability::new( SignerKind::Remote, + ReplayCapability::ExactReplayByRequestId, CancellationSupport::BeforeAndAfterPublication, true, true, ); assert_eq!(capability.kind(), SignerKind::Remote); assert_eq!( + capability.replay(), + ReplayCapability::ExactReplayByRequestId + ); + assert_eq!( capability.cancellation(), CancellationSupport::BeforeAndAfterPublication ); @@ -65,6 +71,10 @@ fn policy_wire_labels_are_stable_and_round_trip() { serde_json::from_str::<SignPolicy>(&json).expect("deserialize policy"), policy ); + assert!(serde_json::from_str::<SignPolicy>( + r#"{"deadline_unix_ms":0,"cancellation":"local_cooperative","deprecated_plan":"deny","managed_signing":"any_validated_source"}"# + ) + .is_err()); } #[test] diff --git a/crates/signing/tests/package_boundary.rs b/crates/signing/tests/package_boundary.rs @@ -2,8 +2,9 @@ use std::{collections::BTreeSet, fs, path::Path}; #[allow(unused_imports)] use radroots_signing::{ - Actor, Error, SignReceipt, SignRequest, Signer, SignerStatus, actor as _, capability as _, - error as _, receipt as _, request as _, signer as _, status as _, + Actor, Error, SignReceipt, SignRequest, Signer, SignerStatus, actor as _, authorization as _, + capability as _, error as _, identity as _, receipt as _, recovery as _, request as _, + signer as _, status as _, }; const MANIFEST: &str = include_str!("../Cargo.toml"); @@ -20,6 +21,7 @@ fn manifest_has_final_identity_features_and_dependencies() { "publish = [\"crates-io\"]", "default = [\"std\", \"serde\"]", "radroots_event = { workspace = true, default-features = false }", + "radroots_event_codec = { workspace = true, default-features = false }", "radroots_identity = { workspace = true, default-features = false }", "radroots_protocol = { workspace = true, default-features = false }", ] { @@ -35,15 +37,18 @@ fn manifest_has_final_identity_features_and_dependencies() { assert_eq!( table_keys(MANIFEST, "[dependencies]"), BTreeSet::from([ + "hex", "radroots_event", + "radroots_event_codec", "radroots_identity", "radroots_protocol", "serde", + "sha2", ]) ); assert_eq!( table_keys(MANIFEST, "[dev-dependencies]"), - BTreeSet::from(["serde_json"]) + BTreeSet::from(["nostr", "serde_json"]) ); for forbidden in [ "async-trait", @@ -66,8 +71,11 @@ fn crate_root_declares_the_approved_module_skeleton() { assert!(ROOT.contains("#![cfg_attr(not(feature = \"std\"), no_std)]")); for module in [ "actor", + "authorization", "capability", "error", + "identity", + "recovery", "request", "receipt", "signer", @@ -83,9 +91,12 @@ fn crate_root_declares_the_approved_module_skeleton() { root_declarations("pub mod "), BTreeSet::from([ "actor", + "authorization", "capability", "error", + "identity", "receipt", + "recovery", "request", "signer", "status", @@ -100,7 +111,9 @@ fn crate_root_declares_the_approved_module_skeleton() { let _ = assert_object_safe; for root_export in [ "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 request::SignRequest;", "pub use signer::Signer;", @@ -115,7 +128,9 @@ fn crate_root_declares_the_approved_module_skeleton() { .collect::<BTreeSet<_>>(), BTreeSet::from([ "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 request::SignRequest;", "pub use signer::Signer;", @@ -171,8 +186,11 @@ fn every_public_module_has_crate_level_documentation() { let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); for module in [ "actor", + "authorization", "capability", "error", + "identity", + "recovery", "request", "receipt", "signer", @@ -229,7 +247,7 @@ fn production_sources_publish_only_the_approved_traits_and_no_host_stack() { assert_eq!( public_traits, - ["ProgressObserver", "Signer"] + ["CurrentAuthoringAuthority", "ProgressObserver", "Signer"] .into_iter() .map(str::to_owned) .collect()