Skip to content
Merged
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
192 changes: 192 additions & 0 deletions jetsocat/src/doctor/cert_inspect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//! Minimal DER walker for X.509 certificate extension inspection.
//!
//! Navigates the ASN.1 DER structure of an X.509 certificate to check
//! for the presence of specific extensions (SAN, EKU) without requiring
//! a full X.509 parsing library.
//!
//! # Design rationale
//!
//! We intentionally use a hand-written DER walker instead of pulling in a crate
//! such as `x509-cert` or `x509-parser`.
//! Jetsocat is meant to stay lean: adding an X.509 crate would pull a transitive
//! dependency tree (`der`, `spki`, `const-oid`, … or `asn1-rs`, `nom`, `oid-registry`, …),
//! increasing compile times and binary size for what amounts to two boolean questions
//! ("does the cert have a SAN extension?" and "does the EKU contain serverAuth?").
//!
//! Because the X.509 structure and the OIDs we inspect are standardised and frozen,
//! this code carries virtually no maintenance burden.

/// OID for Subject Alternative Name (2.5.29.17).
const OID_SUBJECT_ALT_NAME: &[u8] = &[0x55, 0x1D, 0x11];

/// OID for Extended Key Usage (2.5.29.37).
const OID_EXTENDED_KEY_USAGE: &[u8] = &[0x55, 0x1D, 0x25];

/// OID for id-kp-serverAuth (1.3.6.1.5.5.7.3.1).
const OID_KP_SERVER_AUTH: &[u8] = &[0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x01];

/// Checks if a DER-encoded X.509 certificate has the Subject Alternative Name extension.
pub(super) fn cert_has_san_extension(cert_der: &[u8]) -> anyhow::Result<bool> {
cert_has_extension(cert_der, OID_SUBJECT_ALT_NAME)
}

/// Checks if a DER-encoded X.509 certificate includes the serverAuth Extended Key Usage.
pub(super) fn cert_has_server_auth_eku(cert_der: &[u8]) -> anyhow::Result<bool> {
let Some(eku_value) = cert_find_extension_value(cert_der, OID_EXTENDED_KEY_USAGE)? else {
return Ok(false);
};

// EKU value is a SEQUENCE of KeyPurposeId OIDs.
let (tag, content, _) = der_read_tlv(eku_value)?;
anyhow::ensure!(tag == 0x30, "expected EKU SEQUENCE, got tag {tag:#04X}");

let mut pos = 0;
while pos < content.len() {
let (tag, oid_bytes, consumed) = der_read_tlv(&content[pos..])?;
anyhow::ensure!(tag == 0x06, "expected OID in EKU SEQUENCE, got tag {tag:#04X}");
if oid_bytes == OID_KP_SERVER_AUTH {
return Ok(true);
}
pos += consumed;
}

Ok(false)
}

fn cert_has_extension(cert_der: &[u8], target_oid: &[u8]) -> anyhow::Result<bool> {
Ok(cert_find_extension_value(cert_der, target_oid)?.is_some())
}

/// Finds an extension by OID and returns the content of its `extnValue` OCTET STRING.
fn cert_find_extension_value<'a>(cert_der: &'a [u8], target_oid: &[u8]) -> anyhow::Result<Option<&'a [u8]>> {
let Some(extensions) = cert_find_extensions(cert_der)? else {
return Ok(None);
};

// Extensions is a SEQUENCE of Extension.
let (tag, exts_content, _) = der_read_tlv(extensions)?;
anyhow::ensure!(tag == 0x30, "expected Extensions SEQUENCE, got tag {tag:#04X}");

let mut pos = 0;
while pos < exts_content.len() {
let (tag, ext_bytes, consumed) = der_read_tlv(&exts_content[pos..])?;
anyhow::ensure!(tag == 0x30, "expected Extension SEQUENCE, got tag {tag:#04X}");

// Extension ::= SEQUENCE { extnID OID, critical BOOLEAN OPTIONAL, extnValue OCTET STRING }
let (oid_tag, oid_bytes, mut inner_pos) = der_read_tlv(ext_bytes)?;
anyhow::ensure!(oid_tag == 0x06, "expected extension OID, got tag {oid_tag:#04X}");

if oid_bytes == target_oid {
// Walk remaining fields to find the OCTET STRING value.
while inner_pos < ext_bytes.len() {
let (inner_tag, inner_bytes, next_inner) = der_read_tlv(&ext_bytes[inner_pos..])?;
if inner_tag == 0x04 {
return Ok(Some(inner_bytes));
}
inner_pos += next_inner;
}
}

pos += consumed;
}

Ok(None)
}

