diff --git a/docs/advanced-features.md b/docs/advanced-features.md index aba855e..0564f11 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b5e3275..71ce68b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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. diff --git a/src/tls/inspect.rs b/src/tls/inspect.rs index ad6c32e..d73a984 100644 --- a/src/tls/inspect.rs +++ b/src/tls/inspect.rs @@ -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] @@ -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] diff --git a/src/tls/inspect/cert.rs b/src/tls/inspect/cert.rs index c1ae056..0365060 100644 --- a/src/tls/inspect/cert.rs +++ b/src/tls/inspect/cert.rs @@ -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 { if raw.is_empty() { return None; @@ -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 { let mut top = DerReader::new(raw); let basic = top.read_tlv()?; @@ -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 { @@ -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 { diff --git a/src/tls/inspect/render.rs b/src/tls/inspect/render.rs index 013e85d..de7ae5c 100644 --- a/src/tls/inspect/render.rs +++ b/src/tls/inspect/render.rs @@ -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 { @@ -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) -> String { cert_expiry_info_and_color(not_after).0