ic_core/error.rs
1//! The single error domain for the whole library.
2
3use core::fmt;
4
5/// Result alias used by every fallible operation in IronCrypto.
6pub type Result<T> = core::result::Result<T, Error>;
7
8/// Machine-actionable classification of a failure.
9///
10/// The discriminants are stable and are mirrored verbatim into the ontology
11/// (`ic-ontology::error_catalog`) so an agent can reason about recovery
12/// strategy without parsing English prose.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14#[non_exhaustive]
15pub enum ErrorKind {
16 /// A buffer was too short or too long for the algorithm's contract.
17 InvalidLength,
18 /// A key, nonce, or parameter was structurally unacceptable.
19 InvalidParameter,
20 /// Authentication (MAC / AEAD tag / signature) failed to verify.
21 AuthenticationFailed,
22 /// The requested algorithm exists but is not implemented in this build.
23 Unsupported,
24 /// The operation is not permitted while the module is in FIPS approved mode.
25 NotApprovedInFipsMode,
26 /// A FIPS 140-3 self-test failed; the module has entered the error state.
27 SelfTestFailed,
28 /// The module is in a hard error state and refuses all cryptographic service.
29 ModuleErrorState,
30 /// The entropy source failed or did not pass its health tests.
31 EntropyFailure,
32 /// A counter (DRBG reseed, GCM invocation, sequence number) was exhausted.
33 CounterExhausted,
34 /// Input could not be decoded (hex, base64, DER, point encoding).
35 MalformedEncoding,
36 /// Whatever holds the key -- a token, a TPM, a key service -- could not
37 /// be reached or could not answer, and the inputs were not at fault: a
38 /// device error, a lost session, a network failure. The same call may
39 /// succeed later.
40 ///
41 /// Nothing in this library returns it. It is for implementations of
42 /// [`Signer`](crate::sig::Signer) and the like over keys held elsewhere,
43 /// which otherwise have no honest kind to report.
44 ProviderUnavailable,
45 /// Whatever holds the key answered, and the answer was no: a PIN expired
46 /// or locked out, a policy that forbids the operation, a credential
47 /// without the permission. The inputs were not at fault and the same
48 /// call will be refused again until someone changes that.
49 ///
50 /// A provider that cannot tell this from
51 /// [`ProviderUnavailable`](Self::ProviderUnavailable) reports this one,
52 /// so that nothing retries into a lockout.
53 ProviderRefused,
54 /// An internal invariant was violated — always a library bug.
55 Internal,
56}
57
58impl ErrorKind {
59 /// Every kind, for callers that enumerate them.
60 ///
61 /// This type is `#[non_exhaustive]`, so no other crate can match on it
62 /// exhaustively and none can tell whether it has seen them all. That is
63 /// deliberate — it lets a variant be added without breaking callers — but
64 /// it also means a list like the ontology's error catalog cannot check its
65 /// own completeness. This crate can, so the list is published from here and
66 /// a test keeps it honest.
67 pub const ALL: &'static [ErrorKind] = &[
68 ErrorKind::InvalidLength,
69 ErrorKind::InvalidParameter,
70 ErrorKind::AuthenticationFailed,
71 ErrorKind::MalformedEncoding,
72 ErrorKind::Unsupported,
73 ErrorKind::NotApprovedInFipsMode,
74 ErrorKind::SelfTestFailed,
75 ErrorKind::ModuleErrorState,
76 ErrorKind::EntropyFailure,
77 ErrorKind::CounterExhausted,
78 ErrorKind::ProviderUnavailable,
79 ErrorKind::ProviderRefused,
80 ErrorKind::Internal,
81 ];
82
83 /// Stable kebab-case identifier used in ontology exports and CLI/MCP output.
84 pub const fn id(self) -> &'static str {
85 match self {
86 Self::InvalidLength => "invalid-length",
87 Self::InvalidParameter => "invalid-parameter",
88 Self::AuthenticationFailed => "authentication-failed",
89 Self::Unsupported => "unsupported",
90 Self::NotApprovedInFipsMode => "not-approved-in-fips-mode",
91 Self::SelfTestFailed => "self-test-failed",
92 Self::ModuleErrorState => "module-error-state",
93 Self::EntropyFailure => "entropy-failure",
94 Self::CounterExhausted => "counter-exhausted",
95 Self::MalformedEncoding => "malformed-encoding",
96 Self::ProviderUnavailable => "provider-unavailable",
97 Self::ProviderRefused => "provider-refused",
98 Self::Internal => "internal",
99 }
100 }
101
102 /// Whether retrying the identical call could plausibly succeed.
103 ///
104 /// Agents use this to decide between *retry*, *re-parameterize*, and *abort*.
105 #[must_use]
106 pub const fn retryable(self) -> bool {
107 matches!(self, Self::EntropyFailure | Self::ProviderUnavailable)
108 }
109
110 /// Whether the caller should change inputs and try again.
111 #[must_use]
112 pub const fn caller_correctable(self) -> bool {
113 matches!(
114 self,
115 Self::InvalidLength
116 | Self::InvalidParameter
117 | Self::MalformedEncoding
118 | Self::Unsupported
119 | Self::NotApprovedInFipsMode
120 | Self::CounterExhausted
121 )
122 }
123}
124
125/// A failure from any IronCrypto operation.
126///
127/// Deliberately opaque about *why* an authentication check failed — the
128/// [`ErrorKind`] is the only distinguishing datum for verification failures, so
129/// error handling cannot become a decryption oracle.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct Error {
132 kind: ErrorKind,
133 context: &'static str,
134}
135
136impl Error {
137 /// Construct an error with a static context string (an algorithm or field name).
138 pub const fn new(kind: ErrorKind, context: &'static str) -> Self {
139 Self { kind, context }
140 }
141
142 /// The machine-actionable classification.
143 pub const fn kind(&self) -> ErrorKind {
144 self.kind
145 }
146
147 /// A short static hint naming the failing parameter or component.
148 pub const fn context(&self) -> &'static str {
149 self.context
150 }
151}
152
153impl fmt::Display for Error {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(f, "{}: {}", self.kind.id(), self.context)
156 }
157}
158
159#[cfg(feature = "std")]
160impl std::error::Error for Error {}
161
162/// Shorthand for building an [`Error`].
163#[macro_export]
164macro_rules! err {
165 ($kind:ident, $ctx:literal) => {
166 $crate::Error::new($crate::ErrorKind::$kind, $ctx)
167 };
168}
169
170/// Shorthand for `return Err(err!(..))` guarded by a condition.
171#[macro_export]
172macro_rules! ensure {
173 ($cond:expr, $kind:ident, $ctx:literal) => {
174 if !($cond) {
175 return Err($crate::Error::new($crate::ErrorKind::$kind, $ctx));
176 }
177 };
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 /// `ALL` must really be all of them.
185 ///
186 /// The match below is exhaustive, and this is the crate that defines the
187 /// type, so `#[non_exhaustive]` does not apply here and adding a variant
188 /// stops this compiling until it is handled. Requiring `ALL` to contain
189 /// each one is what turns "the compiler noticed" into "the list was
190 /// updated".
191 #[test]
192 fn the_variant_list_is_complete() {
193 // The match is a no-op by construction, and that is the point: it
194 // exists so the compiler refuses this file when a variant is added,
195 // not to compute anything. Clippy is right that it does nothing and
196 // wrong that it is therefore unnecessary.
197 #[allow(clippy::needless_match)]
198 fn identify(kind: ErrorKind) -> ErrorKind {
199 match kind {
200 ErrorKind::InvalidLength => ErrorKind::InvalidLength,
201 ErrorKind::InvalidParameter => ErrorKind::InvalidParameter,
202 ErrorKind::AuthenticationFailed => ErrorKind::AuthenticationFailed,
203 ErrorKind::MalformedEncoding => ErrorKind::MalformedEncoding,
204 ErrorKind::Unsupported => ErrorKind::Unsupported,
205 ErrorKind::NotApprovedInFipsMode => ErrorKind::NotApprovedInFipsMode,
206 ErrorKind::SelfTestFailed => ErrorKind::SelfTestFailed,
207 ErrorKind::ModuleErrorState => ErrorKind::ModuleErrorState,
208 ErrorKind::EntropyFailure => ErrorKind::EntropyFailure,
209 ErrorKind::CounterExhausted => ErrorKind::CounterExhausted,
210 ErrorKind::ProviderUnavailable => ErrorKind::ProviderUnavailable,
211 ErrorKind::ProviderRefused => ErrorKind::ProviderRefused,
212 ErrorKind::Internal => ErrorKind::Internal,
213 }
214 }
215
216 // The match catches a variant being added: it stops compiling until
217 // the new one is handled, and handling it means editing the list right
218 // there. It does not catch one being dropped from `ALL`, because a
219 // shorter list still maps each of its members to itself. This count
220 // sits beside the match so the two are edited together.
221 assert_eq!(ErrorKind::ALL.len(), 13);
222
223 for kind in ErrorKind::ALL {
224 assert_eq!(identify(*kind), *kind);
225 }
226
227 // Identifiers are the join key the ontology matches on, so a duplicate
228 // would make two kinds indistinguishable to every consumer. Compared
229 // pairwise rather than collected, since this crate has no allocator.
230 for (i, a) in ErrorKind::ALL.iter().enumerate() {
231 for b in ErrorKind::ALL.iter().skip(i + 1) {
232 assert_ne!(a.id(), b.id(), "two kinds share an identifier");
233 }
234 assert!(!a.id().is_empty());
235 }
236 }
237
238 /// What a caller is told to do when a key's holder fails.
239 ///
240 /// The two kinds differ in exactly this, and a retry loop reads it: one
241 /// that retried a refusal would walk a token into its PIN lockout.
242 /// Neither is the caller's to correct by changing what it passed.
243 #[test]
244 fn only_an_unavailable_provider_is_worth_retrying() {
245 assert!(ErrorKind::ProviderUnavailable.retryable());
246 assert!(!ErrorKind::ProviderRefused.retryable());
247 assert!(!ErrorKind::ProviderUnavailable.caller_correctable());
248 assert!(!ErrorKind::ProviderRefused.caller_correctable());
249 // And the rest are as they were: only these two and a failed entropy
250 // source say a retry may help.
251 for kind in ErrorKind::ALL {
252 let expected = matches!(
253 kind,
254 ErrorKind::EntropyFailure | ErrorKind::ProviderUnavailable
255 );
256 assert_eq!(kind.retryable(), expected, "{}", kind.id());
257 }
258 }
259}