commit 0a594be27573167f6022716b12684d4c611badea
parent 636c661ed6818e46d348c10a5b029535d57bb4b7
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Jul 2026 10:56:17 +0000
signing: define signing requests, receipts, progress, and status
- bind operation actor frozen draft and bounded cancellation policy
- expose runtime-local progress with typed redacted authentication challenges
- model serializable capabilities status and signed-event receipts
- cover construction malformed wire and redaction across native and WASM
Diffstat:
7 files changed, 794 insertions(+), 20 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4959,6 +4959,8 @@ dependencies = [
"radroots_event",
"radroots_identity",
"radroots_protocol",
+ "serde",
+ "serde_json",
]
[[package]]
diff --git a/crates/signing/Cargo.toml b/crates/signing/Cargo.toml
@@ -22,6 +22,7 @@ std = [
"radroots_protocol/std",
]
serde = [
+ "dep:serde",
"radroots_event/serde",
"radroots_identity/serde",
"radroots_protocol/serde",
@@ -31,6 +32,13 @@ serde = [
radroots_event = { workspace = true, default-features = false }
radroots_identity = { workspace = true, default-features = false }
radroots_protocol = { workspace = true, default-features = false }
+serde = { workspace = true, default-features = false, features = [
+ "alloc",
+ "derive",
+], optional = true }
+
+[dev-dependencies]
+serde_json = { workspace = true, features = ["std"] }
[lints]
workspace = true
diff --git a/crates/signing/src/capability.rs b/crates/signing/src/capability.rs
@@ -1 +1,104 @@
//! Signer capability declarations.
+
+/// How a signer implementation obtains signatures.
+#[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 SignerKind {
+ /// A local adapter performs signing without publishing a remote request.
+ Local,
+ /// A remote service or device performs signing.
+ Remote,
+ /// A composing host mediates signing through an explicit user interaction.
+ HostMediated,
+}
+
+/// Cancellation behavior advertised by a signer.
+#[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 CancellationSupport {
+ /// Cancellation is observed only before a remote request is published.
+ BeforePublication,
+ /// Cancellation remains observable after publication, without implying
+ /// rollback of the already-published request.
+ BeforeAndAfterPublication,
+}
+
+/// Portable signer behavior advertised to a composing host.
+#[non_exhaustive]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct SignerCapability {
+ kind: SignerKind,
+ cancellation: CancellationSupport,
+ reports_progress: bool,
+ may_require_authentication: bool,
+}
+
+impl SignerCapability {
+ /// Creates an explicit capability declaration.
+ #[must_use]
+ pub const fn new(
+ kind: SignerKind,
+ cancellation: CancellationSupport,
+ reports_progress: bool,
+ may_require_authentication: bool,
+ ) -> Self {
+ Self {
+ kind,
+ cancellation,
+ reports_progress,
+ may_require_authentication,
+ }
+ }
+
+ /// Returns the implementation kind.
+ #[must_use]
+ pub const fn kind(self) -> SignerKind {
+ self.kind
+ }
+
+ /// Returns the advertised cancellation contract.
+ #[must_use]
+ pub const fn cancellation(self) -> CancellationSupport {
+ self.cancellation
+ }
+
+ /// Reports whether the signer emits progress updates.
+ #[must_use]
+ pub const fn reports_progress(self) -> bool {
+ self.reports_progress
+ }
+
+ /// Reports whether the signer may emit an authentication challenge.
+ #[must_use]
+ pub const fn may_require_authentication(self) -> bool {
+ self.may_require_authentication
+ }
+}
+
+#[cfg(all(test, feature = "serde"))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn capability_round_trips_with_stable_wire_labels() {
+ let capability = SignerCapability::new(
+ SignerKind::Remote,
+ CancellationSupport::BeforeAndAfterPublication,
+ true,
+ true,
+ );
+ let encoded = serde_json::to_string(&capability).expect("serialize capability");
+ let decoded: SignerCapability =
+ serde_json::from_str(&encoded).expect("deserialize capability");
+
+ assert_eq!(decoded, capability);
+ assert!(encoded.contains("remote"));
+ assert!(encoded.contains("before_and_after_publication"));
+ }
+}
diff --git a/crates/signing/src/receipt.rs b/crates/signing/src/receipt.rs
@@ -1,7 +1,61 @@
//! Signing receipts.
-/// Opaque receipt vocabulary for the object-safe SPI.
-///
-/// Step 102 defines the validated receipt contract before consumer migration.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub struct SignReceipt;
+use core::fmt;
+use radroots_event::SignedEvent;
+use radroots_protocol::runtime::v1::OperationId;
+
+/// Successful signer output with portable operation provenance.
+#[non_exhaustive]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SignReceipt {
+ operation_id: OperationId,
+ signed_event: SignedEvent,
+ completed_at_unix: 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("signed_event_id", &self.signed_event.id_str())
+ .field("completed_at_unix", &self.completed_at_unix)
+ .finish()
+ }
+}
+
+impl SignReceipt {
+ /// Creates a receipt from an invariant-checked signed event.
+ #[must_use]
+ pub const fn new(
+ operation_id: OperationId,
+ signed_event: SignedEvent,
+ completed_at_unix: u64,
+ ) -> Self {
+ Self {
+ operation_id,
+ signed_event,
+ completed_at_unix,
+ }
+ }
+
+ /// Returns the originating runtime operation identity.
+ #[must_use]
+ pub const fn operation_id(&self) -> OperationId {
+ self.operation_id
+ }
+
+ /// Borrows the invariant-checked signed event.
+ #[must_use]
+ pub const fn signed_event(&self) -> &SignedEvent {
+ &self.signed_event
+ }
+
+ /// Returns the host-supplied completion timestamp.
+ #[must_use]
+ pub const fn completed_at_unix(&self) -> u64 {
+ self.completed_at_unix
+ }
+}
diff --git a/crates/signing/src/request.rs b/crates/signing/src/request.rs
@@ -1,8 +1,259 @@
//! Validated signing requests.
-/// Opaque request vocabulary for the object-safe SPI.
+use core::fmt;
+use radroots_event::EventDraft;
+use radroots_protocol::runtime::v1::OperationId;
+
+#[cfg(not(feature = "std"))]
+use alloc::sync::Arc;
+#[cfg(feature = "std")]
+use std::sync::Arc;
+
+use crate::{Actor, status::SignProgress};
+
+/// How a signer must interpret cancellation around remote publication.
+#[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 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.
+#[non_exhaustive]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct SignPolicy {
+ deadline_unix: u64,
+ cancellation: CancellationPolicy,
+}
+
+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, SignPolicyError> {
+ if deadline_unix == 0 {
+ return Err(SignPolicyError::InvalidDeadline);
+ }
+ Ok(Self {
+ deadline_unix,
+ cancellation,
+ })
+ }
+
+ /// Returns the absolute Unix deadline.
+ #[must_use]
+ pub const fn deadline_unix(self) -> u64 {
+ self.deadline_unix
+ }
+
+ /// Returns the explicit cancellation contract.
+ #[must_use]
+ pub const fn cancellation(self) -> CancellationPolicy {
+ self.cancellation
+ }
+}
+
+/// Invalid signing policy input.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SignPolicyError {
+ /// The deadline was the Unix epoch sentinel rather than a real bound.
+ InvalidDeadline,
+}
+
+impl fmt::Display for SignPolicyError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("signing deadline must be greater than zero")
+ }
+}
+
+impl core::error::Error for SignPolicyError {}
+
+/// Runtime-local observer for signing progress.
///
-/// Step 102 defines the actor, frozen-draft, deadline, policy, and progress
-/// fields before consumer migration.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub struct SignRequest;
+/// 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.
+#[derive(Clone)]
+pub struct SignRequest {
+ operation_id: OperationId,
+ actor: Actor,
+ draft: EventDraft,
+ policy: SignPolicy,
+ progress_observer: Option<Arc<dyn ProgressObserver>>,
+}
+
+impl SignRequest {
+ /// Creates a request without installing a progress observer.
+ #[must_use]
+ pub fn new(
+ operation_id: OperationId,
+ actor: Actor,
+ draft: EventDraft,
+ policy: SignPolicy,
+ ) -> Self {
+ Self {
+ operation_id,
+ actor,
+ draft,
+ policy,
+ progress_observer: None,
+ }
+ }
+
+ /// Installs a runtime-local progress observer.
+ #[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
+ }
+
+ /// 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
+ }
+
+ /// 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.
+ pub fn report_progress(&self, progress: &SignProgress) {
+ if let Some(observer) = &self.progress_observer {
+ observer.on_progress(progress);
+ }
+ }
+}
+
+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("actor", &self.actor)
+ .field("draft", &"[redacted frozen event draft]")
+ .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_identity::PublicKey;
+
+ #[cfg(not(feature = "std"))]
+ use alloc::{string::String, sync::Arc, vec::Vec};
+ #[cfg(feature = "std")]
+ use std::{string::String, sync::Arc, vec::Vec};
+
+ const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ 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"),
+ )
+ }
+
+ #[test]
+ fn policy_requires_a_real_deadline() {
+ assert_eq!(
+ SignPolicy::new(0, CancellationPolicy::LocalCooperative),
+ Err(SignPolicyError::InvalidDeadline)
+ );
+ }
+
+ #[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"));
+ }
+
+ 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
+ }
+}
diff --git a/crates/signing/src/signer.rs b/crates/signing/src/signer.rs
@@ -41,17 +41,17 @@ mod tests {
impl Signer for LocalSigner {
fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> {
- Box::pin(async { Ok(SignerStatus) })
+ Box::pin(async { Ok(SignerStatus::unavailable()) })
}
fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> {
- Box::pin(async { Ok(SignReceipt) })
+ Box::pin(async { Err(Error) })
}
}
impl Signer for RemoteSigner {
fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> {
- Box::pin(async { Ok(SignerStatus) })
+ Box::pin(async { Ok(SignerStatus::unavailable()) })
}
fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> {
@@ -61,7 +61,6 @@ mod tests {
fn assert_dyn_signer(signer: &dyn Signer) {
drop(signer.status());
- drop(signer.sign(SignRequest));
}
#[test]
diff --git a/crates/signing/src/status.rs b/crates/signing/src/status.rs
@@ -1,8 +1,365 @@
//! Signer progress and status models.
-/// Opaque status vocabulary for the object-safe SPI.
-///
-/// Step 102 defines the capability, progress, and challenge state model before
-/// consumer migration.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub struct SignerStatus;
+use core::fmt;
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+#[cfg(feature = "std")]
+use std::{string::String, vec::Vec};
+
+use crate::capability::SignerCapability;
+
+const MAX_AUTH_URI_BYTES: usize = 2_048;
+
+/// A remote authentication interaction required to continue signing.
+#[non_exhaustive]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, PartialEq, Eq)]
+pub struct AuthChallenge {
+ uri: String,
+ required_at_unix: u64,
+ expires_at_unix: Option<u64>,
+}
+
+impl AuthChallenge {
+ /// Creates a bounded HTTPS authentication challenge.
+ pub fn new(
+ uri: impl Into<String>,
+ required_at_unix: u64,
+ expires_at_unix: Option<u64>,
+ ) -> Result<Self, AuthChallengeError> {
+ let uri = uri.into();
+ if uri.len() > MAX_AUTH_URI_BYTES
+ || uri.trim() != uri
+ || !uri.starts_with("https://")
+ || uri.chars().any(char::is_control)
+ {
+ return Err(AuthChallengeError::InvalidUri);
+ }
+ if let Some(expires_at_unix) = expires_at_unix
+ && expires_at_unix < required_at_unix
+ {
+ return Err(AuthChallengeError::ExpiresBeforeRequired);
+ }
+ Ok(Self {
+ uri,
+ required_at_unix,
+ expires_at_unix,
+ })
+ }
+
+ /// Borrows the host-displayable authentication URI.
+ #[must_use]
+ pub fn uri(&self) -> &str {
+ self.uri.as_str()
+ }
+
+ /// Returns when the challenge became required.
+ #[must_use]
+ pub const fn required_at_unix(&self) -> u64 {
+ self.required_at_unix
+ }
+
+ /// Returns the optional absolute challenge expiry.
+ #[must_use]
+ pub const fn expires_at_unix(&self) -> Option<u64> {
+ self.expires_at_unix
+ }
+}
+
+impl fmt::Debug for AuthChallenge {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("AuthChallenge")
+ .field("uri", &"[redacted]")
+ .field("required_at_unix", &self.required_at_unix)
+ .field("expires_at_unix", &self.expires_at_unix)
+ .finish()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for AuthChallenge {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Repr {
+ uri: String,
+ required_at_unix: u64,
+ expires_at_unix: Option<u64>,
+ }
+
+ let value = Repr::deserialize(deserializer)?;
+ Self::new(value.uri, value.required_at_unix, value.expires_at_unix)
+ .map_err(serde::de::Error::custom)
+ }
+}
+
+/// Invalid authentication challenge input.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AuthChallengeError {
+ /// The URI was not bounded canonical HTTPS text.
+ InvalidUri,
+ /// The challenge expiry preceded the required timestamp.
+ ExpiresBeforeRequired,
+}
+
+impl fmt::Display for AuthChallengeError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::InvalidUri => "authentication challenge URI is invalid",
+ Self::ExpiresBeforeRequired => "authentication challenge expires before it is required",
+ })
+ }
+}
+
+impl core::error::Error for AuthChallengeError {}
+
+/// Stable signing progress stages.
+#[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 SignProgressStage {
+ Queued,
+ Validating,
+ AwaitingAuthentication,
+ RequestPublished,
+ AwaitingSignature,
+ VerifyingOutput,
+ Complete,
+}
+
+/// One immutable signer progress update.
+#[non_exhaustive]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SignProgress {
+ stage: SignProgressStage,
+ challenge: Option<AuthChallenge>,
+}
+
+impl SignProgress {
+ /// Creates a progress update without an authentication challenge.
+ pub const fn stage(stage: SignProgressStage) -> Result<Self, SignProgressError> {
+ if matches!(stage, SignProgressStage::AwaitingAuthentication) {
+ return Err(SignProgressError::MissingAuthenticationChallenge);
+ }
+ Ok(Self {
+ stage,
+ challenge: None,
+ })
+ }
+
+ /// Creates an explicit authentication-challenge update.
+ #[must_use]
+ pub const fn authentication(challenge: AuthChallenge) -> Self {
+ Self {
+ stage: SignProgressStage::AwaitingAuthentication,
+ challenge: Some(challenge),
+ }
+ }
+
+ /// Returns the stable progress stage.
+ #[must_use]
+ pub const fn stage_value(&self) -> SignProgressStage {
+ self.stage
+ }
+
+ /// Borrows the authentication challenge, when present.
+ #[must_use]
+ pub const fn challenge(&self) -> Option<&AuthChallenge> {
+ self.challenge.as_ref()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for SignProgress {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Repr {
+ stage: SignProgressStage,
+ challenge: Option<AuthChallenge>,
+ }
+
+ let value = Repr::deserialize(deserializer)?;
+ match (value.stage, value.challenge) {
+ (SignProgressStage::AwaitingAuthentication, Some(challenge)) => {
+ Ok(Self::authentication(challenge))
+ }
+ (SignProgressStage::AwaitingAuthentication, None) => Err(serde::de::Error::custom(
+ SignProgressError::MissingAuthenticationChallenge,
+ )),
+ (_, Some(_)) => Err(serde::de::Error::custom(
+ SignProgressError::UnexpectedAuthenticationChallenge,
+ )),
+ (stage, None) => Self::stage(stage).map_err(serde::de::Error::custom),
+ }
+ }
+}
+
+/// Invalid progress construction.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SignProgressError {
+ MissingAuthenticationChallenge,
+ UnexpectedAuthenticationChallenge,
+}
+
+impl fmt::Display for SignProgressError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::MissingAuthenticationChallenge => "authentication progress requires a challenge",
+ Self::UnexpectedAuthenticationChallenge => {
+ "only authentication progress may carry a challenge"
+ }
+ })
+ }
+}
+
+impl core::error::Error for SignProgressError {}
+
+/// Current signer availability.
+#[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 SignerAvailability {
+ Ready,
+ Busy,
+ AwaitingAuthentication,
+ Unavailable,
+}
+
+/// Current signer availability, capabilities, and optional progress.
+#[non_exhaustive]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SignerStatus {
+ availability: SignerAvailability,
+ capabilities: Vec<SignerCapability>,
+ progress: Option<SignProgress>,
+}
+
+impl SignerStatus {
+ /// Creates an explicit status snapshot.
+ #[must_use]
+ pub fn new(
+ availability: SignerAvailability,
+ capabilities: Vec<SignerCapability>,
+ progress: Option<SignProgress>,
+ ) -> Self {
+ Self {
+ availability,
+ capabilities,
+ progress,
+ }
+ }
+
+ /// Creates an unavailable status without claiming capabilities.
+ #[must_use]
+ pub const fn unavailable() -> Self {
+ Self {
+ availability: SignerAvailability::Unavailable,
+ capabilities: Vec::new(),
+ progress: None,
+ }
+ }
+
+ #[must_use]
+ pub const fn availability(&self) -> SignerAvailability {
+ self.availability
+ }
+
+ #[must_use]
+ pub fn capabilities(&self) -> &[SignerCapability] {
+ &self.capabilities
+ }
+
+ #[must_use]
+ pub const fn progress(&self) -> Option<&SignProgress> {
+ self.progress.as_ref()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::capability::{CancellationSupport, SignerKind};
+
+ #[test]
+ fn challenge_validation_and_debug_redaction_are_explicit() {
+ let challenge =
+ AuthChallenge::new("https://auth.example/approve?token=sensitive", 10, Some(20))
+ .expect("challenge");
+
+ assert_eq!(challenge.required_at_unix(), 10);
+ assert_eq!(challenge.expires_at_unix(), Some(20));
+ assert!(!format!("{challenge:?}").contains("sensitive"));
+ assert_eq!(
+ AuthChallenge::new("http://auth.example", 10, None),
+ Err(AuthChallengeError::InvalidUri)
+ );
+ assert_eq!(
+ AuthChallenge::new("https://auth.example", 20, Some(10)),
+ Err(AuthChallengeError::ExpiresBeforeRequired)
+ );
+ }
+
+ #[test]
+ fn progress_requires_challenges_only_at_the_authentication_stage() {
+ assert_eq!(
+ SignProgress::stage(SignProgressStage::AwaitingAuthentication),
+ Err(SignProgressError::MissingAuthenticationChallenge)
+ );
+ let challenge =
+ AuthChallenge::new("https://auth.example/approve", 10, None).expect("challenge");
+ let progress = SignProgress::authentication(challenge);
+ assert_eq!(
+ progress.stage_value(),
+ SignProgressStage::AwaitingAuthentication
+ );
+ assert!(progress.challenge().is_some());
+ }
+
+ #[cfg(feature = "serde")]
+ #[test]
+ fn status_round_trips_and_invalid_progress_fails_closed() {
+ let capability = SignerCapability::new(
+ SignerKind::Remote,
+ CancellationSupport::BeforePublication,
+ true,
+ true,
+ );
+ let challenge =
+ AuthChallenge::new("https://auth.example/approve", 10, Some(20)).expect("challenge");
+ let status = SignerStatus::new(
+ SignerAvailability::AwaitingAuthentication,
+ vec![capability],
+ Some(SignProgress::authentication(challenge)),
+ );
+ let encoded = serde_json::to_string(&status).expect("serialize status");
+ let decoded: SignerStatus = serde_json::from_str(&encoded).expect("deserialize status");
+
+ assert_eq!(decoded, status);
+ assert!(
+ serde_json::from_str::<SignProgress>(
+ r#"{"stage":"awaiting_authentication","challenge":null}"#
+ )
+ .is_err()
+ );
+ assert!(serde_json::from_str::<SignProgress>(
+ r#"{"stage":"queued","challenge":{"uri":"https://auth.example","required_at_unix":1,"expires_at_unix":null}}"#
+ )
+ .is_err());
+ }
+}