a5fb22e2
feat: ssh's prompt is a line on a socket, and mux askpass carries it
a73x 2026-08-30 10:58
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -178,6 +178,11 @@ const mod_table = [_]ModSpec{ | |||
| 178 | // makePrivateParent only — the 0700-parent discipline the cache file | 178 | // makePrivateParent only — the 0700-parent discipline the cache file |
| 179 | // shares with the key file; xdg is a leaf, so no cycle. | 179 | // shares with the key file; xdg is a leaf, so no cycle. |
| 180 | .{ .name = "handoff", .path = "src/client/handoff.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} }, | 180 | .{ .name = "handoff", .path = "src/client/handoff.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} }, |
| 181 | // Both ends of ssh's prompt carriage: the client's listening socket and | ||
| 182 | // the `mux askpass` helper ssh execs. No terminal and no spawn — the | ||
| 183 | // wall paints the popup and ssh does the exec — so the whole protocol | ||
| 184 | // is drivable by a test with neither in the picture. | ||
| 185 | .{ .name = "askpass", .path = "src/client/askpass.zig", .layer = 1, .test_imports = &.{"testtmp"} }, | ||
| 181 | // Row-level change tracking behind the delta stream. Engine plus | 186 | // Row-level change tracking behind the delta stream. Engine plus |
| 182 | // protocol and nothing else — no daemon, no clients — so the tracker's | 187 | // protocol and nothing else — no daemon, no clients — so the tracker's |
| 183 | // own tests drive it with an engine and no socket in sight. | 188 | // own tests drive it with an engine and no socket in sight. |
| @@ -809,12 +814,12 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 809 | /// they carry the argument parsers — a test that is never built is not a | 814 | /// they carry the argument parsers — a test that is never built is not a |
| 810 | /// test (decisions.md). | 815 | /// test (decisions.md). |
| 811 | const test_order = [_][]const u8{ | 816 | const test_order = [_][]const u8{ |
| 812 | "script", "select", "protocol", "client_core", "interact", "engine", "pty", | 817 | "script", "select", "protocol", "client_core", "interact", "engine", "pty", |
| 813 | "delta", "cmd", "hosts", "upgrade", "shellint", "replica", "keymap", | 818 | "delta", "cmd", "hosts", "askpass", "upgrade", "shellint", "replica", |
| 814 | "webhub", "wallview", "sockpath", "agent_main", "server", "client", "proxy", | 819 | "keymap", "webhub", "wallview", "sockpath", "agent_main", "server", "client", |
| 815 | "client_main", "quic", "quic_server", "daemon_main", "testtmp", "predict", "rawmode", | 820 | "proxy", "client_main", "quic", "quic_server", "daemon_main", "testtmp", "predict", |
| 816 | "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", "render", | 821 | "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", |
| 817 | "ptyclient", "hub_main", "wsclient", "cliflags", "mux", | 822 | "render", "ptyclient", "hub_main", "wsclient", "cliflags", "mux", |
| 818 | }; | 823 | }; |
| 819 | 824 | ||
| 820 | comptime { | 825 | comptime { |
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | client_core_wasm_check.zig 0 | 1 | askpass.zig 0 |
| 2 | client_core.zig 0 | ||
| 3 | client.zig 0 | 2 | client.zig 0 |
| 3 | client_core.zig 0 | ||
| 4 | client_core_wasm_check.zig 0 | ||
| 4 | cmd.zig 0 | 5 | cmd.zig 0 |
| 5 | delta.zig 0 | 6 | delta.zig 0 |
| 6 | docscheck.zig 0 | 7 | docscheck.zig 0 |
| @@ -12,49 +13,49 @@ interact.zig 0 | |||
| 12 | keymap.zig 0 | 13 | keymap.zig 0 |
| 13 | layout.zig 761 | 14 | layout.zig 761 |
| 14 | main.zig 0 | 15 | main.zig 0 |
| 15 | muxa.zig 0 | 16 | mux.zig 0 |
| 16 | mux_main.zig 0 | 17 | mux_main.zig 0 |
| 18 | muxa.zig 0 | ||
| 17 | paint.zig 0 | 19 | paint.zig 0 |
| 18 | predict.zig 0 | 20 | predict.zig 0 |
| 19 | protocol.zig 0 | 21 | protocol.zig 0 |
| 20 | proxy.zig 0 | 22 | proxy.zig 0 |
| 21 | pty.zig 0 | 23 | pty.zig 0 |
| 24 | quic.zig 0 | ||
| 22 | quic_client.zig 0 | 25 | quic_client.zig 0 |
| 23 | quic_server.zig 0 | 26 | quic_server.zig 0 |
| 24 | quic.zig 0 | ||
| 25 | replica.zig 0 | 27 | replica.zig 0 |
| 26 | select.zig 0 | 28 | select.zig 0 |
| 27 | server.zig 0 | 29 | server.zig 0 |
| 28 | shellint.zig 0 | 30 | server_agent.zig 0 |
| 29 | sockpath.zig 0 | 31 | server_sessions.zig 0 |
| 30 | spawn.zig 0 | 32 | server_test_agent.zig 0 |
| 31 | testtmp.zig 0 | ||
| 32 | upgrade.zig 269 | ||
| 33 | wallview.zig 0 | ||
| 34 | wasm_core.zig 0 | ||
| 35 | webhub_main.zig 0 | ||
| 36 | webhub.zig 0 | ||
| 37 | xdg.zig 0 | ||
| 38 | server_test_harness.zig 0 | ||
| 39 | server_test_attach.zig 0 | 33 | server_test_attach.zig 0 |
| 34 | server_test_await.zig 0 | ||
| 35 | server_test_clipboard.zig 0 | ||
| 40 | server_test_deliver.zig 0 | 36 | server_test_deliver.zig 0 |
| 37 | server_test_harness.zig 0 | ||
| 41 | server_test_modes.zig 0 | 38 | server_test_modes.zig 0 |
| 42 | server_test_session.zig 0 | ||
| 43 | server_test_quic.zig 0 | 39 | server_test_quic.zig 0 |
| 44 | server_test_await.zig 0 | 40 | server_test_session.zig 0 |
| 45 | server_test_clipboard.zig 0 | ||
| 46 | server_test_agent.zig 0 | ||
| 47 | server_test_upgrade.zig 0 | 41 | server_test_upgrade.zig 0 |
| 48 | server_agent.zig 0 | 42 | shellint.zig 0 |
| 49 | server_sessions.zig 0 | 43 | sockpath.zig 0 |
| 44 | spawn.zig 0 | ||
| 45 | testtmp.zig 0 | ||
| 46 | upgrade.zig 269 | ||
| 50 | wall_host.zig 0 | 47 | wall_host.zig 0 |
| 48 | wall_layout.zig 1432 | ||
| 51 | wall_picker.zig 0 | 49 | wall_picker.zig 0 |
| 52 | wall_pump.zig 0 | 50 | wall_pump.zig 0 |
| 53 | wall_layout.zig 1432 | ||
| 54 | wall_test_harness.zig 0 | 51 | wall_test_harness.zig 0 |
| 55 | wall_test_host.zig 0 | 52 | wall_test_host.zig 0 |
| 53 | wall_test_layout.zig 0 | ||
| 56 | wall_test_picker.zig 0 | 54 | wall_test_picker.zig 0 |
| 57 | wall_test_pump.zig 0 | 55 | wall_test_pump.zig 0 |
| 58 | wall_test_layout.zig 0 | ||
| 59 | wall_test_wall.zig 0 | 56 | wall_test_wall.zig 0 |
| 60 | mux.zig 0 | 57 | wallview.zig 0 |
| 58 | wasm_core.zig 0 | ||
| 59 | webhub.zig 0 | ||
| 60 | webhub_main.zig 0 | ||
| 61 | xdg.zig 0 | ||
src/client/askpass.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1018 @@ | |||
| 1 | //! ssh's prompts, and the one place mux can answer them. | ||
| 2 | //! | ||
| 3 | //! ssh reads passwords, passphrases and the host-key question from | ||
| 4 | //! `/dev/tty` — a read no fd mux sets can reach, and under a wall's | ||
| 5 | //! alternate screen a read nobody can even see. `SSH_ASKPASS_REQUIRE=force` | ||
| 6 | //! turns each of those reads into an exec of `$SSH_ASKPASS "<prompt>"` | ||
| 7 | //! whose stdout is the answer, so the prompt becomes BYTES, and bytes | ||
| 8 | //! travel. This module is both ends of that trip: `Listener` is the | ||
| 9 | //! client's, one socket per client process, and `helperMain` is what | ||
| 10 | //! `mux askpass` runs. | ||
| 11 | //! | ||
| 12 | //! Nothing here paints and nothing here spawns: the wall owns the popup and | ||
| 13 | //! ssh owns the exec, which is what lets the whole carriage be driven by a | ||
| 14 | //! test with no pty, no ssh and no wall in the picture. | ||
| 15 | const std = @import("std"); | ||
| 16 | |||
| 17 | /// Env var naming the socket. The mode word for the helper, too: ssh execs | ||
| 18 | /// its helper with the prompt as argv[1] and nothing else, so there is no | ||
| 19 | /// word to give it, and this variable exists in exactly one process tree — | ||
| 20 | /// the ssh a wall dial spawned. | ||
| 21 | pub const sock_env = "MUX_ASKPASS_SOCK"; | ||
| 22 | |||
| 23 | /// ssh's longest ordinary question is the host-key one, ~220 bytes with a | ||
| 24 | /// fingerprint in it. Cut, never refused: a prompt the user cannot read | ||
| 25 | /// whole is still a prompt they can answer. | ||
| 26 | pub const prompt_max = 512; | ||
| 27 | |||
| 28 | /// A passphrase, not a document. | ||
| 29 | pub const answer_max = 256; | ||
| 30 | |||
| 31 | /// How many declined dials are remembered. A pid the ring has evicted reads | ||
| 32 | /// `declined` false, which costs one more prompt — the failure a caller | ||
| 33 | /// cannot see is a tile parked forever, so the ring errs the other way. | ||
| 34 | pub const decline_ring = 8; | ||
| 35 | |||
| 36 | /// How many helpers may be waiting to be served. A separate number from | ||
| 37 | /// the ring above, which it briefly shared by accident: one counts refused | ||
| 38 | /// dials, this one counts sshs queued behind a popup. | ||
| 39 | const backlog = 8; | ||
| 40 | |||
| 41 | /// The reply's first byte. An EMPTY answer is a real answer — a key whose | ||
| 42 | /// passphrase is empty, an Enter on a question the user means to leave | ||
| 43 | /// blank — so "declined" cannot be spelled by an empty line, which is the | ||
| 44 | /// whole reason there is a tag at all. | ||
| 45 | const reply_answer = '+'; | ||
| 46 | const reply_decline = '-'; | ||
| 47 | |||
| 48 | /// What OpenSSH 8.4+ puts in the helper's environment to say what it is | ||
| 49 | /// asking for. An exact signal, which is why there is no guessing here: | ||
| 50 | /// matching `assword` against the text would call a server-authored | ||
| 51 | /// keyboard-interactive prompt a secret only when the server happened to | ||
| 52 | /// spell it that way, and paint the answer in the clear when it did not. | ||
| 53 | pub const prompt_env = "SSH_ASKPASS_PROMPT"; | ||
| 54 | |||
| 55 | /// The three things ssh can want, and the one byte the wire carries to say | ||
| 56 | /// which. A value that is neither of ssh's two words is a SECRET: the | ||
| 57 | /// mistake that hides an answer is cheaper than the one that paints it. | ||
| 58 | pub const Kind = enum(u8) { | ||
| 59 | secret = 's', | ||
| 60 | /// The host-key question and every other yes/no. Painted in the clear, | ||
| 61 | /// because the user is comparing a fingerprint. | ||
| 62 | confirm = 'c', | ||
| 63 | /// Not a question: "Confirm user presence for key ..." — the FIDO touch | ||
| 64 | /// notifier. ssh SIGTERMs the helper when the touch lands, so this box | ||
| 65 | /// takes no answer and closes when the peer hangs up. | ||
| 66 | notice = 'n', | ||
| 67 | |||
| 68 | pub fn of(env: ?[]const u8) Kind { | ||
| 69 | const v = env orelse return .secret; | ||
| 70 | if (std.mem.eql(u8, v, "confirm")) return .confirm; | ||
| 71 | if (std.mem.eql(u8, v, "none")) return .notice; | ||
| 72 | return .secret; | ||
| 73 | } | ||
| 74 | |||
| 75 | fn ofTag(b: u8) Kind { | ||
| 76 | return switch (b) { | ||
| 77 | @intFromEnum(Kind.confirm) => .confirm, | ||
| 78 | @intFromEnum(Kind.notice) => .notice, | ||
| 79 | else => .secret, | ||
| 80 | }; | ||
| 81 | } | ||
| 82 | }; | ||
| 83 | |||
| 84 | /// How long `serve` gives a peer to finish its request line, and the slice | ||
| 85 | /// it re-checks the peer on while the box is up. A connected peer that | ||
| 86 | /// never speaks would otherwise park the accept thread for the wall's life, | ||
| 87 | /// and every later prompt behind it. | ||
| 88 | const request_ms_default: i32 = 5000; | ||
| 89 | const watch_ns: u64 = 200 * std.time.ns_per_ms; | ||
| 90 | |||
| 91 | /// One pending prompt, attributed to the ssh child that raised it. | ||
| 92 | pub const Prompt = struct { | ||
| 93 | text: [prompt_max]u8 = undefined, | ||
| 94 | text_len: usize = 0, | ||
| 95 | kind: Kind = .secret, | ||
| 96 | /// pid of the ssh whose helper connected: `SO_PEERCRED` names the | ||
| 97 | /// helper, and the helper's parent IS ssh, which execs it directly. | ||
| 98 | /// 0 when the walk failed — attribution is a convenience, never a | ||
| 99 | /// condition for answering. | ||
| 100 | ssh_pid: std.posix.pid_t = 0, | ||
| 101 | |||
| 102 | pub fn slice(self: *const Prompt) []const u8 { | ||
| 103 | return self.text[0..self.text_len]; | ||
| 104 | } | ||
| 105 | }; | ||
| 106 | |||
| 107 | /// The doorbell a prompt rings: `SessionPoll.Hooks`, for its reason. | ||
| 108 | pub const Hooks = struct { | ||
| 109 | ctx: *anyopaque, | ||
| 110 | wake: *const fn (*anyopaque) void, | ||
| 111 | }; | ||
| 112 | |||
| 113 | /// The wall's end: one socket, one accept thread, one prompt at a time. | ||
| 114 | /// | ||
| 115 | /// Serialized BY CONSTRUCTION rather than by a rule — the accept thread | ||
| 116 | /// serves a connection to completion before it accepts the next, so a | ||
| 117 | /// second ssh asking while a popup is up waits in the listen backlog. That | ||
| 118 | /// is also the whole of the fairness policy: accept order. | ||
| 119 | pub const Listener = struct { | ||
| 120 | /// The state one prompt moves through. `shown` exists so a doorbell the | ||
| 121 | /// keyboard rings twice opens one popup: `take` is the transition, not | ||
| 122 | /// a read. | ||
| 123 | const Phase = enum { idle, pending, shown, done }; | ||
| 124 | |||
| 125 | alloc: std.mem.Allocator, | ||
| 126 | path: []const u8, | ||
| 127 | fd: std.posix.socket_t, | ||
| 128 | /// How `stop` reaches a thread parked in `poll`. Closing the listening | ||
| 129 | /// fd under an accept is not defined to wake it; a byte here is. | ||
| 130 | stop_r: std.posix.fd_t, | ||
| 131 | stop_w: std.posix.fd_t, | ||
| 132 | thread: ?std.Thread = null, | ||
| 133 | wake: Hooks, | ||
| 134 | |||
| 135 | mu: std.Thread.Mutex = .{}, | ||
| 136 | cv: std.Thread.Condition = .{}, | ||
| 137 | running: bool = true, | ||
| 138 | phase: Phase = .idle, | ||
| 139 | prompt: Prompt = .{}, | ||
| 140 | answer_buf: [answer_max]u8 = undefined, | ||
| 141 | answer_len: usize = 0, | ||
| 142 | was_declined: bool = false, | ||
| 143 | ring: [decline_ring]std.posix.pid_t = @splat(0), | ||
| 144 | ring_at: usize = 0, | ||
| 145 | /// A field rather than the constant, `HandoffTarget.deadline_ms`'s | ||
| 146 | /// shape: a test that has to prove the deadline exists must not spend | ||
| 147 | /// it. | ||
| 148 | request_ms: i32 = request_ms_default, | ||
| 149 | |||
| 150 | /// `runtime_dir` is where the socket goes; the pid in the name is what | ||
| 151 | /// makes it this client's and not another's. | ||
| 152 | pub fn start(alloc: std.mem.Allocator, runtime_dir: []const u8, wake: Hooks) !*Listener { | ||
| 153 | const path = try std.fmt.allocPrint( | ||
| 154 | alloc, | ||
| 155 | "{s}/mux-ask-{d}.sock", | ||
| 156 | .{ runtime_dir, std.os.linux.getpid() }, | ||
| 157 | ); | ||
| 158 | errdefer alloc.free(path); | ||
| 159 | // A client that died without unlinking left a file, and a pid comes | ||
| 160 | // round again. Nothing else may own this name: it has our pid in it. | ||
| 161 | std.fs.cwd().deleteFile(path) catch {}; | ||
| 162 | const addr = try std.net.Address.initUnix(path); | ||
| 163 | const fd = try std.posix.socket( | ||
| 164 | std.posix.AF.UNIX, | ||
| 165 | std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, | ||
| 166 | 0, | ||
| 167 | ); | ||
| 168 | errdefer std.posix.close(fd); | ||
| 169 | try std.posix.bind(fd, &addr.any, addr.getOsSockLen()); | ||
| 170 | try std.posix.listen(fd, backlog); | ||
| 171 | const bell = try std.posix.pipe2(.{ .CLOEXEC = true }); | ||
| 172 | errdefer { | ||
| 173 | std.posix.close(bell[0]); | ||
| 174 | std.posix.close(bell[1]); | ||
| 175 | } | ||
| 176 | const self = try alloc.create(Listener); | ||
| 177 | errdefer alloc.destroy(self); | ||
| 178 | self.* = .{ | ||
| 179 | .alloc = alloc, | ||
| 180 | .path = path, | ||
| 181 | .fd = fd, | ||
| 182 | .stop_r = bell[0], | ||
| 183 | .stop_w = bell[1], | ||
| 184 | .wake = wake, | ||
| 185 | }; | ||
| 186 | self.thread = try std.Thread.spawn(.{}, acceptLoop, .{self}); | ||
| 187 | return self; | ||
| 188 | } | ||
| 189 | |||
| 190 | /// Unlinks and joins. A helper still waiting is answered with a decline, | ||
| 191 | /// because an ssh blocked on a socket nobody will ever read is a dial | ||
| 192 | /// that never ends. | ||
| 193 | pub fn stop(self: *Listener) void { | ||
| 194 | self.retire(); | ||
| 195 | if (self.thread) |t| t.join(); | ||
| 196 | std.posix.close(self.fd); | ||
| 197 | std.posix.close(self.stop_r); | ||
| 198 | std.posix.close(self.stop_w); | ||
| 199 | self.alloc.free(self.path); | ||
| 200 | self.alloc.destroy(self); | ||
| 201 | } | ||
| 202 | |||
| 203 | /// The half of `stop` a process about to `exit` may run. | ||
| 204 | pub fn retire(self: *Listener) void { | ||
| 205 | // The name leaves the filesystem and a waiting helper is declined; | ||
| 206 | // nothing is joined, closed or freed. Split because the wall ends | ||
| 207 | // in `std.posix.exit` with detached pump threads still live, one of | ||
| 208 | // which may be inside `declined` on this very object — a free in | ||
| 209 | // that window is a use-after-free, while a socket left on disk is a | ||
| 210 | // file the next client of this pid finds on its own name. | ||
| 211 | self.mu.lock(); | ||
| 212 | self.running = false; | ||
| 213 | self.cv.broadcast(); | ||
| 214 | self.mu.unlock(); | ||
| 215 | _ = std.posix.write(self.stop_w, "x") catch {}; | ||
| 216 | std.fs.cwd().deleteFile(self.path) catch {}; | ||
| 217 | } | ||
| 218 | |||
| 219 | /// The keyboard's side. Copies the pending prompt out and marks it | ||
| 220 | /// shown; false when there is nothing waiting. | ||
| 221 | pub fn take(self: *Listener, out: *Prompt) bool { | ||
| 222 | self.mu.lock(); | ||
| 223 | defer self.mu.unlock(); | ||
| 224 | if (self.phase != .pending) return false; | ||
| 225 | out.* = self.prompt; | ||
| 226 | self.phase = .shown; | ||
| 227 | return true; | ||
| 228 | } | ||
| 229 | |||
| 230 | pub fn answer(self: *Listener, text: []const u8) void { | ||
| 231 | self.finish(text, false); | ||
| 232 | } | ||
| 233 | |||
| 234 | pub fn decline(self: *Listener) void { | ||
| 235 | self.finish("", true); | ||
| 236 | } | ||
| 237 | |||
| 238 | fn finish(self: *Listener, text: []const u8, declining: bool) void { | ||
| 239 | self.mu.lock(); | ||
| 240 | defer self.mu.unlock(); | ||
| 241 | // Not `.pending`: an answer for a prompt nobody showed is a driver | ||
| 242 | // bug, and unblocking the helper with it would hide the bug behind | ||
| 243 | // a working login. | ||
| 244 | if (self.phase != .shown) return; | ||
| 245 | const n = @min(text.len, answer_max); | ||
| 246 | @memcpy(self.answer_buf[0..n], text[0..n]); | ||
| 247 | self.answer_len = n; | ||
| 248 | self.was_declined = declining; | ||
| 249 | // pid 0 is the failed walk, and remembering it would park every | ||
| 250 | // tile whose attribution failed. | ||
| 251 | if (declining and self.prompt.ssh_pid != 0) { | ||
| 252 | self.ring[self.ring_at % decline_ring] = self.prompt.ssh_pid; | ||
| 253 | self.ring_at += 1; | ||
| 254 | // The dial ends HERE, before the helper is released, because a | ||
| 255 | // refused askpass is not a refused login to OpenSSH: a helper | ||
| 256 | // that exits non-zero on a password or passphrase prompt is | ||
| 257 | // read as the EMPTY password (`read_passphrase`, flags 0), ssh | ||
| 258 | // tries it, the server says no, and ssh asks again up to | ||
| 259 | // `NumberOfPasswordPrompts`. Measured against this box's sshd: | ||
| 260 | // one Esc, three prompts. Killing the ssh this prompt belongs | ||
| 261 | // to — our own child, by `dialOwner` — makes one Esc one | ||
| 262 | // refusal, which is what the wall says it does. | ||
| 263 | std.posix.kill(self.prompt.ssh_pid, std.posix.SIG.TERM) catch {}; | ||
| 264 | } | ||
| 265 | self.phase = .done; | ||
| 266 | self.cv.signal(); | ||
| 267 | } | ||
| 268 | |||
| 269 | /// The pump's "stop redialing" test: a dial whose prompt the user | ||
| 270 | /// refused must not come straight back with the same question. | ||
| 271 | pub fn declined(self: *Listener, ssh_pid: std.posix.pid_t) bool { | ||
| 272 | if (ssh_pid == 0) return false; | ||
| 273 | self.mu.lock(); | ||
| 274 | defer self.mu.unlock(); | ||
| 275 | for (self.ring) |p| if (p == ssh_pid) return true; | ||
| 276 | return false; | ||
| 277 | } | ||
| 278 | |||
| 279 | fn acceptLoop(self: *Listener) void { | ||
| 280 | while (true) { | ||
| 281 | var pfds = [_]std.posix.pollfd{ | ||
| 282 | .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 283 | .{ .fd = self.stop_r, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 284 | }; | ||
| 285 | _ = std.posix.poll(&pfds, -1) catch return; | ||
| 286 | if (pfds[1].revents != 0) return; | ||
| 287 | if (pfds[0].revents & std.posix.POLL.IN == 0) continue; | ||
| 288 | const c = std.posix.accept(self.fd, null, null, std.posix.SOCK.CLOEXEC) catch continue; | ||
| 289 | self.serve(c); | ||
| 290 | std.posix.close(c); | ||
| 291 | self.mu.lock(); | ||
| 292 | const go = self.running; | ||
| 293 | self.mu.unlock(); | ||
| 294 | if (!go) return; | ||
| 295 | } | ||
| 296 | } | ||
| 297 | |||
| 298 | fn serve(self: *Listener, c: std.posix.socket_t) void { | ||
| 299 | const cred = peerCred(c) orelse return; | ||
| 300 | // The 0700 runtime directory is the boundary, and mux takes | ||
| 301 | // `$XDG_RUNTIME_DIR` as found rather than verifying it. On a box | ||
| 302 | // where that directory is not private, this line is what stops | ||
| 303 | // another local user raising a prompt on this wall and reading the | ||
| 304 | // answer the user types into it. | ||
| 305 | if (cred.uid != std.os.linux.geteuid()) return; | ||
| 306 | var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, std.os.linux.getpid(), parentOf) }; | ||
| 307 | var raw: [prompt_max + 1]u8 = undefined; | ||
| 308 | // Bounded, because an accept thread parked in `read` is every later | ||
| 309 | // prompt of this wall parked behind it — and `stop`'s join with it. | ||
| 310 | const got = readLine(c, &raw, self.request_ms) orelse return; | ||
| 311 | if (got == 0) return; | ||
| 312 | p.kind = Kind.ofTag(raw[0]); | ||
| 313 | // Again on this side: the helper folds before it sends, and this is | ||
| 314 | // the end that paints. See `foldControl`. | ||
| 315 | p.text_len = foldControl(&p.text, raw[1..got]); | ||
| 316 | self.mu.lock(); | ||
| 317 | if (!self.running) { | ||
| 318 | self.mu.unlock(); | ||
| 319 | return; | ||
| 320 | } | ||
| 321 | self.prompt = p; | ||
| 322 | self.phase = .pending; | ||
| 323 | self.mu.unlock(); | ||
| 324 | self.wake.wake(self.wake.ctx); | ||
| 325 | const abandoned = self.awaitAnswer(c); | ||
| 326 | |||
| 327 | var reply: [answer_max + 2]u8 = undefined; | ||
| 328 | var len: usize = 0; | ||
| 329 | self.mu.lock(); | ||
| 330 | // A stop mid-prompt declines: the popup is gone with the wall that | ||
| 331 | // painted it, and ssh is owed an answer either way. A peer that has | ||
| 332 | // gone gets nothing — there is nobody to answer. | ||
| 333 | const refused = self.was_declined or self.phase != .done; | ||
| 334 | reply[0] = if (refused) reply_decline else reply_answer; | ||
| 335 | if (!refused) { | ||
| 336 | @memcpy(reply[1 .. 1 + self.answer_len], self.answer_buf[0..self.answer_len]); | ||
| 337 | len = self.answer_len; | ||
| 338 | } | ||
| 339 | // The wall's copy dies with the prompt it answered: this buffer | ||
| 340 | // outlives the box on screen, and a core dump is a file. | ||
| 341 | @memset(&self.answer_buf, 0); | ||
| 342 | self.answer_len = 0; | ||
| 343 | self.phase = .idle; | ||
| 344 | self.mu.unlock(); | ||
| 345 | reply[1 + len] = '\n'; | ||
| 346 | if (!abandoned) _ = writeAll(c, reply[0 .. len + 2]); | ||
| 347 | @memset(&reply, 0); | ||
| 348 | } | ||
| 349 | |||
| 350 | /// Blocks until the keyboard answers, the wall stops, or the PEER goes. | ||
| 351 | /// True when it was the peer: ssh SIGTERMs its notifier helper the | ||
| 352 | /// moment the touch lands, and a box left standing after ssh has moved | ||
| 353 | /// on is one the user has to dismiss for no reason — and dismissing it | ||
| 354 | /// would record a live dial's pid as declined. | ||
| 355 | fn awaitAnswer(self: *Listener, c: std.posix.socket_t) bool { | ||
| 356 | while (true) { | ||
| 357 | self.mu.lock(); | ||
| 358 | const open = self.running and (self.phase == .pending or self.phase == .shown); | ||
| 359 | if (!open) { | ||
| 360 | self.mu.unlock(); | ||
| 361 | return false; | ||
| 362 | } | ||
| 363 | self.cv.timedWait(&self.mu, watch_ns) catch {}; | ||
| 364 | const still = self.running and (self.phase == .pending or self.phase == .shown); | ||
| 365 | self.mu.unlock(); | ||
| 366 | if (!still) return false; | ||
| 367 | if (!peerGone(c)) continue; | ||
| 368 | self.mu.lock(); | ||
| 369 | if (self.phase == .pending or self.phase == .shown) self.phase = .idle; | ||
| 370 | self.mu.unlock(); | ||
| 371 | // The box on screen is showing a question nobody is waiting on. | ||
| 372 | self.wake.wake(self.wake.ctx); | ||
| 373 | return true; | ||
| 374 | } | ||
| 375 | } | ||
| 376 | |||
| 377 | /// Whether the box on screen still has an ssh behind it. The keyboard | ||
| 378 | /// asks every pass: `serve` sets the phase back to idle on a hangup, | ||
| 379 | /// and the popup has to follow. | ||
| 380 | pub fn showing(self: *Listener) bool { | ||
| 381 | self.mu.lock(); | ||
| 382 | defer self.mu.unlock(); | ||
| 383 | return self.phase == .pending or self.phase == .shown; | ||
| 384 | } | ||
| 385 | }; | ||
| 386 | |||
| 387 | /// The helper's end: `mux askpass`. One line out, one line back, and the | ||
| 388 | /// answer on `out` — which is ssh's own stdin-side pipe, so a byte written | ||
| 389 | /// here that is not the answer is a byte ssh tries to log in with. | ||
| 390 | /// | ||
| 391 | /// Every failure is exit 1 with NOTHING written: ssh reads a non-zero exit | ||
| 392 | /// as a refused prompt, which is the only honest report of a wall that | ||
| 393 | /// never answered. | ||
| 394 | pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 { | ||
| 395 | const stream = std.net.connectUnixSocket(sock) catch return 1; | ||
| 396 | defer stream.close(); | ||
| 397 | var line: [prompt_max + 2]u8 = undefined; | ||
| 398 | line[0] = @intFromEnum(kind); | ||
| 399 | // The wire is one line each way, and ssh's own prompts are multi-line | ||
| 400 | // on some builds (the host-key question). Folded, not split: the popup | ||
| 401 | // wraps it to the width anyway — and the same fold is what stops a | ||
| 402 | // server-authored prompt carrying an escape sequence onto the screen. | ||
| 403 | const n = foldControl(line[1 .. prompt_max + 1], prompt); | ||
| 404 | line[n + 1] = '\n'; | ||
| 405 | if (!writeAll(stream.handle, line[0 .. n + 2])) return 1; | ||
| 406 | var reply: [answer_max + 2]u8 = undefined; | ||
| 407 | // No deadline here, unlike the listener's read: this wait IS the user | ||
| 408 | // reading the question, and ssh is content to wait on its helper. | ||
| 409 | const got = readLine(stream.handle, &reply, -1) orelse return 1; | ||
| 410 | if (got == 0 or reply[0] != reply_answer) return 1; | ||
| 411 | const wrote = writeAll(out_fd, reply[1..got]) and writeAll(out_fd, "\n"); | ||
| 412 | // The helper is a whole process holding one password. It exits in a | ||
| 413 | // microsecond, but zeroing is one line and a core dump is a file. | ||
| 414 | @memset(&reply, 0); | ||
| 415 | return if (wrote) 0 else 1; | ||
| 416 | } | ||
| 417 | |||
| 418 | /// One prompt's bytes, made safe to paint: every control byte becomes a | ||
| 419 | /// space. Returns how many were written. | ||
| 420 | /// | ||
| 421 | /// The rule `handoff.Reason` already states for ssh's stderr, applied to | ||
| 422 | /// the other channel ssh has: a prompt is painted INSIDE a wall's | ||
| 423 | /// alternate screen with the box's own attribute, so an escape sequence in | ||
| 424 | /// one moves a cursor, sets a mode or fakes a row in somebody's tile. And | ||
| 425 | /// the text is not always ssh's: a keyboard-interactive prompt is the | ||
| 426 | /// SERVER's wording handed to the helper as argv[1], which OpenSSH does | ||
| 427 | /// not sanitize on this path. | ||
| 428 | /// | ||
| 429 | /// Run on BOTH ends — the helper before it sends, the listener on what it | ||
| 430 | /// received — because the wire is a socket any same-uid peer can reach, | ||
| 431 | /// and the end that paints is the end that must not trust. | ||
| 432 | pub fn foldControl(dst: []u8, src: []const u8) usize { | ||
| 433 | const n = @min(src.len, dst.len); | ||
| 434 | for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch; | ||
| 435 | return n; | ||
| 436 | } | ||
| 437 | |||
| 438 | /// Bytes up to the first '\n', which is dropped; anything past `buf.len` is | ||
| 439 | /// dropped too. Null is an EOF with nothing at all — a peer that hung up | ||
| 440 | /// before saying anything, which is not a prompt and not an answer. | ||
| 441 | fn readLine(fd: std.posix.fd_t, buf: []u8, timeout_ms: i32) ?usize { | ||
| 442 | var len: usize = 0; | ||
| 443 | var seen = false; | ||
| 444 | while (true) { | ||
| 445 | if (timeout_ms >= 0) { | ||
| 446 | var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 447 | const ready = std.posix.poll(&pfd, timeout_ms) catch return null; | ||
| 448 | if (ready == 0) return null; | ||
| 449 | } | ||
| 450 | var one: [1]u8 = undefined; | ||
| 451 | const n = std.posix.read(fd, &one) catch return null; | ||
| 452 | if (n == 0) return if (seen) len else null; | ||
| 453 | seen = true; | ||
| 454 | if (one[0] == '\n') return len; | ||
| 455 | if (len < buf.len) { | ||
| 456 | buf[len] = one[0]; | ||
| 457 | len += 1; | ||
| 458 | } | ||
| 459 | } | ||
| 460 | } | ||
| 461 | |||
| 462 | /// Whether the peer has closed its end. A zero-length PEEK is the only | ||
| 463 | /// answer that means it: `POLL.IN` also fires on bytes we never asked for. | ||
| 464 | fn peerGone(c: std.posix.socket_t) bool { | ||
| 465 | var pfd = [_]std.posix.pollfd{.{ .fd = c, .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 466 | const ready = std.posix.poll(&pfd, 0) catch return true; | ||
| 467 | if (ready == 0) return false; | ||
| 468 | if (pfd[0].revents & (std.posix.POLL.ERR | std.posix.POLL.NVAL) != 0) return true; | ||
| 469 | var b: [1]u8 = undefined; | ||
| 470 | const n = std.posix.recv(c, &b, std.posix.MSG.PEEK) catch return true; | ||
| 471 | return n == 0; | ||
| 472 | } | ||
| 473 | |||
| 474 | fn writeAll(fd: std.posix.fd_t, bytes: []const u8) bool { | ||
| 475 | var off: usize = 0; | ||
| 476 | while (off < bytes.len) { | ||
| 477 | const n = std.posix.write(fd, bytes[off..]) catch return false; | ||
| 478 | if (n == 0) return false; | ||
| 479 | off += n; | ||
| 480 | } | ||
| 481 | return true; | ||
| 482 | } | ||
| 483 | |||
| 484 | const Ucred = extern struct { | ||
| 485 | pid: std.posix.pid_t, | ||
| 486 | uid: std.posix.uid_t, | ||
| 487 | gid: std.posix.gid_t, | ||
| 488 | }; | ||
| 489 | |||
| 490 | /// How far up the tree the walk goes. ssh execs its helper directly, so | ||
| 491 | /// production is one step; the slack is for a shell in between. | ||
| 492 | const ancestor_max = 8; | ||
| 493 | |||
| 494 | /// Who is on the other end, or null when the kernel will not say. | ||
| 495 | fn peerCred(c: std.posix.socket_t) ?Ucred { | ||
| 496 | var buf: [@sizeOf(Ucred)]u8 = undefined; | ||
| 497 | std.posix.getsockopt(c, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, &buf) catch return null; | ||
| 498 | return std.mem.bytesToValue(Ucred, &buf); | ||
| 499 | } | ||
| 500 | |||
| 501 | /// The ssh THIS process spawned that is behind `peer`: the ancestor whose | ||
| 502 | /// parent is us. | ||
| 503 | fn dialOwner( | ||
| 504 | peer: std.posix.pid_t, | ||
| 505 | me: std.posix.pid_t, | ||
| 506 | parent: *const fn (std.posix.pid_t) std.posix.pid_t, | ||
| 507 | ) std.posix.pid_t { | ||
| 508 | // Not simply the helper's parent, which is what ssh execing its helper | ||
| 509 | // directly makes it. A shell can sit in between — a test's stand-in | ||
| 510 | // does, and so does a real ssh whose ProxyCommand is one — and an | ||
| 511 | // attribution that breaks on one extra fork returns 0 exactly when a | ||
| 512 | // wall needs a name. What is INVARIANT is the other end: the ssh a | ||
| 513 | // dial spawned is a child of this process, and nothing else on the | ||
| 514 | // path is. | ||
| 515 | var at = peer; | ||
| 516 | var steps: usize = 0; | ||
| 517 | while (at > 0 and steps < ancestor_max) : (steps += 1) { | ||
| 518 | const up = parent(at); | ||
| 519 | if (up == me) return at; | ||
| 520 | at = up; | ||
| 521 | } | ||
| 522 | return 0; | ||
| 523 | } | ||
| 524 | |||
| 525 | /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by | ||
| 526 | /// counting spaces: field 2 is the executable's name, unquoted, and a | ||
| 527 | /// program free to call itself `a b) c` is a program free to move every | ||
| 528 | /// field after it. | ||
| 529 | fn parentOf(pid: std.posix.pid_t) std.posix.pid_t { | ||
| 530 | if (pid <= 0) return 0; | ||
| 531 | var path_buf: [64]u8 = undefined; | ||
| 532 | const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return 0; | ||
| 533 | var stat_buf: [512]u8 = undefined; | ||
| 534 | const f = std.fs.cwd().openFile(path, .{}) catch return 0; | ||
| 535 | defer f.close(); | ||
| 536 | const n = f.read(&stat_buf) catch return 0; | ||
| 537 | const text = stat_buf[0..n]; | ||
| 538 | const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return 0; | ||
| 539 | var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' '); | ||
| 540 | _ = it.next() orelse return 0; // the run state | ||
| 541 | const ppid = it.next() orelse return 0; | ||
| 542 | return std.fmt.parseInt(std.posix.pid_t, ppid, 10) catch 0; | ||
| 543 | } | ||
| 544 | |||
| 545 | // ---- tests ---- | ||
| 546 | |||
| 547 | const testtmp = @import("testtmp"); | ||
| 548 | |||
| 549 | /// A wake that counts, so a test can assert the doorbell rang at all. | ||
| 550 | const Counter = struct { | ||
| 551 | n: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), | ||
| 552 | fn bump(ctx: *anyopaque) void { | ||
| 553 | const self: *Counter = @ptrCast(@alignCast(ctx)); | ||
| 554 | _ = self.n.fetchAdd(1, .release); | ||
| 555 | } | ||
| 556 | fn hooks(self: *Counter) Hooks { | ||
| 557 | return .{ .ctx = self, .wake = bump }; | ||
| 558 | } | ||
| 559 | }; | ||
| 560 | |||
| 561 | /// Waits for the accept thread to park a prompt, so no test spins on a | ||
| 562 | /// race it cannot see. Returns false on the 2 s budget. | ||
| 563 | fn awaitPrompt(l: *Listener, out: *Prompt) bool { | ||
| 564 | var waited: usize = 0; | ||
| 565 | while (waited < 2000) : (waited += 5) { | ||
| 566 | if (l.take(out)) return true; | ||
| 567 | std.Thread.sleep(5 * std.time.ns_per_ms); | ||
| 568 | } | ||
| 569 | return false; | ||
| 570 | } | ||
| 571 | |||
| 572 | const HelperRun = struct { | ||
| 573 | code: u8 = 0, | ||
| 574 | out_path: []const u8, | ||
| 575 | prompt: []const u8, | ||
| 576 | sock: []const u8, | ||
| 577 | kind: Kind = .secret, | ||
| 578 | fn go(self: *HelperRun) void { | ||
| 579 | const f = std.fs.cwd().createFile(self.out_path, .{}) catch return; | ||
| 580 | defer f.close(); | ||
| 581 | self.code = helperMain(self.prompt, self.sock, self.kind, f.handle); | ||
| 582 | } | ||
| 583 | }; | ||
| 584 | |||
| 585 | fn helperStdout(alloc: std.mem.Allocator, path: []const u8) ![]u8 { | ||
| 586 | return std.fs.cwd().readFileAlloc(alloc, path, 4096); | ||
| 587 | } | ||
| 588 | |||
| 589 | test "askpass.Listener: a helper's line reaches take, and answer reaches the helper" { | ||
| 590 | const alloc = std.testing.allocator; | ||
| 591 | var tmp = try testtmp.TmpDir.make(); | ||
| 592 | defer tmp.cleanup(); | ||
| 593 | var counter: Counter = .{}; | ||
| 594 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 595 | defer l.stop(); | ||
| 596 | |||
| 597 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 598 | defer alloc.free(out_path); | ||
| 599 | var run: HelperRun = .{ | ||
| 600 | .out_path = out_path, | ||
| 601 | .prompt = "box's password: ", | ||
| 602 | .sock = l.path, | ||
| 603 | }; | ||
| 604 | const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run}); | ||
| 605 | |||
| 606 | var p: Prompt = .{}; | ||
| 607 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 608 | try std.testing.expectEqualStrings("box's password: ", p.slice()); | ||
| 609 | l.answer("s3cret"); | ||
| 610 | th.join(); | ||
| 611 | |||
| 612 | const got = try helperStdout(alloc, out_path); | ||
| 613 | defer alloc.free(got); | ||
| 614 | try std.testing.expectEqualStrings("s3cret\n", got); | ||
| 615 | try std.testing.expectEqual(@as(u8, 0), run.code); | ||
| 616 | try std.testing.expect(counter.n.load(.acquire) >= 1); | ||
| 617 | } | ||
| 618 | |||
| 619 | test "askpass.Listener: decline gives the helper exit 1 and no stdout" { | ||
| 620 | const alloc = std.testing.allocator; | ||
| 621 | var tmp = try testtmp.TmpDir.make(); | ||
| 622 | defer tmp.cleanup(); | ||
| 623 | var counter: Counter = .{}; | ||
| 624 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 625 | defer l.stop(); | ||
| 626 | |||
| 627 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 628 | defer alloc.free(out_path); | ||
| 629 | var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path }; | ||
| 630 | const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run}); | ||
| 631 | |||
| 632 | var p: Prompt = .{}; | ||
| 633 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 634 | l.decline(); | ||
| 635 | th.join(); | ||
| 636 | |||
| 637 | const got = try helperStdout(alloc, out_path); | ||
| 638 | defer alloc.free(got); | ||
| 639 | // ssh logs in with whatever this fd carried, so "nothing" is the claim, | ||
| 640 | // not "something short". | ||
| 641 | try std.testing.expectEqualStrings("", got); | ||
| 642 | try std.testing.expectEqual(@as(u8, 1), run.code); | ||
| 643 | } | ||
| 644 | |||
| 645 | test "askpass.Listener: an EMPTY answer is an answer, not a decline" { | ||
| 646 | const alloc = std.testing.allocator; | ||
| 647 | var tmp = try testtmp.TmpDir.make(); | ||
| 648 | defer tmp.cleanup(); | ||
| 649 | var counter: Counter = .{}; | ||
| 650 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 651 | defer l.stop(); | ||
| 652 | |||
| 653 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 654 | defer alloc.free(out_path); | ||
| 655 | var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path }; | ||
| 656 | const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run}); | ||
| 657 | |||
| 658 | var p: Prompt = .{}; | ||
| 659 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 660 | // Enter on an empty line. A key with no passphrase asks anyway, and the | ||
| 661 | // user who answers nothing has answered. | ||
| 662 | l.answer(""); | ||
| 663 | th.join(); | ||
| 664 | |||
| 665 | const got = try helperStdout(alloc, out_path); | ||
| 666 | defer alloc.free(got); | ||
| 667 | try std.testing.expectEqualStrings("\n", got); | ||
| 668 | try std.testing.expectEqual(@as(u8, 0), run.code); | ||
| 669 | } | ||
| 670 | |||
| 671 | test "askpass.Listener: two helpers are served one at a time, in accept order" { | ||
| 672 | const alloc = std.testing.allocator; | ||
| 673 | var tmp = try testtmp.TmpDir.make(); | ||
| 674 | defer tmp.cleanup(); | ||
| 675 | var counter: Counter = .{}; | ||
| 676 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 677 | defer l.stop(); | ||
| 678 | |||
| 679 | const out_a = try std.fmt.allocPrint(alloc, "{s}/a", .{tmp.path()}); | ||
| 680 | defer alloc.free(out_a); | ||
| 681 | var first: HelperRun = .{ .out_path = out_a, .prompt = "first: ", .sock = l.path }; | ||
| 682 | const th_a = try std.Thread.spawn(.{}, HelperRun.go, .{&first}); | ||
| 683 | |||
| 684 | var p: Prompt = .{}; | ||
| 685 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 686 | try std.testing.expectEqualStrings("first: ", p.slice()); | ||
| 687 | |||
| 688 | const out_b = try std.fmt.allocPrint(alloc, "{s}/b", .{tmp.path()}); | ||
| 689 | defer alloc.free(out_b); | ||
| 690 | var second: HelperRun = .{ .out_path = out_b, .prompt = "second: ", .sock = l.path }; | ||
| 691 | const th_b = try std.Thread.spawn(.{}, HelperRun.go, .{&second}); | ||
| 692 | |||
| 693 | // The second helper is connected and waiting in the backlog, and the | ||
| 694 | // popup on screen is still the first one's: a second `take` here would | ||
| 695 | // be a popup that changed its question under the user's fingers. | ||
| 696 | std.Thread.sleep(100 * std.time.ns_per_ms); | ||
| 697 | var q: Prompt = .{}; | ||
| 698 | try std.testing.expect(!l.take(&q)); | ||
| 699 | |||
| 700 | l.answer("one"); | ||
| 701 | th_a.join(); | ||
| 702 | try std.testing.expect(awaitPrompt(l, &q)); | ||
| 703 | try std.testing.expectEqualStrings("second: ", q.slice()); | ||
| 704 | l.answer("two"); | ||
| 705 | th_b.join(); | ||
| 706 | |||
| 707 | const a = try helperStdout(alloc, out_a); | ||
| 708 | defer alloc.free(a); | ||
| 709 | const b = try helperStdout(alloc, out_b); | ||
| 710 | defer alloc.free(b); | ||
| 711 | try std.testing.expectEqualStrings("one\n", a); | ||
| 712 | try std.testing.expectEqualStrings("two\n", b); | ||
| 713 | } | ||
| 714 | |||
| 715 | /// A process tree as a table, so the walk can be driven over shapes no | ||
| 716 | /// test could arrange with real forks. | ||
| 717 | const FakeTree = struct { | ||
| 718 | // helper 100 <- sh 99 <- sh 98 <- ssh 97 <- us 7 <- init 1 | ||
| 719 | const rows = [_][2]std.posix.pid_t{ | ||
| 720 | .{ 100, 99 }, .{ 99, 98 }, .{ 98, 97 }, .{ 97, 7 }, .{ 7, 1 }, .{ 1, 0 }, | ||
| 721 | }; | ||
| 722 | fn parent(pid: std.posix.pid_t) std.posix.pid_t { | ||
| 723 | for (rows) |r| if (r[0] == pid) return r[1]; | ||
| 724 | return 0; | ||
| 725 | } | ||
| 726 | // A cycle no real tree has, for the bound below. | ||
| 727 | fn ring(pid: std.posix.pid_t) std.posix.pid_t { | ||
| 728 | return if (pid == 1) 2 else 1; | ||
| 729 | } | ||
| 730 | }; | ||
| 731 | |||
| 732 | test "askpass: a helper two shells below the ssh we spawned is still that ssh's" { | ||
| 733 | // ssh execs its helper directly, so production is one step. A stand-in | ||
| 734 | // — and a real ssh whose ProxyCommand is a shell — puts more in | ||
| 735 | // between, and an attribution that breaks on one extra fork answers 0 | ||
| 736 | // exactly where a wall needs a name. The invariant is the far end: the | ||
| 737 | // ssh a dial spawned is OUR child, and nothing else on the path is. | ||
| 738 | try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent)); | ||
| 739 | // The direct case, unchanged. | ||
| 740 | try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent)); | ||
| 741 | // A peer that is not ours at all: someone else's process on the same | ||
| 742 | // box. Answering a pid here would park a tile on a stranger's decline. | ||
| 743 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(100, 4242, FakeTree.parent)); | ||
| 744 | // A tree that loops must end the walk rather than the process. | ||
| 745 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring)); | ||
| 746 | } | ||
| 747 | |||
| 748 | test "askpass.parentOf: the field it reads is the one the OS calls ppid" { | ||
| 749 | // The reader, against the OS itself rather than against a fixture — | ||
| 750 | // `/proc/<pid>/stat` field 4 is positional, and a comment claiming | ||
| 751 | // which field that is cannot fail. | ||
| 752 | try std.testing.expectEqual(std.os.linux.getppid(), parentOf(std.os.linux.getpid())); | ||
| 753 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), parentOf(0)); | ||
| 754 | } | ||
| 755 | |||
| 756 | test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" { | ||
| 757 | const alloc = std.testing.allocator; | ||
| 758 | var tmp = try testtmp.TmpDir.make(); | ||
| 759 | defer tmp.cleanup(); | ||
| 760 | var counter: Counter = .{}; | ||
| 761 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 762 | defer l.stop(); | ||
| 763 | |||
| 764 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 765 | defer alloc.free(out_path); | ||
| 766 | var run: HelperRun = .{ .out_path = out_path, .prompt = "who: ", .sock = l.path }; | ||
| 767 | const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run}); | ||
| 768 | |||
| 769 | var p: Prompt = .{}; | ||
| 770 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 771 | // The helper here is a THREAD, so the peer IS this process and no | ||
| 772 | // ancestor of it is our child. 0, and the prompt is still answered: | ||
| 773 | // attribution is a convenience, never a condition for answering. | ||
| 774 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), p.ssh_pid); | ||
| 775 | l.answer("x"); | ||
| 776 | th.join(); | ||
| 777 | const got = try helperStdout(alloc, out_path); | ||
| 778 | defer alloc.free(got); | ||
| 779 | try std.testing.expectEqualStrings("x\n", got); | ||
| 780 | } | ||
| 781 | |||
| 782 | test "askpass.Listener: a decline ends the dial's ssh, it does not just refuse the prompt" { | ||
| 783 | const alloc = std.testing.allocator; | ||
| 784 | var tmp = try testtmp.TmpDir.make(); | ||
| 785 | defer tmp.cleanup(); | ||
| 786 | var counter: Counter = .{}; | ||
| 787 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 788 | defer l.stop(); | ||
| 789 | |||
| 790 | // A live process standing in for the dial's ssh, because the claim is | ||
| 791 | // about the OS and not about a flag: an Esc that only refused the | ||
| 792 | // prompt would leave OpenSSH trying the empty password and asking | ||
| 793 | // again, three times against a real sshd. | ||
| 794 | var ssh = std.process.Child.init(&.{ "/bin/sleep", "30" }, alloc); | ||
| 795 | try ssh.spawn(); | ||
| 796 | l.mu.lock(); | ||
| 797 | l.prompt.ssh_pid = ssh.id; | ||
| 798 | l.phase = .shown; | ||
| 799 | l.mu.unlock(); | ||
| 800 | l.decline(); | ||
| 801 | |||
| 802 | const term = try ssh.wait(); | ||
| 803 | switch (term) { | ||
| 804 | .Signal => |sig| try std.testing.expectEqual(@as(u32, std.posix.SIG.TERM), sig), | ||
| 805 | else => { | ||
| 806 | std.debug.print("the declined dial's ssh was not signalled: {any}\n", .{term}); | ||
| 807 | return error.TestUnexpectedResult; | ||
| 808 | }, | ||
| 809 | } | ||
| 810 | } | ||
| 811 | |||
| 812 | test "askpass.Listener: a declined pid parks, an untouched one does not" { | ||
| 813 | const alloc = std.testing.allocator; | ||
| 814 | var tmp = try testtmp.TmpDir.make(); | ||
| 815 | defer tmp.cleanup(); | ||
| 816 | var counter: Counter = .{}; | ||
| 817 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 818 | defer l.stop(); | ||
| 819 | |||
| 820 | // Driven through the same door the pump asks at, with the prompt state | ||
| 821 | // set by hand: the ring is what a redial reads, and the pid it is keyed | ||
| 822 | // on comes from a dial, not from this socket. | ||
| 823 | l.mu.lock(); | ||
| 824 | l.prompt.ssh_pid = 4242; | ||
| 825 | l.phase = .shown; | ||
| 826 | l.mu.unlock(); | ||
| 827 | l.decline(); | ||
| 828 | try std.testing.expect(l.declined(4242)); | ||
| 829 | try std.testing.expect(!l.declined(4243)); | ||
| 830 | // A dial whose attribution failed is not every dial: pid 0 must never | ||
| 831 | // park a tile. | ||
| 832 | try std.testing.expect(!l.declined(0)); | ||
| 833 | } | ||
| 834 | |||
| 835 | test "askpass.Listener: a peer that says nothing does not park the next prompt" { | ||
| 836 | const alloc = std.testing.allocator; | ||
| 837 | var tmp = try testtmp.TmpDir.make(); | ||
| 838 | defer tmp.cleanup(); | ||
| 839 | var counter: Counter = .{}; | ||
| 840 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 841 | // Registered FIRST so it runs LAST: the silent peer below must be | ||
| 842 | // closed before this join, or a broken deadline wedges the runner | ||
| 843 | // instead of failing — and a wedged test step prints nothing at all. | ||
| 844 | defer l.stop(); | ||
| 845 | l.request_ms = 150; | ||
| 846 | |||
| 847 | // Connected, and then nothing: no tag, no text, no newline. The accept | ||
| 848 | // thread serves one connection to completion, so without a deadline | ||
| 849 | // this is every later prompt of this wall parked behind one peer. | ||
| 850 | const mute = try std.net.connectUnixSocket(l.path); | ||
| 851 | defer mute.close(); | ||
| 852 | |||
| 853 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 854 | defer alloc.free(out_path); | ||
| 855 | var run: HelperRun = .{ .out_path = out_path, .prompt = "after: ", .sock = l.path }; | ||
| 856 | const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run}); | ||
| 857 | var p: Prompt = .{}; | ||
| 858 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 859 | try std.testing.expectEqualStrings("after: ", p.slice()); | ||
| 860 | l.answer("x"); | ||
| 861 | th.join(); | ||
| 862 | } | ||
| 863 | |||
| 864 | test "askpass.Listener: a helper that hangs up closes the box behind it" { | ||
| 865 | const alloc = std.testing.allocator; | ||
| 866 | var tmp = try testtmp.TmpDir.make(); | ||
| 867 | defer tmp.cleanup(); | ||
| 868 | var counter: Counter = .{}; | ||
| 869 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 870 | defer l.stop(); | ||
| 871 | |||
| 872 | // The FIDO notifier's shape: ssh runs the helper to say "touch your | ||
| 873 | // key" and SIGTERMs it the moment the touch lands. A box left standing | ||
| 874 | // after that is one the user must dismiss for no reason — and | ||
| 875 | // dismissing it would record a LIVE dial's pid as declined. | ||
| 876 | const peer = try std.net.connectUnixSocket(l.path); | ||
| 877 | _ = try peer.write("nConfirm user presence for key ED25519-SK\n"); | ||
| 878 | var p: Prompt = .{}; | ||
| 879 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 880 | try std.testing.expectEqual(Kind.notice, p.kind); | ||
| 881 | try std.testing.expect(l.showing()); | ||
| 882 | peer.close(); | ||
| 883 | |||
| 884 | var waited: usize = 0; | ||
| 885 | while (waited < 3000 and l.showing()) : (waited += 25) | ||
| 886 | std.Thread.sleep(25 * std.time.ns_per_ms); | ||
| 887 | try std.testing.expect(!l.showing()); | ||
| 888 | // The doorbell rang, which is what makes the keyboard look. | ||
| 889 | try std.testing.expect(counter.n.load(.acquire) >= 2); | ||
| 890 | // Nothing was declined: the ssh behind that notifier is alive and its | ||
| 891 | // dial is still going. | ||
| 892 | try std.testing.expect(!l.declined(p.ssh_pid)); | ||
| 893 | } | ||
| 894 | |||
| 895 | test "askpass.helperMain: a socket nobody is listening on is a refused prompt" { | ||
| 896 | const alloc = std.testing.allocator; | ||
| 897 | var tmp = try testtmp.TmpDir.make(); | ||
| 898 | defer tmp.cleanup(); | ||
| 899 | const sock = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()}); | ||
| 900 | defer alloc.free(sock); | ||
| 901 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 902 | defer alloc.free(out_path); | ||
| 903 | const f = try std.fs.cwd().createFile(out_path, .{}); | ||
| 904 | defer f.close(); | ||
| 905 | try std.testing.expectEqual(@as(u8, 1), helperMain("password: ", sock, .secret, f.handle)); | ||
| 906 | const got = try helperStdout(alloc, out_path); | ||
| 907 | defer alloc.free(got); | ||
| 908 | try std.testing.expectEqualStrings("", got); | ||
| 909 | } | ||
| 910 | |||
| 911 | test "askpass: a server-authored prompt cannot move a cursor" { | ||
| 912 | // The rule `handoff.Reason` states for ssh's stderr ("control bytes are | ||
| 913 | // dropped, so nothing ssh says can move a cursor"), on the channel that | ||
| 914 | // is not even ssh's own words: a keyboard-interactive prompt is the | ||
| 915 | // SERVER's text, and it is painted inside a wall's alternate screen. | ||
| 916 | var buf: [64]u8 = undefined; | ||
| 917 | const n = foldControl(&buf, "\x1b[2J\x1b[1;1HEnter\x07 code:\x7f "); | ||
| 918 | try std.testing.expectEqualStrings(" [2J [1;1HEnter code: ", buf[0..n]); | ||
| 919 | // Every byte survives as SOMETHING: a prompt shortened by a filter is a | ||
| 920 | // prompt the user reads half of. | ||
| 921 | try std.testing.expectEqual("\x1b[2J\x1b[1;1HEnter\x07 code:\x7f ".len, n); | ||
| 922 | // High bytes are not control bytes: a UTF-8 prompt paints. | ||
| 923 | const m = foldControl(&buf, "clé:"); | ||
| 924 | try std.testing.expectEqualStrings("clé:", buf[0..m]); | ||
| 925 | } | ||
| 926 | |||
| 927 | test "askpass.Listener: control bytes a peer sends direct are folded too" { | ||
| 928 | const alloc = std.testing.allocator; | ||
| 929 | var tmp = try testtmp.TmpDir.make(); | ||
| 930 | defer tmp.cleanup(); | ||
| 931 | var counter: Counter = .{}; | ||
| 932 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 933 | defer l.stop(); | ||
| 934 | // Not through `helperMain`: the socket is reachable by any same-uid | ||
| 935 | // peer, and the end that paints is the end that must not trust. | ||
| 936 | const stream = try std.net.connectUnixSocket(l.path); | ||
| 937 | defer stream.close(); | ||
| 938 | // Hand-written wire: the tag byte, then the text. `q` is no tag ssh | ||
| 939 | // has, so this also pins the unknown-tag default. | ||
| 940 | _ = try stream.write("qboom\x1b[31m: \n"); | ||
| 941 | var p: Prompt = .{}; | ||
| 942 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 943 | try std.testing.expectEqualStrings("boom [31m: ", p.slice()); | ||
| 944 | try std.testing.expectEqual(Kind.secret, p.kind); | ||
| 945 | l.decline(); | ||
| 946 | } | ||
| 947 | |||
| 948 | test "askpass.helperMain: a prompt's newlines fold, so one prompt is one line" { | ||
| 949 | const alloc = std.testing.allocator; | ||
| 950 | var tmp = try testtmp.TmpDir.make(); | ||
| 951 | defer tmp.cleanup(); | ||
| 952 | var counter: Counter = .{}; | ||
| 953 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 954 | defer l.stop(); | ||
| 955 | |||
| 956 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 957 | defer alloc.free(out_path); | ||
| 958 | var run: HelperRun = .{ | ||
| 959 | .out_path = out_path, | ||
| 960 | // ssh's host-key question arrives with the fingerprint on its own | ||
| 961 | // line on some builds. Split at the newline, the second half would | ||
| 962 | // read as the ANSWER to the first. | ||
| 963 | .prompt = "The authenticity of host 'box' can't be established.\nED25519 key fingerprint is SHA256:xyz.\nAre you sure you want to continue connecting (yes/no)? ", | ||
| 964 | .sock = l.path, | ||
| 965 | }; | ||
| 966 | const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run}); | ||
| 967 | |||
| 968 | var p: Prompt = .{}; | ||
| 969 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 970 | try std.testing.expect(std.mem.indexOfScalar(u8, p.slice(), '\n') == null); | ||
| 971 | try std.testing.expect(std.mem.endsWith(u8, p.slice(), "(yes/no)? ")); | ||
| 972 | l.answer("yes"); | ||
| 973 | th.join(); | ||
| 974 | } | ||
| 975 | |||
| 976 | test "askpass.Kind: ssh's own variable says what it is asking for, and an unknown value is a secret" { | ||
| 977 | // The exact signal, in place of matching `assword` against the text: a | ||
| 978 | // keyboard-interactive prompt is the SERVER's wording, and a substring | ||
| 979 | // rule paints its answer in the clear whenever the server spells the | ||
| 980 | // question its own way. | ||
| 981 | try std.testing.expectEqual(Kind.secret, Kind.of(null)); | ||
| 982 | try std.testing.expectEqual(Kind.confirm, Kind.of("confirm")); | ||
| 983 | try std.testing.expectEqual(Kind.notice, Kind.of("none")); | ||
| 984 | // A value neither of ssh's words: the mistake that hides an answer is | ||
| 985 | // cheaper than the one that paints it. | ||
| 986 | try std.testing.expectEqual(Kind.secret, Kind.of("something-openssh-adds-in-2027")); | ||
| 987 | } | ||
| 988 | |||
| 989 | test "askpass.Listener: the kind ssh named rides the wire, one byte ahead of the text" { | ||
| 990 | const alloc = std.testing.allocator; | ||
| 991 | var tmp = try testtmp.TmpDir.make(); | ||
| 992 | defer tmp.cleanup(); | ||
| 993 | var counter: Counter = .{}; | ||
| 994 | const l = try Listener.start(alloc, tmp.path(), counter.hooks()); | ||
| 995 | defer l.stop(); | ||
| 996 | const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()}); | ||
| 997 | defer alloc.free(out_path); | ||
| 998 | |||
| 999 | for ([_]struct { k: Kind, text: []const u8 }{ | ||
| 1000 | .{ .k = .secret, .text = "box's password: " }, | ||
| 1001 | .{ .k = .confirm, .text = "Are you sure you want to continue connecting (yes/no)? " }, | ||
| 1002 | .{ .k = .notice, .text = "Confirm user presence for key ED25519-SK SHA256:xyz" }, | ||
| 1003 | }) |c| { | ||
| 1004 | var run: HelperRun = .{ .out_path = out_path, .prompt = c.text, .sock = l.path, .kind = c.k }; | ||
| 1005 | const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run}); | ||
| 1006 | var p: Prompt = .{}; | ||
| 1007 | try std.testing.expect(awaitPrompt(l, &p)); | ||
| 1008 | try std.testing.expectEqual(c.k, p.kind); | ||
| 1009 | // The tag is carriage, not content: the text arrives whole. | ||
| 1010 | try std.testing.expectEqualStrings(c.text, p.slice()); | ||
| 1011 | l.answer("x"); | ||
| 1012 | th.join(); | ||
| 1013 | } | ||
| 1014 | } | ||
| 1015 | |||
| 1016 | test { | ||
| 1017 | std.testing.refAllDeclsRecursive(@This()); | ||
| 1018 | } | ||