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}