diff --git a/SPEC/RFC5280_CHAIN_VALIDATION.md b/SPEC/RFC5280_CHAIN_VALIDATION.md index e4841a0..50ebae7 100644 --- a/SPEC/RFC5280_CHAIN_VALIDATION.md +++ b/SPEC/RFC5280_CHAIN_VALIDATION.md @@ -19,6 +19,7 @@ #### System Root CAs - `loadSystemCaBundle()` helper wraps `Certificate.Bundle.rescan()` for OS-native roots - Supports macOS (Keychain), Linux (`/etc/ssl/certs/`), Windows (CertStore), FreeBSD, OpenBSD, etc. +- Callers must pass a populated `ca_bundle` when `skip_cert_verify = false`; the handshake now fails closed if no trust bundle is provided ### Configuration @@ -39,10 +40,10 @@ const tls_config = TlsConfig{ - **Extended Key Usage** — `id-kp-serverAuth` not checked on leaf (recommended but not required by TLS 1.3) - **Name Constraints** — RFC 5280 §4.2.1.10 - **Policy Constraints** — RFC 5280 §4.2.1.11 -- **Mandatory ca_bundle enforcement** — When `skip_cert_verify=false` and no `ca_bundle` is provided, the chain's self-signed root is accepted without trust anchor verification +- **Automatic system CA loading** — callers must currently wire `loadSystemCaBundle()` into client setup themselves; the handshake does not auto-populate `ca_bundle` ### Caveats -- `skip_cert_verify` defaults to `true` for backward compatibility +- `skip_cert_verify` defaults to `false`; callers must opt out explicitly for test-only/self-signed scenarios - V1 certificates (no extensions) are accepted as CAs when no basicConstraints is present — this matches common practice but is less strict than RFC 5280's recommendation - The interop client always uses `skip_cert_verify=true` since interop test peers use various self-signed certs diff --git a/src/quic/tls13.zig b/src/quic/tls13.zig index 33229fe..de9b100 100644 --- a/src/quic/tls13.zig +++ b/src/quic/tls13.zig @@ -508,7 +508,7 @@ pub const TlsConfig = struct { private_key_bytes: []const u8, // Raw ECDSA P-256 private key (32 bytes) alpn: []const []const u8, server_name: ?[]const u8 = null, // SNI (client only) - skip_cert_verify: bool = true, // Skip X.509 chain + CertificateVerify validation + skip_cert_verify: bool = false, // Skip X.509 chain + CertificateVerify validation ca_bundle: ?*Certificate.Bundle = null, // Caller-owned CA bundle for trust anchor verification session_ticket: ?*const SessionTicket = null, // Stored ticket from previous connection (client) ticket_key: ?[16]u8 = null, // AES-128-GCM key for encrypting/decrypting tickets (server) @@ -1119,6 +1119,8 @@ pub const Tls13Handshake = struct { @memcpy(self.leaf_pub_key_buf[0..pub_key.len], pub_key); self.leaf_pub_key_len = @intCast(pub_key.len); self.leaf_pub_key_algo = std.meta.activeTag(parsed.pub_key_algo); + } else { + return error.BadCertificate; } // Verify hostname if SNI was set @@ -1150,10 +1152,9 @@ pub const Tls13Handshake = struct { // If this is the last cert, verify against CA bundle if (pos >= cert_list_end) { - if (self.config.ca_bundle) |bundle| { - const now_sec = std.time.timestamp(); - bundle.verify(parsed, now_sec) catch return error.BadCertificate; - } + const bundle = self.config.ca_bundle orelse return error.BadCertificate; + const now_sec = std.time.timestamp(); + bundle.verify(parsed, now_sec) catch return error.BadCertificate; } prev_parsed = parsed; @@ -1172,7 +1173,9 @@ pub const Tls13Handshake = struct { if (msg[0] != @intFromEnum(MsgType.certificate_verify)) return error.UnexpectedMessage; - if (!self.config.skip_cert_verify and self.leaf_pub_key_len > 0) { + if (!self.config.skip_cert_verify) { + if (self.leaf_pub_key_len == 0) return error.BadCertificateVerify; + // Get transcript hash BEFORE updating with CertificateVerify const transcript_hash = self.transcript.current(); @@ -2794,6 +2797,16 @@ test "buildServerHello: produces valid message" { try std.testing.expectEqual(msg.len - 4, body_len); } +test "TlsConfig defaults to certificate verification enabled" { + const config = TlsConfig{ + .cert_chain_der = &.{}, + .private_key_bytes = &.{}, + .alpn = &.{}, + }; + + try std.testing.expect(!config.skip_cert_verify); +} + test "loopback handshake: client and server complete" { // Generate an ECDSA P-256 key pair for the server const server_key_pair = EcdsaP256Sha256.KeyPair.generate(); @@ -2815,6 +2828,7 @@ test "loopback handshake: client and server complete" { .private_key_bytes = &.{}, .alpn = &[_][]const u8{"h3"}, .server_name = "localhost", + .skip_cert_verify = true, }; const server_tp = transport_params.TransportParams{ @@ -2961,6 +2975,7 @@ test "loopback PSK resumption: two handshakes with session ticket" { .private_key_bytes = &.{}, .alpn = &[_][]const u8{"h3"}, .server_name = "localhost", + .skip_cert_verify = true, }; const tp = transport_params.TransportParams{