lib

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

commit 2219a79cc9ea05afd53a424d683cf895c66a1732
parent 23ee816e25f36062535c6ef33b0f772c6c8a9a69
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Jul 2026 11:32:57 +0000

signing: normalize signing errors from one authority

- replace temporary construction failures with one normalized signing error
- derive code class retry and recovery metadata from governed catalogs
- preserve native sources while redacting diagnostics and protocol reports
- add signer-output protocol authority and refresh generated inventory

Diffstat:
Mcontracts/codegen/protocol_v1.inventory.json | 2+-
Mcontracts/codegen/protocol_v1.inventory.sha256 | 2+-
Mcrates/protocol/src/error/v1.rs | 3++-
Mcrates/signing/src/actor.rs | 57+++++++++++++++++----------------------------------------
Mcrates/signing/src/error.rs | 299++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/signing/src/receipt.rs | 24+++++++++++++++++-------
Mcrates/signing/src/request.rs | 41+++++++++++++++--------------------------
Mcrates/signing/src/signer.rs | 12++++++++----
Mcrates/signing/src/status.rs | 80++++++++++++++++++++++---------------------------------------------------------
9 files changed, 374 insertions(+), 146 deletions(-)

diff --git a/contracts/codegen/protocol_v1.inventory.json b/contracts/codegen/protocol_v1.inventory.json @@ -42,7 +42,7 @@ { "module": "error::v1", "path": "crates/protocol/src/error/v1.rs", - "sha256": "59553954506a56aca8c2cdef385b67e593514f846bbda3ac38907f73f58a8da6", + "sha256": "f6f8578988a7a3cf57e0a3081fa592a5e16998e666696e24c41368ea93226b80", "types": [ { "rust_path": "radroots_protocol::error::v1::CapabilityId", diff --git a/contracts/codegen/protocol_v1.inventory.sha256 b/contracts/codegen/protocol_v1.inventory.sha256 @@ -1 +1 @@ -36d98bfc7a76f8ac9c0bd36f6775b8ae2024ff821593d76ceef57ebc559a331c +86a317bf2eb502ca0c89f627a823c3df9652b95a4b1a71169b03ffb9e2f9b4ce diff --git a/crates/protocol/src/error/v1.rs b/crates/protocol/src/error/v1.rs @@ -156,6 +156,7 @@ error_catalog! { SignerRejected => ("signer_rejected", Signer, false, [SelectAuthorizedActor]), SignerTimeout => ("signer_timeout", Signer, true, [RetryAfterTransportFailure]), SignerCancelled => ("signer_cancelled", Signer, false, [ConfigureSigner]), + SignerOutputInvalid => ("signer_output_invalid", Signer, false, [ConfigureSigner]), RelayAuthRequired => ("relay_auth_required", Network, true, [CompleteSignerAuthentication]), RelayAuthRejected => ("relay_auth_rejected", Network, false, [CompleteSignerAuthentication]), RelayPaymentRequired => ("relay_payment_required", Network, false, [ConfigureTransportTargets]), @@ -762,7 +763,7 @@ mod tests { #[test] fn generated_catalog_is_complete_unique_and_self_consistent() { - assert_eq!(CATALOG.len(), 56); + assert_eq!(CATALOG.len(), 57); assert_eq!(KnownCode::ALL.len(), CATALOG.len()); let mut codes = BTreeSet::new(); for (index, descriptor) in CATALOG.iter().enumerate() { diff --git a/crates/signing/src/actor.rs b/crates/signing/src/actor.rs @@ -4,10 +4,11 @@ //! It describes host selection and provenance but does not select accounts, //! acquire keys, or prove that a host granted a role. -use core::fmt; use radroots_event::contract::AuthorRole; use radroots_identity::{AccountId, PublicKey}; +use crate::{Error, error::Kind}; + #[cfg(not(feature = "std"))] use alloc::collections::BTreeSet; #[cfg(feature = "std")] @@ -102,29 +103,6 @@ impl ActorResolutionRequest { } } -/// Validation failures while constructing actor provenance. -#[non_exhaustive] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ActorError { - /// Text did not contain a canonical public key. - InvalidPublicKey, - /// Account provenance did not identify the same public key as the actor. - AccountPublicKeyMismatch, -} - -impl fmt::Display for ActorError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::InvalidPublicKey => "actor public key is invalid", - Self::AccountPublicKeyMismatch => { - "actor account identifier does not match the actor public key" - } - }) - } -} - -impl core::error::Error for ActorError {} - /// An actor-role claim and its public provenance. /// /// Construction validates that account-backed provenance and the public key @@ -139,14 +117,14 @@ pub struct Actor { impl Actor { /// Creates actor provenance from canonical public values. - pub fn new<I>(public_key: PublicKey, source: ActorSource, roles: I) -> Result<Self, ActorError> + pub fn new<I>(public_key: PublicKey, source: ActorSource, roles: I) -> Result<Self, Error> where I: IntoIterator<Item = AuthorRole>, { if let Some(account_id) = source.account_id() && account_id.as_bytes() != public_key.as_bytes() { - return Err(ActorError::AccountPublicKeyMismatch); + return Err(Error::new(Kind::InvalidArgument)); } Ok(Self { public_key, @@ -160,12 +138,12 @@ impl Actor { public_key: &str, source: ActorSource, roles: I, - ) -> Result<Self, ActorError> + ) -> Result<Self, Error> where I: IntoIterator<Item = AuthorRole>, { let public_key = - PublicKey::from_hex(public_key).map_err(|_| ActorError::InvalidPublicKey)?; + PublicKey::from_hex(public_key).map_err(|_| Error::new(Kind::InvalidArgument))?; Self::new(public_key, source, roles) } @@ -245,22 +223,21 @@ mod tests { assert_eq!(actor.account_id(), Some(account_id(ALICE))); } - assert_eq!( - Actor::new( - public_key(ALICE), - ActorSource::LocalAccount(account_id(BOB)), - [AuthorRole::Farmer], - ), - Err(ActorError::AccountPublicKeyMismatch) - ); + let error = Actor::new( + public_key(ALICE), + ActorSource::LocalAccount(account_id(BOB)), + [AuthorRole::Farmer], + ) + .expect_err("mismatched account must fail"); + assert_eq!(error.kind(), Kind::InvalidArgument); } #[test] fn invalid_public_key_text_is_rejected() { - assert_eq!( - Actor::from_public_key_hex("not-a-public-key", ActorSource::ExplicitPublicKey, []), - Err(ActorError::InvalidPublicKey) - ); + let error = + Actor::from_public_key_hex("not-a-public-key", ActorSource::ExplicitPublicKey, []) + .expect_err("invalid public key must fail"); + assert_eq!(error.kind(), Kind::InvalidArgument); } #[test] diff --git a/crates/signing/src/error.rs b/crates/signing/src/error.rs @@ -1,19 +1,302 @@ -//! Normalized signing failures. +//! Normalized, secret-safe signing failures. use core::fmt; -/// A signing failure. +use radroots_protocol::{ + error::v1::{Class, Descriptor as ProtocolDescriptor, ErrorReport, KnownCode, RecoveryAction}, + runtime::v1::OperationId, +}; + +#[cfg(feature = "std")] +use std::boxed::Box; + +/// Stable native signing failure kinds. /// -/// Step 104 replaces this opaque pre-release value with the governed error -/// catalog. It intentionally carries no dependency-specific or secret data. +/// These variants describe the signing contract rather than any concrete +/// signer library. Additive variants remain possible before 1.0. #[non_exhaustive] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Error; +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Kind { + InvalidArgument, + AuthorizationDenied, + SignerCapabilityMissing, + SignerUnavailable, + SignerRejected, + SignerTimeout, + SignerCancelled, + SignerOutputInvalid, + DeadlineExceeded, + InternalError, +} + +/// One signing error descriptor generated from the native-to-protocol map. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Descriptor { + kind: Kind, + code: KnownCode, + message: &'static str, +} + +impl Descriptor { + #[must_use] + pub const fn kind(self) -> Kind { + self.kind + } + + #[must_use] + pub const fn known_code(self) -> KnownCode { + self.code + } + + #[must_use] + pub const fn code(self) -> &'static str { + self.code.as_str() + } + + #[must_use] + pub const fn class(self) -> Class { + self.protocol_descriptor().class + } + + #[must_use] + pub const fn retryable(self) -> bool { + self.protocol_descriptor().retryable + } + + #[must_use] + pub const fn recovery_actions(self) -> &'static [RecoveryAction] { + self.protocol_descriptor().recovery_actions + } + + #[must_use] + pub const fn message(self) -> &'static str { + self.message + } + + const fn protocol_descriptor(self) -> ProtocolDescriptor { + self.code.descriptor() + } +} + +macro_rules! signing_error_catalog { + ($( $variant:ident => ($code:ident, $message:literal) ),+ $(,)?) => { + impl Kind { + /// Every native signing failure kind in stable catalog order. + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + /// Returns metadata generated from this package's single mapping + /// and the protocol catalog's single class/recovery authority. + #[must_use] + pub const fn descriptor(self) -> Descriptor { + match self { + $(Self::$variant => Descriptor { + kind: Self::$variant, + code: KnownCode::$code, + message: $message, + },)+ + } + } + } + + /// Complete stable native signing error catalog. + pub const CATALOG: &[Descriptor] = &[ + $(Descriptor { + kind: Kind::$variant, + code: KnownCode::$code, + message: $message, + },)+ + ]; + }; +} + +signing_error_catalog! { + InvalidArgument => (InvalidArgument, "signing request is invalid"), + AuthorizationDenied => (AuthorizationDenied, "signing authorization was denied"), + SignerCapabilityMissing => (SignerCapabilityMissing, "required signer capability is missing"), + SignerUnavailable => (SignerUnavailable, "signer is unavailable"), + SignerRejected => (SignerRejected, "signer rejected the request"), + SignerTimeout => (SignerTimeout, "signer timed out"), + SignerCancelled => (SignerCancelled, "signing was cancelled"), + SignerOutputInvalid => (SignerOutputInvalid, "signer output did not match the frozen draft"), + DeadlineExceeded => (DeadlineExceeded, "signing deadline was exceeded"), + InternalError => (InternalError, "internal signing failure"), +} + +/// A normalized signing failure with an optional native source. +/// +/// Display and debug output are stable and never copy source text. Under the +/// `std` feature callers may inspect the explicit `source()` chain for local +/// diagnostics; protocol conversion always discards it. +pub struct Error { + kind: Kind, + #[cfg(feature = "std")] + source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>, +} + +impl Error { + /// Creates a source-free normalized failure. + #[must_use] + pub const fn new(kind: Kind) -> Self { + Self { + kind, + #[cfg(feature = "std")] + source: None, + } + } + + /// Preserves a native source without exposing it through display, debug, + /// or protocol serialization. + #[cfg(feature = "std")] + pub fn with_source<E>(kind: Kind, source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self { + kind, + source: Some(Box::new(source)), + } + } + + #[must_use] + pub const fn kind(&self) -> Kind { + self.kind + } + + #[must_use] + pub const fn descriptor(&self) -> Descriptor { + self.kind.descriptor() + } + + #[must_use] + pub const fn code(&self) -> &'static str { + self.descriptor().code() + } + + #[must_use] + pub const fn class(&self) -> Class { + self.descriptor().class() + } + + #[must_use] + pub const fn retryable(&self) -> bool { + self.descriptor().retryable() + } + + #[must_use] + pub const fn recovery_actions(&self) -> &'static [RecoveryAction] { + self.descriptor().recovery_actions() + } + + /// Produces the versioned boundary report without copying source text. + #[must_use] + pub fn to_report(&self, operation_id: Option<OperationId>) -> ErrorReport { + ErrorReport::redacted_from_source(self.descriptor().known_code(), operation_id, None) + } +} + +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); + #[cfg(feature = "std")] + value.field("source", &self.source.as_ref().map(|_| "[redacted]")); + value.finish() + } +} impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("signing operation failed") + formatter.write_str(self.descriptor().message()) } } -impl core::error::Error for Error {} +impl core::error::Error for Error { + #[cfg(feature = "std")] + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn core::error::Error + 'static)) + } +} + +impl From<&Error> for ErrorReport { + fn from(error: &Error) -> Self { + error.to_report(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "std")] + use std::{collections::BTreeSet, error::Error as _}; + + #[test] + fn catalog_codes_are_unique_and_metadata_matches_protocol_authority() { + assert_eq!(CATALOG.len(), Kind::ALL.len()); + let mut codes = alloc_or_std_set(); + for (index, descriptor) in CATALOG.iter().copied().enumerate() { + assert!(codes.insert(descriptor.code())); + assert_eq!(descriptor.kind(), Kind::ALL[index]); + assert_eq!(descriptor.kind().descriptor(), descriptor); + let protocol = descriptor.known_code().descriptor(); + assert_eq!(descriptor.class(), protocol.class); + assert_eq!(descriptor.retryable(), protocol.retryable); + assert_eq!(descriptor.recovery_actions(), protocol.recovery_actions); + assert!(!descriptor.message().is_empty()); + let report = Error::new(descriptor.kind()).to_report(None); + assert_eq!(report.code().known_code(), Some(descriptor.known_code())); + assert_eq!(report.class(), descriptor.class()); + assert_eq!(report.retryable(), descriptor.retryable()); + assert_eq!(report.recovery_actions(), descriptor.recovery_actions()); + assert_eq!(report.message().as_str(), "[redacted]"); + } + } + + #[cfg(feature = "std")] + #[test] + fn native_source_is_preserved_but_diagnostics_and_reports_are_redacted() { + #[derive(Debug)] + struct SensitiveSource; + + impl fmt::Display for SensitiveSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("nsec1-do-not-disclose") + } + } + + impl std::error::Error for SensitiveSource {} + + let error = Error::with_source(Kind::SignerUnavailable, SensitiveSource); + assert_eq!( + error.source().expect("source").to_string(), + "nsec1-do-not-disclose" + ); + assert!(!error.to_string().contains("nsec1")); + assert!(!format!("{error:?}").contains("nsec1")); + assert_eq!(error.code(), "signer_unavailable"); + assert!(error.retryable()); + + let report = error.to_report(Some(OperationId::SyncPush)); + assert_eq!(report.code().as_str(), "signer_unavailable"); + assert_eq!(report.operation_id(), Some(OperationId::SyncPush)); + assert_eq!(report.message().as_str(), "[redacted]"); + #[cfg(feature = "serde")] + assert!( + !serde_json::to_string(&report) + .expect("report") + .contains("nsec1") + ); + } + + #[cfg(feature = "std")] + fn alloc_or_std_set() -> BTreeSet<&'static str> { + BTreeSet::new() + } + + #[cfg(not(feature = "std"))] + fn alloc_or_std_set() -> alloc::collections::BTreeSet<&'static str> { + alloc::collections::BTreeSet::new() + } +} diff --git a/crates/signing/src/receipt.rs b/crates/signing/src/receipt.rs @@ -4,7 +4,7 @@ use core::fmt; use radroots_event::{SignedEvent, draft::validate_signed_nostr_event_matches_draft}; use radroots_protocol::runtime::v1::OperationId; -use crate::{Error, SignRequest}; +use crate::{Error, SignRequest, error::Kind}; /// Successful signer output with portable operation provenance. #[non_exhaustive] @@ -36,8 +36,19 @@ impl SignReceipt { signed_event: SignedEvent, completed_at_unix: u64, ) -> Result<Self, Error> { - validate_signed_nostr_event_matches_draft(&signed_event, request.draft()) - .map_err(|_| 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) + } + }, + )?; Ok(Self { operation_id: request.operation_id(), signed_event, @@ -207,10 +218,9 @@ mod tests { ]; for event in cases { - assert_eq!( - SignReceipt::from_signed_event(&request, event, 42), - Err(Error) - ); + let error = + SignReceipt::from_signed_event(&request, event, 42).expect_err("drift must fail"); + assert_eq!(error.kind(), Kind::SignerOutputInvalid); } } } diff --git a/crates/signing/src/request.rs b/crates/signing/src/request.rs @@ -9,7 +9,7 @@ use alloc::sync::Arc; #[cfg(feature = "std")] use std::sync::Arc; -use crate::{Actor, Error, status::SignProgress}; +use crate::{Actor, Error, error::Kind, status::SignProgress}; /// How a signer must interpret cancellation around remote publication. #[non_exhaustive] @@ -36,12 +36,9 @@ pub struct SignPolicy { 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> { + pub const fn new(deadline_unix: u64, cancellation: CancellationPolicy) -> Result<Self, Error> { if deadline_unix == 0 { - return Err(SignPolicyError::InvalidDeadline); + return Err(Error::new(Kind::InvalidArgument)); } Ok(Self { deadline_unix, @@ -62,21 +59,6 @@ impl SignPolicy { } } -/// 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. /// /// Observers are not serialized, persisted, or invoked by hidden workers. @@ -105,7 +87,7 @@ impl SignRequest { draft: EventDraft, policy: SignPolicy, ) -> Result<Self, Error> { - authorize_actor_for_draft(&actor, &draft).map_err(|_| Error)?; + authorize_actor_for_draft(&actor, &draft).map_err(authorization_error)?; Ok(Self { operation_id, actor, @@ -180,6 +162,14 @@ fn authorize_actor_for_draft( 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 @@ -254,10 +244,9 @@ mod tests { #[test] fn policy_requires_a_real_deadline() { - assert_eq!( - SignPolicy::new(0, CancellationPolicy::LocalCooperative), - Err(SignPolicyError::InvalidDeadline) - ); + let error = SignPolicy::new(0, CancellationPolicy::LocalCooperative) + .expect_err("zero deadline must fail"); + assert_eq!(error.kind(), Kind::InvalidArgument); } #[test] diff --git a/crates/signing/src/signer.rs b/crates/signing/src/signer.rs @@ -40,6 +40,7 @@ mod tests { use crate::{ Actor, actor::ActorSource, + error::Kind, request::{CancellationPolicy, SignPolicy}, }; use core::sync::atomic::{AtomicUsize, Ordering}; @@ -67,7 +68,7 @@ mod tests { } fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { - Box::pin(async { Err(Error) }) + Box::pin(async { Err(Error::new(Kind::InternalError)) }) } } @@ -77,7 +78,7 @@ mod tests { } fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { - Box::pin(async { Err(Error) }) + Box::pin(async { Err(Error::new(Kind::InternalError)) }) } } @@ -88,7 +89,7 @@ mod tests { fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { self.0.fetch_add(1, Ordering::Relaxed); - Box::pin(async { Err(Error) }) + Box::pin(async { Err(Error::new(Kind::InternalError)) }) } } @@ -169,7 +170,10 @@ mod tests { ), ]; for request in requests { - assert!(request.is_err()); + assert_eq!( + request.as_ref().expect_err("request must fail").kind(), + Kind::AuthorizationDenied + ); if let Ok(request) = request { drop(signer.sign(request)); } diff --git a/crates/signing/src/status.rs b/crates/signing/src/status.rs @@ -7,7 +7,7 @@ use alloc::{string::String, vec::Vec}; #[cfg(feature = "std")] use std::{string::String, vec::Vec}; -use crate::capability::SignerCapability; +use crate::{Error, capability::SignerCapability, error::Kind}; const MAX_AUTH_URI_BYTES: usize = 2_048; @@ -28,19 +28,19 @@ impl AuthChallenge { uri: impl Into<String>, required_at_unix: u64, expires_at_unix: Option<u64>, - ) -> Result<Self, AuthChallengeError> { + ) -> Result<Self, Error> { 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); + return Err(Error::new(Kind::InvalidArgument)); } if let Some(expires_at_unix) = expires_at_unix && expires_at_unix < required_at_unix { - return Err(AuthChallengeError::ExpiresBeforeRequired); + return Err(Error::new(Kind::InvalidArgument)); } Ok(Self { uri, @@ -99,26 +99,6 @@ impl<'de> serde::Deserialize<'de> for AuthChallenge { } } -/// 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))] @@ -146,9 +126,9 @@ pub struct SignProgress { impl SignProgress { /// Creates a progress update without an authentication challenge. - pub const fn stage(stage: SignProgressStage) -> Result<Self, SignProgressError> { + pub const fn stage(stage: SignProgressStage) -> Result<Self, Error> { if matches!(stage, SignProgressStage::AwaitingAuthentication) { - return Err(SignProgressError::MissingAuthenticationChallenge); + return Err(Error::new(Kind::InvalidArgument)); } Ok(Self { stage, @@ -196,37 +176,15 @@ impl<'de> serde::Deserialize<'de> for SignProgress { (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, - )), + (SignProgressStage::AwaitingAuthentication, None) => { + Err(serde::de::Error::custom(Error::new(Kind::InvalidArgument))) + } + (_, Some(_)) => Err(serde::de::Error::custom(Error::new(Kind::InvalidArgument))), (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))] @@ -306,20 +264,26 @@ mod tests { 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) + AuthChallenge::new("http://auth.example", 10, None) + .expect_err("HTTP challenge must fail") + .kind(), + Kind::InvalidArgument ); assert_eq!( - AuthChallenge::new("https://auth.example", 20, Some(10)), - Err(AuthChallengeError::ExpiresBeforeRequired) + AuthChallenge::new("https://auth.example", 20, Some(10)) + .expect_err("invalid expiry must fail") + .kind(), + Kind::InvalidArgument ); } #[test] fn progress_requires_challenges_only_at_the_authentication_stage() { assert_eq!( - SignProgress::stage(SignProgressStage::AwaitingAuthentication), - Err(SignProgressError::MissingAuthenticationChallenge) + SignProgress::stage(SignProgressStage::AwaitingAuthentication) + .expect_err("missing challenge must fail") + .kind(), + Kind::InvalidArgument ); let challenge = AuthChallenge::new("https://auth.example/approve", 10, None).expect("challenge");