a73x

76b3bce5

feat: QUIC key handling — refused before anything listens

a73x   2026-08-08 14:08

Commit message
feat: QUIC key handling — refused before anything listens

build.zig
Old New
@@ -81,6 +81,15 @@ pub fn build(b: *std.Build) void {
81 .link_libc = true, 81 .link_libc = true,
82 }); 82 });
83 83
84 // The QUIC listener: std + the vendored C stack, and deliberately no
85 // protocol import — it carries opaque bytes, exactly as proxy.zig does.
86 const quic_mod = b.createModule(.{
87 .root_source_file = b.path("src/quic_server.zig"),
88 .target = target,
89 .optimize = optimize,
90 .link_libc = true,
91 });
92
84 const server_mod = b.createModule(.{ 93 const server_mod = b.createModule(.{
85 .root_source_file = b.path("src/server.zig"), 94 .root_source_file = b.path("src/server.zig"),
86 .target = target, 95 .target = target,
@@ -145,7 +154,7 @@ pub fn build(b: *std.Build) void {
145 const test_step = b.step("test", "Run unit tests"); 154 const test_step = b.step("test", "Run unit tests");
146 // mux_mod is an executable root, but it carries the argument parser, and 155 // mux_mod is an executable root, but it carries the argument parser, and
147 // a test that is never built is not a test. 156 // a test that is never built is not a test.
148 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod }) |mod| { 157 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod }) |mod| {
149 const t = b.addTest(.{ .root_module = mod }); 158 const t = b.addTest(.{ .root_module = mod });
150 t.use_llvm = true; 159 t.use_llvm = true;
151 t.use_lld = true; 160 t.use_lld = true;
@@ -155,7 +164,7 @@ pub fn build(b: *std.Build) void {
155 // deps when they are absent. Without it the dependency reached only 164 // deps when they are absent. Without it the dependency reached only
156 // `muxd`, and a clean checkout running `make test` first would have 165 // `muxd`, and a clean checkout running `make test` first would have
157 // found no libraries and no explanation. 166 // found no libraries and no explanation.
158 if (mod == server_mod) linkQuic(b, t, quic); 167 if (mod == server_mod or mod == quic_mod) linkQuic(b, t, quic);
159 test_step.dependOn(&b.addRunArtifact(t).step); 168 test_step.dependOn(&b.addRunArtifact(t).step);
160 } 169 }
161 170
src/quic_server.zig
Old New
@@ -0,0 +1,152 @@
1 //! muxd's QUIC listener: one UDP socket, N authenticated connections, each
2 //! carrying exactly one bidirectional stream of opaque bytes.
3 //!
4 //! This file follows proxy.zig's discipline and for the same reason: it
5 //! knows NOTHING about the frame protocol it carries. It moves bytes
6 //! between a QUIC stream and a callback the daemon supplies, and the daemon
7 //! does the framing — so the claim "the transport is a swap" stays checkable
8 //! rather than aspirational. If a `proto.` import ever appears here,
9 //! something has gone wrong.
10 //!
11 //! Authentication is TLS 1.3 external PSK: both ends hold the same 32-byte
12 //! key and nobody holds a certificate. See spike/quic/README.md for why, and
13 //! for the integration assessment this implements.
14 const std = @import("std");
15
16 const c = @cImport({
17 @cInclude("ngtcp2/ngtcp2.h");
18 @cInclude("ngtcp2/ngtcp2_crypto.h");
19 @cInclude("ngtcp2/ngtcp2_crypto_wolfssl.h");
20 @cInclude("wolfssl/options.h");
21 @cInclude("wolfssl/ssl.h");
22 });
23
24 pub const key_len = 32;
25
26 /// The pre-shared key, and the rules for getting one off disk.
27 ///
28 /// A key file is exactly as sensitive as an ssh private key, so it is held
29 /// to the same standard: readable by nobody but its owner. Refusing is the
30 /// whole point — a daemon that starts anyway with a world-readable key has
31 /// authenticated nothing, and would do it silently.
32 pub const Key = struct {
33 bytes: [key_len]u8,
34
35 pub const LoadError = error{
36 KeyFileMissing,
37 KeyFilePermissive,
38 KeyFileMalformed,
39 };
40
41 /// Accepts either 32 raw bytes or 64 hex characters (trailing
42 /// whitespace ignored, so `xxd -p` and a text editor both work).
43 pub fn load(path: []const u8) !Key {
44 const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
45 error.FileNotFound => return error.KeyFileMissing,
46 else => return err,
47 };
48 defer file.close();
49
50 const st = try file.stat();
51 // Group or other bits set = refuse, exactly as ssh does for a
52 // private key. Checked before the contents are read, so a bad-mode
53 // key is never even loaded into memory.
54 if (st.mode & 0o077 != 0) return error.KeyFilePermissive;
55
56 var buf: [128]u8 = undefined;
57 const n = try file.readAll(&buf);
58 const raw = std.mem.trimRight(u8, buf[0..n], " \t\r\n");
59
60 if (raw.len == key_len) {
61 var k: Key = undefined;
62 @memcpy(&k.bytes, raw);
63 return k;
64 }
65 if (raw.len == key_len * 2) {
66 var k: Key = undefined;
67 _ = std.fmt.hexToBytes(&k.bytes, raw) catch return error.KeyFileMalformed;
68 return k;
69 }
70 return error.KeyFileMalformed;
71 }
72 };
73
74 /// Test helper: chmod a file inside a Dir. `Dir.chmod` applies to the
75 /// directory itself, not to an entry in it.
76 fn chmodAt(dir: std.fs.Dir, sub: []const u8, mode: std.posix.mode_t) !void {
77 const f = try dir.openFile(sub, .{});
78 defer f.close();
79 try f.chmod(mode);
80 }
81
82 test "Key.load: accepts 32 raw bytes and 64 hex chars, owner-only" {
83 var tmp = std.testing.tmpDir(.{});
84 defer tmp.cleanup();
85
86 const raw = [_]u8{0xAB} ** key_len;
87 try tmp.dir.writeFile(.{ .sub_path = "raw.key", .data = &raw });
88 try chmodAt(tmp.dir, "raw.key", 0o600);
89
90 var hex: [key_len * 2]u8 = undefined;
91 _ = try std.fmt.bufPrint(&hex, "{x}", .{&raw});
92 // A key pasted by a human ends in a newline; that must not change it.
93 try tmp.dir.writeFile(.{ .sub_path = "hex.key", .data = hex ++ "\n" });
94 try chmodAt(tmp.dir, "hex.key", 0o600);
95
96 var path_buf: [256]u8 = undefined;
97 const dir = try tmp.dir.realpath(".", &path_buf);
98 var jb: [512]u8 = undefined;
99
100 const from_raw = try Key.load(try std.fmt.bufPrint(&jb, "{s}/raw.key", .{dir}));
101 try std.testing.expectEqualSlices(u8, &raw, &from_raw.bytes);
102
103 const from_hex = try Key.load(try std.fmt.bufPrint(&jb, "{s}/hex.key", .{dir}));
104 try std.testing.expectEqualSlices(u8, &raw, &from_hex.bytes);
105 }
106
107 test "Key.load: refuses a permissive mode, a missing file, and a bad length" {
108 var tmp = std.testing.tmpDir(.{});
109 defer tmp.cleanup();
110 var path_buf: [256]u8 = undefined;
111 const dir = try tmp.dir.realpath(".", &path_buf);
112 var jb: [512]u8 = undefined;
113
114 const raw = [_]u8{0xCD} ** key_len;
115
116 // Group-readable: refused, like an ssh private key.
117 try tmp.dir.writeFile(.{ .sub_path = "group.key", .data = &raw });
118 try chmodAt(tmp.dir, "group.key", 0o640);
119 try std.testing.expectError(
120 error.KeyFilePermissive,
121 Key.load(try std.fmt.bufPrint(&jb, "{s}/group.key", .{dir})),
122 );
123
124 // World-readable: same.
125 try tmp.dir.writeFile(.{ .sub_path = "world.key", .data = &raw });
126 try chmodAt(tmp.dir, "world.key", 0o604);
127 try std.testing.expectError(
128 error.KeyFilePermissive,
129 Key.load(try std.fmt.bufPrint(&jb, "{s}/world.key", .{dir})),
130 );
131
132 try std.testing.expectError(
133 error.KeyFileMissing,
134 Key.load(try std.fmt.bufPrint(&jb, "{s}/nope.key", .{dir})),
135 );
136
137 // Right mode, wrong content: neither 32 raw nor 64 hex.
138 try tmp.dir.writeFile(.{ .sub_path = "short.key", .data = "too short" });
139 try chmodAt(tmp.dir, "short.key", 0o600);
140 try std.testing.expectError(
141 error.KeyFileMalformed,
142 Key.load(try std.fmt.bufPrint(&jb, "{s}/short.key", .{dir})),
143 );
144
145 // 64 characters, but not hex.
146 try tmp.dir.writeFile(.{ .sub_path = "nothex.key", .data = "z" ** 64 });
147 try chmodAt(tmp.dir, "nothex.key", 0o600);
148 try std.testing.expectError(
149 error.KeyFileMalformed,
150 Key.load(try std.fmt.bufPrint(&jb, "{s}/nothex.key", .{dir})),
151 );
152 }