Skip to main content

authkestra_engine/token/
cert_binding.rs

1//! RFC 8705 §3 certificate-bound access tokens.
2//!
3//! This module computes the `cnf.x5t#S256` confirmation value — the
4//! base64url (no padding) SHA-256 thumbprint of a client's DER-encoded X.509
5//! certificate — and nothing more. It does not parse X.509, does not
6//! terminate TLS, and does not validate a certificate chain: extracting the
7//! *actual* peer certificate presented on a live mTLS connection is a
8//! framework/deployment concern, deliberately left outside this
9//! framework-agnostic crate (see [`ClientCertificateDer`]'s doc comment for
10//! where that hand-off happens today).
11//!
12//! Consumed by `authkestra-op::handlers::token::handle_client_credentials`
13//! (to stamp `cnf.x5t#S256` at issuance) and by
14//! `authkestra-resource::jwt::JwtStrategy` (to verify a presented
15//! certificate against it) — see issue #224.
16
17use base64::Engine;
18use sha2::{Digest, Sha256};
19
20/// The DER-encoded bytes of a client certificate presented on the current
21/// connection.
22///
23/// Neither `authkestra-op` nor `authkestra-resource` terminates TLS itself,
24/// so nothing in this crate family populates a `ClientCertificateDer`
25/// automatically. A host application — or the mTLS-terminating layer it
26/// runs in front of/alongside its service (a reverse proxy, an
27/// `axum-server`/actix-web rustls acceptor configured to require and expose
28/// client certificates, etc.) — is responsible for extracting the peer
29/// certificate and handing its DER bytes to this crate:
30///
31/// - On the OP side, `authkestra-axum`'s `axum_token_handler` reads one back
32///   out of an `axum::Extension<ClientCertificateDer>` (so a host inserts it
33///   as a request extension via its own middleware/acceptor), and
34///   `authkestra-actix`'s `actix_token_handler` reads one out of the actix
35///   request's own extension map the same way. Both then forward the DER
36///   bytes into
37///   [`handle_token_with_client_cert`](../../../authkestra_op/handlers/token/fn.handle_token_with_client_cert.html).
38/// - On the resource-server side, `JwtStrategy::authenticate` looks one up
39///   in the `http::request::Parts` extension map it is handed, when
40///   `ValidationConfig::require_cert_binding` is set.
41///
42/// If nothing ever inserts one, callers simply see `None` throughout, and
43/// `client_credentials` tokens are issued as plain (unbound) bearer tokens,
44/// same as before this existed.
45///
46/// # The source of these bytes is the entire security boundary
47///
48/// **Inserting a `ClientCertificateDer` from a source that has not
49/// cryptographically verified the certificate — i.e. actually terminated
50/// mTLS and validated the chain — provides no security benefit and a false
51/// sense of one.**
52///
53/// Nothing here parses X.509, validates a chain, or checks that these bytes
54/// are even DER; [`x5t_s256_thumbprint`] hashes whatever it is handed. So a
55/// binding built from, say, a reverse-proxy header that relays a
56/// client-supplied value the proxy never verified degrades to "proof that
57/// the caller knows a byte string the caller chose" — while looking
58/// *identical* to a real RFC 8705 binding: a `cnf.x5t#S256` claim is present
59/// and `require_cert_binding` accepts it. Issuance and verification trust the
60/// same extension, so both fail together, and silently.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ClientCertificateDer(pub Vec<u8>);
63
64/// Computes the RFC 8705 §3 `x5t#S256` confirmation value: base64url
65/// (no padding) of the SHA-256 digest of the DER-encoded certificate.
66pub fn x5t_s256_thumbprint(cert_der: &[u8]) -> String {
67    let digest = Sha256::digest(cert_der);
68    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
69}
70
71/// Constant-time comparison of two `x5t#S256` thumbprints (or any two
72/// strings) — a short, dependency-free byte-XOR loop rather than pulling in
73/// `subtle` for a single fixed-length string comparison. Mirrors
74/// `authkestra_op::attestation::constant_time_eq`, used for the analogous
75/// `cnf.jkt` device-attestation check.
76pub fn constant_time_eq(a: &str, b: &str) -> bool {
77    let (a, b) = (a.as_bytes(), b.as_bytes());
78    if a.len() != b.len() {
79        return false;
80    }
81    let mut diff = 0u8;
82    for (x, y) in a.iter().zip(b.iter()) {
83        diff |= x ^ y;
84    }
85    diff == 0
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    /// Cross-checked against an independent implementation:
93    /// `printf 'hello' | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '='`.
94    #[test]
95    fn x5t_s256_matches_known_answer_vector() {
96        assert_eq!(
97            x5t_s256_thumbprint(b"hello"),
98            "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ"
99        );
100    }
101
102    #[test]
103    fn x5t_s256_is_deterministic() {
104        assert_eq!(
105            x5t_s256_thumbprint(b"cert-bytes"),
106            x5t_s256_thumbprint(b"cert-bytes")
107        );
108    }
109
110    #[test]
111    fn x5t_s256_differs_for_different_certs() {
112        assert_ne!(
113            x5t_s256_thumbprint(b"cert-a"),
114            x5t_s256_thumbprint(b"cert-b")
115        );
116    }
117
118    #[test]
119    fn constant_time_eq_matches_and_rejects() {
120        assert!(constant_time_eq("abc", "abc"));
121        assert!(!constant_time_eq("abc", "abd"));
122        assert!(!constant_time_eq("abc", "abcd"));
123        assert!(!constant_time_eq("", "a"));
124        assert!(constant_time_eq("", ""));
125    }
126}