authkestra_devsig/error.rs
1//! Rejection reasons for the device-signature verification algorithm.
2//!
3//! Every variant corresponds to exactly one named rejection in the algorithm this crate
4//! implements (see the crate-level docs), so a caller — or a conformance test — can assert on
5//! the precise reason a request was rejected rather than just "it failed".
6
7use thiserror::Error;
8
9/// Why [`crate::verify`] rejected a request. Never carries the raw cryptographic material that
10/// caused the rejection — only enough to log or monitor safely.
11#[derive(Debug, Error, Clone, PartialEq, Eq)]
12pub enum VerifyError {
13 /// `X-Signature` and/or `X-Attestation` absent.
14 #[error("missing_credential: X-Signature and X-Attestation are both required")]
15 MissingCredential,
16
17 /// One of the two headers is not a syntactically valid compact JWS.
18 #[error("malformed: {0}")]
19 Malformed(String),
20
21 /// `alg` is not in the configured allow-list, is `none`, or is a symmetric algorithm
22 /// (HS256/384/512) — rejected unconditionally regardless of configuration, because this
23 /// scheme is asymmetric-only by construction (the private key never leaves the device).
24 #[error("bad_alg: {0}")]
25 BadAlg(String),
26
27 /// The attestation's `iss` is not one of the configured trusted issuers.
28 #[error("untrusted_issuer: {0}")]
29 UntrustedIssuer(String),
30
31 /// The attestation's `kid` is not present in the cached Issuer JWKS.
32 #[error("unknown_kid: {0}")]
33 UnknownKid(String),
34
35 /// The attestation's signature does not verify against the resolved Issuer key.
36 #[error("bad_attestation: {0}")]
37 BadAttestation(String),
38
39 /// `now` falls outside `[att.iat - skew, att.exp + skew]`.
40 #[error("attestation_expired")]
41 AttestationExpired,
42
43 /// The attestation's `att.status` is not `"active"`.
44 #[error("device_not_active")]
45 DeviceNotActive,
46
47 /// The embedded `jwk` (from the request signature's protected header) is missing, is not a
48 /// public key of an allowed type, or carries a private component (`d`, `p`, `q`, `dp`, `dq`,
49 /// `qi`, `k`).
50 #[error("bad_jwk: {0}")]
51 BadJwk(String),
52
53 /// **The security-critical rejection.** `thumbprint(sig.header.jwk) != att.claims.cnf.jkt`.
54 ///
55 /// An attacker who presents a victim's valid attestation (it is public — it travels in
56 /// every request) alongside a request signed with the attacker's *own* key lands here. The
57 /// attestation's signature verifies fine (it's genuinely from the Issuer) and the request
58 /// signature verifies fine (against the attacker's own, genuinely-held key) — only this
59 /// thumbprint comparison detects that the two credentials do not describe the same key.
60 /// Skipping, reordering, or short-circuiting this check is a total authentication bypass.
61 #[error("key_not_bound: embedded jwk thumbprint does not match attestation cnf.jkt")]
62 KeyNotBound,
63
64 /// The request signature does not verify against the embedded `jwk`.
65 #[error("bad_signature: {0}")]
66 BadSignature(String),
67
68 /// `now` falls outside `[sig.iat - skew, sig.exp + skew]`.
69 #[error("signature_expired")]
70 SignatureExpired,
71
72 /// `sig.exp - sig.iat` exceeds the configured maximum signature lifetime.
73 #[error("lifetime_too_long")]
74 LifetimeTooLong,
75
76 /// `sig.mth != request.method`.
77 #[error("method_mismatch")]
78 MethodMismatch,
79
80 /// `sig.pth != request.path`.
81 #[error("path_mismatch")]
82 PathMismatch,
83
84 /// `sig.aud != expected_audience`.
85 #[error("audience_mismatch")]
86 AudienceMismatch,
87
88 /// A query string is present on the live request but `sig.qsh` disagrees (or is absent).
89 #[error("query_mismatch")]
90 QueryMismatch,
91
92 /// A body is present on the live request but `sig.bdh` disagrees (or is absent).
93 #[error("body_mismatch")]
94 BodyMismatch,
95
96 /// `jti` was already seen, or the replay store was unreachable. These reject identically —
97 /// an unreachable replay store must never be treated as "no replay recorded, so allow".
98 #[error("replay_detected")]
99 ReplayDetected,
100
101 /// The request body exceeded the configured maximum size before it could be hashed. Only
102 /// produced by the optional framework-integration layer/middleware (in `authkestra-axum` or
103 /// `authkestra-actix`), which must buffer the body to compute `bdh` — never by
104 /// [`crate::verify`] itself, which takes body bytes the caller already has.
105 #[error("body_too_large: request body exceeds the configured maximum of {0} bytes")]
106 BodyTooLarge(usize),
107}
108
109impl VerifyError {
110 /// The machine-readable rejection code (e.g. `"key_not_bound"`), stable across changes to
111 /// the `Display` message wording. Suitable for metrics labels and log fields.
112 pub fn code(&self) -> &'static str {
113 match self {
114 VerifyError::MissingCredential => "missing_credential",
115 VerifyError::Malformed(_) => "malformed",
116 VerifyError::BadAlg(_) => "bad_alg",
117 VerifyError::UntrustedIssuer(_) => "untrusted_issuer",
118 VerifyError::UnknownKid(_) => "unknown_kid",
119 VerifyError::BadAttestation(_) => "bad_attestation",
120 VerifyError::AttestationExpired => "attestation_expired",
121 VerifyError::DeviceNotActive => "device_not_active",
122 VerifyError::BadJwk(_) => "bad_jwk",
123 VerifyError::KeyNotBound => "key_not_bound",
124 VerifyError::BadSignature(_) => "bad_signature",
125 VerifyError::SignatureExpired => "signature_expired",
126 VerifyError::LifetimeTooLong => "lifetime_too_long",
127 VerifyError::MethodMismatch => "method_mismatch",
128 VerifyError::PathMismatch => "path_mismatch",
129 VerifyError::AudienceMismatch => "audience_mismatch",
130 VerifyError::QueryMismatch => "query_mismatch",
131 VerifyError::BodyMismatch => "body_mismatch",
132 VerifyError::ReplayDetected => "replay_detected",
133 VerifyError::BodyTooLarge(_) => "body_too_large",
134 }
135 }
136}