diff --git a/CHANGELOG.md b/CHANGELOG.md index 49610a5..0fc2571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add `--check --hostname ` 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. + ## 0.0.43 - Format `OtherName` values as text for supported ASN.1 string types, or as tagged hex for other values. Resolve known object identifiers to names. diff --git a/Cargo.lock b/Cargo.lock index 7211212..582101f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,45 @@ dependencies = [ "x11rb", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -158,6 +197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -195,6 +235,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -207,7 +253,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", ] [[package]] @@ -216,6 +262,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -263,9 +318,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "camino" @@ -412,9 +467,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -567,6 +622,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deltae" version = "0.3.2" @@ -596,6 +657,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der_derive" version = "0.8.0" @@ -658,6 +733,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "document-features" version = "0.2.12" @@ -737,6 +823,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -991,11 +1083,15 @@ dependencies = [ "pem-rfc7468", "pkcs1", "ratatui", + "rcgen", "rustls", + "rustls-native-certs", "rustls-pemfile", "rustls-pki-types", "rustls-platform-verifier", + "rustls-webpki", "serde_json", + "tempfile", "tracing", "tracing-subscriber", "x509-cert", @@ -1298,6 +1394,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1315,6 +1421,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1415,6 +1530,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1495,6 +1619,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -1800,6 +1934,20 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1848,7 +1996,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1867,6 +2015,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1961,7 +2118,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2131,9 +2288,9 @@ dependencies = [ [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -2249,6 +2406,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termina" version = "0.3.3" @@ -2290,7 +2471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "fancy-regex", "filedescriptor", @@ -2381,12 +2562,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2395,6 +2578,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tls_codec" version = "0.4.2" @@ -2531,6 +2724,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2691,9 +2890,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -3133,6 +3332,34 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "zeroize" version = "1.8.2" diff --git a/Cargo.toml b/Cargo.toml index a71786d..0b91138 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,14 +28,22 @@ pem-rfc7468 = { version = "1", features = ["std"] } pkcs1 = { version = "0.7", features = ["std"] } ratatui = "0.30" rustls = "0.23" +rustls-native-certs = "0.8" rustls-pemfile = "2" rustls-pki-types = "1" rustls-platform-verifier = "0.7" serde_json = "1" tracing = { version = "0.1.44", features = ["log", "release_max_level_debug"] } tracing-subscriber = "0.3" +webpki = { package = "rustls-webpki", version = "0.103", default-features = false, features = [ + "std", +] } x509-cert = { version = "0.3", features = ["sct"] } +[dev-dependencies] +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem"] } +tempfile = "3" + [profile.release] lto = true codegen-units = 1 diff --git a/README.md b/README.md index 1e672f5..0c1a9f4 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,15 @@ inspect-cert-chain --host 192.0.2.10 --server-name staging.example.com Remote fetching has a `10s` overall timeout. Use `--timeout ` to change it, for example `500ms`, `30s`, or `2m`. The duration must be greater than zero. +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 and certificate date statuses. Only the supplied intermediates are used; cached intermediates and AIA downloads cannot hide an incomplete chain. + +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 in the chain do not become trusted roots. + +```console +inspect-cert-chain --file --check --hostname --ca-file +inspect-cert-chain --file --check --hostname --ca-file --ca-file +``` + From chain file: ```console @@ -68,7 +77,7 @@ cat | inspect-cert-chain --file - ## Check mode -Use `--check` to check the validity dates of every certificate in the chain. It checks that the current time is between `not_before` and `not_after`, inclusive. It does not verify signatures, hostname matches, trust roots, or revocation. +Use `--check` to check the validity dates of every certificate in the chain. It checks that the current time is between `not_before` and `not_after`, inclusive. Check mode exit codes reflect dates and expiry thresholds. For local trust validation, add `--hostname` as described below. ```console inspect-cert-chain --host example.com --check @@ -152,11 +161,25 @@ 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. +Local checks with `--hostname` also include a top-level `validation` object. Its `status` is `valid` or `invalid`. It includes separate `path`, `hostname`, `dates`, and leaf `revocation` results. 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 is not checked and has status `not_checked`. `--fields` selects certificate data and keeps the validation results. + +## Local chain validation + +Use `--check --hostname ` to show validation status for a local chain or stdin. The first certificate must be the server certificate. The remaining certificates supply the intermediates. The hostname can be a DNS name or an IP address. + +```console +inspect-cert-chain --file --check --hostname +inspect-cert-chain --file --check --hostname --ca-file +cat | inspect-cert-chain --file - --check --hostname +``` + +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, and signature failures are displayed without changing these exit codes. + # Remote smoke tests Run `just test-remote` to inspect the hosts in `tests/fixtures/remote-hosts.txt`. This manual check requires internet access and reports all failures before it exits. Each host has a 30-second time limit. Use `just test-remote 10s` to change this limit. You can pass a different fixture as the second argument. -The CLI does not validate certificates. It must inspect expired certificates, self-signed certificates, and certificates for another hostname from [BadSSL](https://badssl.com/). Unsupported TLS versions, unsupported cipher suites, and oversized handshake messages must fail with the specified TLS error. DNS errors and timeouts do not count as expected TLS failures. +The CLI deliberately bypasses remote certificate verification to inspect invalid certificates. The smoke tests must inspect expired certificates, self-signed certificates, and certificates for another hostname from [BadSSL](https://badssl.com/). Unsupported TLS versions, unsupported cipher suites, and oversized handshake messages must fail with the specified TLS error. DNS errors and timeouts do not count as expected TLS failures. # Roadmap @@ -165,7 +188,7 @@ The CLI does not validate certificates. It must inspect expired certificates, se - [x] Read certificate chain from file and stdin. - [x] Interpret standard X.509 extensions. - [x] Option to read local chain files. -- [ ] Determine chain validity. +- [x] Determine chain validity. [`homebrew`]: https://brew.sh [`cargo-binstall`]: https://github.com/cargo-bins/cargo-binstall diff --git a/src/main.rs b/src/main.rs index 07d5d12..d119276 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod logging; mod report; mod tui; mod util; +mod validation; cfg_if::cfg_if! { if #[cfg(windows)] { @@ -47,6 +48,14 @@ struct Args { #[arg(long, value_name = "DURATION", default_value = "10s", value_parser = parse_timeout)] timeout: Duration, + /// Add trusted CA certificates from a PEM file. Can be repeated. + #[arg(long, value_name = "PATH", requires = "hostname")] + ca_file: Vec, + + /// Expected DNS name or IP address for a local chain check. + #[arg(long, requires_all = ["file", "check"])] + hostname: Option, + /// When provided, writes downloaded chain to file in PEM format. #[clap(long, conflicts_with = "file")] dump: Option, @@ -162,6 +171,8 @@ fn run(args: &Args) -> eyre::Result { .install_default() .map_err(|_| eyre!("Failed to install TLS crypto provider"))?; + let mut validation = None; + let certs = if let Some(host) = &args.host { let server_name = args.server_name.as_deref().unwrap_or(host); @@ -188,6 +199,25 @@ fn run(args: &Args) -> eyre::Result { tracing::debug!("reading certificate chain PEM files"); let certs = rustls_pemfile::certs(&mut input).collect::, _>>()?; + if let Some(hostname) = args.hostname.as_deref() { + let server_name = rustls_pki_types::ServerName::try_from(hostname) + .wrap_err_with(|| format!("Invalid hostname: {hostname}"))?; + let (end_entity, intermediates) = certs + .split_first() + .ok_or_else(|| eyre!("Chain contained 0 certificates"))?; + let verifier = validation::verifier(&args.ca_file)?; + + let report = validation::Report::check( + &verifier, + end_entity, + intermediates, + &server_name, + rustls_pki_types::UnixTime::now(), + ); + + validation = Some(report); + } + tracing::debug!("parsing certificate chain"); certs .into_iter() @@ -241,7 +271,7 @@ fn run(args: &Args) -> eyre::Result { let mut stdout = io::stdout().lock(); if args.json { - report.write_json(&mut stdout, args.check)?; + report.write_json(&mut stdout, args.check, validation.as_ref())?; } else { if !args.fields.is_empty() { report.write_fields(&mut stdout)?; @@ -260,6 +290,10 @@ fn run(args: &Args) -> eyre::Result { if args.check { report.write_check(&mut stdout)?; } + + if let Some(validation) = &validation { + validation.write_to(&certs, &mut stdout)?; + } } } diff --git a/src/report.rs b/src/report.rs index fd064ba..3c00217 100644 --- a/src/report.rs +++ b/src/report.rs @@ -9,7 +9,7 @@ use itertools::Itertools as _; use serde_json::{Map, Value, json}; use x509_cert::{Certificate, ext::pkix::SubjectAltName, time::Validity}; -use crate::{ext, util}; +use crate::{ext, util, validation}; #[derive(Clone, Copy, Debug, ValueEnum)] #[value(rename_all = "snake_case")] @@ -230,7 +230,12 @@ impl<'a> Report<'a> { .unwrap_or(Status::Ok) } - pub(crate) fn write_json(&self, mut writer: impl io::Write, check: bool) -> eyre::Result<()> { + pub(crate) fn write_json( + &self, + mut writer: impl io::Write, + check: bool, + validation: Option<&validation::Report>, + ) -> eyre::Result<()> { let fields = if self.fields.is_empty() { Field::value_variants() } else { @@ -258,6 +263,10 @@ impl<'a> Report<'a> { value["check"] = json!({ "status": self.status().as_str() }); } + if let Some(validation) = validation { + value["validation"] = validation.to_json(self.certs); + } + serde_json::to_writer_pretty(&mut writer, &value)?; writeln!(writer)?; diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..3bfbb74 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,341 @@ +use std::{cell::Cell, fmt, fs, io, sync::Arc}; + +use camino::Utf8PathBuf; +use der::Decode as _; +use eyre::{WrapErr as _, eyre}; +use rustls::server::ParsedCertificate; +use rustls_pki_types::{CertificateDer, ServerName, UnixTime}; +use serde_json::{Value, json}; +use x509_cert::Certificate; + +#[derive(Debug)] +pub(crate) struct Verifier { + pub(crate) provider: Arc, + roots: rustls::RootCertStore, +} + +#[derive(Clone, Debug)] +pub(crate) struct CustomRoots { + store: rustls::RootCertStore, +} + +pub(crate) fn read_ca_files(ca_files: &[Utf8PathBuf]) -> eyre::Result { + let mut roots = Vec::new(); + + for path in ca_files { + let file = + fs::File::open(path).wrap_err_with(|| format!("Could not open CA file: {path}"))?; + let certs = rustls_pemfile::certs(&mut io::BufReader::new(file)) + .collect::, _>>() + .wrap_err_with(|| format!("Could not read CA certificates from: {path}"))?; + + if certs.is_empty() { + return Err(eyre!("CA file contained 0 certificates: {path}")); + } + + for cert in &certs { + Certificate::from_der(cert) + .wrap_err_with(|| format!("Invalid CA certificate in: {path}"))?; + } + + roots.extend(certs); + } + + let mut root_store = rustls::RootCertStore::empty(); + + for root in &roots { + root_store + .add(root.clone()) + .wrap_err("Invalid custom trust root")?; + } + + Ok(CustomRoots { store: root_store }) +} + +pub(crate) fn verifier(ca_files: &[Utf8PathBuf]) -> eyre::Result { + Ok(Verifier::new(read_ca_files(ca_files)?)) +} + +impl Verifier { + pub(crate) fn new(mut custom: CustomRoots) -> Self { + let native = rustls_native_certs::load_native_certs(); + let (_, ignored) = custom + .store + .add_parsable_certificates(native.certs.iter().cloned()); + + if ignored > 0 { + tracing::warn!("Ignored {ignored} invalid system trust roots"); + } + + for error in native.errors { + tracing::warn!("Could not load a system trust root: {error}"); + } + + Self { + provider: Arc::new(rustls::crypto::aws_lc_rs::default_provider()), + roots: custom.store, + } + } + + fn verify_path( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + now: UnixTime, + ) -> Result<(), PathError> { + let invalid_key_usage = Cell::new(false); + + // WebPKI does not check CA key usage. Check each candidate path so + // an unused intermediate cannot invalidate a valid alternative. + let check_key_usage = |path: &webpki::VerifiedPath<'_>| { + for intermediate in path.intermediate_certificates() { + let cert = Certificate::from_der(intermediate.der().as_ref()) + .map_err(|_| webpki::Error::BadDer)?; + let usage = cert + .tbs_certificate() + .get_extension::() + .map_err(|_| webpki::Error::ExtensionValueInvalid)?; + + if usage.is_some_and(|(_, usage)| !usage.key_cert_sign()) { + invalid_key_usage.set(true); + return Err(webpki::Error::ExtensionValueInvalid); + } + } + + Ok(()) + }; + + let verify_path = || { + let cert = webpki::EndEntityCert::try_from(end_entity)?; + + cert.verify_for_usage( + self.provider.signature_verification_algorithms.all, + &self.roots.roots, + intermediates, + now, + webpki::KeyUsage::server_auth(), + None, + Some(&check_key_usage), + )?; + + Ok::<_, webpki::Error>(()) + }; + + verify_path().map_err(|err| { + if err == webpki::Error::ExtensionValueInvalid && invalid_key_usage.get() { + PathError::CaKeyUsage + } else { + PathError::Certificate(err) + } + }) + } +} + +#[derive(Clone, Debug)] +enum PathError { + Certificate(webpki::Error), + CaKeyUsage, +} + +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CaKeyUsage => f.write_str("CA does not allow certificate signing"), + Self::Certificate(err) => f.write_str(&certificate_error(err)), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Report { + path: Result<(), PathError>, + hostname: Result<(), rustls::Error>, + server_name: String, + now: UnixTime, +} + +impl Report { + pub(crate) fn check( + verifier: &Verifier, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + now: UnixTime, + ) -> Self { + Self { + path: verifier.verify_path(end_entity, intermediates, now), + hostname: ParsedCertificate::try_from(end_entity) + .and_then(|cert| rustls::client::verify_server_name(&cert, server_name)), + server_name: server_name.to_str().into_owned(), + now, + } + } + + fn has_failure(&self) -> bool { + self.path.is_err() || self.hostname.is_err() + } + + fn status(&self) -> &'static str { + if self.has_failure() { + "invalid" + } else { + "valid" + } + } + + fn path_result(&self, certs: &[Certificate]) -> Result<(), String> { + self.path.as_ref().copied().map_err(|error| match error { + PathError::Certificate(webpki::Error::UnknownIssuer) => issuer_error(certs).to_owned(), + error => error.to_string(), + }) + } + + fn date_result(&self, cert: &Certificate) -> Result<(), &'static str> { + let validity = cert.tbs_certificate().validity(); + let not_before = validity.not_before.to_unix_duration().as_secs(); + let not_after = validity.not_after.to_unix_duration().as_secs(); + let now = self.now.as_secs(); + + if not_before > not_after { + Err("invalid validity period") + } else if now < not_before { + Err("not yet valid") + } else if now > not_after { + Err("expired") + } else { + Ok(()) + } + } + + pub(crate) fn to_json(&self, certs: &[Certificate]) -> Value { + let mut hostname = json_status(&self.hostname.as_ref().map_err(tls_error)); + + hostname["name"] = json!(self.server_name); + + let value = json!({ + "status": self.status(), + "path": json_status(&self.path_result(certs)), + "hostname": hostname, + "dates": certs.iter().map(|cert| json_status(&self.date_result(cert))).collect::>(), + "revocation": { "status": "not_checked" }, + }); + + value + } + + pub(crate) fn write_to( + &self, + certs: &[Certificate], + mut out: impl io::Write, + ) -> io::Result<()> { + writeln!( + out, + "Certificate chain: {}", + self.status().to_ascii_uppercase(), + )?; + + write!(out, "Path validation: ")?; + write_status(&mut out, &self.path_result(certs))?; + + write!(out, "Hostname ({}): ", self.server_name)?; + write_status(&mut out, &self.hostname.as_ref().map_err(tls_error))?; + + for (index, cert) in certs.iter().enumerate() { + write!(out, "Certificate {} dates: ", index + 1)?; + write_status(&mut out, &self.date_result(cert))?; + } + + writeln!(out, "Revocation (leaf): NOT CHECKED")?; + + Ok(()) + } +} + +fn json_status(result: &Result) -> Value { + match result { + Ok(_) => json!({ "status": "valid" }), + Err(error) => json!({ "status": "invalid", "reason": error.to_string() }), + } +} + +fn write_status( + out: &mut impl io::Write, + result: &Result, +) -> io::Result<()> { + match result { + Ok(_) => writeln!(out, "VALID"), + Err(err) => writeln!(out, "INVALID ({err})"), + } +} + +fn issuer_error(certs: &[Certificate]) -> &'static str { + let Some(mut cert) = certs.first() else { + return "issuer is not trusted"; + }; + + for index in 0..certs.len() { + let tbs = cert.tbs_certificate(); + + if tbs.subject() == tbs.issuer() { + return if index == 0 { + "self-issued certificate is not trusted" + } else { + "root CA is not trusted" + }; + } + + let Some(issuer) = certs + .iter() + .find(|issuer| issuer.tbs_certificate().subject() == tbs.issuer()) + else { + break; + }; + + cert = issuer; + } + + "missing issuer certificate or untrusted issuer" +} + +fn certificate_error(error: &webpki::Error) -> String { + use webpki::Error as E; + + let message = match error { + E::CertExpired { .. } => "certificate expired", + E::CertNotValidYet { .. } => "certificate is not yet valid", + E::InvalidSignatureForPublicKey => "invalid certificate signature", + E::UnknownIssuer => "issuer is not trusted", + E::EndEntityUsedAsCa => "issuer is not a CA", + E::CaUsedAsEndEntity => "CA certificate used as a server certificate", + E::PathLenConstraintViolated => "CA path length constraint violated", + E::NameConstraintViolation => "CA name constraint violated", + E::RequiredEkuNotFoundContext(_) | E::EmptyEkuExtension => { + "certificate does not allow TLS server authentication" + } + E::UnsupportedCriticalExtension => "unsupported critical extension", + E::SignatureAlgorithmMismatch => "certificate signature algorithms do not match", + E::UnsupportedSignatureAlgorithmContext(_) + | E::UnsupportedSignatureAlgorithmForPublicKeyContext(_) => { + "unsupported certificate signature algorithm" + } + E::InvalidCertValidity => "invalid certificate validity period", + E::BadDer | E::BadDerTime | E::MalformedExtensions | E::ExtensionValueInvalid => { + "invalid certificate encoding" + } + _ => return error.to_string(), + }; + + message.to_owned() +} + +fn tls_error(error: &rustls::Error) -> String { + use rustls::CertificateError as E; + + match error { + rustls::Error::InvalidCertificate( + E::NotValidForName | E::NotValidForNameContext { .. }, + ) => "hostname does not match certificate names".to_owned(), + rustls::Error::InvalidCertificate(E::BadSignature) => "invalid signature".to_owned(), + _ => error.to_string(), + } +} diff --git a/tests/remote_timeout.rs b/tests/remote_timeout.rs index 3ce78d7..0c63615 100644 --- a/tests/remote_timeout.rs +++ b/tests/remote_timeout.rs @@ -808,3 +808,29 @@ fn local_file_inspection_still_works_with_the_timeout_option() { assert!(String::from_utf8_lossy(&output.stdout).contains("CN=localhost")); } } + +#[test] +fn local_chain_checks_report_validity_without_rejecting_the_chain() { + let (output, _) = run( + &[ + "--file", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/server.pem"), + "--check", + "--hostname", + "localhost", + ], + Duration::from_secs(3), + ); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!(stdout.contains("Hostname (localhost): VALID"), "{stdout}"); + assert!(!stdout.contains("TLS handshake signature:"), "{stdout}"); +} diff --git a/tests/validation.rs b/tests/validation.rs new file mode 100644 index 0000000..37fae09 --- /dev/null +++ b/tests/validation.rs @@ -0,0 +1,514 @@ +use std::{fs, io::Write as _, process::Command}; + +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, + Issuer, KeyPair, KeyUsagePurpose, date_time_ymd, +}; + +fn ca_params(name: &str) -> CertificateParams { + let mut params = CertificateParams::default(); + + params.distinguished_name.push(DnType::CommonName, name); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + + params +} + +fn root() -> (Certificate, Issuer<'static, KeyPair>) { + let params = ca_params("Test root"); + let key = KeyPair::generate().unwrap(); + let cert = params.self_signed(&key).unwrap(); + + (cert, Issuer::new(params, key)) +} + +fn leaf_params() -> CertificateParams { + let mut params = CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]).unwrap(); + + params + .distinguished_name + .push(DnType::CommonName, "localhost"); + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + + params +} + +fn check(chain: &str, roots: &[&str], hostname: &str) -> String { + let output = local_output(chain, roots, hostname, &[]); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + String::from_utf8(output.stdout).unwrap() +} + +fn local_output( + chain: &str, + roots: &[&str], + hostname: &str, + args: &[&str], +) -> std::process::Output { + let dir = tempfile::tempdir().unwrap(); + let chain_path = dir.path().join("chain.pem"); + + fs::write(&chain_path, chain).unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_inspect-cert-chain")); + + cmd.arg("--file") + .arg(&chain_path) + .args(["--check", "--hostname", hostname]); + + for (index, root) in roots.iter().enumerate() { + let path = dir.path().join(format!("root-{index}.pem")); + + fs::write(&path, root).unwrap(); + cmd.arg("--ca-file").arg(path); + } + + cmd.args(args).output().unwrap() +} + +#[test] +fn custom_ca_file_trusts_a_valid_chain() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let stdout = check(&leaf.pem(), &[&root.pem()], "localhost"); + + assert!(stdout.contains("Certificate chain: VALID"), "{stdout}"); + assert!(stdout.contains("Hostname (localhost): VALID"), "{stdout}"); + assert!(stdout.contains("Certificate 1 dates: VALID"), "{stdout}"); +} + +#[test] +fn a_root_in_the_chain_is_not_a_trust_anchor() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let stdout = check(&(leaf.pem() + &root.pem()), &[], "localhost"); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!(stdout.contains("root CA is not trusted"), "{stdout}"); +} + +#[test] +fn a_missing_intermediate_is_reported() { + let (root, root_issuer) = root(); + let params = ca_params("Missing intermediate"); + let key = KeyPair::generate().unwrap(); + let _intermediate = params.signed_by(&key, &root_issuer).unwrap(); + let issuer = Issuer::new(params, key); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let stdout = check(&leaf.pem(), &[&root.pem()], "localhost"); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!(stdout.contains("missing issuer certificate"), "{stdout}"); +} + +#[test] +fn an_untrusted_self_issued_certificate_is_reported() { + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().self_signed(&key).unwrap(); + let stdout = check(&leaf.pem(), &[], "localhost"); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!( + stdout.contains("self-issued certificate is not trusted"), + "{stdout}" + ); +} + +#[test] +fn hostname_mismatch_is_reported_without_rejecting_the_chain() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let stdout = check(&leaf.pem(), &[&root.pem()], "wrong.example"); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!( + stdout.contains("Hostname (wrong.example): INVALID"), + "{stdout}" + ); +} + +#[test] +fn json_validation_preserves_field_selection_and_date_thresholds() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let output = local_output( + &leaf.pem(), + &[&root.pem()], + "wrong.example", + &["--json", "--fields", "subject", "--warn-within", "1000000d"], + ); + + assert_eq!(output.status.code(), Some(1), "{output:?}"); + + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!( + value["certificates"], + serde_json::json!([{ "subject": "CN=localhost" }]) + ); + assert_eq!(value["check"]["status"], "warning"); + assert_eq!(value["validation"]["status"], "invalid"); + assert_eq!(value["validation"]["path"]["status"], "valid"); + assert_eq!(value["validation"]["hostname"]["name"], "wrong.example"); + assert_eq!( + value["validation"]["hostname"]["reason"], + "hostname does not match certificate names" + ); + assert_eq!(value["validation"]["dates"][0]["status"], "valid"); + assert_eq!(value["validation"]["revocation"]["status"], "not_checked"); + assert!(value["validation"].get("handshake_signature").is_none()); +} + +#[test] +fn ip_address_subject_alternative_names_are_checked() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let stdout = check(&leaf.pem(), &[&root.pem()], "127.0.0.1"); + + assert!(stdout.contains("Certificate chain: VALID"), "{stdout}"); + assert!(stdout.contains("Hostname (127.0.0.1): VALID"), "{stdout}"); +} + +#[test] +fn expired_certificate_dates_are_reported() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let mut params = leaf_params(); + + params.not_before = date_time_ymd(1999, 1, 1); + params.not_after = date_time_ymd(2000, 1, 1); + + let leaf = params.signed_by(&key, &issuer).unwrap(); + let output = local_output(&leaf.pem(), &[&root.pem()], "localhost", &[]); + + assert_eq!(output.status.code(), Some(2), "{output:?}"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!( + stdout.contains("Certificate 1 dates: INVALID (expired)"), + "{stdout}" + ); +} + +#[test] +fn future_certificate_dates_are_reported() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let mut params = leaf_params(); + + params.not_before = date_time_ymd(2100, 1, 1); + + let leaf = params.signed_by(&key, &issuer).unwrap(); + let output = local_output(&leaf.pem(), &[&root.pem()], "localhost", &[]); + + assert_eq!(output.status.code(), Some(2), "{output:?}"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!( + stdout.contains("Certificate 1 dates: INVALID (not yet valid)"), + "{stdout}" + ); +} + +#[test] +fn a_tampered_certificate_signature_is_reported() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let mut der = leaf.der().to_vec(); + + *der.last_mut().unwrap() ^= 1; + + let pem = pem_rfc7468::encode_string("CERTIFICATE", pem_rfc7468::LineEnding::LF, &der).unwrap(); + let stdout = check(&pem, &[&root.pem()], "localhost"); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!(stdout.contains("Hostname (localhost): VALID"), "{stdout}"); +} + +fn check_intermediate(params: CertificateParams) -> String { + let (root, root_issuer) = root(); + let intermediate_key = KeyPair::generate().unwrap(); + let intermediate = params.signed_by(&intermediate_key, &root_issuer).unwrap(); + let intermediate_issuer = Issuer::new(params, intermediate_key); + let leaf_key = KeyPair::generate().unwrap(); + let leaf = leaf_params() + .signed_by(&leaf_key, &intermediate_issuer) + .unwrap(); + + check( + &(leaf.pem() + &intermediate.pem()), + &[&root.pem()], + "localhost", + ) +} + +#[test] +fn a_valid_intermediate_ca_is_accepted() { + let stdout = check_intermediate(ca_params("Intermediate")); + + assert!(stdout.contains("Certificate chain: VALID"), "{stdout}"); +} + +#[test] +fn an_intermediate_must_be_a_ca() { + let mut params = ca_params("Intermediate"); + + params.is_ca = IsCa::ExplicitNoCa; + + let stdout = check_intermediate(params); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); +} + +#[test] +fn an_intermediate_must_allow_certificate_signing() { + let mut params = ca_params("Intermediate"); + + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + + let stdout = check_intermediate(params); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); +} + +#[test] +fn ca_name_constraints_are_checked() { + let mut params = ca_params("Intermediate"); + + params.name_constraints = Some(rcgen::NameConstraints { + permitted_subtrees: vec![rcgen::GeneralSubtree::DnsName("allowed.example".into())], + excluded_subtrees: vec![], + }); + + let stdout = check_intermediate(params); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); +} + +#[test] +fn unknown_critical_extensions_are_reported() { + let (root, issuer) = root(); + let key = KeyPair::generate().unwrap(); + let mut params = leaf_params(); + let mut extension = rcgen::CustomExtension::from_oid_content(&[1, 2, 3, 4], vec![0x05, 0x00]); + + extension.set_criticality(true); + params.custom_extensions.push(extension); + + let leaf = params.signed_by(&key, &issuer).unwrap(); + let stdout = check(&leaf.pem(), &[&root.pem()], "localhost"); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); +} + +#[test] +fn a_valid_alternative_path_can_bypass_an_invalid_ca_key_usage() { + let (root, root_issuer) = root(); + let key = KeyPair::generate().unwrap(); + let mut good_params = ca_params("Alternate intermediate"); + + good_params.serial_number = Some(2.into()); + + let good = good_params.signed_by(&key, &root_issuer).unwrap(); + let mut bad_params = good_params.clone(); + + bad_params.serial_number = Some(3.into()); + bad_params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + + let bad = bad_params.signed_by(&key, &root_issuer).unwrap(); + let issuer = Issuer::new(good_params, key); + let leaf_key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&leaf_key, &issuer).unwrap(); + let chain = leaf.pem() + &bad.pem() + &good.pem(); + let stdout = check(&chain, &[&root.pem()], "localhost"); + + assert!(stdout.contains("Certificate chain: VALID"), "{stdout}"); +} + +#[test] +fn system_trust_roots_are_used_and_custom_ca_files_add_to_them() { + let (root, issuer) = root(); + let (custom_root, _) = self::root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let chain_path = dir.path().join("chain.pem"); + let root_path = dir.path().join("system-root.pem"); + let custom_path = dir.path().join("custom-root.pem"); + let empty_ca_dir = dir.path().join("empty-ca-dir"); + + fs::write(&chain_path, leaf.pem()).unwrap(); + fs::write(&root_path, root.pem()).unwrap(); + fs::write(&custom_path, custom_root.pem()).unwrap(); + fs::create_dir(&empty_ca_dir).unwrap(); + + for add_custom_root in [false, true] { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_inspect-cert-chain")); + + cmd.arg("--file") + .arg(&chain_path) + .args(["--check", "--hostname", "localhost"]) + .env("SSL_CERT_FILE", &root_path) + .env("SSL_CERT_DIR", &empty_ca_dir); + + if add_custom_root { + cmd.arg("--ca-file").arg(&custom_path); + } + + let output = cmd.output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(stdout.contains("Certificate chain: VALID"), "{stdout}"); + } +} + +#[test] +fn stdin_chain_checks_show_statuses() { + let mut child = Command::new(env!("CARGO_BIN_EXE_inspect-cert-chain")) + .args(["--file", "-", "--check", "--hostname", "localhost"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + child + .stdin + .take() + .unwrap() + .write_all(include_bytes!("fixtures/server.pem")) + .unwrap(); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); + assert!(stdout.contains("Hostname (localhost): VALID"), "{stdout}"); +} + +#[test] +fn hostname_and_ca_files_require_local_validation() { + for extra in [ + vec!["--hostname", "localhost"], + vec!["--ca-file", "ca.pem"], + vec!["--check", "--ca-file", "ca.pem"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_inspect-cert-chain")) + .args([ + "--file", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/server.pem"), + ]) + .args(extra) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("required arguments")); + } +} + +#[test] +fn ca_path_length_constraints_are_checked() { + let (root, root_issuer) = root(); + let mut params = ca_params("Intermediate"); + + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + + let key = KeyPair::generate().unwrap(); + let intermediate = params.signed_by(&key, &root_issuer).unwrap(); + let issuer = Issuer::new(params, key); + let params = ca_params("Subordinate CA"); + let key = KeyPair::generate().unwrap(); + let subordinate = params.signed_by(&key, &issuer).unwrap(); + let issuer = Issuer::new(params, key); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + let chain = leaf.pem() + &subordinate.pem() + &intermediate.pem(); + let stdout = check(&chain, &[&root.pem()], "localhost"); + + assert!(stdout.contains("Certificate chain: INVALID"), "{stdout}"); +} + +#[test] +fn repeated_ca_files_and_pem_bundles_are_supported() { + let (root, issuer) = root(); + let (unrelated_root, _) = self::root(); + let key = KeyPair::generate().unwrap(); + let leaf = leaf_params().signed_by(&key, &issuer).unwrap(); + + for roots in [ + vec![unrelated_root.pem(), root.pem()], + vec![unrelated_root.pem() + &root.pem()], + ] { + let roots = roots.iter().map(String::as_str).collect::>(); + let stdout = check(&leaf.pem(), &roots, "localhost"); + + assert!(stdout.contains("Certificate chain: VALID"), "{stdout}"); + } +} + +#[test] +fn invalid_ca_files_are_input_errors() { + let dir = tempfile::tempdir().unwrap(); + let ca_path = dir.path().join("ca.pem"); + + for (pem, error) in [ + ("", "CA file contained 0 certificates"), + ( + "-----BEGIN CERTIFICATE-----\n!\n-----END CERTIFICATE-----\n", + "Could not read CA certificates", + ), + ( + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + "Invalid CA certificate", + ), + ] { + fs::write(&ca_path, pem).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_inspect-cert-chain")) + .args([ + "--file", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/server.pem"), + "--check", + "--hostname", + "localhost", + "--ca-file", + ]) + .arg(&ca_path) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success(), "Invalid CA file was accepted"); + assert!(stderr.contains(error), "{stderr}"); + } +}