/// Locates the extensions block in a DER-encoded X.509 certificate.
///
/// Returns the raw bytes of the `[3] EXPLICIT` wrapper content (which contains
/// the Extensions SEQUENCE), or `None` if extensions are absent.
fn cert_find_extensions(cert_der: &[u8]) -> anyhow::Result<Option<&[u8]>> {
// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
let (tag, cert_content, _) = der_read_tlv(cert_der)?;
anyhow::ensure!(tag == 0x30, "expected Certificate SEQUENCE, got tag {tag:#04X}");

// TBSCertificate ::= SEQUENCE { version, serial, sig, issuer, validity, subject, spki, ... }
let (tag, tbs_content, _) = der_read_tlv(cert_content)?;
anyhow::ensure!(tag == 0x30, "expected TBSCertificate SEQUENCE, got tag {tag:#04X}");

let mut pos = 0;

// version [0] EXPLICIT (optional)
if pos < tbs_content.len() && tbs_content[pos] == 0xA0 {
let (_, _, consumed) = der_read_tlv(&tbs_content[pos..])?;
pos += consumed;
}

// Skip fixed fields: serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
for field_name in [
"serialNumber",
"signature",
"issuer",
"validity",
"subject",
"subjectPublicKeyInfo",
] {
anyhow::ensure!(
pos < tbs_content.len(),
"unexpected end of TBSCertificate before {field_name}"
);
let (_, _, consumed) = der_read_tlv(&tbs_content[pos..])?;
pos += consumed;
}

// issuerUniqueID [1] IMPLICIT (optional)
if pos < tbs_content.len() && tbs_content[pos] == 0x81 {
let (_, _, consumed) = der_read_tlv(&tbs_content[pos..])?;
pos += consumed;
}

// subjectUniqueID [2] IMPLICIT (optional)
if pos < tbs_content.len() && tbs_content[pos] == 0x82 {
let (_, _, consumed) = der_read_tlv(&tbs_content[pos..])?;
pos += consumed;
}

// extensions [3] EXPLICIT (optional)
if pos < tbs_content.len() && tbs_content[pos] == 0xA3 {
let (_, exts_wrapper, _) = der_read_tlv(&tbs_content[pos..])?;
return Ok(Some(exts_wrapper));
}

Ok(None)
}

/// Reads a DER TLV (Tag-Length-Value) at the start of `data`.
///
/// Returns `(tag, value_bytes, total_bytes_consumed)`.
fn der_read_tlv(data: &[u8]) -> anyhow::Result<(u8, &[u8], usize)> {
anyhow::ensure!(!data.is_empty(), "unexpected end of DER data");

let tag = data[0];
let (length, length_size) = der_read_length(&data[1..])?;
let header_size = 1 + length_size;
let end = header_size + length;

anyhow::ensure!(end <= data.len(), "DER value extends beyond available data");

Ok((tag, &data[header_size..end], end))
}

/// Decodes a DER length field. Returns `(length_value, bytes_consumed)`.
fn der_read_length(data: &[u8]) -> anyhow::Result<(usize, usize)> {
anyhow::ensure!(!data.is_empty(), "unexpected end of DER data reading length");

let first = data[0];

if first < 0x80 {
// Short form.
Ok((first as usize, 1))
} else if first == 0x80 {
anyhow::bail!("indefinite-length encoding is not valid DER");
} else {
// Long form.
let num_bytes = (first & 0x7F) as usize;
anyhow::ensure!(num_bytes <= 4 && num_bytes < data.len(), "invalid DER length encoding");
let mut length = 0usize;
for i in 0..num_bytes {
length = (length << 8) | data[1 + i] as usize;
}
Ok((length, 1 + num_bytes))
}
}
20 changes: 20 additions & 0 deletions jetsocat/src/doctor/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ You need to generate a separate certificate valid for server authentication."
)
}

pub(crate) fn cert_missing_san(ctx: &mut DiagnosticCtx) {
ctx.attach_help(
"The certificate is missing the Subject Alternative Name (SAN) extension.
Modern clients (e.g., Chrome, macOS) require certificates to include SAN entries instead of relying on the Common Name (CN) field.
The Devolutions Gateway will reject this certificate when the TlsVerifyStrict option is enabled.
To resolve this, generate a new certificate that includes the appropriate SAN entries for your domain."
.to_owned(),
);
}

pub(crate) fn cert_missing_server_auth_eku(ctx: &mut DiagnosticCtx) {
ctx.attach_help(
"The certificate does not include the serverAuth Extended Key Usage (EKU).
The serverAuth purpose indicates that the certificate is valid for TLS server authentication.
The Devolutions Gateway will reject this certificate when the TlsVerifyStrict option is enabled.
To resolve this, generate a new certificate that includes the serverAuth Extended Key Usage."
.to_owned(),
);
}

pub(crate) fn x509_io_link<C>(ctx: &mut DiagnosticCtx, certs: C)
where
C: Iterator,
Expand Down
1 change: 1 addition & 0 deletions jetsocat/src/doctor/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod cert_inspect;
mod common;
mod help;
mod macros;
Expand Down
50 changes: 50 additions & 0 deletions jetsocat/src/doctor/native_tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ mod openssl {
}

