Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Add `--check --hostname <name>` to validate local PEM chains and stdin. Check trust, certificate signatures, dates, hostnames, and CA constraints with system roots or repeatable `--ca-file` inputs. Show text and JSON results while keeping date-check exit codes.
- Show validation results by default for remote certificate chains in text, JSON, and interactive output. Check TLS handshake signatures and use the selected server name. Invalid chains remain available for inspection and `--dump`.
- Add repeatable `--crl-file` inputs for signed leaf revocation checks with DER files or PEM bundles. Report `UNKNOWN` when supplied revocation data cannot be verified.
- Download leaf CRLs from HTTP or HTTPS distribution points when no CRL file is supplied. Bound retrieval by the fetch deadline and preserve inspection when revocation services fail.

## 0.0.43

Expand Down
56 changes: 56 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ rustls-pki-types = "1"
serde_json = "1"
tracing = { version = "0.1.44", features = ["log", "release_max_level_debug"] }
tracing-subscriber = "0.3"
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider"] }
webpki = { package = "rustls-webpki", version = "0.103", default-features = false, features = [
"std",
] }
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ Remote chains always include validation status in text, JSON, and interactive ou

Path validation uses WebPKI to check trust, certificate signatures, validity dates, and CA constraints for TLS server authentication, with an additional check for CA certificate-signing key usage. These checks follow [RFC 5280 path validation](https://www.rfc-editor.org/rfc/rfc5280.html#section-6). The report shows the failure reason and separate hostname, certificate date, TLS handshake signature, and leaf revocation statuses. Only the supplied intermediates are used; cached intermediates and AIA downloads cannot hide an incomplete chain.

Remote checks download the leaf certificate's HTTP or HTTPS CRLs. CRL checks verify the issuer, scope, signature, signing key usage, and update dates. A revoked leaf reports `INVALID`. Missing, unavailable, expired, unsupported, or unauthenticated revocation data reports `UNKNOWN`. If the other checks pass but revocation is unknown, the overall status is also `UNKNOWN`. These checks cover leaf CRLs; they do not check OCSP or intermediate revocation.

CRL downloads use up to 5 seconds within the overall fetch timeout. Up to 8 URLs are tried, with a 10 MiB response limit per URL. CRL service failures do not prevent inspection or `--dump`.

System trust roots are used by default. Add custom trust roots from PEM files with `--ca-file`. Each file can contain multiple CA certificates, and the option can be repeated. Custom roots add to the system roots. Certificates supplied by the server do not become trusted roots.

```console
Expand Down Expand Up @@ -163,7 +167,7 @@ For example, `--json --fields subject,not_after` returns:

With `--check`, JSON adds a top-level `"check": { "status": "ok" }` object. After options pass validation, a JSON input, fetch, or dump error returns an `error` string. Check mode also returns `"check": { "status": "unknown" }`. Invalid options leave stdout empty. JSON does not change the exit codes.

Remote JSON output and local checks with `--hostname` also include a top-level `validation` object. Its `status` is `valid`, `invalid`, or `unknown`. It includes separate `path`, `hostname`, `dates`, and leaf `revocation` results. Remote results include `handshake_signature`. Each result has a `status` and a `reason` when it fails or is unknown. The hostname result also includes the expected `name`. The `dates` array follows certificate order. Revocation has status `not_checked` when no CRL is supplied. `--fields` selects certificate data and keeps the validation results.
Remote JSON output and local checks with `--hostname` also include a top-level `validation` object. Its `status` is `valid`, `invalid`, or `unknown`. It includes separate `path`, `hostname`, `dates`, and leaf `revocation` results. Remote results include `handshake_signature`. Each result has a `status` and a `reason` when it fails or is unknown. The hostname result also includes the expected `name`. The `dates` array follows certificate order. Local revocation has status `not_checked` when no CRL is supplied. `--fields` selects certificate data and keeps the validation results.

## Local chain validation

Expand All @@ -175,17 +179,15 @@ inspect-cert-chain --file <chain.pem> --check --hostname <hostname> --ca-file <c
cat <chain.pem> | inspect-cert-chain --file - --check --hostname <hostname>
```

CRL checks verify the issuer, scope, signature, signing key usage, and update dates. A revoked leaf reports `INVALID`. Unverified revocation data reports `UNKNOWN`. These checks cover leaf CRLs; they do not check OCSP or intermediate revocation.

Use repeatable `--crl-file` options for offline leaf revocation checks with DER files or PEM bundles. CRLs are not downloaded. Without `--crl-file`, revocation is not checked.
Use repeatable `--crl-file` options for offline leaf revocation checks with DER files or PEM bundles. Remote checks use these files instead of CRL downloads when the option is supplied. Local checks do not download CRLs; without `--crl-file`, local validation covers the path and hostname.

```console
inspect-cert-chain --file <chain.pem> --check --hostname <hostname> --ca-file <ca.pem> --crl-file <issuer.crl>
```

The date status is shown for each supplied certificate. A trust anchor is an input to path validation; its own expiry or self-signature is not a required path check. The existing date check and its exit codes still apply to every supplied certificate. Trust, hostname, signature, and revocation failures are displayed without changing these exit codes.

`just test` runs the CLI tests with generated chains and local TLS servers. `just test-badssl` runs the public badssl.com checks for expiry, hostname mismatch, missing trust, incomplete chains, plus a valid control. The public checks require network access and are excluded from the normal test run.
`just test` runs the CLI tests with generated chains and local TLS and CRL servers. `just test-badssl` runs the public badssl.com checks for expiry, hostname mismatch, missing trust, incomplete chains, and revocation, plus a valid control. The public checks require network access and are excluded from the normal test run.

# Remote smoke tests

Expand Down
14 changes: 14 additions & 0 deletions src/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub(crate) fn cert_chain(
&worker_server_name,
&ca_files,
&crl_files,
deadline,
));
})?;

