Skip to content

ccsr: trust a server certificate that exactly matches the configured CA - #39459

Open
jasonhernandez wants to merge 1 commit into
mainfrom
jason/ccsr-exact-match-ca
Open

jasonhernandez wants to merge 1 commit into
mainfrom
jason/ccsr-exact-match-ca

Conversation

@jasonhernandez

@jasonhernandez jasonhernandez commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

This addresses the QA review finding on #39415. Under rustls, a self-signed CA:TRUE schema registry certificate supplied as its own SSL CERTIFICATE AUTHORITY fails with CaUsedAsEndEntity, though OpenSSL accepted it. Follows #39415.

Description

  • When a ccsr client has configured root certificates, mz-ccsr builds the rustls config (aws-lc-rs) and hands it to reqwest with tls_backend_preconfigured.
  • A server certificate byte-for-byte identical to a configured root skips the chain, basic-constraints and key-usage checks, but its validity period and server name are still checked. The name is checked against SANs, falling back to the subject CN only when the pinned cert has no DNS or IP SAN, as OpenSSL does. Validity is read with x509-cert because rustls-webpki exposes no validity accessor.
  • Every other certificate goes to rustls-platform-verifier, with the same native and extra roots reqwest uses. Handshake signatures are always verified.
  • The client identity moves into the rustls config.
  • Without configured roots, reqwest's default path is unchanged. Non-pinned CN-only leaves without a SAN are still rejected.

Verification

New test_tls_server_verification runs against a local TLS server. It accepts a SAN leaf and exact-match self-signed CAs matched by SAN or by CN, and rejects an exact match with the wrong SAN or CN, an expired or not-yet-valid exact match, a different self-signed cert, and a CN-only leaf. New test_tls_client_identity_with_roots covers mTLS with configured roots. Each check was confirmed to make its test fail when removed.

🤖 Generated with Claude Code

@jasonhernandez
jasonhernandez changed the base branch from jason/reqwest-rustls-base to main October 1, 2026 16:36
@jasonhernandez
jasonhernandez force-pushed the jason/ccsr-exact-match-ca branch 2 times, most recently from ec2cbca to ef9f076 Compare October 1, 2026 17:15
@jasonhernandez
jasonhernandez marked this pull request as ready for review October 1, 2026 19:15
@jasonhernandez
jasonhernandez requested review from a team as code owners October 1, 2026 19:15
@jasonhernandez

Copy link
Copy Markdown
Contributor Author

sorry - this is some small followup to minimize changes in behavior handling ssl certs moving to rustls @antiguru

@antiguru antiguru left a comment

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.

The approach looks right. Preconfiguring rustls with rustls-platform-verifier::new_with_extra_roots and the aws-lc-rs provider matches reqwest 0.13.5's default path for the options ccsr uses, and handshake signatures still go through inner, so a pinned cert still requires its private key.

Main open question: the exact-match path skips hostname verification, which is broader than the OpenSSL behavior this restores (inline). Also a doc comment that contradicts the validity check, and test gaps for the name-skip and the CA + client-identity handshake. Happy to approve once those are settled.

Minor: x509-cert is a new dependency used only for notBefore/notAfter. Fine given der/spki are already in the tree, but worth a line in the description that webpki exposes no validity accessor.

Posted by Claude Code on behalf of @antiguru.

Comment thread src/ccsr/src/config.rs Outdated
///
/// Certificates in the system's certificate store are trusted by default.
/// A server certificate identical to `cert` is trusted regardless of its
/// name, validity period or extensions.

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.

This contradicts the implementation: ExactRootMatch does check the validity period and returns NotValidYet/Expired, and the test asserts it. Drop "validity period" here.

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.

Fixed: the doc now says an identical certificate is trusted even if it isn't a valid end-entity certificate, and its name and validity period are still checked.

Comment thread src/ccsr/src/tls.rs
if now > not_after {
return Err(rustls::Error::InvalidCertificate(CertificateError::Expired));
}
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.

Comment thread src/ccsr/tests/client.rs

// webpki rejects a `CA:TRUE` server certificate as `CaUsedAsEndEntity`.
// It is accepted because it is identical to the configured root.
let self_signed_ca = TestCert::new(TLS_TEST_HOST, Some(TLS_TEST_HOST), true, None);

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.

This cert's SAN matches TLS_TEST_HOST, so the documented "regardless of its name" behavior is not exercised. Please add a pinned cert with a mismatched SAN and assert whichever outcome we settle on.

Separately, the client identity now flows through rustls_config whenever roots are set, but test_pem_identity only calls build() and never does a handshake. A case where the test server requires a client certificate would cover CA + mTLS, unless an mzcompose suite (kafka-auth?) already covers CSR mTLS in CI.

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.

