// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Client-side TLS transport layered over an existing stream, wrapping //! `std.crypto.tls.Client` with sensible defaults (system trust store, //! entropy, and clock wiring). Works for both implicit TLS (connect, then //! `init` before any SMTP traffic) and STARTTLS (after `Client.starttls` //! succeeds, `init` over the same stream, then `Client.setTransport` and a //! fresh `hello`). //! //! The underlying stream reader and writer must each have a buffer of at //! least `min_buffer_len` bytes. const Tls = @This(); const std = @import("std"); const Io = std.Io; tls_client: std.crypto.tls.Client, read_buffer: []u8, write_buffer: []u8, /// Pass-through writer handed out by `writer`. `std.crypto.tls.Client`'s own /// writer encrypts on flush but leaves the records in the underlying stream /// writer's buffer; this wrapper's flush pushes them all the way to the /// stream, which line-oriented protocols like SMTP depend on (each command /// must reach the server before its reply can arrive). writer_state: Io.Writer, /// Minimum buffer size for the underlying stream reader and writer. pub const min_buffer_len = std.crypto.tls.Client.min_buffer_len; pub const Options = struct { /// Host name the server's certificate must be valid for (also sent via /// SNI). Ignored with `ca = .insecure`. host: []const u8, ca: Ca = .system, pub const Ca = union(enum) { /// Verify the server certificate against the system trust store, /// loaded fresh for this connection. system, /// Verify against a caller-managed bundle (reusable across /// connections; see `std.crypto.Certificate.Bundle.rescan`). bundle: struct { lock: *Io.RwLock, bundle: *std.crypto.Certificate.Bundle, }, /// No certificate verification at all. This provides encryption but /// no authentication — fine for tests, unsafe on real networks. insecure, }; }; pub const InitError = std.crypto.tls.Client.InitError || error{ OutOfMemory, CertificateBundleLoadFailure, }; /// Performs the TLS handshake over `input`/`output` (the stream's /// reader/writer, each with a buffer of at least `min_buffer_len` bytes). /// Allocates the TLS record buffers from `gpa`; free them with `deinit`. pub fn init( gpa: std.mem.Allocator, io: Io, input: *Io.Reader, output: *Io.Writer, options: Options, ) InitError!Tls { const read_buffer = try gpa.alloc(u8, min_buffer_len); errdefer gpa.free(read_buffer); const write_buffer = try gpa.alloc(u8, min_buffer_len); errdefer gpa.free(write_buffer); var entropy: [std.crypto.tls.Client.Options.entropy_len]u8 = undefined; io.random(&entropy); const now = Io.Clock.real.now(io); const tls_client = switch (options.ca) { .system => system: { var lock: Io.RwLock = .init; var bundle: std.crypto.Certificate.Bundle = .empty; defer bundle.deinit(gpa); bundle.rescan(gpa, io, now) catch return error.CertificateBundleLoadFailure; break :system try std.crypto.tls.Client.init(input, output, .{ .host = .{ .explicit = options.host }, .ca = .{ .bundle = .{ .gpa = gpa, .io = io, .lock = &lock, .bundle = &bundle, } }, .read_buffer = read_buffer, .write_buffer = write_buffer, .entropy = &entropy, .realtime_now = now, }); }, .bundle => |ca| try std.crypto.tls.Client.init(input, output, .{ .host = .{ .explicit = options.host }, .ca = .{ .bundle = .{ .gpa = gpa, .io = io, .lock = ca.lock, .bundle = ca.bundle, } }, .read_buffer = read_buffer, .write_buffer = write_buffer, .entropy = &entropy, .realtime_now = now, }), .insecure => try std.crypto.tls.Client.init(input, output, .{ .host = .no_verification, .ca = .no_verification, .read_buffer = read_buffer, .write_buffer = write_buffer, .entropy = &entropy, .realtime_now = now, }), }; return .{ .tls_client = tls_client, .read_buffer = read_buffer, .write_buffer = write_buffer, .writer_state = .{ .vtable = &.{ .drain = drain, .flush = flushThrough }, .buffer = &.{}, }, }; } fn drain(w: *Io.Writer, data: []const []const u8, splat: usize) Io.Writer.Error!usize { const t: *Tls = @alignCast(@fieldParentPtr("writer_state", w)); if (data.len == 0) return 0; var n: usize = 0; for (data[0 .. data.len - 1]) |bytes| { try t.tls_client.writer.writeAll(bytes); n += bytes.len; } const pattern = data[data.len - 1]; for (0..splat) |_| { try t.tls_client.writer.writeAll(pattern); n += pattern.len; } return n; } fn flushThrough(w: *Io.Writer) Io.Writer.Error!void { const t: *Tls = @alignCast(@fieldParentPtr("writer_state", w)); try t.tls_client.writer.flush(); try t.tls_client.output.flush(); } /// The decrypted stream from the server. pub fn reader(t: *Tls) *Io.Reader { return &t.tls_client.reader; } /// The plaintext stream to the server. Flushing encrypts and pushes the /// records through to the underlying stream. pub fn writer(t: *Tls) *Io.Writer { return &t.writer_state; } /// Flushes pending data and sends a TLS close_notify alert, letting the /// server distinguish a clean shutdown from a truncation attack. Call before /// closing the underlying stream. pub fn end(t: *Tls) Io.Writer.Error!void { try t.tls_client.end(); try t.tls_client.output.flush(); } pub fn deinit(t: *Tls, gpa: std.mem.Allocator) void { gpa.free(t.read_buffer); gpa.free(t.write_buffer); t.* = undefined; } test { // The handshake needs a live peer, so tests only force full semantic // analysis here; end-to-end coverage comes from the demo CLI. std.testing.refAllDecls(Tls); }