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:
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()