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
9 changes: 6 additions & 3 deletions docs/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,12 @@ TLS13_AES_256_GCM_SHA384`). HTTP/3 reports the cipher suite as unavailable
not expose the selected anchor. The output reports that its details are
unavailable instead of inferring a root from certificate metadata.
- **Subject Alternative Names** (DNS names and IP addresses)
- **OCSP staple status** (good, revoked, or unknown). The embedded status is
labeled unverified; TLS inspection does not validate its signature or
freshness.
- **OCSP staple presence**. If the response CertID matches the inspected leaf
certificate, the output includes its embedded status (good, revoked, or
unknown). If the issuer is unavailable or no CertID matches, the output only
reports that an unverified staple is present. TLS inspection does not validate
the response signature, responder authorization, or freshness, so unverified
OCSP data uses neutral styling.

Expiry is color-coded: red if expired or less than 7 days remaining, yellow if less than 30 days, green otherwise.

Expand Down
5 changes: 3 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,8 +564,9 @@ See [Encrypted Client Hello](ech.md) for details.
Inspect the TLS certificate chain with a TLS handshake. This operation does not
make an HTTP request. The output shows the TLS version, negotiated cipher
suite, ALPN protocol, certificate chain and expiry status, Subject Alternative
Names (SANs), and the unverified embedded status from an OCSP staple. Use
an HTTPS URL. With `--http 3`,
Names (SANs), and OCSP staple presence. It shows an embedded OCSP status only
when the response CertID matches the inspected leaf certificate. Use an HTTPS
URL. With `--http 3`,
inspection uses a QUIC handshake and offers `h3` ALPN. The current QUIC
integration does not expose the negotiated TLS cipher suite, so HTTP/3 output
reports it as unavailable.
Expand Down
82 changes: 65 additions & 17 deletions src/tls/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1119,25 +1119,32 @@ TQt+xSSOMTZFrHhhVqsL9JQlHg==
}

#[test]
fn parse_ocsp_status_reads_basic_response_statuses() {
fn parse_ocsp_status_reads_matching_basic_response_statuses() {
let cert = ParsedCert::parse(
&super::super::pem_certificates(TEST_QUIC_CERT_PEM)
.unwrap()
.remove(0),
)
.unwrap();
for (tag, want) in [
(0x80, OcspStatus::Good),
(0xa1, OcspStatus::Revoked),
(0x82, OcspStatus::Unknown),
] {
let response = test_ocsp_response(tag);
let cert_id = test_ocsp_cert_id(&cert, &cert);
let response = test_ocsp_response_entries(vec![(cert_id, tag)]);
assert_eq!(
parse_ocsp_status(&response, None, None),
parse_ocsp_status(&response, &cert, &cert),
Some(want),
"tag {tag:#x}"
);
}

assert_eq!(
parse_ocsp_status(&der_seq(&[der(0x0a, &[1])]), None, None),
parse_ocsp_status(&der_seq(&[der(0x0a, &[1])]), &cert, &cert),
None
);
assert_eq!(parse_ocsp_status(b"not der", None, None), None);
assert_eq!(parse_ocsp_status(b"not der", &cert, &cert), None);
}

#[test]
Expand All @@ -1162,32 +1169,73 @@ TQt+xSSOMTZFrHhhVqsL9JQlHg==
let no_match_response = test_ocsp_response_entries(vec![(unrelated_cert_id, 0x80)]);

assert_eq!(
parse_ocsp_status(&response, Some(&cert), Some(&cert)),
parse_ocsp_status(&response, &cert, &cert),
Some(OcspStatus::Good)
);
assert_eq!(
parse_ocsp_status(&no_match_response, Some(&cert), Some(&cert)),
None
);
assert_eq!(parse_ocsp_status(&no_match_response, &cert, &cert), None);
}

#[test]
fn render_ocsp_status_matches_go_stapled_status_line() {
let mut out = Printer::new(false);
render_ocsp_status(&mut out, &test_ocsp_response(0x80), None, None);
fn render_ocsp_status_is_neutral_for_unverified_matching_response() {
let cert = ParsedCert::parse(
&super::super::pem_certificates(TEST_QUIC_CERT_PEM)
.unwrap()
.remove(0),
)
.unwrap();
let cert_id = test_ocsp_cert_id(&cert, &cert);
let response = test_ocsp_response_entries(vec![(cert_id, 0x80)]);

let mut out = Printer::new(false);
render_ocsp_status(&mut out, &response, Some(&cert), Some(&cert));
assert_eq!(
out.into_string().unwrap(),
"* OCSP: good (stapled, unverified)\n"
);

let mut out = Printer::new(false);
render_ocsp_status(&mut out, b"malformed", None, None);
assert!(out.bytes().is_empty());
let mut out = Printer::new(true);
render_ocsp_status(&mut out, &response, Some(&cert), Some(&cert));
let rendered = out.into_string().unwrap();
assert!(rendered.contains("OCSP: good (stapled, unverified)"));
assert!(!rendered.contains("\x1b[32m"));
assert!(!rendered.contains("\x1b[31m"));
assert!(!rendered.contains("\x1b[33m"));
}

#[test]
fn render_ocsp_status_hides_status_for_unrelated_response() {
let cert = ParsedCert::parse(
&super::super::pem_certificates(TEST_QUIC_CERT_PEM)
.unwrap()
.remove(0),
)
.unwrap();
let unrelated_cert_id = der_seq(&[
der_seq(&[der(0x06, &[0x2b, 0x0e, 0x03, 0x02, 0x1a]), der(0x05, &[])]),
der(0x04, &[9; 20]),
der(0x04, &[8; 20]),
der(0x02, &[7]),
]);
let response = test_ocsp_response_entries(vec![(unrelated_cert_id, 0xa1)]);

let mut out = Printer::new(true);
render_ocsp_status(&mut out, &response, Some(&cert), Some(&cert));
let rendered = out.into_string().unwrap();

assert!(rendered.contains("OCSP staple present (unverified)"));
assert!(!rendered.contains("revoked"));
assert!(!rendered.contains("\x1b[31m"));
}

#[test]
fn render_ocsp_status_hides_status_without_issuer() {
let mut out = Printer::new(true);
render_ocsp_status(&mut out, &test_ocsp_response(0x80), None, None);
assert!(out.into_string().unwrap().contains("\x1b[32mgood\x1b[0m"));
let rendered = out.into_string().unwrap();

assert!(rendered.contains("OCSP staple present (unverified)"));
assert!(!rendered.contains("good"));
assert!(!rendered.contains("\x1b[32m"));
}

#[tokio::test]
Expand Down
22 changes: 7 additions & 15 deletions src/tls/inspect/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ pub(super) enum OcspStatus {

pub(super) fn parse_ocsp_status(
raw: &[u8],
leaf: Option<&ParsedCert>,
issuer: Option<&ParsedCert>,
leaf: &ParsedCert,
issuer: &ParsedCert,
) -> Option<OcspStatus> {
if raw.is_empty() {
return None;
Expand Down Expand Up @@ -59,8 +59,8 @@ pub(super) fn parse_ocsp_status(

fn parse_basic_ocsp_response_status(
raw: &[u8],
leaf: Option<&ParsedCert>,
issuer: Option<&ParsedCert>,
leaf: &ParsedCert,
issuer: &ParsedCert,
) -> Option<OcspStatus> {
let mut top = DerReader::new(raw);
let basic = top.read_tlv()?;
Expand Down Expand Up @@ -91,8 +91,6 @@ fn parse_basic_ocsp_response_status(
return None;
}

let matching = leaf.zip(issuer);
let mut fallback = None;
let mut responses = DerReader::new(responses.value);
while let Some(single) = responses.read_tlv() {
if single.tag != 0x30 {
Expand All @@ -106,18 +104,12 @@ fn parse_basic_ocsp_response_status(
continue;
};

if let Some((leaf, issuer)) = matching {
if ocsp_cert_id_matches(cert_id.raw, leaf, issuer) {
return Some(status);
}
} else if fallback.is_none() {
fallback = Some(status);
if ocsp_cert_id_matches(cert_id.raw, leaf, issuer) {
return Some(status);
}
}

// Without both certificates there is no possible certificate match. The
// embedded status is still useful for diagnostics, but it is unverified.
fallback
None
}

fn parse_ocsp_cert_status(status: super::der::Tlv<'_>) -> Option<OcspStatus> {
Expand Down
26 changes: 13 additions & 13 deletions src/tls/inspect/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,21 @@ pub(super) fn render_ocsp_status(
leaf: Option<&ParsedCert>,
issuer: Option<&ParsedCert>,
) {
let Some(status) = parse_ocsp_status(raw_ocsp, leaf, issuer) else {
if raw_ocsp.is_empty() {
return;
};
}

let status = leaf
.zip(issuer)
.and_then(|(leaf, issuer)| parse_ocsp_status(raw_ocsp, leaf, issuer));
out.write_info_prefix();
out.push_str("OCSP: ");
out.write_styled(ocsp_status_label(status), &[ocsp_status_color(status)]);
out.push_str(" (stapled, unverified)\n");
if let Some(status) = status {
out.push_str("OCSP: ");
out.push_str(ocsp_status_label(status));
out.push_str(" (stapled, unverified)\n");
} else {
out.push_str("OCSP staple present (unverified)\n");
}
}

fn ocsp_status_label(status: OcspStatus) -> &'static str {
Expand All @@ -167,14 +175,6 @@ fn ocsp_status_label(status: OcspStatus) -> &'static str {
}
}

fn ocsp_status_color(status: OcspStatus) -> Sequence {
match status {
OcspStatus::Good => Sequence::Green,
OcspStatus::Revoked => Sequence::Red,
OcspStatus::Unknown => Sequence::Yellow,
}
}

#[cfg(test)]
pub(super) fn cert_expiry_info(not_after: Option<time::OffsetDateTime>) -> String {
cert_expiry_info_and_color(not_after).0
Expand Down