Expand All @@ -62,6 +63,7 @@ fn fetch_cert_chain(
server_name: &str,
ca_files: &[camino::Utf8PathBuf],
crl_files: &[camino::Utf8PathBuf],
deadline: Instant,
) -> eyre::Result<FetchedChain> {
let supplied_crls = revocation::read_files(crl_files)?;
let tls_server_name = ServerName::try_from(server_name)
Expand Down Expand Up @@ -109,6 +111,18 @@ fn fetch_cert_chain(

if !crl_files.is_empty() {
validation.check_revocation(verifier.inner(), peer_certs, &supplied_crls);
} else if validation.can_check_revocation() {
match revocation::download(&certs[0], verifier.inner(), deadline) {
Ok(crls) => validation.check_revocation(verifier.inner(), peer_certs, &crls),
Err(error) => {
validation.revocation =
Some(validation::RevocationStatus::Unknown(format!("{error:#}")))
}
}
} else {
validation.revocation = Some(validation::RevocationStatus::Unknown(
"certificate path is invalid".to_owned(),
));
}

Ok(FetchedChain { certs, validation })
Expand Down
131 changes: 130 additions & 1 deletion src/revocation.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
use std::fs;
use std::{
fs,
sync::mpsc,
thread,
time::{Duration, Instant},
};

use camino::Utf8PathBuf;
use der::Decode as _;
use eyre::{WrapErr as _, eyre};
use x509_cert::{
Certificate,
ext::pkix::{
CrlDistributionPoints,
name::{DistributionPointName, GeneralName},
},
};

use crate::validation::Verifier;

pub(crate) fn read_files(paths: &[Utf8PathBuf]) -> eyre::Result<Vec<Vec<u8>>> {
let mut crls = Vec::new();
Expand All @@ -16,6 +30,121 @@ pub(crate) fn read_files(paths: &[Utf8PathBuf]) -> eyre::Result<Vec<Vec<u8>>> {
Ok(crls)
}

pub(crate) fn download(
leaf: &Certificate,
verifier: &Verifier,
deadline: Instant,
) -> eyre::Result<Vec<Vec<u8>>> {
let points = leaf
.tbs_certificate()
.get_extension::<CrlDistributionPoints>()?
.ok_or_else(|| eyre!("certificate has no CRL distribution points"))?
.1;
let urls = points
.0
.into_iter()
.filter_map(|point| match point.distribution_point {
Some(DistributionPointName::FullName(names)) => Some(names),
_ => None,
})
.flatten()
.filter_map(|name| match name {
GeneralName::UniformResourceIdentifier(uri)
if uri.as_str().starts_with("http://") || uri.as_str().starts_with("https://") =>
{
Some(uri.to_string())
}
_ => None,
})
.take(8)
.collect::<Vec<_>>();

if urls.is_empty() {
return Err(eyre!(
"certificate has no supported HTTP CRL distribution points"
));
}

let roots = verifier
.root_certificates
.iter()
.map(|der| ureq::tls::Certificate::from_der(der).to_owned());
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::from(roots))
.unversioned_rustls_crypto_provider(verifier.provider.clone())
.build();
let agent: ureq::Agent = ureq::Agent::config_builder().tls_config(tls).build().into();

// DNS and socket timeouts can overrun. Keep a separate deadline for CRL
// retrieval so an unavailable service cannot discard the certificate report.
let budget = deadline
.saturating_duration_since(Instant::now())
.saturating_sub(Duration::from_millis(100))
.min(Duration::from_secs(5));

if budget.is_zero() {
return Err(eyre!("CRL download timed out"));
}

let crl_deadline = Instant::now() + budget;
let (sender, receiver) = mpsc::sync_channel(1);

thread::Builder::new().spawn(move || {
let _ = sender.send(download_urls(agent, urls, crl_deadline));
})?;

match receiver.recv_timeout(crl_deadline.saturating_duration_since(Instant::now())) {
Ok(result) => result,
Err(mpsc::RecvTimeoutError::Timeout) => Err(eyre!("CRL download timed out")),
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err(eyre!("CRL download worker stopped without a result"))
}
}
}

fn download_urls(
agent: ureq::Agent,
urls: Vec<String>,
crl_deadline: Instant,
) -> eyre::Result<Vec<Vec<u8>>> {
let mut crls = Vec::new();
let mut last_error = eyre!("CRL download timed out");

for url in urls {
let remaining = crl_deadline.saturating_duration_since(Instant::now());

if remaining.is_zero() {
break;
}

let result = (|| {
let bytes = agent
.get(&url)
.config()
.timeout_global(Some(remaining))
.build()
.call()?
.body_mut()
.with_config()
.limit(10 * 1024 * 1024)
.read_to_vec()?;

decode_crls(&bytes)
})();

match result {
Ok(lists) => crls.extend(lists),
Err(error) => last_error = error,
}
}

if crls.is_empty() {
return Err(last_error.wrap_err("could not download a usable CRL"));
}

Ok(crls)
}

fn decode_crls(bytes: &[u8]) -> eyre::Result<Vec<Vec<u8>>> {
let crls = if bytes
.iter()
Expand Down
10 changes: 9 additions & 1 deletion src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ use x509_cert::Certificate;
pub(crate) struct Verifier {
pub(crate) provider: Arc<rustls::crypto::CryptoProvider>,
roots: rustls::RootCertStore,
pub(crate) root_certificates: Vec<CertificateDer<'static>>,
}

#[derive(Clone, Debug)]
pub(crate) struct CustomRoots {
store: rustls::RootCertStore,
certificates: Vec<CertificateDer<'static>>,
}

pub(crate) fn read_ca_files(ca_files: &[Utf8PathBuf]) -> eyre::Result<CustomRoots> {
Expand Down Expand Up @@ -49,7 +51,10 @@ pub(crate) fn read_ca_files(ca_files: &[Utf8PathBuf]) -> eyre::Result<CustomRoot
.wrap_err("Invalid custom trust root")?;
}

Ok(CustomRoots { store: root_store })
Ok(CustomRoots {
store: root_store,
certificates: roots,
})
}

pub(crate) fn verifier(ca_files: &[Utf8PathBuf]) -> eyre::Result<Verifier> {
Expand All @@ -63,6 +68,8 @@ impl Verifier {
.store
.add_parsable_certificates(native.certs.iter().cloned());

custom.certificates.extend(native.certs);

if ignored > 0 {
tracing::warn!("Ignored {ignored} invalid system trust roots");
}
Expand All @@ -74,6 +81,7 @@ impl Verifier {
Self {
provider: Arc::new(rustls::crypto::aws_lc_rs::default_provider()),
roots: custom.store,
root_certificates: custom.certificates,
}
}

Expand Down
22 changes: 18 additions & 4 deletions tests/badssl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ fn incomplete_chain_reports_the_missing_issuer() {
);
}

#[test]
#[ignore = "requires access to the public badssl.com test service"]
fn revoked_certificate_reports_revocation() {
let stdout = inspect("revoked.badssl.com");

assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}");
assert!(
stdout.contains("Revocation (leaf): INVALID (certificate revoked)"),
"{stdout}"
);
assert!(stdout.contains("Path validation: VALID"), "{stdout}");
assert!(
stdout.contains("Hostname (revoked.badssl.com): VALID"),
"{stdout}"
);
}

#[test]
#[ignore = "requires access to the public badssl.com test service"]
fn expired_certificate_reports_expiry() {
Expand Down Expand Up @@ -90,8 +107,5 @@ fn trusted_control_reports_validity() {

assert!(stdout.contains("Certificate chain: VALID"), "{stdout}");
assert!(stdout.contains("Path validation: VALID"), "{stdout}");
assert!(
stdout.contains("Revocation (leaf): NOT CHECKED"),
"{stdout}"
);
assert!(stdout.contains("Revocation (leaf): VALID"), "{stdout}");
}
Loading
Loading