From 506ab8e038b16a7266df7c1ef4e0e22adbed7756 Mon Sep 17 00:00:00 2001 From: m5r Date: Fri, 31 Jul 2026 23:59:39 +0200 Subject: [PATCH] platform layer: address types, stdlib tls client wrapper, mbedtls tls server --- src/platform/address.zig | 411 +++++++++ src/platform/mbedtls_shim.c | 35 + src/platform/tls_client.zig | 280 +++++++ src/platform/tls_client_integration_test.zig | 110 +++ src/platform/tls_server.zig | 823 +++++++++++++++++++ tests/fixtures/README.md | 34 + tests/fixtures/fixtures.zig | 9 + tests/fixtures/mismatched_key.pem | 5 + tests/fixtures/self_signed_cert.pem | 11 + tests/fixtures/self_signed_key.pem | 5 + 10 files changed, 1723 insertions(+) create mode 100644 src/platform/address.zig create mode 100644 src/platform/mbedtls_shim.c create mode 100644 src/platform/tls_client.zig create mode 100644 src/platform/tls_client_integration_test.zig create mode 100644 src/platform/tls_server.zig create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/fixtures.zig create mode 100644 tests/fixtures/mismatched_key.pem create mode 100644 tests/fixtures/self_signed_cert.pem create mode 100644 tests/fixtures/self_signed_key.pem diff --git a/src/platform/address.zig b/src/platform/address.zig new file mode 100644 index 0000000..ca435bd --- /dev/null +++ b/src/platform/address.zig @@ -0,0 +1,411 @@ +//! IP address and prefix values for nxdns. Pure: no `std.Io` operations, only +//! conversions to and from `std.Io.net.IpAddress`. + +const std = @import("std"); +const net = std.Io.net; +const Writer = std.Io.Writer; + +pub const NetAddress = union(enum) { + ip4: [4]u8, + ip6: [16]u8, + + /// Tag byte (4 or 6) followed by the address bytes, zero-padded for ip4. + pub const Key = [17]u8; + + pub const ParseError = error{InvalidAddress}; + + /// Accepts "1.2.3.4" and "fd00::1". No port, no brackets, no scope id. + /// An IPv4-mapped IPv6 literal normalizes to `.ip4`. + pub fn parse(text: []const u8) ParseError!NetAddress { + const addr = net.IpAddress.parse(text, 0) catch return error.InvalidAddress; + return fromIp(addr); + } + + /// IPv4 in dotted-decimal, IPv6 per RFC 5952. + pub fn format(self: NetAddress, w: *Writer) Writer.Error!void { + switch (self) { + .ip4 => |b| try w.print("{d}.{d}.{d}.{d}", .{ b[0], b[1], b[2], b[3] }), + .ip6 => |b| try formatIp6(b, w), + } + } + + pub fn key(self: NetAddress) Key { + var out: Key = @splat(0); + switch (self) { + .ip4 => |b| { + out[0] = 4; + @memcpy(out[1..5], &b); + }, + .ip6 => |b| { + out[0] = 6; + @memcpy(out[1..17], &b); + }, + } + return out; + } + + /// Drops the port. An IPv4-mapped IPv6 address (::ffff:a.b.c.d) yields `.ip4`. + pub fn fromIp(addr: net.IpAddress) NetAddress { + return switch (addr) { + .ip4 => |a| .{ .ip4 = a.bytes }, + .ip6 => |a| if (net.Ip4Address.fromIp6(a)) |mapped| + .{ .ip4 = mapped.bytes } + else + .{ .ip6 = a.bytes }, + }; + } + + pub fn toIp(self: NetAddress, port: u16) net.IpAddress { + return switch (self) { + .ip4 => |b| .{ .ip4 = .{ .bytes = b, .port = port } }, + .ip6 => |b| .{ .ip6 = .{ .bytes = b, .port = port } }, + }; + } + + pub fn eql(a: NetAddress, b: NetAddress) bool { + return switch (a) { + .ip4 => |x| switch (b) { + .ip4 => |y| std.mem.eql(u8, &x, &y), + .ip6 => false, + }, + .ip6 => |x| switch (b) { + .ip4 => false, + .ip6 => |y| std.mem.eql(u8, &x, &y), + }, + }; + } + + fn bytes(self: *const NetAddress) []const u8 { + return switch (self.*) { + .ip4 => |*b| b, + .ip6 => |*b| b, + }; + } + + fn familyBits(self: NetAddress) u8 { + return switch (self) { + .ip4 => 32, + .ip6 => 128, + }; + } +}; + +fn formatIp6(addr: [16]u8, w: *Writer) Writer.Error!void { + var parts: [8]u16 = undefined; + for (&parts, 0..) |*part, i| { + part.* = std.mem.readInt(u16, addr[i * 2 ..][0..2], .big); + } + + // RFC 5952 4.2: compress the longest run of zero groups, earliest run on a + // tie, and never a run of only one group. + var run_start: usize = 0; + var run_len: usize = 0; + var current_start: usize = 0; + var current_len: usize = 0; + for (parts, 0..) |part, i| { + if (part != 0) { + current_len = 0; + continue; + } + if (current_len == 0) current_start = i; + current_len += 1; + if (current_len > run_len) { + run_start = current_start; + run_len = current_len; + } + } + if (run_len < 2) run_len = 0; + + var i: usize = 0; + var at_start = true; + while (i < parts.len) { + if (run_len != 0 and i == run_start) { + try w.writeAll("::"); + i += run_len; + at_start = true; + continue; + } + if (!at_start) try w.writeByte(':'); + try w.print("{x}", .{parts[i]}); + at_start = false; + i += 1; + } +} + +pub const Prefix = struct { + /// Host bits are zeroed by `parse`. + addr: NetAddress, + bits: u8, + + pub const ParseError = error{InvalidPrefix}; + + /// Accepts "192.168.1.0/24" and "fd00:abcd::/48". + pub fn parse(text: []const u8) ParseError!Prefix { + const slash = std.mem.findScalar(u8, text, '/') orelse return error.InvalidPrefix; + const addr = NetAddress.parse(text[0..slash]) catch return error.InvalidPrefix; + const bits = std.fmt.parseInt(u8, text[slash + 1 ..], 10) catch return error.InvalidPrefix; + if (bits > addr.familyBits()) return error.InvalidPrefix; + + var masked = addr; + switch (masked) { + .ip4 => |*b| maskBytes(b, bits), + .ip6 => |*b| maskBytes(b, bits), + } + return .{ .addr = masked, .bits = bits }; + } + + /// A family mismatch is never a match. + pub fn contains(self: Prefix, addr: NetAddress) bool { + if (std.meta.activeTag(self.addr) != std.meta.activeTag(addr)) return false; + const net_bytes = self.addr.bytes(); + const addr_bytes = addr.bytes(); + var remaining = self.bits; + var i: usize = 0; + while (remaining >= 8) : (i += 1) { + if (net_bytes[i] != addr_bytes[i]) return false; + remaining -= 8; + } + if (remaining == 0) return true; + const mask = ~(@as(u8, 0xff) >> @intCast(remaining)); + return (net_bytes[i] & mask) == (addr_bytes[i] & mask); + } + + pub fn format(self: Prefix, w: *Writer) Writer.Error!void { + try self.addr.format(w); + try w.print("/{d}", .{self.bits}); + } +}; + +fn maskBytes(addr: []u8, bits: u8) void { + for (addr, 0..) |*byte, i| { + const offset = i * 8; + if (bits >= offset + 8) continue; + if (bits <= offset) { + byte.* = 0; + continue; + } + byte.* &= ~(@as(u8, 0xff) >> @intCast(bits - offset)); + } +} + +/// Longest-prefix winner; ties broken by lower `priority` value. Returns null +/// when nothing matches. `T` must have fields `prefix: Prefix` and +/// `priority: i64`. +pub fn matchLongest(comptime T: type, entries: []const T, addr: NetAddress) ?*const T { + var best: ?*const T = null; + for (entries) |*entry| { + if (!entry.prefix.contains(addr)) continue; + const current = best orelse { + best = entry; + continue; + }; + if (entry.prefix.bits > current.prefix.bits) { + best = entry; + } else if (entry.prefix.bits == current.prefix.bits and entry.priority < current.priority) { + best = entry; + } + } + return best; +} + +const testing = std.testing; + +fn expectFormat(expected: []const u8, value: anytype) !void { + var buf: [64]u8 = undefined; + var w = Writer.fixed(&buf); + try value.format(&w); + try testing.expectEqualStrings(expected, w.buffered()); +} + +fn expectRoundTrip(text: []const u8) !void { + try expectFormat(text, try NetAddress.parse(text)); +} + +test "round trip of canonical addresses" { + try expectRoundTrip("192.168.1.1"); + try expectRoundTrip("0.0.0.0"); + try expectRoundTrip("255.255.255.255"); + try expectRoundTrip("fd00::1"); + try expectRoundTrip("::"); + try expectRoundTrip("::1"); + try expectRoundTrip("2001:db8::8:800:200c:417a"); + try expectRoundTrip("2001:db8::"); +} + +test "rfc 5952 formatting rules" { + // Longest run wins; earliest run wins a tie; uppercase input lowercases. + try expectFormat("2001:db8::1:0:0:1", try NetAddress.parse("2001:0DB8:0:0:1::1")); + // A single zero group is never compressed. + try expectFormat("2001:db8:0:1:1:1:1:1", try NetAddress.parse("2001:db8:0:1:1:1:1:1")); + // Leading zeros in a group are suppressed. + try expectFormat("2001:db8:aaaa:bbbb:cccc:dddd:eeee:1", try NetAddress.parse("2001:0db8:aaaa:bbbb:cccc:dddd:eeee:0001")); + // The longest run is compressed even when a shorter run comes first. + try expectFormat("2001:0:0:1::1", try NetAddress.parse("2001:0:0:1:0:0:0:1")); + // A trailing run compresses to a trailing "::". + try expectFormat("2001:db8::", try NetAddress.parse("2001:0db8:0000:0000:0000:0000:0000:0000")); + // Hex digits are lowercase. + try expectFormat("fe80::abcd:ef01", try NetAddress.parse("FE80::ABCD:EF01")); +} + +test "parse rejects malformed input" { + try testing.expectError(error.InvalidAddress, NetAddress.parse("")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("1.2.3")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("1.2.3.4.5")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("256.1.1.1")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("1.2.3.4:53")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("[fd00::1]")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("[fd00::1]:853")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("fd00::1%eth0")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("fd00:::1")); + try testing.expectError(error.InvalidAddress, NetAddress.parse("nonsense")); +} + +test "key encodes family and bytes" { + const v4 = try NetAddress.parse("192.168.1.1"); + var expected_v4: NetAddress.Key = @splat(0); + expected_v4[0] = 4; + expected_v4[1] = 192; + expected_v4[2] = 168; + expected_v4[3] = 1; + expected_v4[4] = 1; + try testing.expectEqualSlices(u8, &expected_v4, &v4.key()); + + const v6 = try NetAddress.parse("fd00::1"); + const k6 = v6.key(); + try testing.expectEqual(@as(u8, 6), k6[0]); + try testing.expectEqual(@as(u8, 0xfd), k6[1]); + try testing.expectEqual(@as(u8, 1), k6[16]); + + // Distinct families never collide. + try testing.expect(!std.mem.eql(u8, &v4.key(), &v6.key())); +} + +test "fromIp normalizes ipv4-mapped ipv6" { + const mapped = try net.IpAddress.parse("::ffff:192.168.1.1", 853); + try testing.expectEqual(net.IpAddress.Family.ip6, std.meta.activeTag(mapped)); + + const addr = NetAddress.fromIp(mapped); + try testing.expectEqual(NetAddress.ip4, std.meta.activeTag(addr)); + try testing.expect(addr.eql(.{ .ip4 = .{ 192, 168, 1, 1 } })); + try expectFormat("192.168.1.1", addr); + + // "::" is not a mapped address. + const any6 = try net.IpAddress.parse("::", 0); + try testing.expectEqual(NetAddress.ip6, std.meta.activeTag(NetAddress.fromIp(any6))); +} + +test "toIp restores family and sets port" { + const v4 = (try NetAddress.parse("10.0.0.1")).toIp(53); + try testing.expectEqual(@as(u16, 53), v4.getPort()); + try testing.expectEqualSlices(u8, &.{ 10, 0, 0, 1 }, &v4.ip4.bytes); + + const v6 = (try NetAddress.parse("fd00::1")).toIp(853); + try testing.expectEqual(@as(u16, 853), v6.getPort()); + try testing.expect(NetAddress.fromIp(v6).eql(try NetAddress.parse("fd00::1"))); +} + +test "eql compares family and bytes" { + const a = try NetAddress.parse("192.168.1.1"); + const b = try NetAddress.parse("192.168.1.2"); + try testing.expect(a.eql(a)); + try testing.expect(!a.eql(b)); + try testing.expect(!a.eql(try NetAddress.parse("::"))); + try testing.expect((try NetAddress.parse("fd00::1")).eql(try NetAddress.parse("fd00:0:0:0:0:0:0:1"))); +} + +test "prefix parse zeroes host bits" { + try expectFormat("192.168.1.0/24", try Prefix.parse("192.168.1.55/24")); + try expectFormat("0.0.0.0/0", try Prefix.parse("192.168.1.55/0")); + try expectFormat("10.128.0.0/9", try Prefix.parse("10.255.3.4/9")); + try expectFormat("192.168.1.55/32", try Prefix.parse("192.168.1.55/32")); + try expectFormat("fd00:abcd::/48", try Prefix.parse("fd00:abcd:0:1234::5/48")); + try expectFormat("::/0", try Prefix.parse("2001:db8::1/0")); +} + +test "prefix parse rejects malformed input" { + try testing.expectError(error.InvalidPrefix, Prefix.parse("192.168.1.0")); + try testing.expectError(error.InvalidPrefix, Prefix.parse("192.168.1.0/")); + try testing.expectError(error.InvalidPrefix, Prefix.parse("192.168.1.0/33")); + try testing.expectError(error.InvalidPrefix, Prefix.parse("fd00::/129")); + try testing.expectError(error.InvalidPrefix, Prefix.parse("/24")); + try testing.expectError(error.InvalidPrefix, Prefix.parse("192.168.1.0/x")); +} + +test "prefix contains" { + const p24 = try Prefix.parse("192.168.1.0/24"); + try testing.expect(p24.contains(try NetAddress.parse("192.168.1.5"))); + try testing.expect(p24.contains(try NetAddress.parse("192.168.1.0"))); + try testing.expect(p24.contains(try NetAddress.parse("192.168.1.255"))); + try testing.expect(!p24.contains(try NetAddress.parse("192.168.2.5"))); + + // Family mismatch is false in both directions. + try testing.expect(!p24.contains(try NetAddress.parse("fd00::1"))); + const p6 = try Prefix.parse("fd00::/8"); + try testing.expect(!p6.contains(try NetAddress.parse("192.168.1.5"))); + try testing.expect(p6.contains(try NetAddress.parse("fdff::abcd"))); + try testing.expect(!p6.contains(try NetAddress.parse("fe80::1"))); + + // /0 contains everything of its family, nothing of the other. + const any4 = try Prefix.parse("0.0.0.0/0"); + try testing.expect(any4.contains(try NetAddress.parse("8.8.8.8"))); + try testing.expect(any4.contains(try NetAddress.parse("0.0.0.0"))); + try testing.expect(!any4.contains(try NetAddress.parse("::"))); + const any6 = try Prefix.parse("::/0"); + try testing.expect(any6.contains(try NetAddress.parse("2001:db8::1"))); + try testing.expect(!any6.contains(try NetAddress.parse("8.8.8.8"))); + + // Single-host prefixes. + const host4 = try Prefix.parse("10.1.2.3/32"); + try testing.expect(host4.contains(try NetAddress.parse("10.1.2.3"))); + try testing.expect(!host4.contains(try NetAddress.parse("10.1.2.4"))); + const host6 = try Prefix.parse("fd00::1/128"); + try testing.expect(host6.contains(try NetAddress.parse("fd00::1"))); + try testing.expect(!host6.contains(try NetAddress.parse("fd00::2"))); + + // Non-byte-aligned boundary. + const p9 = try Prefix.parse("10.128.0.0/9"); + try testing.expect(p9.contains(try NetAddress.parse("10.255.255.255"))); + try testing.expect(!p9.contains(try NetAddress.parse("10.127.255.255"))); +} + +const Rule = struct { + prefix: Prefix, + priority: i64, + name: []const u8, +}; + +test "matchLongest prefers longer prefixes" { + const rules = [_]Rule{ + .{ .prefix = try Prefix.parse("192.168.0.0/16"), .priority = 0, .name = "wide" }, + .{ .prefix = try Prefix.parse("192.168.1.0/24"), .priority = 0, .name = "narrow" }, + .{ .prefix = try Prefix.parse("0.0.0.0/0"), .priority = 0, .name = "default" }, + }; + const hit = matchLongest(Rule, &rules, try NetAddress.parse("192.168.1.5")).?; + try testing.expectEqualStrings("narrow", hit.name); + + const wide = matchLongest(Rule, &rules, try NetAddress.parse("192.168.2.5")).?; + try testing.expectEqualStrings("wide", wide.name); + + const fallback = matchLongest(Rule, &rules, try NetAddress.parse("8.8.8.8")).?; + try testing.expectEqualStrings("default", fallback.name); +} + +test "matchLongest breaks ties by lower priority" { + const rules = [_]Rule{ + .{ .prefix = try Prefix.parse("192.168.1.0/24"), .priority = 7, .name = "later" }, + .{ .prefix = try Prefix.parse("192.168.1.0/24"), .priority = -3, .name = "winner" }, + .{ .prefix = try Prefix.parse("192.168.1.0/24"), .priority = 2, .name = "middle" }, + }; + const hit = matchLongest(Rule, &rules, try NetAddress.parse("192.168.1.5")).?; + try testing.expectEqualStrings("winner", hit.name); +} + +test "matchLongest returns null when nothing matches" { + const rules = [_]Rule{ + .{ .prefix = try Prefix.parse("192.168.1.0/24"), .priority = 0, .name = "a" }, + .{ .prefix = try Prefix.parse("fd00::/8"), .priority = 0, .name = "b" }, + }; + try testing.expect(matchLongest(Rule, &rules, try NetAddress.parse("10.0.0.1")) == null); + try testing.expect(matchLongest(Rule, &rules, try NetAddress.parse("2001:db8::1")) == null); + try testing.expect(matchLongest(Rule, &[_]Rule{}, try NetAddress.parse("10.0.0.1")) == null); +} diff --git a/src/platform/mbedtls_shim.c b/src/platform/mbedtls_shim.c new file mode 100644 index 0000000..5b89a97 --- /dev/null +++ b/src/platform/mbedtls_shim.c @@ -0,0 +1,35 @@ +/* Reports the C sizes and alignments of the Mbed TLS context structs so + * src/platform/tls_server.zig can treat them as opaque and heap-allocate them + * without mirroring their layout in Zig. */ + +#include + +#include +#include +#include +#include +#include + +size_t nx_sizeof_ssl_context(void) { return sizeof(mbedtls_ssl_context); } +size_t nx_sizeof_ssl_config(void) { return sizeof(mbedtls_ssl_config); } +size_t nx_sizeof_x509_crt(void) { return sizeof(mbedtls_x509_crt); } +size_t nx_sizeof_pk_context(void) { return sizeof(mbedtls_pk_context); } +size_t nx_sizeof_entropy_context(void) { return sizeof(mbedtls_entropy_context); } +size_t nx_sizeof_ctr_drbg_context(void) { return sizeof(mbedtls_ctr_drbg_context); } + +/* The public key of the first certificate in a chain. tls_server.zig keeps + * mbedtls_x509_crt opaque, so it cannot reach this field on its own. */ +mbedtls_pk_context *nx_x509_crt_pk(mbedtls_x509_crt *crt) { return &crt->pk; } + +/* Largest alignment any of the above needs. tls_server.zig allocates every + * context at a fixed comptime alignment and asserts it covers this value. */ +size_t nx_max_context_alignment(void) +{ + size_t max = _Alignof(mbedtls_ssl_context); + if (_Alignof(mbedtls_ssl_config) > max) max = _Alignof(mbedtls_ssl_config); + if (_Alignof(mbedtls_x509_crt) > max) max = _Alignof(mbedtls_x509_crt); + if (_Alignof(mbedtls_pk_context) > max) max = _Alignof(mbedtls_pk_context); + if (_Alignof(mbedtls_entropy_context) > max) max = _Alignof(mbedtls_entropy_context); + if (_Alignof(mbedtls_ctr_drbg_context) > max) max = _Alignof(mbedtls_ctr_drbg_context); + return max; +} diff --git a/src/platform/tls_client.zig b/src/platform/tls_client.zig new file mode 100644 index 0000000..fe5cc78 --- /dev/null +++ b/src/platform/tls_client.zig @@ -0,0 +1,280 @@ +//! Client-side TLS over a `std.Io.net.Stream`, used for upstream DoT. +//! +//! `std.crypto.tls.Client` holds its `Io.Reader`/`Io.Writer` by value and its +//! `input`/`output` point at the stream reader/writer stored beside it, so +//! `TlsStream` is pinned: it MUST NOT be moved or copied after `init`. + +const std = @import("std"); +const tls = std.crypto.tls; +const net = std.Io.net; +const Certificate = std.crypto.Certificate; + +const log = std.log.scoped(.tls_client); + +pub const ErrorClass = enum { handshake, certificate, io, protocol }; + +pub const InitError = tls.Client.InitError || error{CertificateBundleLoadFailure}; + +/// Exhaustive over `std.crypto.tls.Client.InitError`. A new error in that stdlib set +/// breaks this switch at compile time, which is the point. +fn classifyInit(err: tls.Client.InitError) ErrorClass { + return switch (err) { + error.InsufficientEntropy, + error.TlsAlert, + error.TlsUnexpectedMessage, + error.TlsIllegalParameter, + error.TlsDecryptFailure, + error.TlsDecryptError, + error.TlsRecordOverflow, + error.TlsBadRecordMac, + => .handshake, + + error.CertificateFieldHasInvalidLength, + error.CertificateHostMismatch, + error.CertificatePublicKeyInvalid, + error.CertificateExpired, + error.CertificateFieldHasWrongDataType, + error.CertificateIssuerMismatch, + error.CertificateNotYetValid, + error.CertificateSignatureAlgorithmMismatch, + error.CertificateSignatureAlgorithmUnsupported, + error.CertificateSignatureInvalid, + error.CertificateSignatureInvalidLength, + error.CertificateSignatureNamedCurveUnsupported, + error.CertificateSignatureUnsupportedBitCount, + error.CertificateTimeInvalid, + error.CertificateHasUnrecognizedObjectId, + error.CertificateHasInvalidBitString, + error.UnsupportedCertificateVersion, + error.TlsCertificateNotVerified, + error.TlsBadSignatureScheme, + error.TlsBadRsaSignatureBitCount, + error.SignatureVerificationFailed, + error.InvalidSignature, + => .certificate, + + error.ReadFailed, + error.WriteFailed, + error.Canceled, + error.DiskQuota, + error.LockViolation, + error.NotOpenForWriting, + => .io, + + error.TlsConnectionTruncated, + error.TlsDecodeError, + error.InvalidEncoding, + error.IdentityElement, + error.MessageTooLong, + error.NegativeIntoUnsigned, + error.TargetTooSmall, + error.BufferTooSmall, + error.NotSquare, + error.NonCanonical, + error.WeakPublicKey, + => .protocol, + }; +} + +/// Errors reachable after the handshake, or added by this module. +fn classifyOther(err: anyerror) ErrorClass { + return switch (err) { + error.CertificateBundleLoadFailure, + error.CertificateIssuerNotFound, + => .certificate, + + error.TlsBadLength, + error.TlsSequenceOverflow, + => .protocol, + + error.EndOfStream, + error.ConnectionResetByPeer, + error.ConnectionRefused, + error.ConnectionTimedOut, + error.BrokenPipe, + error.NetworkDown, + error.NetworkUnreachable, + error.HostUnreachable, + error.Timeout, + error.SocketUnconnected, + error.SystemResources, + error.AccessDenied, + => .io, + + else => .protocol, + }; +} + +pub fn classify(err: anyerror) ErrorClass { + inline for (@typeInfo(tls.Client.InitError).error_set.?) |member| { + if (err == @field(anyerror, member.name)) { + return classifyInit(@field(tls.Client.InitError, member.name)); + } + } + return classifyOther(err); +} + +pub const TlsStream = struct { + stream_reader: net.Stream.Reader, + stream_writer: net.Stream.Writer, + client: tls.Client, + + pub const Options = struct { + /// SNI name, and the name matched against the leaf certificate. Sent and + /// matched under both `ca` settings. + host: []const u8, + /// `.insecure_skip_verify` drops only the chain of trust: the stdlib + /// verifies the leaf certificate host name before it consults `ca` + /// (crypto/tls/Client.zig:645), so `host` must still match the leaf + /// certificate or the handshake fails with `error.CertificateHostMismatch`. + /// Issuer authorization and certificate expiry are not checked. + ca: enum { system, insecure_skip_verify }, + /// Plaintext read buffer. + read_buffer: []u8, + /// Plaintext write buffer. + write_buffer: []u8, + /// Ciphertext read buffer; must hold at least `tls.Client.min_buffer_len` bytes. + stream_read_buffer: []u8, + /// Ciphertext write buffer; must hold at least `tls.Client.min_buffer_len` bytes. + stream_write_buffer: []u8, + }; + + /// In-place init. `self` must not move afterwards. + /// + /// With `.ca = .system`, an empty `bundle` is filled by + /// `Certificate.Bundle.rescan` under `bundle_lock`. The caller owns the + /// lifetime of `bundle`, `bundle_lock` and `stream`. + pub fn init( + self: *TlsStream, + io: std.Io, + stream: *net.Stream, + bundle: *Certificate.Bundle, + bundle_lock: *std.Io.RwLock, + gpa: std.mem.Allocator, + options: Options, + ) InitError!void { + std.debug.assert(options.stream_read_buffer.len >= tls.Client.min_buffer_len); + std.debug.assert(options.stream_write_buffer.len >= tls.Client.min_buffer_len); + + const now = std.Io.Clock.real.now(io); + + switch (options.ca) { + .system => try ensureBundle(io, bundle, bundle_lock, gpa, now), + .insecure_skip_verify => {}, + } + + var entropy: [tls.Client.Options.entropy_len]u8 = undefined; + io.random(&entropy); + + self.* = .{ + .stream_reader = stream.reader(io, options.stream_read_buffer), + .stream_writer = stream.writer(io, options.stream_write_buffer), + .client = undefined, + }; + + self.client = try tls.Client.init( + &self.stream_reader.interface, + &self.stream_writer.interface, + .{ + .host = .{ .explicit = options.host }, + .ca = switch (options.ca) { + .system => .{ .bundle = .{ + .gpa = gpa, + .io = io, + .lock = bundle_lock, + .bundle = bundle, + } }, + .insecure_skip_verify => .no_verification, + }, + .read_buffer = options.read_buffer, + .write_buffer = options.write_buffer, + .entropy = &entropy, + .realtime_now = now, + }, + ); + } + + fn ensureBundle( + io: std.Io, + bundle: *Certificate.Bundle, + bundle_lock: *std.Io.RwLock, + gpa: std.mem.Allocator, + now: std.Io.Timestamp, + ) InitError!void { + { + try bundle_lock.lockShared(io); + defer bundle_lock.unlockShared(io); + if (bundle.map.count() != 0) return; + } + + try bundle_lock.lock(io); + defer bundle_lock.unlock(io); + if (bundle.map.count() != 0) return; + + // A partial rescan leaves entries in `map`, which the fast path above would + // read as "already loaded". Reset so the next init retries a full rescan. + bundle.rescan(gpa, io, now) catch |err| { + bundle.deinit(gpa); + bundle.* = .empty; + return switch (err) { + error.Canceled => error.Canceled, + else => error.CertificateBundleLoadFailure, + }; + }; + } + + /// Plaintext reader. + pub fn reader(self: *TlsStream) *std.Io.Reader { + return &self.client.reader; + } + + /// Plaintext writer. + pub fn writer(self: *TlsStream) *std.Io.Writer { + return &self.client.writer; + } + + /// Sends close_notify and flushes the socket. Does not close the underlying + /// stream; the caller owns it. + pub fn close(self: *TlsStream) void { + self.client.end() catch |err| { + log.debug("close_notify failed: {s}", .{@errorName(err)}); + }; + self.stream_writer.interface.flush() catch |err| { + log.debug("flush after close_notify failed: {s}", .{@errorName(err)}); + }; + } +}; + +test "classify maps handshake errors" { + try std.testing.expectEqual(ErrorClass.handshake, classify(error.TlsAlert)); + try std.testing.expectEqual(ErrorClass.handshake, classify(error.TlsIllegalParameter)); + try std.testing.expectEqual(ErrorClass.handshake, classify(error.InsufficientEntropy)); +} + +test "classify maps certificate errors" { + try std.testing.expectEqual(ErrorClass.certificate, classify(error.CertificateExpired)); + try std.testing.expectEqual(ErrorClass.certificate, classify(error.CertificateHostMismatch)); + try std.testing.expectEqual(ErrorClass.certificate, classify(error.TlsCertificateNotVerified)); + try std.testing.expectEqual(ErrorClass.certificate, classify(error.CertificateBundleLoadFailure)); +} + +test "classify maps io errors" { + try std.testing.expectEqual(ErrorClass.io, classify(error.ReadFailed)); + try std.testing.expectEqual(ErrorClass.io, classify(error.WriteFailed)); + try std.testing.expectEqual(ErrorClass.io, classify(error.Canceled)); + try std.testing.expectEqual(ErrorClass.io, classify(error.ConnectionResetByPeer)); +} + +test "classify maps protocol errors" { + try std.testing.expectEqual(ErrorClass.protocol, classify(error.TlsDecodeError)); + try std.testing.expectEqual(ErrorClass.protocol, classify(error.InvalidEncoding)); + try std.testing.expectEqual(ErrorClass.protocol, classify(error.TlsBadLength)); + try std.testing.expectEqual(ErrorClass.protocol, classify(error.TlsConnectionTruncated)); + try std.testing.expectEqual(ErrorClass.protocol, classify(error.SomethingNobodyMapped)); +} + +test "classify covers every InitError member" { + inline for (@typeInfo(tls.Client.InitError).error_set.?) |member| { + _ = classifyInit(@field(tls.Client.InitError, member.name)); + } +} diff --git a/src/platform/tls_client_integration_test.zig b/src/platform/tls_client_integration_test.zig new file mode 100644 index 0000000..40cfd94 --- /dev/null +++ b/src/platform/tls_client_integration_test.zig @@ -0,0 +1,110 @@ +//! Network-dependent tests for `tls_client.zig`. +//! +//! This lives in its own file because it needs `@import("build_options")`, which only +//! exists when the compilation is driven by build.zig. Keeping it out of +//! `tls_client.zig` lets that file stay compilable with a bare +//! `zig test src/platform/tls_client.zig`. +//! +//! The test is compiled by every `zig build test` run, so it cannot rot, and skips at +//! run time unless `-Dlive` is passed. (`-Dintegration` stays hermetic: loopback only. +//! `-Dlive` is the gate for tests that leave the machine.) + +const std = @import("std"); +const build_options = @import("build_options"); +const tls = std.crypto.tls; +const net = std.Io.net; +const Certificate = std.crypto.Certificate; + +const tls_client = @import("tls_client.zig"); + +const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }; + +/// `net.IpAddress.ConnectOptions.timeout` panics with "TODO implement +/// netConnectIpPosix with timeout" on POSIX in 0.16.0, and TLS stream reads take no +/// timeout at all. So the whole exchange runs as one task raced against a sleep, and +/// the loser is canceled. +const Outcome = union(enum) { + exchange: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +const Params = struct { + gpa: std.mem.Allocator, + bundle: *Certificate.Bundle, + bundle_lock: *std.Io.RwLock, + stream_read_buffer: []u8, + stream_write_buffer: []u8, +}; + +/// Connects to the documented anycast address rather than resolving the name, so the +/// test exercises this module and not the stdlib resolver. `host` still drives SNI and +/// certificate hostname verification against "cloudflare-dns.com". +fn connectAndHandshake(io: std.Io, params: Params) anyerror!void { + const address: net.IpAddress = try .parse("1.1.1.1", 853); + var stream = try address.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var read_buffer: [4096]u8 = undefined; + var write_buffer: [4096]u8 = undefined; + + var tls_stream: tls_client.TlsStream = undefined; + try tls_stream.init(io, &stream, params.bundle, params.bundle_lock, params.gpa, .{ + .host = "cloudflare-dns.com", + .ca = .system, + .read_buffer = &read_buffer, + .write_buffer = &write_buffer, + .stream_read_buffer = params.stream_read_buffer, + .stream_write_buffer = params.stream_write_buffer, + }); + tls_stream.close(); +} + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +test "live DoT handshake against cloudflare-dns.com" { + if (!build_options.live) return error.SkipZigTest; + + const gpa = std.testing.allocator; + + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var bundle: Certificate.Bundle = .empty; + defer bundle.deinit(gpa); + var bundle_lock: std.Io.RwLock = .init; + + const stream_read_buffer = try gpa.alloc(u8, tls.Client.min_buffer_len); + defer gpa.free(stream_read_buffer); + const stream_write_buffer = try gpa.alloc(u8, tls.Client.min_buffer_len); + defer gpa.free(stream_write_buffer); + + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + try race.concurrent(.exchange, connectAndHandshake, .{ io, Params{ + .gpa = gpa, + .bundle = &bundle, + .bundle_lock = &bundle_lock, + .stream_read_buffer = stream_read_buffer, + .stream_write_buffer = stream_write_buffer, + } }); + try race.concurrent(.expiry, expire, .{ io, budget }); + + switch (try race.await()) { + .exchange => |result| result catch |err| { + std.debug.print("DoT handshake failed: {s} (class: {t})\n", .{ + @errorName(err), + tls_client.classify(err), + }); + return err; + }, + .expiry => |result| { + try result; + return error.DotExchangeTimedOut; + }, + } +} diff --git a/src/platform/tls_server.zig b/src/platform/tls_server.zig new file mode 100644 index 0000000..39f8fa1 --- /dev/null +++ b/src/platform/tls_server.zig @@ -0,0 +1,823 @@ +//! Server-side TLS termination backed by Mbed TLS 3.6, exposing plaintext as +//! `std.Io.Reader` / `std.Io.Writer` so `std.http.Server` and the DoT server can +//! sit on top of any accepted `std.Io.net.Stream`. +//! +//! The Mbed TLS surface is declared by hand below. No `@cImport` — the context +//! structs stay opaque and are heap-allocated using sizes reported by +//! `src/platform/mbedtls_shim.c`. + +const std = @import("std"); +const Io = std.Io; +const Reader = std.Io.Reader; +const Writer = std.Io.Writer; +const assert = std.debug.assert; + +const log = std.log.scoped(.tls_server); + +/// Every Mbed TLS context is allocated at this alignment; `ServerContext.init` +/// asserts the shim agrees it is enough. +const context_alignment = 16; +const ContextAlignment: std.mem.Alignment = .fromByteUnits(context_alignment); + +pub const InitError = error{ + OutOfMemory, + /// The certificate chain PEM could not be parsed. + CertParse, + /// The private key PEM could not be parsed. + KeyParse, + /// The private key does not belong to the leaf certificate. + KeyMismatch, + /// Seeding the CTR-DRBG from the platform entropy source failed. + EntropyFailed, + /// Mbed TLS rejected the server configuration. + ConfigFailed, +}; + +pub const AcceptError = error{ + OutOfMemory, + /// Binding the connection to the shared configuration failed. + SetupFailed, + /// The peer went away before the handshake completed. + PeerClosed, + HandshakeFailed, +}; + +/// Parsed certificate chain, private key, RNG and `mbedtls_ssl_config` for one +/// listener. Create it once and share it across every accepted connection. +/// +/// Safe to move: every Mbed TLS context lives on the heap, so the pointers Mbed +/// TLS stores internally stay valid. +pub const ServerContext = struct { + entropy: Blocks.Entropy, + drbg: Blocks.Drbg, + cert: Blocks.Cert, + key: Blocks.Key, + config: Blocks.Config, + + const Blocks = struct { + const Entropy = Block(EntropyContext); + const Drbg = Block(CtrDrbgContext); + const Cert = Block(X509Crt); + const Key = Block(PkContext); + const Config = Block(SslConfig); + }; + + pub fn init(gpa: std.mem.Allocator, cert_pem: [:0]const u8, key_pem: [:0]const u8) InitError!ServerContext { + assert(nx_max_context_alignment() <= context_alignment); + + // TLS 1.3 is on in the stock config; its key schedule runs through PSA. + const psa_status = psa_crypto_init(); + if (psa_status != 0) { + log.err("psa_crypto_init failed: {d}", .{psa_status}); + return error.ConfigFailed; + } + + var entropy = try Blocks.Entropy.alloc(gpa, nx_sizeof_entropy_context()); + errdefer entropy.free(gpa); + mbedtls_entropy_init(entropy.ptr); + errdefer mbedtls_entropy_free(entropy.ptr); + + var drbg = try Blocks.Drbg.alloc(gpa, nx_sizeof_ctr_drbg_context()); + errdefer drbg.free(gpa); + mbedtls_ctr_drbg_init(drbg.ptr); + errdefer mbedtls_ctr_drbg_free(drbg.ptr); + + const personalization = "nxdns-tls-server"; + try check(mbedtls_ctr_drbg_seed( + drbg.ptr, + mbedtls_entropy_func, + entropy.ptr, + personalization, + personalization.len, + ), "ctr_drbg_seed", error.EntropyFailed); + + var cert = try Blocks.Cert.alloc(gpa, nx_sizeof_x509_crt()); + errdefer cert.free(gpa); + mbedtls_x509_crt_init(cert.ptr); + errdefer mbedtls_x509_crt_free(cert.ptr); + + // PEM input must include the terminating zero byte in the length. + try check( + mbedtls_x509_crt_parse(cert.ptr, cert_pem.ptr, cert_pem.len + 1), + "x509_crt_parse", + error.CertParse, + ); + + var key = try Blocks.Key.alloc(gpa, nx_sizeof_pk_context()); + errdefer key.free(gpa); + mbedtls_pk_init(key.ptr); + errdefer mbedtls_pk_free(key.ptr); + + try check(mbedtls_pk_parse_key( + key.ptr, + key_pem.ptr, + key_pem.len + 1, + null, + 0, + mbedtls_ctr_drbg_random, + drbg.ptr, + ), "pk_parse_key", error.KeyParse); + + // mbedtls_ssl_conf_own_cert accepts a mismatched pair; only this call + // rejects one, and it needs an RNG for the blinded EC comparison. + try check(mbedtls_pk_check_pair( + nx_x509_crt_pk(cert.ptr), + key.ptr, + mbedtls_ctr_drbg_random, + drbg.ptr, + ), "pk_check_pair", error.KeyMismatch); + + var config = try Blocks.Config.alloc(gpa, nx_sizeof_ssl_config()); + errdefer config.free(gpa); + mbedtls_ssl_config_init(config.ptr); + errdefer mbedtls_ssl_config_free(config.ptr); + + try check(mbedtls_ssl_config_defaults( + config.ptr, + ssl_is_server, + ssl_transport_stream, + ssl_preset_default, + ), "ssl_config_defaults", error.ConfigFailed); + + mbedtls_ssl_conf_rng(config.ptr, mbedtls_ctr_drbg_random, drbg.ptr); + // nxdns never asks clients for a certificate. + mbedtls_ssl_conf_authmode(config.ptr, ssl_verify_none); + try check( + mbedtls_ssl_conf_own_cert(config.ptr, cert.ptr, key.ptr), + "ssl_conf_own_cert", + error.ConfigFailed, + ); + + return .{ + .entropy = entropy, + .drbg = drbg, + .cert = cert, + .key = key, + .config = config, + }; + } + + pub fn deinit(self: *ServerContext, gpa: std.mem.Allocator) void { + mbedtls_ssl_config_free(self.config.ptr); + mbedtls_pk_free(self.key.ptr); + mbedtls_x509_crt_free(self.cert.ptr); + mbedtls_ctr_drbg_free(self.drbg.ptr); + mbedtls_entropy_free(self.entropy.ptr); + + self.config.free(gpa); + self.key.free(gpa); + self.cert.free(gpa); + self.drbg.free(gpa); + self.entropy.free(gpa); + + self.* = undefined; + } +}; + +/// One TLS connection. Pinned once `accept` succeeds: the `std.Io` interfaces +/// are recovered from their addresses inside this struct, and Mbed TLS holds a +/// pointer to it as the BIO context. +pub const ServerStream = struct { + ssl: Block(SslContext), + io: Io, + net_reader: Io.net.Stream.Reader, + net_writer: Io.net.Stream.Writer, + reader_interface: Reader, + writer_interface: Writer, + /// Set once the peer's close_notify is seen. A transport EOF without one + /// is a truncated stream, not a close. + peer_closed: bool, + /// The concrete failure behind `error.ReadFailed`, in the arrangement + /// `std.crypto.tls.Client.read_err` uses. + read_err: ?ReadError, + /// Most recent Mbed TLS code behind `read_err`. + read_code: c_int, + /// Most recent negative Mbed TLS code behind `error.WriteFailed`. + write_code: c_int, + + pub const ReadError = error{ + /// The peer closed the transport without sending close_notify. Any + /// data it withheld is indistinguishable from data an attacker cut + /// off, so the stream never ends cleanly this way. + TlsConnectionTruncated, + /// Mbed TLS rejected the record; `read_code` holds its code. + TlsFailed, + }; + + /// Zero-length transport buffers: Mbed TLS keeps its own record buffers, so + /// a second layer of buffering under the BIO callbacks would only add + /// copies. Mutable because `Stream.reader`/`Stream.writer` take `[]u8`. + var no_transport_buffer: [0]u8 = .{}; + + const reader_vtable: Reader.VTable = .{ + .stream = readerStream, + .readVec = readerReadVec, + }; + + const writer_vtable: Writer.VTable = .{ + .drain = writerDrain, + }; + + /// Runs the TLS handshake over an already accepted TCP stream. `stream` + /// must outlive the `ServerStream`; closing it stays the caller's job. + /// + /// `read_buffer` and `write_buffer` hold plaintext for the `reader` and + /// `writer` interfaces. + pub fn accept( + self: *ServerStream, + gpa: std.mem.Allocator, + ctx: *ServerContext, + io: Io, + stream: *Io.net.Stream, + read_buffer: []u8, + write_buffer: []u8, + ) AcceptError!void { + var ssl = try Block(SslContext).alloc(gpa, nx_sizeof_ssl_context()); + errdefer ssl.free(gpa); + mbedtls_ssl_init(ssl.ptr); + errdefer mbedtls_ssl_free(ssl.ptr); + + self.* = .{ + .ssl = ssl, + .io = io, + .net_reader = stream.reader(io, &no_transport_buffer), + .net_writer = stream.writer(io, &no_transport_buffer), + .reader_interface = .{ + .vtable = &reader_vtable, + .buffer = read_buffer, + .seek = 0, + .end = 0, + }, + .writer_interface = .{ + .vtable = &writer_vtable, + .buffer = write_buffer, + }, + .peer_closed = false, + .read_err = null, + .read_code = 0, + .write_code = 0, + }; + + try check(mbedtls_ssl_setup(self.ssl.ptr, ctx.config.ptr), "ssl_setup", error.SetupFailed); + mbedtls_ssl_set_bio(self.ssl.ptr, self, bioSend, bioRecv, null); + + while (true) { + const rc = mbedtls_ssl_handshake(self.ssl.ptr); + if (rc == 0) return; + if (isRetry(rc)) continue; + if (rc == err_conn_eof or rc == err_peer_close_notify) return error.PeerClosed; + report("ssl_handshake", rc); + return error.HandshakeFailed; + } + } + + /// Plaintext read side. The `ServerStream` must not move after `accept`. + pub fn reader(self: *ServerStream) *Reader { + return &self.reader_interface; + } + + /// Plaintext write side. Buffered — call `flush` to force records out. + pub fn writer(self: *ServerStream) *Writer { + return &self.writer_interface; + } + + /// Flushes pending plaintext, sends close_notify, and frees the connection + /// context. Does not close the underlying TCP stream. + pub fn close(self: *ServerStream, gpa: std.mem.Allocator) void { + self.writer_interface.flush() catch { + log.debug("flush before close_notify failed: mbedtls {d}", .{self.write_code}); + }; + while (true) { + const rc = mbedtls_ssl_close_notify(self.ssl.ptr); + if (isRetry(rc)) continue; + if (rc != 0) report("ssl_close_notify", rc); + break; + } + mbedtls_ssl_free(self.ssl.ptr); + self.ssl.free(gpa); + self.* = undefined; + } + + fn readerStream(r: *Reader, w: *Writer, limit: Io.Limit) Reader.StreamError!usize { + _ = w; + _ = limit; + const self: *ServerStream = @alignCast(@fieldParentPtr("reader_interface", r)); + return self.readIntoBuffer(); + } + + fn readerReadVec(r: *Reader, data: [][]u8) Reader.Error!usize { + _ = data; + const self: *ServerStream = @alignCast(@fieldParentPtr("reader_interface", r)); + return self.readIntoBuffer(); + } + + /// Decrypts into `Reader.buffer` and reports zero bytes streamed, the + /// arrangement `Reader.VTable` documents for buffer-writing implementations. + fn readIntoBuffer(self: *ServerStream) Reader.Error!usize { + const r = &self.reader_interface; + if (self.read_err) |err| return self.failRead(err, self.read_code); + if (self.peer_closed) return error.EndOfStream; + + if (r.end == r.buffer.len and r.seek != 0) { + const pending = r.buffer[r.seek..r.end]; + @memmove(r.buffer[0..pending.len], pending); + r.seek = 0; + r.end = pending.len; + } + const dest = r.buffer[r.end..]; + if (dest.len == 0) return 0; + + while (true) { + const rc = mbedtls_ssl_read(self.ssl.ptr, dest.ptr, dest.len); + if (isRetry(rc)) continue; + if (rc == err_peer_close_notify) { + self.peer_closed = true; + return error.EndOfStream; + } + // mbedtls_ssl_read reports a transport EOF that arrived without a + // close_notify as 0 (ssl_msg.c maps MBEDTLS_ERR_SSL_CONN_EOF to 0 + // for application data); the raw code reaches us from the + // handshake it may run internally. Both are a truncation attack + // until the peer proves otherwise, so neither ends the stream + // cleanly. + if (rc == 0 or rc == err_conn_eof) { + // Debug, not warn: any client that drops its TCP connection + // reaches this, so a louder level would be a log-spam vector. + log.debug("peer closed the transport without close_notify", .{}); + return self.failRead(error.TlsConnectionTruncated, rc); + } + if (rc < 0) { + report("ssl_read", rc); + return self.failRead(error.TlsFailed, rc); + } + r.end += @intCast(rc); + return 0; + } + } + + fn failRead(self: *ServerStream, err: ReadError, rc: c_int) error{ReadFailed} { + self.read_err = err; + self.read_code = rc; + return error.ReadFailed; + } + + fn writerDrain(w: *Writer, data: []const []const u8, splat: usize) Writer.Error!usize { + const self: *ServerStream = @alignCast(@fieldParentPtr("writer_interface", w)); + + const buffered = w.buffered(); + if (buffered.len != 0) return w.consume(try self.sslWrite(buffered)); + + for (data[0 .. data.len - 1]) |chunk| { + if (chunk.len == 0) continue; + return w.consume(try self.sslWrite(chunk)); + } + + const last = data[data.len - 1]; + if (last.len == 0) return w.consume(0); + var written: usize = 0; + for (0..splat) |_| { + const n = try self.sslWrite(last); + written += n; + if (n < last.len) break; + } + return w.consume(written); + } + + fn sslWrite(self: *ServerStream, bytes: []const u8) Writer.Error!usize { + while (true) { + // A retry after WANT_WRITE must repeat the same arguments. + const rc = mbedtls_ssl_write(self.ssl.ptr, bytes.ptr, bytes.len); + if (isRetry(rc)) continue; + if (rc < 0) { + self.write_code = rc; + report("ssl_write", rc); + return error.WriteFailed; + } + return @intCast(rc); + } + } + + fn bioSend(bio: ?*anyopaque, buf: [*]const u8, len: usize) callconv(.c) c_int { + const self: *ServerStream = @ptrCast(@alignCast(bio.?)); + const w = &self.net_writer.interface; + w.writeAll(buf[0..len]) catch return err_net_send_failed; + w.flush() catch return err_net_send_failed; + return @intCast(len); + } + + fn bioRecv(bio: ?*anyopaque, buf: [*]u8, len: usize) callconv(.c) c_int { + const self: *ServerStream = @ptrCast(@alignCast(bio.?)); + if (len == 0) return 0; + var data: [1][]u8 = .{buf[0..len]}; + const n = self.net_reader.interface.readVec(&data) catch |err| switch (err) { + error.EndOfStream => return 0, + error.ReadFailed => return err_net_recv_failed, + }; + // A zero-length transport buffer makes a short read impossible, but the + // interface permits it; ask Mbed TLS to come back rather than reporting EOF. + if (n == 0) return err_want_read; + return @intCast(n); + } +}; + +/// Heap storage for one Mbed TLS context whose layout stays opaque to Zig. +fn Block(comptime T: type) type { + return struct { + ptr: *T, + bytes: []align(context_alignment) u8, + + const Self = @This(); + + fn alloc(gpa: std.mem.Allocator, size: usize) error{OutOfMemory}!Self { + const bytes = try gpa.alignedAlloc(u8, ContextAlignment, size); + return .{ .ptr = @ptrCast(bytes.ptr), .bytes = bytes }; + } + + fn free(self: Self, gpa: std.mem.Allocator) void { + gpa.free(self.bytes); + } + }; +} + +/// Codes that mean "call me again with the same arguments". +/// +/// MBEDTLS_ERR_SSL_{CRYPTO,ASYNC}_IN_PROGRESS are deliberately absent: nxdns +/// configures no async or opaque crypto, so those codes can only mean the +/// configuration is wrong. Retrying them would spin. +fn isRetry(rc: c_int) bool { + return rc == err_want_read or rc == err_want_write; +} + +fn check(rc: c_int, comptime op: []const u8, comptime failure: anytype) @TypeOf(failure)!void { + if (rc == 0) return; + report(op, rc); + return failure; +} + +fn report(comptime op: []const u8, rc: c_int) void { + var text: [160]u8 = undefined; + mbedtls_strerror(rc, &text, text.len); + log.warn("mbedtls_{s} failed: {s} ({d})", .{ op, std.mem.sliceTo(&text, 0), rc }); +} + +// -- Mbed TLS 3.6.7 surface ------------------------------------------------ +// Signatures transcribed from include/mbedtls/{ssl,x509_crt,pk,entropy,ctr_drbg,error}.h +// and include/psa/crypto.h of the pinned release. + +const SslContext = opaque {}; +const SslConfig = opaque {}; +const X509Crt = opaque {}; +const PkContext = opaque {}; +const EntropyContext = opaque {}; +const CtrDrbgContext = opaque {}; + +/// `mbedtls_f_rng_t` (platform_util.h) +const RngFn = fn (p_rng: ?*anyopaque, output: [*]u8, output_len: usize) callconv(.c) c_int; +/// `mbedtls_ssl_send_t` (ssl.h) +const SendFn = fn (ctx: ?*anyopaque, buf: [*]const u8, len: usize) callconv(.c) c_int; +/// `mbedtls_ssl_recv_t` (ssl.h) +const RecvFn = fn (ctx: ?*anyopaque, buf: [*]u8, len: usize) callconv(.c) c_int; +/// `mbedtls_ssl_recv_timeout_t` (ssl.h) +const RecvTimeoutFn = fn (ctx: ?*anyopaque, buf: [*]u8, len: usize, timeout: u32) callconv(.c) c_int; + +const ssl_transport_stream: c_int = 0; +const ssl_is_server: c_int = 1; +const ssl_verify_none: c_int = 0; +const ssl_preset_default: c_int = 0; + +const err_conn_eof: c_int = -0x7280; +const err_peer_close_notify: c_int = -0x7880; +const err_want_read: c_int = -0x6900; +const err_want_write: c_int = -0x6880; +const err_net_recv_failed: c_int = -0x004C; +const err_net_send_failed: c_int = -0x004E; + +extern fn mbedtls_ssl_init(ssl: *SslContext) void; +extern fn mbedtls_ssl_free(ssl: *SslContext) void; +extern fn mbedtls_ssl_setup(ssl: *SslContext, conf: *const SslConfig) c_int; +extern fn mbedtls_ssl_handshake(ssl: *SslContext) c_int; +extern fn mbedtls_ssl_read(ssl: *SslContext, buf: [*]u8, len: usize) c_int; +extern fn mbedtls_ssl_write(ssl: *SslContext, buf: [*]const u8, len: usize) c_int; +extern fn mbedtls_ssl_close_notify(ssl: *SslContext) c_int; +extern fn mbedtls_ssl_set_bio( + ssl: *SslContext, + p_bio: ?*anyopaque, + f_send: ?*const SendFn, + f_recv: ?*const RecvFn, + f_recv_timeout: ?*const RecvTimeoutFn, +) void; + +extern fn mbedtls_ssl_config_init(conf: *SslConfig) void; +extern fn mbedtls_ssl_config_free(conf: *SslConfig) void; +extern fn mbedtls_ssl_config_defaults( + conf: *SslConfig, + endpoint: c_int, + transport: c_int, + preset: c_int, +) c_int; +extern fn mbedtls_ssl_conf_rng(conf: *SslConfig, f_rng: *const RngFn, p_rng: ?*anyopaque) void; +extern fn mbedtls_ssl_conf_authmode(conf: *SslConfig, authmode: c_int) void; +extern fn mbedtls_ssl_conf_own_cert(conf: *SslConfig, own_cert: *X509Crt, pk_key: *PkContext) c_int; + +extern fn mbedtls_x509_crt_init(crt: *X509Crt) void; +extern fn mbedtls_x509_crt_free(crt: *X509Crt) void; +/// For PEM input, `buflen` must count the terminating zero byte. +extern fn mbedtls_x509_crt_parse(chain: *X509Crt, buf: [*]const u8, buflen: usize) c_int; + +extern fn mbedtls_pk_init(ctx: *PkContext) void; +extern fn mbedtls_pk_free(ctx: *PkContext) void; +/// For PEM input, `keylen` must count the terminating zero byte. +extern fn mbedtls_pk_parse_key( + ctx: *PkContext, + key: [*]const u8, + keylen: usize, + pwd: ?[*]const u8, + pwdlen: usize, + f_rng: ?*const RngFn, + p_rng: ?*anyopaque, +) c_int; + +/// `f_rng` must not be NULL. Returns 0 when the pair matches. +extern fn mbedtls_pk_check_pair( + pub_key: *const PkContext, + prv: *const PkContext, + f_rng: *const RngFn, + p_rng: ?*anyopaque, +) c_int; + +extern fn mbedtls_entropy_init(ctx: *EntropyContext) void; +extern fn mbedtls_entropy_free(ctx: *EntropyContext) void; +extern fn mbedtls_entropy_func(data: ?*anyopaque, output: [*]u8, len: usize) callconv(.c) c_int; + +extern fn mbedtls_ctr_drbg_init(ctx: *CtrDrbgContext) void; +extern fn mbedtls_ctr_drbg_free(ctx: *CtrDrbgContext) void; +extern fn mbedtls_ctr_drbg_seed( + ctx: *CtrDrbgContext, + f_entropy: *const fn (?*anyopaque, [*]u8, usize) callconv(.c) c_int, + p_entropy: ?*anyopaque, + custom: ?[*]const u8, + len: usize, +) c_int; +extern fn mbedtls_ctr_drbg_random(p_rng: ?*anyopaque, output: [*]u8, output_len: usize) callconv(.c) c_int; + +extern fn mbedtls_strerror(errnum: c_int, buffer: [*]u8, buflen: usize) void; + +/// `psa_status_t psa_crypto_init(void)` — TLS 1.3 runs its key schedule through PSA. +extern fn psa_crypto_init() i32; + +// -- src/platform/mbedtls_shim.c ------------------------------------------- + +extern fn nx_sizeof_ssl_context() usize; +extern fn nx_sizeof_ssl_config() usize; +extern fn nx_sizeof_x509_crt() usize; +extern fn nx_sizeof_pk_context() usize; +extern fn nx_sizeof_entropy_context() usize; +extern fn nx_sizeof_ctr_drbg_context() usize; +extern fn nx_max_context_alignment() usize; +/// The public key of the first certificate in `crt`. +extern fn nx_x509_crt_pk(crt: *X509Crt) *PkContext; + +// -- tests ----------------------------------------------------------------- + +test "ServerContext.init accepts the fixture cert and key" { + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem); + defer ctx.deinit(gpa); +} + +test "ServerContext.init rejects a truncated certificate" { + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + const truncated = try truncate(gpa, fixtures.cert_pem); + defer gpa.free(truncated); + + try std.testing.expectError( + error.CertParse, + ServerContext.init(gpa, truncated, fixtures.key_pem), + ); +} + +test "ServerContext.init rejects a truncated private key" { + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + const truncated = try truncate(gpa, fixtures.key_pem); + defer gpa.free(truncated); + + try std.testing.expectError( + error.KeyParse, + ServerContext.init(gpa, fixtures.cert_pem, truncated), + ); +} + +test "ServerContext.init rejects a key that is not the certificate's" { + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + try std.testing.expectError( + error.KeyMismatch, + ServerContext.init(gpa, fixtures.cert_pem, fixtures.mismatched_key_pem), + ); +} + +test "the shim agrees with the alignment contexts are allocated at" { + try std.testing.expect(nx_max_context_alignment() <= context_alignment); + try std.testing.expect(nx_sizeof_ssl_context() > 0); + try std.testing.expect(nx_sizeof_ssl_config() > 0); + try std.testing.expect(nx_sizeof_x509_crt() > 0); + try std.testing.expect(nx_sizeof_pk_context() > 0); + try std.testing.expect(nx_sizeof_entropy_context() > 0); + try std.testing.expect(nx_sizeof_ctr_drbg_context() > 0); +} + +/// Drops the second half of a PEM document, keeping the header intact so the +/// parser fails on the body rather than on a missing "-----BEGIN" line. +fn truncate(gpa: std.mem.Allocator, pem: [:0]const u8) ![:0]const u8 { + return gpa.dupeZ(u8, pem[0 .. pem.len / 2]); +} + +const loopback_message = "nxdns loopback probe"; + +test "loopback echo between the mbedtls server and std.crypto.tls.Client" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + var threaded: Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem); + defer ctx.deinit(gpa); + + const listen_address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; + var server = try listen_address.listen(io, .{ .reuse_address = true }); + defer server.deinit(io); + + var server_task = try io.concurrent(echoOnce, .{ gpa, &ctx, io, &server }); + const client_result = runEchoClient(io, server.socket.address); + // A client that never connects would leave the server blocked in `accept`. + const server_result = if (client_result) |_| + server_task.await(io) + else |_| + server_task.cancel(io); + + try client_result; + try server_result; +} + +test "a transport EOF without close_notify reads as a truncated stream" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + var threaded: Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem); + defer ctx.deinit(gpa); + + const listen_address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; + var server = try listen_address.listen(io, .{ .reuse_address = true }); + defer server.deinit(io); + + var server_task = try io.concurrent(expectTruncation, .{ gpa, &ctx, io, &server }); + const client_result = runTruncatingClient(io, server.socket.address); + const server_result = if (client_result) |_| + server_task.await(io) + else |_| + server_task.cancel(io); + + try client_result; + try server_result; +} + +fn expectTruncation( + gpa: std.mem.Allocator, + ctx: *ServerContext, + io: Io, + server: *Io.net.Server, +) anyerror!void { + var stream = try server.accept(io); + defer stream.close(io); + + var read_buffer: [4096]u8 = undefined; + var write_buffer: [4096]u8 = undefined; + var tls: ServerStream = undefined; + try tls.accept(gpa, ctx, io, &stream, &read_buffer, &write_buffer); + defer tls.close(gpa); + + var received: [loopback_message.len]u8 = undefined; + try tls.reader().readSliceAll(&received); + try std.testing.expectEqualStrings(loopback_message, &received); + + var tail: [1]u8 = undefined; + try std.testing.expectError(error.ReadFailed, tls.reader().readSliceAll(&tail)); + try std.testing.expectEqual(ServerStream.ReadError.TlsConnectionTruncated, tls.read_err.?); + try std.testing.expect(!tls.peer_closed); +} + +/// Writes one message, then drops the TCP connection without close_notify. +fn runTruncatingClient(io: Io, address: Io.net.IpAddress) anyerror!void { + const tls = std.crypto.tls; + + var stream = try address.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var transport_read_buffer: [tls.Client.min_buffer_len]u8 = undefined; + var transport_write_buffer: [tls.Client.min_buffer_len]u8 = undefined; + var net_reader = stream.reader(io, &transport_read_buffer); + var net_writer = stream.writer(io, &transport_write_buffer); + + var entropy: [tls.Client.Options.entropy_len]u8 = undefined; + io.random(&entropy); + + var plaintext_read_buffer: [4096]u8 = undefined; + var plaintext_write_buffer: [4096]u8 = undefined; + + var client = try tls.Client.init(&net_reader.interface, &net_writer.interface, .{ + .host = .no_verification, + .ca = .no_verification, + .read_buffer = &plaintext_read_buffer, + .write_buffer = &plaintext_write_buffer, + .entropy = &entropy, + .realtime_now = Io.Timestamp.now(io, .real), + }); + try net_writer.interface.flush(); + + try client.writer.writeAll(loopback_message); + try client.writer.flush(); + try net_writer.interface.flush(); +} + +fn echoOnce( + gpa: std.mem.Allocator, + ctx: *ServerContext, + io: Io, + server: *Io.net.Server, +) anyerror!void { + var stream = try server.accept(io); + defer stream.close(io); + + var read_buffer: [4096]u8 = undefined; + var write_buffer: [4096]u8 = undefined; + var tls: ServerStream = undefined; + try tls.accept(gpa, ctx, io, &stream, &read_buffer, &write_buffer); + defer tls.close(gpa); + + var received: [loopback_message.len]u8 = undefined; + try tls.reader().readSliceAll(&received); + try tls.writer().writeAll(&received); + try tls.writer().flush(); +} + +fn runEchoClient(io: Io, address: Io.net.IpAddress) anyerror!void { + const tls = std.crypto.tls; + + var stream = try address.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var transport_read_buffer: [tls.Client.min_buffer_len]u8 = undefined; + var transport_write_buffer: [tls.Client.min_buffer_len]u8 = undefined; + var net_reader = stream.reader(io, &transport_read_buffer); + var net_writer = stream.writer(io, &transport_write_buffer); + + var entropy: [tls.Client.Options.entropy_len]u8 = undefined; + io.random(&entropy); + + var plaintext_read_buffer: [4096]u8 = undefined; + var plaintext_write_buffer: [4096]u8 = undefined; + + var client = try tls.Client.init(&net_reader.interface, &net_writer.interface, .{ + .host = .no_verification, + .ca = .no_verification, + .read_buffer = &plaintext_read_buffer, + .write_buffer = &plaintext_write_buffer, + .entropy = &entropy, + .realtime_now = Io.Timestamp.now(io, .real), + }); + try net_writer.interface.flush(); + + try client.writer.writeAll(loopback_message); + try client.writer.flush(); + try net_writer.interface.flush(); + + var echoed: [loopback_message.len]u8 = undefined; + try client.reader.readSliceAll(&echoed); + try std.testing.expectEqualStrings(loopback_message, &echoed); + + try client.end(); + try net_writer.interface.flush(); + + // The server's close_notify must arrive as a clean end of stream. + var tail: [1]u8 = undefined; + try std.testing.expectError(error.EndOfStream, client.reader.readSliceAll(&tail)); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..b840744 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,34 @@ +# Test fixtures + +`self_signed_cert.pem` and `self_signed_key.pem` are a **test fixture**. The +private key is **intentionally committed** to this public repository. It is not +a secret and it must never protect anything real. + +Properties: + +- EC P-256 (`prime256v1`), SHA-256 signature +- Subject `CN=localhost` +- SAN `DNS:localhost`, `IP:127.0.0.1` +- Validity 36500 days from generation + +The loopback TLS test in `src/platform/tls_server.zig` uses this pair. Nothing +in the shipped binary reads it. + +Regenerate with: + +```sh +openssl ecparam -name prime256v1 -genkey -noout -out self_signed_key.pem +openssl req -new -x509 -key self_signed_key.pem -out self_signed_cert.pem \ + -days 36500 -sha256 -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +``` + +`mismatched_key.pem` is a second EC P-256 key with no certificate. It parses, +but it does not belong to `self_signed_cert.pem`, so `ServerContext.init` must +reject the pair with `error.KeyMismatch`. It is not a secret either. + +Regenerate with: + +```sh +openssl ecparam -name prime256v1 -genkey -noout -out mismatched_key.pem +``` diff --git a/tests/fixtures/fixtures.zig b/tests/fixtures/fixtures.zig new file mode 100644 index 0000000..1beb247 --- /dev/null +++ b/tests/fixtures/fixtures.zig @@ -0,0 +1,9 @@ +//! Test fixtures embedded as a separate module so `@embedFile` paths stay +//! inside this directory (a module root may not embed files above itself). +//! See tests/fixtures/README.md — the private key is not a secret. + +pub const cert_pem: [:0]const u8 = @embedFile("self_signed_cert.pem"); +pub const key_pem: [:0]const u8 = @embedFile("self_signed_key.pem"); + +/// A well-formed private key that does not belong to `cert_pem`. +pub const mismatched_key_pem: [:0]const u8 = @embedFile("mismatched_key.pem"); diff --git a/tests/fixtures/mismatched_key.pem b/tests/fixtures/mismatched_key.pem new file mode 100644 index 0000000..3d68871 --- /dev/null +++ b/tests/fixtures/mismatched_key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIHD4PI2utb6T0UJSzHJsS0OBYpxv9Yl1UQ9zkr++7oVWoAoGCCqGSM49 +AwEHoUQDQgAE2tUQSqRzgr7lCvTiin1mhRe4lzQPv9/U62lKm4MAtTQA6TvlqS8M +n7c5kNuoR57s9hV+D64+pJqYMzNocaN5Yw== +-----END EC PRIVATE KEY----- diff --git a/tests/fixtures/self_signed_cert.pem b/tests/fixtures/self_signed_cert.pem new file mode 100644 index 0000000..337622d --- /dev/null +++ b/tests/fixtures/self_signed_cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBmzCCAUGgAwIBAgIUST2RAi7t5xJCdgasH59MhX+teKgwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDczMTIxMzA0MVoYDzIxMjYwNzA3 +MjEzMDQxWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAATZ5CFaK693mW+3/GdkLUo0fSjtAy2wUr0WttWLbzYVv3R3k0RmKMOt +GTkYeN+H8gp281HW3sRcgGCJNNayzmU0o28wbTAdBgNVHQ4EFgQUb2BIm4JeUXZN +WJu1ioHlwZijo9QwHwYDVR0jBBgwFoAUb2BIm4JeUXZNWJu1ioHlwZijo9QwDwYD +VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZI +zj0EAwIDSAAwRQIhAOOhKN8H3xayyKA/2e42d6bdMI7PYLyT4ihymzA9zrFfAiAO +0oHljOrRBIVz2PPxZ4YCOwM140tFesV50EtK3VnGZA== +-----END CERTIFICATE----- diff --git a/tests/fixtures/self_signed_key.pem b/tests/fixtures/self_signed_key.pem new file mode 100644 index 0000000..4b41d5d --- /dev/null +++ b/tests/fixtures/self_signed_key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEILVQieUq8yy8RIuZoDGKWTNiPdq8cOBi9k33Zo1WunVYoAoGCCqGSM49 +AwEHoUQDQgAE2eQhWiuvd5lvt/xnZC1KNH0o7QMtsFK9FrbVi282Fb90d5NEZijD +rRk5GHjfh/IKdvNR1t7EXIBgiTTWss5lNA== +-----END EC PRIVATE KEY-----