Skip to main content

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}