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, carries a private component (`d`, `p`, `q`, `dp`, `dq`,
49    /// `qi`, `k`), or is a key no signature under it could ever prove anything about.
50    ///
51    /// That last case is a **low-order ("weak") Ed25519 public key**: no private key exists for
52    /// one, so it is rejected on its own merits, before verification is attempted, rather than
53    /// as a `bad_signature` — the key is the defect, not the signature. See `crate::eddsa` and
54    /// [authkestra#242](https://github.com/marcjazz/authkestra/issues/242).
55    #[error("bad_jwk: {0}")]
56    BadJwk(String),
57
58    /// **The security-critical rejection.** `thumbprint(sig.header.jwk) != att.claims.cnf.jkt`.
59    ///
60    /// An attacker who presents a victim's valid attestation (it is public — it travels in
61    /// every request) alongside a request signed with the attacker's *own* key lands here. The
62    /// attestation's signature verifies fine (it's genuinely from the Issuer) and the request
63    /// signature verifies fine (against the attacker's own, genuinely-held key) — only this
64    /// thumbprint comparison detects that the two credentials do not describe the same key.
65    /// Skipping, reordering, or short-circuiting this check is a total authentication bypass.
66    #[error("key_not_bound: embedded jwk thumbprint does not match attestation cnf.jkt")]
67    KeyNotBound,
68
69    /// The request signature does not verify against the embedded `jwk`.
70    #[error("bad_signature: {0}")]
71    BadSignature(String),
72
73    /// `now` falls outside `[sig.iat - skew, sig.exp + skew]`.
74    #[error("signature_expired")]
75    SignatureExpired,
76
77    /// `sig.exp - sig.iat` exceeds the configured maximum signature lifetime.
78    #[error("lifetime_too_long")]
79    LifetimeTooLong,
80
81    /// `sig.mth != request.method`.
82    #[error("method_mismatch")]
83    MethodMismatch,
84
85    /// `sig.pth != request.path`.
86    #[error("path_mismatch")]
87    PathMismatch,
88
89    /// `sig.aud != expected_audience`.
90    #[error("audience_mismatch")]
91    AudienceMismatch,
92
93    /// A query string is present on the live request but `sig.qsh` disagrees (or is absent).
94    #[error("query_mismatch")]
95    QueryMismatch,
96
97    /// A body is present on the live request but `sig.bdh` disagrees (or is absent).
98    #[error("body_mismatch")]
99    BodyMismatch,
100
101    /// `jti` was already seen, or the replay store was unreachable. These reject identically —
102    /// an unreachable replay store must never be treated as "no replay recorded, so allow".
103    #[error("replay_detected")]
104    ReplayDetected,
105
106    /// The request body exceeded the configured maximum size before it could be hashed. Only
107    /// produced by the optional framework-integration layer/middleware (in `authkestra-axum` or
108    /// `authkestra-actix`), which must buffer the body to compute `bdh` — never by
109    /// [`crate::verify`] itself, which takes body bytes the caller already has.
110    #[error("body_too_large: request body exceeds the configured maximum of {0} bytes")]
111    BodyTooLarge(usize),
112}
113
114impl VerifyError {
115    /// The machine-readable rejection code (e.g. `"key_not_bound"`), stable across changes to
116    /// the `Display` message wording. Suitable for metrics labels and log fields.
117    pub fn code(&self) -> &'static str {
118        match self {
119            VerifyError::MissingCredential => "missing_credential",
120            VerifyError::Malformed(_) => "malformed",
121            VerifyError::BadAlg(_) => "bad_alg",
122            VerifyError::UntrustedIssuer(_) => "untrusted_issuer",
123            VerifyError::UnknownKid(_) => "unknown_kid",
124            VerifyError::BadAttestation(_) => "bad_attestation",
125            VerifyError::AttestationExpired => "attestation_expired",
126            VerifyError::DeviceNotActive => "device_not_active",
127            VerifyError::BadJwk(_) => "bad_jwk",
128            VerifyError::KeyNotBound => "key_not_bound",
129            VerifyError::BadSignature(_) => "bad_signature",
130            VerifyError::SignatureExpired => "signature_expired",
131            VerifyError::LifetimeTooLong => "lifetime_too_long",
132            VerifyError::MethodMismatch => "method_mismatch",
133            VerifyError::PathMismatch => "path_mismatch",
134            VerifyError::AudienceMismatch => "audience_mismatch",
135            VerifyError::QueryMismatch => "query_mismatch",
136            VerifyError::BodyMismatch => "body_mismatch",
137            VerifyError::ReplayDetected => "replay_detected",
138            VerifyError::BodyTooLarge(_) => "body_too_large",
139        }
140    }
141}