Added both: a pinned self-signed CA with SAN other.test is rejected with the name error (fails without the check), and test_tls_client_identity_with_roots does an mTLS handshake with configured roots, with and without an identity. kafka-auth's test-schema-registry-mssl.td also covers CSR mTLS with a CA in PR CI.

Comment thread src/ccsr/src/config.rs Outdated

if let Some(ident) = self.identity {
// A preconfigured TLS backend makes reqwest ignore its own root
// certificate and identity settings, so `rustls_config` handles both.

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.

A preconfigured backend makes reqwest ignore all of its TLS builder settings (ALPN, SNI, min/max TLS version, CRLs), not only roots and identity. Worth saying so here, so a later builder.min_tls_version(..) isn't silently dropped when roots are configured.

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.

Added a NOTE: a preconfigured backend makes reqwest ignore all its TLS builder settings (roots, identity, ALPN, SNI, TLS versions, CRLs), so they must go into rustls_config.

Comment thread src/ccsr/src/tls.rs
None => builder.with_no_client_auth(),
};
// reqwest only sets ALPN on TLS configurations it builds itself.
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.

@antiguru antiguru left a comment

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.

Looks good! My bot raises some points, but once they're fixed this should be good to go.

@jasonhernandez
jasonhernandez force-pushed the jason/ccsr-exact-match-ca branch from ef9f076 to 5e95bc0 Compare October 1, 2026 20:31
@def-

def- commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- The new name check on the exact-match path still rejects the self-signed CA:TRUE certificate this PR is meant to fix

src/ccsr/src/tls.rs:212

verify_server_name checks subjectAltNames only. A self-signed cert from a plain openssl req -x509 -subj /CN=<host> is CA:TRUE with no SAN, so it is still rejected, now with certificate not valid for name ... (according to its subjectAltName extension) instead of CaUsedAsEndEntity. That is the certificate type the motivating QA finding tested, so registries using such a cert still break on upgrade. OpenSSL accepted the same cert while checking the hostname, because it falls back to the CN.

Details

I generated a cert with openssl req -x509 -newkey ec -subj /CN=sr.test (OpenSSL 3.5). It gets Basic Constraints: critical, CA:TRUE and no SAN. I served it through the PR's start_tls_server(&cert, &cert) helper:

  • At 5e95bc0 it is rejected with the error quoted above.
  • At ef9f076, the previous head, it is accepted.
  • openssl verify -CAfile c.pem -verify_hostname sr.test c.pem gives OK. With -verify_hostname other.test it gives hostname mismatch. So OpenSSL parity means a name check with CN fallback.

The test passes only because its self-signed CA has a SAN (src/ccsr/tests/client.rs:643), and the scenario from the motivation has none.

To match the OpenSSL behavior the reviewer asked for, the exact-match branch could fall back to the subject CN when the certificate has no DNS/IP SAN, for example using the already-decoded x509_cert::Certificate. That keeps the hostname check, and the cert is still pinned byte-for-byte. If SAN-only is the intended outcome, the PR description and release note should say that self-signed certificates without a SAN still fail, and a test should pin that behavior.

With rustls, webpki rejects a self-signed `CA:TRUE` certificate that is
both the schema registry's server certificate and the connection's
`SSL CERTIFICATE AUTHORITY`, reporting `CaUsedAsEndEntity`. OpenSSL
accepted it.

When a schema registry client has configured root certificates, build
the rustls configuration in mz-ccsr and hand it to reqwest as a
preconfigured TLS backend. Its verifier accepts a server certificate
that is byte-for-byte identical to a configured root, which is
equivalent to pinning that certificate, and otherwise defers to
`rustls-platform-verifier` with the same native and extra roots that
reqwest uses by default. Handshake signatures are always checked by the
inner verifier. The client identity moves into the rustls configuration,
because reqwest ignores its own identity setting with a preconfigured
backend.

Clients without configured roots keep reqwest's default TLS path. A
CN-only leaf without a subjectAltName is still rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonhernandez
jasonhernandez force-pushed the jason/ccsr-exact-match-ca branch from 5e95bc0 to f763bd6 Compare October 2, 2026 17:12
@jasonhernandez

Copy link
Copy Markdown
Contributor Author

Addressed in f763bd6: on an exact match the name check now falls back to the subject CN when the pinned certificate has no DNS or IP subjectAltName, like OpenSSL (exact, ASCII case-insensitive, no wildcards, last CN). Every other certificate stays SAN-only. Tests cover the CN-only CA:TRUE cert you used (accepted), a wrong CN (rejected), and a SAN for another host with a matching CN (rejected).

@antiguru this adds a CN fallback to the exact-match path after your approval, could you take another look at common_name_matches in src/ccsr/src/tls.rs?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants