authkestra_devsig/verify.rs
1//! The orchestrator: runs the full verification algorithm end to end, in the mandated order —
2//! cheap checks first, the binding check before anything is allowed to trust the embedded key,
3//! replay recorded last and only once everything else has already succeeded.
4
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crate::attestation;
8use crate::config::VerifierConfig;
9use crate::error::VerifyError;
10use crate::identity::DeviceIdentity;
11use crate::jwks::IssuerJwks;
12use crate::replay::ReplayStore;
13use crate::request::SignedRequest;
14use crate::signature;
15
16/// Verifies a device-bound signed request against the configured trust policy.
17///
18/// `request.signature` and `request.attestation` carry the two credentials (see
19/// [`SignedRequest`]); this function is deliberately framework-agnostic so it can be called from
20/// a `tower::Layer` (see the optional `axum` feature), a future authkestra trait-based
21/// integration, or a plain test harness — the algorithm itself does not care which.
22///
23/// Order, and why it is load-bearing:
24///
25/// 1. **Presence** — both credentials must be present. An attestation alone is a bearer token
26/// (it is public, travels in every request, and is likely logged); rejecting this case is
27/// what keeps the attestation from becoming exactly the weaker-than-normal scheme this design
28/// exists to avoid.
29/// 2. **Parse + `alg` check** — for both credentials, cheap, before any cryptographic work.
30/// 3. **Attestation trust** — issuer, `kid`, signature, expiry, device status.
31/// 4. **The binding** — recompute the embedded `jwk`'s RFC 7638 thumbprint and compare it,
32/// constant-time, to the attestation's `cnf.jkt`. **This is the step that cannot be inferred
33/// from the other two.** An attacker holding a victim's attestation (public, not secret) and
34/// their own genuinely-held keypair passes steps 3 and 5 independently and completely; only
35/// this comparison detects that the two credentials describe different keys. Skipping,
36/// reordering, or short-circuiting it is a total authentication bypass.
37/// 5. **Request-signature verification** against that now-bound `jwk`.
38/// 6. **Freshness** — skew window, maximum signature lifetime.
39/// 7. **Request binding** — method, path, audience, query hash, body hash.
40/// 8. **Replay** — recorded last, and fails closed on any store error, not just "already
41/// present". A replay store that cannot be reached must reject exactly as if the `jti` had
42/// already been seen; falling back to "allow" would silently disable replay protection during
43/// an outage, which is worse than rejecting traffic.
44pub async fn verify(
45 request: &SignedRequest<'_>,
46 config: &VerifierConfig,
47 jwks: &IssuerJwks,
48 replay_store: &dyn ReplayStore,
49) -> Result<DeviceIdentity, VerifyError> {
50 // --- Step 1: PRESENCE ---
51 let (sig_token, att_token) = match (request.signature, request.attestation) {
52 (Some(s), Some(a)) => (s, a),
53 _ => {
54 tracing::debug!(target: "authkestra_devsig", "rejecting request: missing_credential");
55 return Err(VerifyError::MissingCredential);
56 }
57 };
58
59 let now = current_unix_time();
60
61 // --- Step 2: PARSE (both credentials, cheap, before any crypto) ---
62 let parsed_att = attestation::parse(att_token, &config.allowed_algs)?;
63 let parsed_sig = signature::parse(sig_token, &config.allowed_algs)?;
64
65 // --- Step 3: ATTESTATION TRUST ---
66 let att = attestation::verify_trust(&parsed_att, config, jwks, now).await?;
67
68 // --- Steps 4-7: THE BINDING, REQUEST SIGNATURE, FRESHNESS, REQUEST BINDING ---
69 let sig = signature::verify_bound_and_signed(&parsed_sig, request, config, &att.jkt, now)?;
70
71 // --- Step 8: REPLAY — fails closed: a store error rejects exactly like a genuine replay,
72 // never falls back to "allow". ---
73 let ttl = Duration::from_secs((sig.exp - now).max(0) as u64);
74 match replay_store.put_if_absent(&sig.jti, ttl).await {
75 Ok(true) => {}
76 Ok(false) => {
77 tracing::warn!(target: "authkestra_devsig", jti = %sig.jti, "rejecting request: jti already seen (replay_detected)");
78 return Err(VerifyError::ReplayDetected);
79 }
80 Err(store_err) => {
81 tracing::error!(target: "authkestra_devsig", error = %store_err, "rejecting request: replay store unreachable, failing closed");
82 return Err(VerifyError::ReplayDetected);
83 }
84 }
85
86 tracing::debug!(
87 target: "authkestra_devsig",
88 subject = %att.sub,
89 device = %att.did,
90 "device-signature request accepted"
91 );
92
93 Ok(DeviceIdentity {
94 subject: att.sub,
95 device: att.did,
96 key_thumbprint: sig.jwk_thumbprint,
97 attributes: att.att,
98 })
99}
100
101fn current_unix_time() -> i64 {
102 SystemTime::now()
103 .duration_since(UNIX_EPOCH)
104 .expect("system clock before 1970-01-01")
105 .as_secs() as i64
106}