platform layer: address types, stdlib tls client wrapper, mbedtls tls server

This commit is contained in:
2026-07-31 23:59:39 +02:00
parent 1fa82668ec
commit 506ab8e038
10 changed files with 1723 additions and 0 deletions
+411
View File
@@ -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);
}
+35
View File
@@ -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 <stddef.h>
#include <mbedtls/ctr_drbg.h>
#include <mbedtls/entropy.h>
#include <mbedtls/pk.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
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;
}
+280
View File
@@ -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));
}
}
@@ -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;
},
}
}
+823
View File
@@ -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));
}