a73x

564591de

feat: muxd keygen — one command replaces the /dev/urandom incantation

a73x   2026-08-09 12:07

Commit message
feat: muxd keygen — one command replaces the /dev/urandom incantation

build.zig
Old New
@@ -214,6 +214,9 @@ pub fn build(b: *std.Build) void {
214 exe_mod.addImport("quic", quic_mod); 214 exe_mod.addImport("quic", quic_mod);
215 exe_mod.addImport("build_options", version_opts.createModule()); 215 exe_mod.addImport("build_options", version_opts.createModule());
216 exe_mod.addImport("xdg", xdg_mod); 216 exe_mod.addImport("xdg", xdg_mod);
217 // The keygen round-trip test needs a directory to generate into; the
218 // daemon itself never touches this.
219 exe_mod.addImport("testtmp", testtmp_mod);
217 220
218 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 221 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
219 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 222 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -375,7 +375,7 @@ git commit -m "feat: xdg module — default key/log paths and key creation"
375 - Modify: `src/main.zig` (usage, `Cmd`, `parseArgs`, dispatch; new `keygen` fn; round-trip test) 375 - Modify: `src/main.zig` (usage, `Cmd`, `parseArgs`, dispatch; new `keygen` fn; round-trip test)
376 - Modify: `test/e2e.sh` 376 - Modify: `test/e2e.sh`
377 377
378 - [ ] **Step 1: Write the failing round-trip test** 378 - [x] **Step 1: Write the failing round-trip test**
379 379
380 `src/main.zig`, test section — the generated key must satisfy the loader the daemon actually uses: 380 `src/main.zig`, test section — the generated key must satisfy the loader the daemon actually uses:
381 381
@@ -393,7 +393,7 @@ test "keygen: a generated key loads through quic.Key.load" {
393 } 393 }
394 ``` 394 ```
395 395
396 - [ ] **Step 2: Run to verify state** 396 - [x] **Step 2: Run to verify state**
397 397
398 Run: `make test` 398 Run: `make test`
399 Expected: this test PASSES already (writeNewKey exists from Task 2) — it is a regression pin, not a red test; the red step for this task is the parse test below. Add it now: 399 Expected: this test PASSES already (writeNewKey exists from Task 2) — it is a regression pin, not a red test; the red step for this task is the parse test below. Add it now:
@@ -409,7 +409,7 @@ test "parseArgs: keygen takes no flags" {
409 409
410 Run: `make test` — compile error on `.keygen` (the failing state). 410 Run: `make test` — compile error on `.keygen` (the failing state).
411 411
412 - [ ] **Step 3: Implement** 412 - [x] **Step 3: Implement**
413 413
414 `src/main.zig`. Import at top: `const xdg = @import("xdg");` 414 `src/main.zig`. Import at top: `const xdg = @import("xdg");`
415 415
@@ -466,16 +466,16 @@ fn keygen(alloc: std.mem.Allocator) !u8 {
466 466
467 Usage text: add `\\ muxd keygen (write a fresh key to ~/.config/mux/key)`. 467 Usage text: add `\\ muxd keygen (write a fresh key to ~/.config/mux/key)`.
468 468
469 - [ ] **Step 4: Run tests, expect pass** 469 - [x] **Step 4: Run tests, expect pass**
470 470
471 Run: `make test` 471 Run: `make test`
472 Expected: pass. 472 Expected: pass.
473 473
474 - [ ] **Step 5: Mutation check (keygen refusal)** 474 - [x] **Step 5: Mutation check (keygen refusal)**
475 475
476 In `xdg.writeNewKey`, change `.exclusive = true` to `.exclusive = false` and the `error.PathAlreadyExists` arm to unreachable dead code (createFile will no longer produce it). Run `make test`: the `writeNewKey` refusal test from Task 2 MUST fail. Restore. 476 In `xdg.writeNewKey`, change `.exclusive = true` to `.exclusive = false` and the `error.PathAlreadyExists` arm to unreachable dead code (createFile will no longer produce it). Run `make test`: the `writeNewKey` refusal test from Task 2 MUST fail. Restore.
477 477
478 - [ ] **Step 6: e2e — hermetic XDG plus keygen scenario** 478 - [x] **Step 6: e2e — hermetic XDG plus keygen scenario**
479 479
480 `test/e2e.sh`: near the top, after `OUT=` (~line 13), add the hermetic config/state homes (WITHOUT this, the suite would read the developer's real `~/.config/mux/key` once Task 5 lands, and "no key configured" scenarios would silently become "key found"): 480 `test/e2e.sh`: near the top, after `OUT=` (~line 13), add the hermetic config/state homes (WITHOUT this, the suite would read the developer's real `~/.config/mux/key` once Task 5 lands, and "no key configured" scenarios would silently become "key found"):
481 481
@@ -510,7 +510,7 @@ echo "e2e OK: keygen creates once, 0600, refuses twice"
510 Run: `make build && make e2e` 510 Run: `make build && make e2e`
511 Expected: new OK line; suite passes. 511 Expected: new OK line; suite passes.
512 512
513 - [ ] **Step 7: Commit** 513 - [x] **Step 7: Commit**
514 514
515 ```bash 515 ```bash
516 git add src/main.zig test/e2e.sh 516 git add src/main.zig test/e2e.sh
src/main.zig
Old New
@@ -7,6 +7,7 @@ const proto = @import("protocol");
7 const proxy = @import("proxy"); 7 const proxy = @import("proxy");
8 const quic = @import("quic"); 8 const quic = @import("quic");
9 const build_options = @import("build_options"); 9 const build_options = @import("build_options");
10 const xdg = @import("xdg");
10 11
11 const usage = 12 const usage =
12 \\usage: 13 \\usage:
@@ -15,6 +16,7 @@ const usage =
15 \\ muxd dump [--vt] [--sock PATH] 16 \\ muxd dump [--vt] [--sock PATH]
16 \\ muxd stats [--sock PATH] 17 \\ muxd stats [--sock PATH]
17 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) 18 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket)
19 \\ muxd keygen (write a fresh key to ~/.config/mux/key)
18 \\ muxd --version 20 \\ muxd --version
19 \\ 21 \\
20 ; 22 ;
@@ -25,7 +27,7 @@ const usage =
25 /// need death declared on a schedule they can wait for. 27 /// need death declared on a schedule they can wait for.
26 const default_quic_idle_ms: u32 = 15_000; 28 const default_quic_idle_ms: u32 = 15_000;
27 29
28 const Cmd = enum { run, dump, stats, proxy, version }; 30 const Cmd = enum { run, dump, stats, proxy, version, keygen };
29 31
30 /// Everything the command line can say, once. Parsed away from `main` so it 32 /// Everything the command line can say, once. Parsed away from `main` so it
31 /// can be tested without a process to exit from — the same reason 33 /// can be tested without a process to exit from — the same reason
@@ -73,9 +75,16 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
73 .stats 75 .stats
74 else if (std.mem.eql(u8, args[1], "proxy")) 76 else if (std.mem.eql(u8, args[1], "proxy"))
75 .proxy 77 .proxy
78 else if (std.mem.eql(u8, args[1], "keygen"))
79 .keygen
76 else 80 else
77 return .{ .err = .{ .unknown_command = args[1] } }; 81 return .{ .err = .{ .unknown_command = args[1] } };
78 82
83 // keygen configures nothing: its one output is the default path, and a
84 // flag here would be a request this command cannot honor.
85 if (cmd == .keygen and args.len > 2)
86 return .{ .err = .{ .unknown_arg = args[2] } };
87
79 var o: Opts = .{ .cmd = cmd }; 88 var o: Opts = .{ .cmd = cmd };
80 var i: usize = 2; 89 var i: usize = 2;
81 while (i < args.len) : (i += 1) { 90 while (i < args.len) : (i += 1) {
@@ -207,6 +216,7 @@ pub fn main() !u8 {
207 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; 216 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {};
208 return 0; 217 return 0;
209 }, 218 },
219 .keygen => return keygen(alloc),
210 .run => return run(alloc, o, sock_path), 220 .run => return run(alloc, o, sock_path),
211 .dump => return dump(alloc, sock_path, o.vt), 221 .dump => return dump(alloc, sock_path, o.vt),
212 .stats => return stats(alloc, sock_path), 222 .stats => return stats(alloc, sock_path),
@@ -369,6 +379,25 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
369 return 1; 379 return 1;
370 } 380 }
371 381
382 fn keygen(alloc: std.mem.Allocator) !u8 {
383 const path = try xdg.keyPath(alloc);
384 defer alloc.free(path);
385 xdg.writeNewKey(path) catch |err| switch (err) {
386 error.KeyExists => {
387 std.debug.print(
388 "muxd keygen: {s} already exists; rotation is `rm` + `keygen`, deliberately\n",
389 .{path},
390 );
391 return 1;
392 },
393 else => |e| return e,
394 };
395 var buf: [std.fs.max_path_bytes + 1]u8 = undefined;
396 const line = std.fmt.bufPrint(&buf, "{s}\n", .{path}) catch unreachable;
397 _ = std.posix.write(std.posix.STDOUT_FILENO, line) catch {};
398 return 0;
399 }
400
372 // --------------------------------------------------------------------------- 401 // ---------------------------------------------------------------------------
373 // Tests. These run because `exe_mod` was added to build.zig's test loop in 402 // Tests. These run because `exe_mod` was added to build.zig's test loop in
374 // the same commit; before that, a test written here would have compiled and 403 // the same commit; before that, a test written here would have compiled and
@@ -495,8 +524,26 @@ test "parseBindAddr: a hostname is refused, not resolved" {
495 try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433"))); 524 try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433")));
496 } 525 }
497 526
527 test "keygen: a generated key loads through quic.Key.load" {
528 const testtmp = @import("testtmp");
529 var tmp = try testtmp.TmpDir.make();
530 defer tmp.cleanup();
531
532 var buf: [128]u8 = undefined;
533 const path = try std.fmt.bufPrint(&buf, "{s}/key", .{tmp.path()});
534 try xdg.writeNewKey(path);
535 _ = try quic.Key.load(path);
536 }
537
498 test "parseArgs: --version is a command, not a flag on one" { 538 test "parseArgs: --version is a command, not a flag on one" {
499 const r = parse(&.{ "muxd", "--version" }); 539 const r = parse(&.{ "muxd", "--version" });
500 try std.testing.expect(r == .ok); 540 try std.testing.expect(r == .ok);
501 try std.testing.expect(r.ok.cmd == .version); 541 try std.testing.expect(r.ok.cmd == .version);
502 } 542 }
543
544 test "parseArgs: keygen takes no flags" {
545 const r = parse(&.{ "muxd", "keygen" });
546 try std.testing.expect(r == .ok);
547 try std.testing.expect(r.ok.cmd == .keygen);
548 try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg);
549 }
src/xdg.zig
Old New
@@ -43,11 +43,26 @@ pub fn logPathFrom(
43 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h}); 43 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h});
44 } 44 }
45 45
46 /// 32 random bytes at `path`, mode 0600, parent directories created. 46 /// 32 random bytes at `path`, mode 0600, parent directories created and
47 /// the immediate parent tightened to 0700.
47 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both 48 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both
48 /// counts, so overwriting silently would delete a credential. 49 /// counts, so overwriting silently would delete a credential.
49 pub fn writeNewKey(path: []const u8) !void { 50 pub fn writeNewKey(path: []const u8) !void {
50 if (std.fs.path.dirname(path)) |dir| try std.fs.cwd().makePath(dir); 51 if (std.fs.path.dirname(path)) |dir| {
52 try std.fs.cwd().makePath(dir);
53 // makePath leaves 0755, which does not expose the key — that is
54 // 0600 — but does expose that a key exists and what it is called.
55 // ssh's answer for the analogous directory is 0700 and there is no
56 // reason to be looser. Only the LAST component is tightened: the
57 // parents on the way (`~`, `~/.config`) are the user's own business
58 // and are not ours to re-permission.
59 // `.iterate = true` is not optional here: Dir.chmod fchmods the
60 // directory's own fd, and without it the fd is opened O_PATH, which
61 // fchmod refuses.
62 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
63 defer d.close();
64 try d.chmod(0o700);
65 }
51 const f = std.fs.cwd().createFile(path, .{ 66 const f = std.fs.cwd().createFile(path, .{
52 .exclusive = true, 67 .exclusive = true,
53 .mode = 0o600, 68 .mode = 0o600,
@@ -108,6 +123,15 @@ test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" {
108 const fst = try f.stat(); 123 const fst = try f.stat();
109 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777))); 124 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
110 125
126 // The directory holding it is 0700: a 0755 parent leaks the existence
127 // and the name of a key file even though the key itself stays 0600.
128 var dbuf: [128]u8 = undefined;
129 const dir = try std.fmt.bufPrint(&dbuf, "{s}/sub", .{tmp.path()});
130 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
131 defer d.close();
132 const dst = try d.stat();
133 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777)));
134
111 var first: [32]u8 = undefined; 135 var first: [32]u8 = undefined;
112 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&first, 0)); 136 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&first, 0));
113 137
test/e2e.sh
Old New
@@ -10,6 +10,11 @@ RAWMODE="$3"
10 DELAYPIPE="$4" 10 DELAYPIPE="$4"
11 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" 11 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
12 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" 12 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$"
13 # M10: hermetic XDG homes. Key-default scenarios must see OUR key or none,
14 # never the developer's real ~/.config/mux/key.
15 XDG_CONFIG_HOME="${TMPDIR:-/tmp}/mux-e2e-cfg-$$"
16 XDG_STATE_HOME="${TMPDIR:-/tmp}/mux-e2e-state-$$"
17 export XDG_CONFIG_HOME XDG_STATE_HOME
13 # Second daemon, used only by the M7 abort scenario; declared here so the 18 # Second daemon, used only by the M7 abort scenario; declared here so the
14 # trap below can reference them under `set -u` before they are ever started. 19 # trap below can reference them under `set -u` before they are ever started.
15 SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock" 20 SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock"
@@ -100,6 +105,7 @@ cleanup() {
100 "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \ 105 "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \
101 "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \ 106 "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \
102 "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY.wrong" 107 "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY.wrong"
108 rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME"
103 } 109 }
104 trap cleanup EXIT INT TERM 110 trap cleanup EXIT INT TERM
105 111
@@ -113,6 +119,22 @@ trap cleanup EXIT INT TERM
113 echo "e2e FAIL: version drift between binaries"; exit 1; } 119 echo "e2e FAIL: version drift between binaries"; exit 1; }
114 echo "e2e OK: --version on both binaries" 120 echo "e2e OK: --version on both binaries"
115 121
122 # --- M10: keygen writes 0600, prints the path, refuses a second run.
123 KEYOUT=$("$MUXD" keygen)
124 [ "$KEYOUT" = "$XDG_CONFIG_HOME/mux/key" ] || {
125 echo "e2e FAIL: keygen printed '$KEYOUT'"; exit 1; }
126 PERMS=$(stat -c %a "$KEYOUT")
127 [ "$PERMS" = "600" ] || { echo "e2e FAIL: keygen perms $PERMS, want 600"; exit 1; }
128 DPERMS=$(stat -c %a "$XDG_CONFIG_HOME/mux")
129 [ "$DPERMS" = "700" ] || { echo "e2e FAIL: key dir perms $DPERMS, want 700"; exit 1; }
130 SUM1=$(sha256sum "$KEYOUT")
131 if "$MUXD" keygen > /dev/null 2>&1; then
132 echo "e2e FAIL: second keygen did not refuse"; exit 1
133 fi
134 SUM2=$(sha256sum "$KEYOUT")
135 [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; }
136 echo "e2e OK: keygen creates once, 0600 in a 0700 dir, refuses twice"
137
116 "$MUXD" run --sock "$SOCK" --shell /bin/sh & 138 "$MUXD" run --sock "$SOCK" --shell /bin/sh &
117 DPID=$! 139 DPID=$!
118 140
@@ -378,9 +400,12 @@ refuse() {
378 [ ! -e "$SOCK4" ] || { 400 [ ! -e "$SOCK4" ] || {
379 echo "e2e FAIL: muxd run $* was refused but left $SOCK4 behind"; exit 1; 401 echo "e2e FAIL: muxd run $* was refused but left $SOCK4 behind"; exit 1;
380 } 402 }
381 # One line, and not a stack trace: a Zig panic runs to dozens of lines 403 # One line of complaint plus the usage block, and not a stack trace: a
382 # and names a source file, which is what this is guarding against. 404 # Zig panic runs to dozens of lines and names a source file, which is
383 [ "$(wc -l < "$OUT.q")" -le 8 ] || { 405 # what this is guarding against. The bound tracks the usage text, which
406 # grows a line per subcommand (keygen made it 8, so a refusal prints 9);
407 # keep it comfortably above that and far below a panic.
408 [ "$(wc -l < "$OUT.q")" -le 12 ] || {
384 echo "e2e FAIL: muxd run $* answered with more than a message:"; cat "$OUT.q"; exit 1; 409 echo "e2e FAIL: muxd run $* answered with more than a message:"; cat "$OUT.q"; exit 1;
385 } 410 }
386 } 411 }