Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions devolutions-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 13 additions & 5 deletions devolutions-gateway/src/api/fwd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
1 change: 1 addition & 0 deletions devolutions-gateway/src/api/webapp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
293 changes: 291 additions & 2 deletions devolutions-gateway/src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,48 @@ static TLS_CONNECTOR: LazyLock<tokio_rustls::TlsConnector> = LazyLock::new(|| {
});

pub async fn connect<IO>(dns_name: String, stream: IO) -> io::Result<TlsStream<IO>>
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<IO>(
dns_name: String,
stream: IO,
cert_thumb256: Option<String>,
target: String,
assoc_id: uuid::Uuid,
) -> io::Result<TlsStream<IO>>
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<TcpStream>`, this means that data written by `poll_write`
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -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<String>,
/// Target host and port for logging
target: String,
/// Association ID for logging
assoc_id: uuid::Uuid,
/// Standard verifier using system roots
standard_verifier: Arc<dyn ServerCertVerifier>,
}

impl ThumbprintAnchoredVerifier {
pub fn new(expected_thumbprint: Option<String>, 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<ServerCertVerified, Error> {
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<ServerCertVerified, Error> {
// 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<HandshakeSignatureValid, Error> {
self.standard_verifier.verify_tls12_signature(message, cert, dss)
}

fn verify_tls13_signature(
&self,
message: &[u8],
cert: &pki_types::CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
self.standard_verifier.verify_tls13_signature(message, cert, dss)
}

fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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::<String>()
.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<String>,
}

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: "<parse error>".to_string(),
issuer: "<parse error>".to_string(),
not_before: "<parse error>".to_string(),
not_after: "<parse error>".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);
}
}
}
Loading
Loading