diagnostic!(callback, openssl_check_chain(&server_certificates));
diagnostic!(callback, openssl_check_san_extension(&server_certificates));
diagnostic!(callback, openssl_check_server_auth_eku(&server_certificates));
}
}

Expand Down Expand Up @@ -348,6 +350,54 @@ To resolve this issue, you can:
- Obtain and use a certificate signed by a legitimate certification authority.");
}

fn openssl_check_san_extension(ctx: &mut DiagnosticCtx, server_certificates: &[X509]) -> anyhow::Result<()> {
let certificate = server_certificates
.first()
.context("end entity certificate is missing")?;
let cert_der = certificate.to_der().context("failed to encode certificate as DER")?;

info!("Check for Subject Alternative Name extension");

let has_san = crate::doctor::cert_inspect::cert_has_san_extension(&cert_der)?;

if !has_san {
ctx.attach_warning(
"when TlsVerifyStrict is enabled in the Devolutions Gateway configuration, this certificate will be rejected"
.to_owned(),
);
help::cert_missing_san(ctx);
anyhow::bail!("the end entity certificate is missing the Subject Alternative Name (SAN) extension");
}

info!("Subject Alternative Name extension found");

Ok(())
}

fn openssl_check_server_auth_eku(ctx: &mut DiagnosticCtx, server_certificates: &[X509]) -> anyhow::Result<()> {
let certificate = server_certificates
.first()
.context("end entity certificate is missing")?;
let cert_der = certificate.to_der().context("failed to encode certificate as DER")?;

info!("Check for serverAuth Extended Key Usage");

let has_server_auth = crate::doctor::cert_inspect::cert_has_server_auth_eku(&cert_der)?;

if !has_server_auth {
ctx.attach_warning(
"when TlsVerifyStrict is enabled in the Devolutions Gateway configuration, this certificate will be rejected"
.to_owned(),
);
help::cert_missing_server_auth_eku(ctx);
anyhow::bail!("the end entity certificate does not include the serverAuth Extended Key Usage (EKU)");
}

info!("serverAuth Extended Key Usage found");

Ok(())
}

impl InspectCert for X509 {
fn der(&self) -> anyhow::Result<Cow<'_, [u8]>> {
let der = self.to_der()?;
Expand Down
50 changes: 50 additions & 0 deletions jetsocat/src/doctor/rustls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub(super) fn run(args: &Args, callback: &mut dyn FnMut(Diagnostic) -> bool) {
rustls_check_end_entity_cert(&server_certificates, args.subject_name.as_deref())
);
diagnostic!(callback, rustls_check_chain(&root_store, &server_certificates));
diagnostic!(callback, rustls_check_san_extension(&server_certificates));
diagnostic!(callback, rustls_check_server_auth_eku(&server_certificates));
}
}

Expand Down Expand Up @@ -206,6 +208,54 @@ fn rustls_check_chain(
Ok(())
}

fn rustls_check_san_extension(
ctx: &mut DiagnosticCtx,
server_certificates: &[pki_types::CertificateDer<'static>],
) -> anyhow::Result<()> {
let end_entity_cert = server_certificates.first().context("empty chain")?;

info!("Check for Subject Alternative Name extension");

let has_san = crate::doctor::cert_inspect::cert_has_san_extension(end_entity_cert)?;

if !has_san {
ctx.attach_warning(
"when TlsVerifyStrict is enabled in the Devolutions Gateway configuration, this certificate will be rejected"
.to_owned(),
);
help::cert_missing_san(ctx);
anyhow::bail!("the end entity certificate is missing the Subject Alternative Name (SAN) extension");
}

info!("Subject Alternative Name extension found");

Ok(())
}

fn rustls_check_server_auth_eku(
ctx: &mut DiagnosticCtx,
server_certificates: &[pki_types::CertificateDer<'static>],
) -> anyhow::Result<()> {
let end_entity_cert = server_certificates.first().context("empty chain")?;

info!("Check for serverAuth Extended Key Usage");

let has_server_auth = crate::doctor::cert_inspect::cert_has_server_auth_eku(end_entity_cert)?;

if !has_server_auth {
ctx.attach_warning(
"when TlsVerifyStrict is enabled in the Devolutions Gateway configuration, this certificate will be rejected"
.to_owned(),
);
help::cert_missing_server_auth_eku(ctx);
anyhow::bail!("the end entity certificate does not include the serverAuth Extended Key Usage (EKU)");
}

info!("serverAuth Extended Key Usage found");

Ok(())
}

impl InspectCert for pki_types::CertificateDer<'_> {
fn der(&self) -> anyhow::Result<Cow<'_, [u8]>> {
Ok(Cow::Borrowed(self))
Expand Down
Loading
Loading