76b3bce5
feat: QUIC key handling — refused before anything listens
a73x 2026-08-08 14:08
Commit message
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 | } | ||