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
33 changes: 33 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ rlimit = "0.11.0"
rocksdb = { version = "0.25.0", default-features = false, features = ["lz4", "snappy", "zstd"] }
ropey = "1.6.1"
rustls = "0.23.38"
rustls-platform-verifier = "0.7.0"
rpassword = "7.5.1"
rusqlite = { version = "0.40.2", features = ["bundled"] }
ryu = "1.0.23"
Expand Down Expand Up @@ -566,6 +567,7 @@ uuid = "1.19.0"
version-compare = "0.2.1"
walkdir = "2.5.0"
which = "8"
x509-cert = { version = "0.2.5", default-features = false }
yansi = "1.0.1"
zeroize = { version = "1.8.2", features = ["derive", "serde"] }
zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2"] }
Expand Down
3 changes: 3 additions & 0 deletions src/ccsr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ proptest.workspace = true
zeroize.workspace = true
proptest-derive.workspace = true
rustls.workspace = true
rustls-platform-verifier.workspace = true
serde.workspace = true
thiserror.workspace = true
url = { workspace = true, features = ["serde"] }
x509-cert.workspace = true

[dev-dependencies]
hyper.workspace = true
hyper-util.workspace = true
mz-ore = { path = "../ore", features = ["async", "test"] }
openssl.workspace = true
tokio-openssl.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
Expand Down
16 changes: 11 additions & 5 deletions src/ccsr/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ impl ClientConfig {
/// Adds a trusted root TLS certificate.
///
/// Certificates in the system's certificate store are trusted by default.
/// A server certificate identical to `cert` is trusted even if it is not
/// a valid end-entity certificate, for example a self-signed CA. Its name
/// and validity period are still checked, and its name may match any
/// subject common name if it has no DNS or IP subjectAltName.
pub fn add_root_certificate(mut self, cert: Certificate) -> ClientConfig {
self.root_certs.push(cert);
self
Expand Down Expand Up @@ -106,11 +110,13 @@ impl ClientConfig {
pub fn build(self) -> Result<Client, anyhow::Error> {
let mut builder = reqwest::ClientBuilder::new();

for root_cert in self.root_certs {
builder = builder.add_root_certificate(root_cert.into());
}

if let Some(ident) = self.identity {
// NOTE: A preconfigured TLS backend makes reqwest ignore all of its TLS
// builder settings (roots, identity, ALPN, SNI, TLS versions, CRLs), so
// any such setting must go into `rustls_config` instead.
if !self.root_certs.is_empty() {
let tls = crate::tls::rustls_config(&self.root_certs, self.identity.as_ref())?;
builder = builder.tls_backend_preconfigured(tls);
} else if let Some(ident) = self.identity {
builder = builder.identity(ident.into());
}

Expand Down
210 changes: 199 additions & 11 deletions src/ccsr/src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,18 @@
use std::fmt;
use std::sync::Arc;

use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::client::verify_server_name;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::server::ParsedCertificate;
use rustls::{CertificateError, DigitallySignedStruct, SignatureScheme};
use serde::{Deserialize, Serialize};
use x509_cert::der::asn1::{Ia5StringRef, PrintableStringRef, Utf8StringRef};
use x509_cert::der::oid::db::rfc4519;
use x509_cert::der::{Decode, Tag, Tagged};
use x509_cert::ext::pkix::SubjectAltName;
use x509_cert::ext::pkix::name::GeneralName;
use zeroize::{Zeroize, Zeroizing};

/// An error constructing a [`Certificate`] or [`Identity`].
Expand All @@ -30,6 +39,8 @@ pub enum TlsError {
Certificate(rustls::CertificateError),
#[error("invalid TLS identity: {0}")]
Identity(rustls::Error),
#[error("invalid TLS configuration: {0}")]
Config(rustls::Error),
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
}
Expand Down Expand Up @@ -75,16 +86,7 @@ impl Identity {
pem.push(b'\n');
pem.extend_from_slice(cert);

// Mirror `reqwest::Identity::from_pem`, which uses the last private
// key in the buffer.
let mut keys = PrivateKeyDer::pem_slice_iter(&pem).collect::<Result<Vec<_>, _>>()?;
let key = keys.pop().ok_or(TlsError::NoPrivateKey)?;
keys.iter_mut().for_each(Zeroize::zeroize);
let certs = CertificateDer::pem_slice_iter(&pem).collect::<Result<Vec<_>, _>>()?;
if certs.is_empty() {
return Err(TlsError::NoCertificate);
}

let (certs, key) = parse_identity_pem(&pem)?;
// reqwest only checks that the key matches the certificate when the
// client is built, so check here to report the error up front.
let provider = rustls::crypto::aws_lc_rs::default_provider();
Expand All @@ -97,6 +99,192 @@ impl Identity {
}
}

/// Splits an identity PEM buffer into its certificate chain and private key.
fn parse_identity_pem(
pem: &[u8],
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), TlsError> {
// Mirror `reqwest::Identity::from_pem`, which uses the last private key in
// the buffer.
let mut keys = PrivateKeyDer::pem_slice_iter(pem).collect::<Result<Vec<_>, _>>()?;
let key = keys.pop().ok_or(TlsError::NoPrivateKey)?;
keys.iter_mut().for_each(Zeroize::zeroize);
let certs = CertificateDer::pem_slice_iter(pem).collect::<Result<Vec<_>, _>>()?;
if certs.is_empty() {
return Err(TlsError::NoCertificate);
}
Ok((certs, key))
}

/// Builds the rustls configuration for a client that trusts `roots` in
/// addition to the platform's trust store, and that presents `identity`, if
/// any, for client authentication.
///
/// Server certificates are verified by `rustls-platform-verifier`, as reqwest
/// does by default, with the [`ExactRootMatch`] fallback.
pub(crate) fn rustls_config(
roots: &[Certificate],
identity: Option<&Identity>,
) -> Result<rustls::ClientConfig, TlsError> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let roots: Vec<_> = roots
.iter()
.map(|cert| CertificateDer::from(cert.der.clone()))
.collect();
let inner = rustls_platform_verifier::Verifier::new_with_extra_roots(
roots.clone(),
Arc::clone(&provider),
)
.map_err(TlsError::Config)?;
let builder = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(TlsError::Config)?
.dangerous()
.with_custom_certificate_verifier(Arc::new(ExactRootMatch { inner, roots }));
let mut config = match identity {
Some(identity) => {
let (certs, key) = parse_identity_pem(&identity.pem)?;
builder
.with_client_auth_cert(certs, key)
.map_err(TlsError::Identity)?
}
None => builder.with_no_client_auth(),
};
// reqwest only sets ALPN on TLS configurations it builds itself. This
// mirrors its choice while the workspace enables reqwest's `http2` feature.
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this mirrors reqwest only while the workspace enables reqwest's http2 feature. Mention that dependency in the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the comment now ties the ALPN choice to the workspace enabling reqwest's http2 feature.

Ok(config)
}

/// A server certificate verifier that accepts a server certificate that is
/// byte-for-byte identical to one of `roots`, and otherwise defers to `inner`.
///
/// An exact match must still be within its validity period and valid for the
/// server name, but skips the chain, basic constraints and key usage checks.
/// This keeps a self-signed `CA:TRUE` certificate working when it is supplied
/// as its own certificate authority, which webpki rejects as
/// `CaUsedAsEndEntity`. The name check falls back to the subject common names
/// when an exact match has no DNS or IP subjectAltName, see
/// [`common_name_matches`]. Every other certificate is checked against
/// subjectAltNames only. An exact match is decided without consulting
/// `inner`. Handshake signatures are always verified by `inner`.
#[derive(Debug)]
struct ExactRootMatch {
inner: rustls_platform_verifier::Verifier,
roots: Vec<CertificateDer<'static>>,
}

/// Returns whether `server_name` is a DNS name equal, ignoring ASCII case, to
/// any subject common name of `cert`, and `cert` has no DNS or IP
/// subjectAltName.
///
/// This is stricter than OpenSSL's fallback, which also applies when only IP
/// subjectAltNames are present, matches wildcard common names, and decodes
/// string types other than UTF8String, PrintableString and IA5String.
fn common_name_matches(cert: &x509_cert::Certificate, server_name: &ServerName<'_>) -> bool {
let ServerName::DnsName(name) = server_name else {
return false;
};
let tbs = &cert.tbs_certificate;
// An undecodable subjectAltName extension counts as present.
let has_san = tbs.filter::<SubjectAltName>().any(|san| match san {
Ok((_, SubjectAltName(names))) => names
.iter()
.any(|name| matches!(name, GeneralName::DnsName(_) | GeneralName::IpAddress(_))),
Err(_) => true,
});
if has_san {
return false;
}
tbs.subject
.0
.iter()
.flat_map(|rdn| rdn.0.iter())
.filter(|atv| atv.oid == rfc4519::CN)
.any(|cn| {
let cn = match cn.value.tag() {
Tag::Utf8String => cn
.value
.decode_as::<Utf8StringRef<'_>>()
.map(|s| s.as_str().to_owned()),
Tag::PrintableString => cn
.value
.decode_as::<PrintableStringRef<'_>>()
.map(|s| s.as_str().to_owned()),
Tag::Ia5String => cn
.value
.decode_as::<Ia5StringRef<'_>>()
.map(|s| s.as_str().to_owned()),
_ => return false,
};
cn.is_ok_and(|cn| cn.eq_ignore_ascii_case(name.as_ref()))
})
}

impl ServerCertVerifier for ExactRootMatch {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
// Checked before `inner`, which would reject a `CA:TRUE` certificate and
// logs every rejection at error level.
if self
.roots
.iter()
.any(|root| root.as_ref() == end_entity.as_ref())
{
let cert = x509_cert::Certificate::from_der(end_entity)
.map_err(|_| rustls::Error::InvalidCertificate(CertificateError::BadEncoding))?;
let validity = &cert.tbs_certificate.validity;
let now = now.as_secs();
if now < validity.not_before.to_unix_duration().as_secs() {
return Err(rustls::Error::InvalidCertificate(
CertificateError::NotValidYet,
));
}
if now > validity.not_after.to_unix_duration().as_secs() {
return Err(rustls::Error::InvalidCertificate(CertificateError::Expired));
}
// Checks subjectAltNames only, not basic constraints, so a
// `CA:TRUE` certificate passes. Errors match `inner`'s on Linux.
let parsed = ParsedCertificate::try_from(end_entity)?;
match verify_server_name(&parsed, server_name) {
Ok(()) => {}
Err(_) if common_name_matches(&cert, server_name) => {}
Err(e) => return Err(e),
}
return Ok(ServerCertVerified::assertion());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping the name check goes further than OpenSSL parity. Under native-tls (before #39415), reqwest accepted the CA:TRUE self-signed cert but still verified the hostname. The QA finding is only about CaUsedAsEndEntity.

Could we keep the name check on the exact-match path? webpki::EndEntityCert::try_from(end_entity)?.verify_is_valid_for_subject_name(server_name) (rustls-webpki is already in the tree) checks names only, not basic constraints, so the CA case still passes, and the no-CN-fallback behavior stays uniform. If skipping the name is intentional, please state why it is safe when the registry is reached through PrivateLink or an SSH tunnel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, restored in 5e95bc0. On an exact match the verifier checks validity, then rustls::client::verify_server_name (the public wrapper over webpki's verify_is_valid_for_subject_name), so it skips only the chain, basic-constraints and key-usage checks. A self-signed CA:TRUE cert with a matching SAN still passes, and there is no CN fallback.

}
self.inner
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
}

fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
self.inner.verify_tls12_signature(message, cert, dss)
}

fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
self.inner.verify_tls13_signature(message, cert, dss)
}

fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.inner.supported_verify_schemes()
}
}

impl From<Identity> for reqwest::Identity {
fn from(id: Identity) -> Self {
reqwest::Identity::from_pem(&id.pem).expect("known to be a valid identity")
Expand Down
Loading
Loading