Skip to main content

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}