vti_common/error.rs
1use affinidi_tdk::secrets_resolver::errors::SecretsResolverError;
2use axum::http::StatusCode;
3use axum::response::{IntoResponse, Response};
4use tracing::{debug, warn};
5
6#[derive(Debug, thiserror::Error)]
7#[non_exhaustive]
8pub enum AppError {
9 #[error("configuration error: {0}")]
10 Config(String),
11
12 #[error("io error: {0}")]
13 Io(#[from] std::io::Error),
14
15 #[error("store error: {0}")]
16 Store(#[from] fjall::Error),
17
18 #[error("serialization error: {0}")]
19 Serialization(#[from] serde_json::Error),
20
21 #[error("internal error: {0}")]
22 Internal(String),
23
24 #[error("secret store error: {0}")]
25 SecretStore(String),
26
27 #[error("not found: {0}")]
28 NotFound(String),
29
30 #[error("conflict: {0}")]
31 Conflict(String),
32
33 /// The resource existed but has been permanently, irreversibly consumed
34 /// or removed — rendered as **410 Gone**, distinct from [`Self::NotFound`]
35 /// (never existed / not visible to this caller) and [`Self::Conflict`]
36 /// (a transient state mismatch a retry or different request could
37 /// resolve). Canonical use: the TEE Mode B bootstrap carve-out after it
38 /// has been claimed — a second `/bootstrap/request` cannot succeed no
39 /// matter what the caller sends, ever again for this VTA. The SDK's
40 /// `vta_sdk::error::VtaError::Gone` mirrors this on the client side
41 /// (`from_http` maps 410 → `Gone`) with an operator-facing hint.
42 #[error("gone: {0}")]
43 Gone(String),
44
45 #[error("secrets error: {0}")]
46 Secrets(#[from] SecretsResolverError),
47
48 #[error("authentication error: {0}")]
49 Authentication(String),
50
51 #[error("unauthorized: {0}")]
52 Unauthorized(String),
53
54 #[error("forbidden: {0}")]
55 Forbidden(String),
56
57 /// Operation requires a stepped-up (`acr=aal2`) session, but
58 /// the caller's JWT carries a lower acr (typically `aal1`).
59 /// Distinct from [`Self::Forbidden`] so wallets can react —
60 /// `step_up_required` is the operator-friendly signal to
61 /// trigger a passkey-login or VTA-approval ceremony, not a
62 /// hard rejection.
63 ///
64 /// Rendered as **403 Forbidden** with body
65 /// `{ "error": "step_up_required", "message": "...",
66 /// "requiredAcr": "aal2" }` so clients can distinguish it
67 /// from a role-based rejection without parsing English.
68 #[error("step-up required: {0}")]
69 StepUpRequired(String),
70
71 /// The Policy Decision Point refused the request until an approval is
72 /// obtained, and is handing back what obtaining it requires.
73 ///
74 /// Distinct from [`Self::StepUpRequired`] because that variant can only say
75 /// *that* elevation is needed — it renders `{error, message, requiredAcr}`
76 /// and has nowhere to put the `approveRequest` the caller must get signed,
77 /// nor any way to express a consent requirement (approver set, threshold,
78 /// challenge). A REST caller receiving it could learn it was blocked but
79 /// not what to do about it, while the trust-task caller for the very same
80 /// decision received the full document. That asymmetry is what this closes.
81 ///
82 /// `code` is the stable machine-readable reason (`auth:step_up_required`,
83 /// `auth:consent_required`) — the field a client keys on, rather than the
84 /// HTTP status or the English message. `details` is merged into the body,
85 /// so the REST response carries exactly what the trust-task reject's
86 /// `details` carries.
87 ///
88 /// Rendered as **403 Forbidden**: the request was understood and the caller
89 /// authenticated; it is refused pending a decision they can still obtain.
90 #[error("approval required: {code}")]
91 ApprovalRequired {
92 code: &'static str,
93 details: serde_json::Value,
94 },
95
96 #[error("validation error: {0}")]
97 Validation(String),
98
99 /// A peer this service had to reach shares no transport it can use: the
100 /// intersection of what the peer's DID document advertises and what this
101 /// service can initiate is empty.
102 ///
103 /// Typed rather than folded into [`Self::Validation`], whose wire form is
104 /// `malformedRequest` — which tells the producer its document was wrong,
105 /// when nothing in it was. The remedy is a transport on one side or the
106 /// other, and a client can say which only if it is told both sets. The
107 /// SDK's `VtaError::NoMatchingProtocol` is what this becomes on the far
108 /// side of either transport.
109 ///
110 /// Rendered as **502 Bad Gateway**: the failure is in reaching the peer.
111 ///
112 /// Boxed so the variant does not grow every `Result<_, AppError>` in the
113 /// workspace (`clippy::result_large_err`).
114 #[error("{0}")]
115 NoMatchingProtocol(Box<NoMatchingProtocol>),
116
117 /// The request did not carry a required `Trust-Task` header. Routes
118 /// registered via [`crate::trust_task::TrustTaskRouter::route_with_task`]
119 /// reject missing headers with this variant (400). Only `/health` is
120 /// allowed to omit it.
121 #[error("request is missing required Trust-Task header")]
122 TrustTaskMissing,
123
124 /// The request's `Trust-Task` header did not match the handler's
125 /// registered task. Returned as 415 per spec §16.2; the response body
126 /// carries the expected + received task URLs so clients can diagnose
127 /// without re-reading the route table.
128 #[error("Trust-Task header does not match handler (expected {expected})")]
129 TrustTaskMismatch {
130 expected: String,
131 received: Option<String>,
132 },
133
134 /// The supplied Trust-Task value was not a well-formed identifier
135 /// (empty, non-`https://`, or contained header-injection control
136 /// characters). Returned as 400.
137 #[error("malformed Trust-Task identifier: {0}")]
138 TrustTaskMalformed(String),
139
140 /// A request reused an `Idempotency-Key` it had previously sent
141 /// with a *different* body hash. The cached response is preserved
142 /// for the original requester; the conflicting retry is rejected
143 /// with 422 so clients don't silently get a stale response from a
144 /// drifting payload.
145 #[error("Idempotency-Key conflict: same key, different request body")]
146 IdempotencyKeyConflict,
147
148 /// A pagination cursor failed integrity verification — either the
149 /// HMAC tag didn't validate (tampered, forged, or signed under a
150 /// different community's audit_key) or the encoded form was
151 /// malformed. Returned as 400 with no extra detail so an attacker
152 /// can't learn whether their guessed cursor was structurally
153 /// close to a valid one.
154 #[error("invalid pagination cursor")]
155 InvalidCursor,
156
157 /// A bounded computation aborted because it hit a resource ceiling
158 /// (time/instruction budget or an input-size cap) before completing.
159 /// Used by the Rego policy evaluator to refuse pathological policies
160 /// or adversarial inputs on the unauthenticated join path rather than
161 /// burning CPU unbounded. Rendered as **503 Service Unavailable** — the
162 /// evaluation did not complete, and the message is generic so an
163 /// attacker can't probe the exact limits.
164 #[error("resource limit exceeded: {0}")]
165 ResourceExhausted(String),
166
167 /// Catch-all for service-specific errors (e.g., KeyDerivation, BadGateway, TeeAttestation).
168 /// Services create helper functions to construct these with appropriate status codes.
169 #[error("{message}")]
170 ServiceError { status: StatusCode, message: String },
171
172 /// An I/O failure in a vsock operation. Preserves the underlying
173 /// `std::io::Error` via `#[source]` while adding a human-readable
174 /// label of which operation failed (connect / read / write / flush).
175 ///
176 /// Construct via [`AppError::vsock`] for ergonomic `.map_err(...)`.
177 #[error("{operation} failed: {source}")]
178 Vsock {
179 operation: &'static str,
180 #[source]
181 source: std::io::Error,
182 },
183}
184
185/// [`AppError::NoMatchingProtocol`]'s payload: who, and what each side has.
186#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
187#[error(
188 "no transport in common with `{peer}`: it advertises [{}] and this agent can initiate [{}]{}",
189 protocol_list(theirs),
190 protocol_list(ours),
191 note.as_deref().map(|n| format!(". {n}")).unwrap_or_default()
192)]
193pub struct NoMatchingProtocol {
194 /// The peer's DID.
195 pub peer: String,
196 /// What this service can initiate, for the document it was sending.
197 pub ours: Vec<vta_sdk::protocol::matching::Protocol>,
198 /// What the peer's DID document advertises.
199 pub theirs: Vec<vta_sdk::protocol::matching::Protocol>,
200 /// Why a transport one side has was not usable, where that is not
201 /// evident from the two sets (a plaintext REST endpoint; a document
202 /// issued by a DID the messaging transports do not authenticate).
203 pub note: Option<String>,
204}
205
206impl From<NoMatchingProtocol> for AppError {
207 fn from(e: NoMatchingProtocol) -> Self {
208 Self::NoMatchingProtocol(Box::new(e))
209 }
210}
211
212/// `[tsp, rest]`'s inside, or `nothing` for an empty set.
213fn protocol_list(set: &[vta_sdk::protocol::matching::Protocol]) -> String {
214 if set.is_empty() {
215 "nothing".to_string()
216 } else {
217 set.iter()
218 .map(|p| p.as_str())
219 .collect::<Vec<_>>()
220 .join(", ")
221 }
222}
223
224impl AppError {
225 /// Build a closure suitable for `.map_err(...)` that wraps an
226 /// `std::io::Error` into [`AppError::Vsock`] with the given operation
227 /// label. Keeps the source chain intact for downstream error walkers
228 /// while giving log readers the operation name.
229 pub fn vsock(operation: &'static str) -> impl FnOnce(std::io::Error) -> AppError {
230 move |source| AppError::Vsock { operation, source }
231 }
232}
233
234/// Convert the canonical auth-flow errors into [`AppError`] so
235/// the route layer's existing `IntoResponse` plumbing renders
236/// them without backend-specific glue. Each variant lands on the
237/// HTTP status reflected in the [`crate::auth::AuthError`]
238/// doc-comments:
239///
240/// - `Forbidden`, `DidMethodRejected` → 403
241/// - `PendingChallengeLimitReached` → 400 via the Validation arm.
242/// Only a store that cannot list a subject's challenges raises it
243/// (`SessionStore::pending_challenges`); the others evict instead.
244/// - `SessionNotFound`, `SessionStateMismatch`, `ChallengeMismatch`,
245/// `ChallengeExpired`, `SignerMismatch`, `StaleMessage`,
246/// `RefreshTokenInvalid`, `RefreshTokenExpired` → 401
247/// - `SessionIdleTimeout` → 401, but with its own message: it is only
248/// reachable by the proven session owner, so it does not share the
249/// group's enumeration-resistance constraint.
250/// - `AttestationFailed` → 503 via Internal (TEE outages are not
251/// the caller's fault).
252/// - `Internal` → 500.
253impl From<crate::auth::backend::AuthError> for AppError {
254 fn from(e: crate::auth::backend::AuthError) -> Self {
255 use crate::auth::backend::AuthError as A;
256 match e {
257 A::Forbidden | A::DidMethodRejected => AppError::Forbidden(e.to_string()),
258 A::PendingChallengeLimitReached => AppError::Validation(e.to_string()),
259 // One message for every failure reachable *without* the
260 // subject's private key. These variants differ in ways a caller
261 // must not be able to observe: "session not found" versus
262 // "signer mismatch" tells a party probing identifiers whether
263 // the subject it named is enrolled here, which is precisely what
264 // `handle_challenge` stops disclosing at the other end of the
265 // flow. The variant is preserved for the log line; the caller is
266 // told that authentication failed.
267 A::SessionNotFound
268 | A::SessionStateMismatch
269 | A::ChallengeMismatch
270 | A::ChallengeExpired
271 | A::SignerMismatch
272 | A::StaleMessage
273 | A::RefreshTokenInvalid
274 | A::RefreshTokenExpired => {
275 tracing::debug!(reason = %e, "authentication failed");
276 AppError::Authentication("authentication failed".into())
277 }
278 // Told plainly, unlike the group above, because this one is
279 // not reachable by probing. `handle_refresh` claims-and-
280 // deletes the refresh-token index before it runs, so anyone
281 // who gets this answer has already proven possession of a
282 // valid, unconsumed refresh token for an authenticated
283 // session — they are the session's owner, and there is
284 // nothing left to disclose to them. It earns its own message
285 // because "you were signed out for being away" and "your
286 // session hit its maximum age" send an operator to different
287 // places, and the generic string sends them to neither.
288 A::SessionIdleTimeout => {
289 tracing::debug!(reason = %e, "refresh refused: idle timeout");
290 AppError::Authentication(
291 "session signed out after the configured period of inactivity".into(),
292 )
293 }
294 // SPEC §8.3 names both, so the message leads with the code.
295 A::MissingRecipient => AppError::Validation(
296 "malformedRequest: a signed auth/authenticate document must name this service \
297 as its `recipient` (SPEC §7.2 item 5)"
298 .into(),
299 ),
300 A::WrongRecipient { recipient, own } => AppError::Authentication(format!(
301 "wrongRecipient: this document is addressed to {recipient}, not to this \
302 service ({own})"
303 )),
304 A::AttestationFailed(msg) => AppError::Internal(format!("tee attestation: {msg}")),
305 A::Internal(msg) => AppError::Internal(msg),
306 // Deliberately no wildcard arm. `AuthError` is
307 // `#[non_exhaustive]`, but that constrains only *other*
308 // crates — in here the match stays exhaustive, so a new
309 // variant is a compile error at this spot rather than
310 // something that silently inherits a catch-all mapping.
311 }
312 }
313}
314
315impl IntoResponse for AppError {
316 fn into_response(self) -> Response {
317 let status = match &self {
318 AppError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
319 AppError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
320 AppError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
321 AppError::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
322 AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
323 AppError::SecretStore(_) => StatusCode::INTERNAL_SERVER_ERROR,
324 AppError::NotFound(_) => StatusCode::NOT_FOUND,
325 AppError::Conflict(_) => StatusCode::CONFLICT,
326 AppError::Gone(_) => StatusCode::GONE,
327 AppError::Secrets(_) => StatusCode::INTERNAL_SERVER_ERROR,
328 AppError::Authentication(_) => StatusCode::UNAUTHORIZED,
329 AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
330 AppError::Forbidden(_) => StatusCode::FORBIDDEN,
331 AppError::StepUpRequired(_) => StatusCode::FORBIDDEN,
332 AppError::ApprovalRequired { .. } => StatusCode::FORBIDDEN,
333 AppError::Validation(_) => StatusCode::BAD_REQUEST,
334 AppError::NoMatchingProtocol(_) => StatusCode::BAD_GATEWAY,
335 AppError::TrustTaskMissing => StatusCode::BAD_REQUEST,
336 AppError::TrustTaskMismatch { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE,
337 AppError::TrustTaskMalformed(_) => StatusCode::BAD_REQUEST,
338 AppError::IdempotencyKeyConflict => StatusCode::UNPROCESSABLE_ENTITY,
339 AppError::InvalidCursor => StatusCode::BAD_REQUEST,
340 AppError::ResourceExhausted(_) => StatusCode::SERVICE_UNAVAILABLE,
341 AppError::ServiceError { status, .. } => *status,
342 AppError::Vsock { .. } => StatusCode::INTERNAL_SERVER_ERROR,
343 };
344
345 if status.is_server_error() {
346 warn!(status = %status.as_u16(), error = %self, "server error");
347 } else {
348 debug!(status = %status.as_u16(), error = %self, "client error");
349 }
350
351 // Trust-Task variants get structured payloads so clients can
352 // diagnose without re-reading the route table. Every other
353 // variant retains the existing `{ "error": "<display>" }` shape
354 // for backwards-compat with the workspace's existing consumers.
355 let body = match &self {
356 AppError::TrustTaskMissing => serde_json::json!({
357 "error": "TrustTaskMissing",
358 "message": self.to_string(),
359 }),
360 AppError::TrustTaskMismatch { expected, received } => serde_json::json!({
361 "error": "TrustTaskMismatch",
362 "message": self.to_string(),
363 "expected": expected,
364 "received": received,
365 }),
366 AppError::TrustTaskMalformed(value) => serde_json::json!({
367 "error": "TrustTaskMalformed",
368 "message": self.to_string(),
369 "received": value,
370 }),
371 AppError::IdempotencyKeyConflict => serde_json::json!({
372 "error": "IdempotencyKeyConflict",
373 "message": self.to_string(),
374 }),
375 AppError::StepUpRequired(msg) => serde_json::json!({
376 "error": "step_up_required",
377 "message": msg,
378 "requiredAcr": "aal2",
379 }),
380 // `details` is merged at the top level rather than nested, so the
381 // body reads the same as the trust-task reject's `details` object
382 // and a client can key on one shape across both transports. `error`
383 // is written last so a `details` carrying that key cannot displace
384 // the code the caller switches on.
385 AppError::ApprovalRequired { code, details } => {
386 let mut body = match details {
387 serde_json::Value::Object(map) => map.clone(),
388 _ => serde_json::Map::new(),
389 };
390 body.insert("error".to_string(), serde_json::json!(code));
391 serde_json::Value::Object(body)
392 }
393 // The same members the Trust Task rejection carries in `details`,
394 // so the SDK reads both with one parser.
395 AppError::NoMatchingProtocol(e) => serde_json::json!({
396 "error": vta_sdk::protocols::trust_task_reject_reasons::NO_MATCHING_PROTOCOL,
397 "message": self.to_string(),
398 "counterpartyDid": e.peer,
399 "ours": e.ours,
400 "theirs": e.theirs,
401 }),
402 _ => serde_json::json!({ "error": self.to_string() }),
403 };
404 (status, axum::Json(body)).into_response()
405 }
406}
407
408/// Helper to create a service-specific error for key derivation failures.
409pub fn key_derivation_error(msg: impl Into<String>) -> AppError {
410 AppError::ServiceError {
411 status: StatusCode::BAD_REQUEST,
412 message: format!("key derivation error: {}", msg.into()),
413 }
414}
415
416/// Helper to create a service-specific error for bad gateway responses.
417pub fn bad_gateway_error(msg: impl Into<String>) -> AppError {
418 AppError::ServiceError {
419 status: StatusCode::BAD_GATEWAY,
420 message: format!("bad gateway: {}", msg.into()),
421 }
422}
423
424/// Helper to create a service-specific error for TEE attestation failures.
425pub fn tee_attestation_error(msg: impl Into<String>) -> AppError {
426 AppError::ServiceError {
427 status: StatusCode::SERVICE_UNAVAILABLE,
428 message: format!("TEE attestation error: {}", msg.into()),
429 }
430}
431
432#[cfg(test)]
433mod approval_required_tests {
434 use super::*;
435 use axum::body::to_bytes;
436 use axum::response::IntoResponse;
437
438 async fn body_of(err: AppError) -> serde_json::Value {
439 let resp = err.into_response();
440 let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
441 serde_json::from_slice(&bytes).expect("json body")
442 }
443
444 /// The point of the variant: a REST caller must receive the same actionable
445 /// payload the trust-task caller gets, not merely "you were blocked".
446 #[tokio::test]
447 async fn details_are_merged_alongside_the_code() {
448 let body = body_of(AppError::ApprovalRequired {
449 code: "auth:step_up_required",
450 details: serde_json::json!({
451 "requiredAcr": "aal2",
452 "approveRequest": { "id": "urn:uuid:abc" },
453 }),
454 })
455 .await;
456
457 assert_eq!(body["error"], "auth:step_up_required");
458 assert_eq!(body["requiredAcr"], "aal2");
459 assert_eq!(body["approveRequest"]["id"], "urn:uuid:abc");
460 }
461
462 #[tokio::test]
463 async fn consent_details_survive_the_round_trip() {
464 let body = body_of(AppError::ApprovalRequired {
465 code: "auth:consent_required",
466 details: serde_json::json!({
467 "approverSet": "ops",
468 "minApprovals": 2,
469 "excludeRequester": true,
470 }),
471 })
472 .await;
473
474 assert_eq!(body["error"], "auth:consent_required");
475 assert_eq!(body["minApprovals"], 2);
476 assert_eq!(body["excludeRequester"], true);
477 }
478
479 /// `details` is assembled from a policy decision, so a stray `error` key in
480 /// it must not be able to displace the code a client switches on.
481 #[tokio::test]
482 async fn details_cannot_overwrite_the_code() {
483 let body = body_of(AppError::ApprovalRequired {
484 code: "auth:consent_required",
485 details: serde_json::json!({ "error": "allow", "approverSet": "ops" }),
486 })
487 .await;
488
489 assert_eq!(body["error"], "auth:consent_required");
490 assert_eq!(body["approverSet"], "ops");
491 }
492
493 /// A non-object `details` must still yield a well-formed body rather than
494 /// panicking or emitting a bare scalar.
495 #[tokio::test]
496 async fn a_non_object_details_still_renders_the_code() {
497 let body = body_of(AppError::ApprovalRequired {
498 code: "auth:step_up_required",
499 details: serde_json::Value::Null,
500 })
501 .await;
502
503 assert_eq!(body["error"], "auth:step_up_required");
504 assert!(body.as_object().is_some_and(|m| m.len() == 1));
505 }
506
507 #[tokio::test]
508 async fn renders_as_forbidden() {
509 let resp = AppError::ApprovalRequired {
510 code: "auth:consent_required",
511 details: serde_json::json!({}),
512 }
513 .into_response();
514 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
515 }
516}
517
518#[cfg(test)]
519mod gone_tests {
520 use super::*;
521
522 /// The whole point of the variant: a consumed single-use resource (the
523 /// canonical example being the TEE Mode B bootstrap carve-out) must
524 /// render 410, not 403/409 — the SDK's `VtaError::from_http` keys its
525 /// `Gone` mapping off exactly this status code.
526 #[test]
527 fn renders_as_410_gone() {
528 let resp =
529 AppError::Gone("TEE first-boot carve-out has already been used".into()).into_response();
530 assert_eq!(resp.status(), StatusCode::GONE);
531 }
532
533 #[tokio::test]
534 async fn body_carries_the_message() {
535 use axum::body::to_bytes;
536
537 let resp = AppError::Gone("carve-out closed".into()).into_response();
538 let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
539 let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
540 assert_eq!(body["error"], "gone: carve-out closed");
541 }
542}