Improve rustls error messages for invalid certificates

After a recent release rustls provides better error messages for
invalid certificates. For example:

```
invalid peer certificate: certificate not valid for name "wrong.host.badssl.com"; certificate is only valid for DnsName("*.badssl.com") or DnsName("badssl.com")
```

The message for expired certificates still isn't too readable but the
error now contains timestamps so we enhance it ourselves:

```
xh: error: error sending request for url (https://expired.badssl.com/)

Caused by:
    0: client error (Connect)
    1: invalid peer certificate: certificate expired: verification time 1742381579 (UNIX), but certificate is not valid after 1428883199 (313498380 seconds ago)

Certificate not valid after 2015-04-12 23:59:59.0 +00:00:00 (9years 11months 6days 8h 43m 24s ago).
```
This commit is contained in:
Jan Verbeek 2025-03-19 11:15:15 +01:00
parent 7ad28aa483
commit 300203338f
5 changed files with 81 additions and 10 deletions

13
Cargo.lock generated
View File

@ -814,9 +814,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f"
[[package]]
name = "hyper"
@ -1760,9 +1760,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.23"
version = "0.23.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395"
checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c"
dependencies = [
"log",
"once_cell",
@ -1805,9 +1805,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.102.8"
version = "0.103.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
checksum = "0aa4eeac2588ffff23e9d7a7e9b3f971c5fb5b7ebc9452745e0c232c64f83b2f"
dependencies = [
"ring",
"rustls-pki-types",
@ -2798,6 +2798,7 @@ dependencies = [
"flate2",
"form_urlencoded",
"http-body-util",
"humantime",
"hyper",
"hyper-util",
"indicatif",

View File

@ -48,6 +48,7 @@ serde_urlencoded = "0.7.0"
supports-hyperlinks = "3.0.0"
termcolor = "1.1.2"
time = "0.3.16"
humantime = "2.2.0"
unicode-width = "0.1.9"
url = "2.2.2"
ruzstd = { version = "0.7", default-features = false, features = ["std"]}
@ -56,7 +57,7 @@ log = "0.4.21"
# Enable logging in transitive dependencies.
# The rustls version number should be kept in sync with hyper/reqwest.
rustls = { version = "0.23.14", optional = true, default-features = false, features = ["logging"] }
rustls = { version = "0.23.25", optional = true, default-features = false, features = ["logging"] }
tracing = { version = "0.1.41", default-features = false, features = ["log"] }
reqwest_cookie_store = { version = "0.8.0", features = ["serde"] }

View File

@ -1,5 +1,63 @@
use std::process::ExitCode;
pub(crate) fn additional_messages(err: &anyhow::Error, native_tls: bool) -> Vec<String> {
let mut msgs = Vec::new();
#[cfg(feature = "rustls")]
msgs.extend(format_rustls_error(err));
if native_tls && err.root_cause().to_string() == "invalid minimum TLS version for backend" {
msgs.push("Try running without the --native-tls flag.".into());
}
msgs
}
/// Format certificate expired/not valid yet messages. By default these print
/// human-unfriendly Unix timestamps.
///
/// Other rustls error messages (e.g. wrong host) are readable enough.
#[cfg(feature = "rustls")]
fn format_rustls_error(err: &anyhow::Error) -> Option<String> {
use humantime::format_duration;
use rustls::pki_types::UnixTime;
use rustls::CertificateError;
use time::OffsetDateTime;
// Multiple layers of io::Error for some reason?
// This may be fragile
let err = err.root_cause().downcast_ref::<std::io::Error>()?;
let err = err.get_ref()?.downcast_ref::<std::io::Error>()?;
let err = err.get_ref()?.downcast_ref::<rustls::Error>()?;
let rustls::Error::InvalidCertificate(err) = err else {
return None;
};
fn conv_time(unix_time: &UnixTime) -> Option<OffsetDateTime> {
OffsetDateTime::from_unix_timestamp(unix_time.as_secs() as i64).ok()
}
match err {
CertificateError::ExpiredContext { time, not_after } => {
let time = conv_time(time)?;
let not_after = conv_time(not_after)?;
let diff = format_duration((time - not_after).try_into().ok()?);
Some(format!(
"Certificate not valid after {not_after} ({diff} ago).",
))
}
CertificateError::NotValidYetContext { time, not_before } => {
let time = conv_time(time)?;
let not_before = conv_time(not_before)?;
let diff = format_duration((not_before - time).try_into().ok()?);
Some(format!(
"Certificate not valid before {not_before} ({diff} from now).",
))
}
_ => None,
}
}
pub(crate) fn exit_code(err: &anyhow::Error) -> ExitCode {
if let Some(err) = err.downcast_ref::<reqwest::Error>() {
if err.is_timeout() {

View File

@ -82,11 +82,12 @@ fn main() -> ExitCode {
Err(err) => {
log::debug!("{err:#?}");
eprintln!("{bin_name}: error: {err:?}");
let msg = err.root_cause().to_string();
if native_tls && msg == "invalid minimum TLS version for backend" {
for message in error_reporting::additional_messages(&err, native_tls) {
eprintln!();
eprintln!("Try running without the --native-tls flag.");
eprintln!("{message}");
}
error_reporting::exit_code(&err)
}
}

View File

@ -1213,6 +1213,16 @@ fn cert_without_key() {
.stderr(predicates::str::is_empty());
}
#[cfg(all(feature = "rustls", feature = "online-tests"))]
#[test]
fn formatted_certificate_expired_message() {
get_command()
.arg("https://expired.badssl.com")
.assert()
.failure()
.stderr(contains("Certificate not valid after 2015-04-12"));
}
#[test]
fn override_dns_resolution() {
let server = server::http(|req| async move {