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}