diff --git a/Cargo.lock b/Cargo.lock index 103499498..d4ab5019a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,6 +1245,7 @@ dependencies = [ "etherparse", "expect-test", "futures", + "hex", "hostname 0.4.1", "http-body-util", "hyper 1.6.0", @@ -1277,6 +1278,7 @@ dependencies = [ "serde-querystring", "serde_json", "serde_urlencoded", + "sha2", "smol_str", "sysevent", "sysevent-codes", @@ -1305,6 +1307,7 @@ dependencies = [ "utoipa", "uuid", "video-streamer", + "webpki-roots 0.26.11", "x509-cert", "zeroize", ] diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 1c273719e..8cb0b6bf3 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -75,6 +75,9 @@ zeroize = { version = "1.8", features = ["derive"] } multibase = "0.9" argon2 = { version = "0.5", features = ["std"] } x509-cert = { version = "0.2", default-features = false, features = ["std"] } +sha2 = "0.10" +hex = "0.4" +webpki-roots = "0.26" # Logging tracing = "0.1" diff --git a/devolutions-gateway/src/api/fwd.rs b/devolutions-gateway/src/api/fwd.rs index 9f285cec3..5a60adec8 100644 --- a/devolutions-gateway/src/api/fwd.rs +++ b/devolutions-gateway/src/api/fwd.rs @@ -244,11 +244,19 @@ where trace!("Establishing TLS connection with server"); // Establish TLS connection with server - - let server_stream = crate::tls::connect(selected_target.host().to_owned(), server_stream) - .await - .context("TLS connect") - .map_err(ForwardError::BadGateway)?; + let cert_thumb256 = claims.cert_thumb256.as_ref().map(|s| s.to_string()); + let target_str = selected_target.to_string(); + + let server_stream = crate::tls::connect_with_thumbprint( + selected_target.host().to_owned(), + server_stream, + cert_thumb256, + target_str, + claims.jet_aid, + ) + .await + .context("TLS connect") + .map_err(ForwardError::BadGateway)?; info!("WebSocket-TLS forwarding"); diff --git a/devolutions-gateway/src/api/webapp.rs b/devolutions-gateway/src/api/webapp.rs index 2ef980cfb..efe830a38 100644 --- a/devolutions-gateway/src/api/webapp.rs +++ b/devolutions-gateway/src/api/webapp.rs @@ -339,6 +339,7 @@ pub(crate) async fn sign_session_token( jet_reuse: ReconnectionPolicy::Disallowed, exp, jti, + cert_thumb256: None, } .pipe(serde_json::to_value) .map(|mut claims| { diff --git a/devolutions-gateway/src/tls.rs b/devolutions-gateway/src/tls.rs index ce6aba550..b6d4f7feb 100644 --- a/devolutions-gateway/src/tls.rs +++ b/devolutions-gateway/src/tls.rs @@ -32,14 +32,48 @@ static TLS_CONNECTOR: LazyLock = LazyLock::new(|| { }); pub async fn connect(dns_name: String, stream: IO) -> io::Result> +where + IO: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + connect_with_thumbprint(dns_name.clone(), stream, None, dns_name, uuid::Uuid::nil()).await +} + +/// Connect to a TLS server with optional certificate thumbprint anchoring. +/// +/// If `cert_thumb256` is provided and TLS verification fails, the connection will be accepted +/// if the server's leaf certificate thumbprint matches the provided value. +pub async fn connect_with_thumbprint( + dns_name: String, + stream: IO, + cert_thumb256: Option, + target: String, + assoc_id: uuid::Uuid, +) -> io::Result> where IO: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { use tokio::io::AsyncWriteExt as _; - let dns_name = pki_types::ServerName::try_from(dns_name).map_err(io::Error::other)?; + let server_name = pki_types::ServerName::try_from(dns_name.clone()).map_err(io::Error::other)?; + + // Create a TLS connector with thumbprint-anchored verification if a thumbprint is provided + let connector = if cert_thumb256.is_some() { + // Use thumbprint-anchored verifier + let verifier = Arc::new(danger::ThumbprintAnchoredVerifier::new(cert_thumb256, target, assoc_id)); - let mut tls_stream = TLS_CONNECTOR.connect(dns_name, stream).await?; + let mut config = rustls::client::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + + config.resumption = rustls::client::Resumption::disabled(); + tokio_rustls::TlsConnector::from(Arc::new(config)) + } else { + // Use existing no-verification connector for backward compatibility + TLS_CONNECTOR.clone() + }; + + let mut tls_stream = connector.connect(server_name, stream).await?; // > To keep it simple and correct, [TlsStream] will behave like `BufWriter`. // > For `TlsStream`, this means that data written by `poll_write` @@ -530,6 +564,7 @@ pub mod sanity { } pub mod danger { + use std::sync::Arc; use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use tokio_rustls::rustls::{DigitallySignedStruct, Error, SignatureScheme, pki_types}; @@ -584,4 +619,258 @@ pub mod danger { ] } } + + /// Certificate verifier that supports thumbprint anchoring. + /// + /// This verifier attempts normal TLS verification using system roots. + /// If verification fails and a thumbprint is provided that matches the leaf certificate, + /// the connection is accepted and details are logged. + #[derive(Debug)] + pub struct ThumbprintAnchoredVerifier { + /// Expected SHA-256 thumbprint (normalized lowercase hex, no separators) + expected_thumbprint: Option, + /// Target host and port for logging + target: String, + /// Association ID for logging + assoc_id: uuid::Uuid, + /// Standard verifier using system roots + standard_verifier: Arc, + } + + impl ThumbprintAnchoredVerifier { + pub fn new(expected_thumbprint: Option, target: String, assoc_id: uuid::Uuid) -> Self { + // Create a standard verifier using system root certificates + let root_store = tokio_rustls::rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(), + }; + + let standard_verifier = tokio_rustls::rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store)) + .build() + .expect("failed to build WebPkiServerVerifier with system root certificates; this should not fail with valid system roots"); + + Self { + expected_thumbprint: expected_thumbprint.map(normalize_thumbprint), + target, + assoc_id, + standard_verifier, + } + } + + fn check_thumbprint_and_log( + &self, + end_entity: &pki_types::CertificateDer<'_>, + verification_error: &Error, + ) -> Result { + let Some(expected_thumb) = &self.expected_thumbprint else { + // No thumbprint provided, fail with original error + return Err(verification_error.clone()); + }; + + // Compute SHA-256 thumbprint of the certificate + let actual_thumb = compute_sha256_thumbprint(end_entity.as_ref()); + + if actual_thumb != *expected_thumb { + // Thumbprint mismatch, fail with original error + warn!( + expected_thumb = %expected_thumb, + actual_thumb = %actual_thumb, + "Certificate thumbprint mismatch" + ); + return Err(verification_error.clone()); + } + + // Thumbprint matches! Extract certificate details and log + let cert_info = extract_cert_info(end_entity.as_ref()); + + info!( + event = "TLS_ANCHOR_ACCEPT", + assoc_id = %self.assoc_id, + target = %self.target, + cert_subject = %cert_info.subject, + cert_issuer = %cert_info.issuer, + not_before = %cert_info.not_before, + not_after = %cert_info.not_after, + san = ?cert_info.sans, + reason = %format_verification_error(verification_error), + sha256_thumb = %actual_thumb, + hint = "PASTE_THIS_THUMBPRINT_IN_RDM_CONNECTION", + "Accepting TLS connection via certificate thumbprint anchor despite verification failure" + ); + + Ok(ServerCertVerified::assertion()) + } + } + + impl ServerCertVerifier for ThumbprintAnchoredVerifier { + fn verify_server_cert( + &self, + end_entity: &pki_types::CertificateDer<'_>, + intermediates: &[pki_types::CertificateDer<'_>], + server_name: &pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: pki_types::UnixTime, + ) -> Result { + // Try standard verification first + match self + .standard_verifier + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + { + Ok(verified) => Ok(verified), + Err(err) => { + // Standard verification failed, try thumbprint anchoring + self.check_thumbprint_and_log(end_entity, &err) + } + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &pki_types::CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.standard_verifier.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &pki_types::CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.standard_verifier.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.standard_verifier.supported_verify_schemes() + } + } + + /// Normalize thumbprint to lowercase hex with no separators + fn normalize_thumbprint(thumb: String) -> String { + thumb + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase() + } + + /// Compute SHA-256 thumbprint of certificate DER bytes + fn compute_sha256_thumbprint(cert_der: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(cert_der); + hex::encode(hash) + } + + struct CertInfo { + subject: String, + issuer: String, + not_before: String, + not_after: String, + sans: Vec, + } + + fn extract_cert_info(cert_der: &[u8]) -> CertInfo { + match picky::x509::Cert::from_der(cert_der) { + Ok(cert) => { + let subject = cert.subject_name().to_string(); + let issuer = cert.issuer_name().to_string(); + let not_before = cert.valid_not_before().to_string(); + let not_after = cert.valid_not_after().to_string(); + + let mut sans = Vec::new(); + for ext in cert.extensions() { + if let picky::x509::extension::ExtensionView::SubjectAltName(san) = ext.extn_value() { + for name in san.0.iter() { + sans.push(format!("{:?}", name)); + } + } + } + + CertInfo { + subject, + issuer, + not_before, + not_after, + sans, + } + } + Err(_) => CertInfo { + subject: "".to_string(), + issuer: "".to_string(), + not_before: "".to_string(), + not_after: "".to_string(), + sans: vec![], + }, + } + } + + fn format_verification_error(err: &Error) -> String { + format!("{:?}", err) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_normalize_thumbprint() { + // Test lowercase hex remains unchanged + assert_eq!(normalize_thumbprint("abcdef0123456789".to_owned()), "abcdef0123456789"); + + // Test uppercase is converted to lowercase + assert_eq!(normalize_thumbprint("ABCDEF0123456789".to_owned()), "abcdef0123456789"); + + // Test mixed case + assert_eq!(normalize_thumbprint("AbCdEf0123456789".to_owned()), "abcdef0123456789"); + + // Test with colons (common format) + assert_eq!( + normalize_thumbprint("AB:CD:EF:01:23:45:67:89".to_owned()), + "abcdef0123456789" + ); + + // Test with spaces + assert_eq!( + normalize_thumbprint("AB CD EF 01 23 45 67 89".to_owned()), + "abcdef0123456789" + ); + + // Test with mixed separators + assert_eq!( + normalize_thumbprint("AB:CD-EF 01.23_45-67:89".to_owned()), + "abcdef0123456789" + ); + + // Test full SHA-256 thumbprint with colons + let input = + "3A:7F:B2:C4:5E:8D:9F:1A:2B:3C:4D:5E:6F:7A:8B:9C:AD:BE:CF:D0:E1:F2:03:14:25:36:47:58:69:7A:8B:9C"; + let expected = "3a7fb2c45e8d9f1a2b3c4d5e6f7a8b9cadbecfd0e1f20314253647586 97a8b9c"; + assert_eq!(normalize_thumbprint(input.to_owned()), expected.replace(' ', "")); + } + + #[test] + fn test_compute_sha256_thumbprint() { + // Test with known input + let test_data = b"Hello, World!"; + let thumbprint = compute_sha256_thumbprint(test_data); + + // Expected SHA-256 of "Hello, World!" + let expected = "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"; + assert_eq!(thumbprint, expected); + + // Test output format (lowercase hex, no separators) + assert!(thumbprint.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())); + assert_eq!(thumbprint.len(), 64); // SHA-256 is 32 bytes = 64 hex chars + } + + #[test] + fn test_compute_sha256_thumbprint_deterministic() { + // Same input should always produce same thumbprint + let test_data = b"test certificate data"; + let thumbprint1 = compute_sha256_thumbprint(test_data); + let thumbprint2 = compute_sha256_thumbprint(test_data); + assert_eq!(thumbprint1, thumbprint2); + } + } } diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index d5fa02f85..b4523c2c8 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -420,6 +420,10 @@ pub struct AssociationTokenClaims { /// Unique ID for this token pub jti: Uuid, + + /// Optional SHA-256 thumbprint of target server certificate (for anchored TLS validation) + /// Format: lowercase hex string with no separators (e.g., "3a7f...") + pub cert_thumb256: Option, } // ----- scope claims ----- // @@ -1304,6 +1308,8 @@ mod serde_impl { jet_reuse: ReconnectionPolicy, exp: i64, jti: Uuid, + #[serde(default)] + cert_thumb256: Option, } #[derive(Deserialize)] @@ -1411,6 +1417,7 @@ mod serde_impl { jet_reuse: self.jet_reuse, exp: self.exp, jti: self.jti, + cert_thumb256: self.cert_thumb256.clone(), } .serialize(serializer) } @@ -1454,6 +1461,7 @@ mod serde_impl { jet_reuse: claims.jet_reuse, exp: claims.exp, jti: claims.jti, + cert_thumb256: claims.cert_thumb256, }) } } diff --git a/devolutions-gateway/tests/tls_anchoring.rs b/devolutions-gateway/tests/tls_anchoring.rs new file mode 100644 index 000000000..a4ae0102b --- /dev/null +++ b/devolutions-gateway/tests/tls_anchoring.rs @@ -0,0 +1,55 @@ +#![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used)] + +//! Integration tests for TLS certificate thumbprint anchoring feature. +//! +//! ## Scope +//! +//! These tests validate the behavior of the `cert_thumb256` claim in association tokens +//! used with `/jet/fwd/tls` endpoint. +//! +//! ## Key properties verified +//! +//! - **Matching thumbprint with verification failures:** When a certificate thumbprint +//! matches the `cert_thumb256` claim, the connection succeeds even if normal TLS +//! verification fails (expired cert, self-signed, hostname mismatch, etc.) +//! - **Non-matching thumbprint:** When thumbprint doesn't match, connection is rejected +//! - **Missing claim:** When claim is absent, normal TLS validation behavior applies +//! - **Malformed claim:** Malformed thumbprints are properly normalized +//! - **Logging:** Proper structured logging when thumbprint anchoring is used + +use sha2::{Digest, Sha256}; + +#[test] +fn test_compute_thumbprint() { + // Test SHA-256 computation + let test_data = b"Hello, World!"; + let hash = Sha256::digest(test_data); + let thumbprint = hex::encode(hash); + + // Expected SHA-256 of "Hello, World!" + let expected = "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"; + assert_eq!(thumbprint, expected); +} + +#[test] +fn test_thumbprint_format() { + // Verify that thumbprints are 64 hex characters (32 bytes) + let test_data = b"test certificate"; + let hash = Sha256::digest(test_data); + let thumbprint = hex::encode(hash); + + // Should be lowercase hex + assert!(thumbprint.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())); + // Should be 64 characters (SHA-256 = 32 bytes = 64 hex chars) + assert_eq!(thumbprint.len(), 64); +} + +// Note: Full integration tests that actually connect to TLS servers with +// self-signed/expired certificates would require: +// 1. Setting up test TLS servers with various certificate issues +// 2. Creating proper association tokens with cert_thumb256 claims +// 3. Making actual WebSocket connections through the gateway +// +// These would be implemented as part of the broader test infrastructure +// (e.g., in the testsuite crate or as end-to-end tests)