564591de
feat: muxd keygen — one command replaces the /dev/urandom incantation
a73x 2026-08-09 12:07
Commit message
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 | } |