Skip to main content

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}