authorgravatar for ari@solacy.netAri Becker <ari@solacy.net> 2026-08-05 03:03:24+02:00
committergravatar for andrew@ziglang.orgAndrew Kelley <andrew@ziglang.org> 2026-08-05 03:03:24+02:00
log5d2ad0b021f1926f09039827077bf1bd55f83df7
tree80f4bb68f6dcb99641e75333fe65ec85e55b2758
parentce96dcceab7731f63534425f1d25bfe9be014847

Support checking IP addresses in certificate subject alternate names (#36301)

I was trying to get Zig to verify a local TLS certificate (i.e. for "127.0.0.1") issued by [mkcert](https://github.com/FiloSottile/mkcert) and was surprised to see that Zig did not verify it. It turns out that `std` currently only validates DNS hostnames in certificates, and not yet IP addresses. There are, of course, other use-cases for TLS certificates for IP addresses, such as for DNS over TLS (public example: `openssl s_client -connect 1.1.1.1:443 | openssl x509 -text -noout`). This PR allows `std.crypto.Certificate` to verify TLS certificates when they present an IP address as a subject alternate name. Prior art: [Golang standard library crypto/x509](https://cs.opensource.google/go/go/+/refs/tags/go1.26.5:src/crypto/x509/verify.go;l=942) Reviewed-on: https://codeberg.org/ziglang/zig/pulls/36301 Reviewed-by: Andrew Kelley <andrew@ziglang.org>

1 files changed, 56 insertions(+), 0 deletions(-)

lib/std/crypto/Certificate.zig+56
......@@ -175,6 +175,8 @@ pub const GeneralNameTag = enum(u5) {
175175 _,
176176};
177177
178const net = @import("../Io/net.zig");
179
178180pub const Parsed = struct {
179181 certificate: Certificate,
180182 issuer_slice: Slice,
......@@ -315,6 +317,7 @@ pub const Parsed = struct {
315317 // what to check. Otherwise, only the common name is checked.
316318 const subject_alt_name = parsed_subject.subjectAltName();
317319 if (subject_alt_name.len == 0) {
320 // note: checkIpAddress is intentionally omitted, as it is not permitted in the common name field anyway.
318321 if (checkHostName(host_name, parsed_subject.commonName())) {
319322 return;
320323 } else {
......@@ -332,6 +335,10 @@ pub const Parsed = struct {
332335 const dns_name = subject_alt_name[general_name.slice.start..general_name.slice.end];
333336 if (checkHostName(host_name, dns_name)) return;
334337 },
338 .iPAddress => {
339 const ip_address = subject_alt_name[general_name.slice.start..general_name.slice.end];
340 if (checkIpAddress(host_name, ip_address)) return;
341 },
335342 else => {},
336343 }
337344 }
......@@ -376,6 +383,22 @@ pub const Parsed = struct {
376383
377384 return false;
378385 }
386
387 // Check IP address according to RFC 5280 §4.2.1.6.
388 fn checkIpAddress(host_name: []const u8, ip_address: []const u8) bool {
389 switch (ip_address.len) {
390 4 => {
391 // port is irrelevant to SAN matching, so 0 is a harmless placeholder.
392 const address = net.Ip4Address.parse(host_name, 0) catch return false;
393 return mem.eql(u8, &address.bytes, ip_address);
394 },
395 16 => {
396 const address = net.Ip6Address.parse(host_name, 0) catch return false;
397 return mem.eql(u8, &address.bytes, ip_address);
398 },
399 else => return false, // a malformed certificate, neither 4 nor 16 octets
400 }
401 }
379402};
380403
381404test "Parsed.checkHostName RFC 6125 compliance" {
......@@ -417,6 +440,39 @@ test "Parsed.checkHostName RFC 6125 compliance" {
417440 try expectEqual(false, Parsed.checkHostName("example.com", "*."));
418441}
419442
443test "Parsed.checkIpAddress RFC 5280 4.2.1.6 compliance" {
444 const expectEqual = std.testing.expectEqual;
445
446 // Exact match positive tests
447 try expectEqual(true, Parsed.checkIpAddress("127.0.0.1", &[4]u8{ 127, 0, 0, 1 }));
448 try expectEqual(true, Parsed.checkIpAddress("0:0:0:0:0:0:0:1", &[16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }));
449
450 // Mismatches should not pass
451 try expectEqual(false, Parsed.checkIpAddress("1.2.3.4", &[4]u8{ 5, 6, 7, 8 }));
452 try expectEqual(false, Parsed.checkIpAddress("0:0:0:0:0:0:0:1", &[16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2 }));
453
454 // IPv6: the hostname may be in short-form and should match the exact 16 octets specified in the SAN
455 try expectEqual(true, Parsed.checkIpAddress("::1", &[16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }));
456
457 // IPv6: do not match when using DNS64 / NAT64 (i.e. 64:ff9b::/96)
458 // the RFC requires exact octet matches, so this is likely surprising and wrong. The decision here is to fail-safe out of an abundance of caution.
459 // The test assertions are included not to harden on this behavior, but to show that this use-case was considered.
460 // This check may become more lenient in the future if a valid use-case is found.
461 try expectEqual(false, Parsed.checkIpAddress("64:ff9b::192.0.2.10", &[4]u8{ 192, 0, 2, 10 }));
462 try expectEqual(false, Parsed.checkIpAddress("::ffff:127.0.0.1", &[4]u8{ 127, 0, 0, 1 }));
463
464 // Malformed SAN lengths (not 4 or 16 octets) never match.
465 try expectEqual(false, Parsed.checkIpAddress("127.0.0", &[_]u8{ 127, 0, 0 }));
466 try expectEqual(false, Parsed.checkIpAddress("127.0.0.1.0", &[_]u8{ 127, 0, 0, 1, 0 }));
467
468 // A non-parseable host_name never matches.
469 try expectEqual(false, Parsed.checkIpAddress("not-an-ip", &[4]u8{ 127, 0, 0, 1 }));
470
471 // Edge cases - empty strings
472 try expectEqual(false, Parsed.checkIpAddress("", ""));
473 try expectEqual(false, Parsed.checkIpAddress("127.0.0.1", ""));
474}
475
420476pub const ParseError = der.Element.ParseError || ParseVersionError || ParseTimeError || ParseEnumError || ParseBitStringError;
421477
422478pub fn parse(cert: Certificate) ParseError!Parsed {