src/client/askpass.zig
Ref: Size: 42.0 KiB History
//! ssh's prompts, and the one place mux can answer them. ssh reads passwords
//! and the host-key question from `/dev/tty` — a read no fd mux sets can reach,
//! and under a wall's alternate screen one nobody can see.
//! `SSH_ASKPASS_REQUIRE=force` turns each into an exec whose stdout is the
//! answer, so the prompt becomes BYTES.
//!
//! Both ends of that trip live here: `Listener` is the client's, one socket per
//! process, and `helperMain` is what `mux askpass` runs. Nothing here paints or
//! spawns, so the whole carriage drives from a test with no ssh and no wall.
const std = @import("std");
// `serve_mod` and not `serve`: Listener has its own `serve` method, and
// inside the struct the bare name is ambiguous.
const dial = @import("dial");
const serve_mod = @import("serve");
const xdg = @import("xdg");
const client_os = @import("client_os");
/// Env var naming the socket. The mode word for the helper, too: ssh execs
/// its helper with the prompt as argv[1] and nothing else, so there is no
/// word to give it, and this variable exists in exactly one process tree —
/// the ssh a wall dial spawned.
pub const sock_env = "MUX_ASKPASS_SOCK";
/// ssh's longest ordinary question is the host-key one, ~220 bytes with a
/// fingerprint in it. Cut, never refused: a prompt the user cannot read
/// whole is still a prompt they can answer.
pub const prompt_max = 512;
/// A passphrase, not a document.
pub const answer_max = 256;
/// How many declined dials are remembered. A pid the ring has evicted reads
/// `declined` false, which costs one more prompt — the failure a caller
/// cannot see is a tile parked forever, so the ring errs the other way.
pub const decline_ring = 8;
/// How many helpers may be waiting to be served. A separate number from
/// the ring above, which it briefly shared by accident: one counts refused
/// dials, this one counts sshs queued behind a popup.
const backlog = 8;
/// The reply's first byte. An EMPTY answer is a real answer — a key whose
/// passphrase is empty, an Enter on a question the user means to leave
/// blank — so "declined" cannot be spelled by an empty line, which is the
/// whole reason there is a tag at all.
const reply_answer = '+';
const reply_decline = '-';
/// What OpenSSH 8.4+ puts in the helper's environment to say what it is asking
/// for. An exact signal, so there is no guessing: matching `assword` against the
/// text would paint a server-authored prompt's answer in the clear whenever the
/// server spelled it differently.
pub const prompt_env = "SSH_ASKPASS_PROMPT";
/// The three things ssh can want, and the one byte the wire carries to say
/// which. A value that is neither of ssh's two words is a SECRET: the
/// mistake that hides an answer is cheaper than the one that paints it.
pub const Kind = enum(u8) {
secret = 's',
/// The host-key question and every other yes/no. Painted in the clear,
/// because the user is comparing a fingerprint.
confirm = 'c',
/// Not a question: "Confirm user presence for key ..." — the FIDO touch
/// notifier. ssh SIGTERMs the helper when the touch lands, so this box
/// takes no answer and closes when the peer hangs up.
notice = 'n',
pub fn of(env: ?[]const u8) Kind {
const v = env orelse return .secret;
if (std.mem.eql(u8, v, "confirm")) return .confirm;
if (std.mem.eql(u8, v, "none")) return .notice;
return .secret;
}
fn ofTag(b: u8) Kind {
return switch (b) {
@intFromEnum(Kind.confirm) => .confirm,
@intFromEnum(Kind.notice) => .notice,
else => .secret,
};
}
};
/// How long `serve` gives a peer to finish its request line, and the slice
/// it re-checks the peer on while the box is up. A connected peer that
/// never speaks would otherwise park the accept thread for the wall's life,
/// and every later prompt behind it.
const request_ms_default: i32 = 5000;
const watch_ns: u64 = 200 * std.time.ns_per_ms;
/// One pending prompt, attributed to the ssh child that raised it.
pub const Prompt = struct {
text: [prompt_max]u8 = undefined,
text_len: usize = 0,
kind: Kind = .secret,
/// The dial this prompt belongs to: the ancestor of the connecting
/// helper that is THIS process's child, which is the ssh a wall dial
/// spawned (`dialOwner`). 0 when the walk found none — attribution is
/// a convenience, never a condition for answering.
ssh_pid: std.posix.pid_t = 0,
pub fn slice(self: *const Prompt) []const u8 {
return self.text[0..self.text_len];
}
};
/// The doorbell a prompt rings: `SessionPoll.Hooks`, for its reason.
pub const Hooks = struct {
ctx: *anyopaque,
wake: *const fn (*anyopaque) void,
};
/// The wall's end: one socket, one accept thread, one prompt at a time.
/// Serialized BY CONSTRUCTION — the accept thread serves a connection to
/// completion before accepting the next, so a second ssh waits in the backlog.
/// That is also the whole fairness policy: accept order.
pub const Listener = struct {
/// The state one prompt moves through. `shown` exists so a doorbell the
/// keyboard rings twice opens one popup: `take` is the transition, not
/// a read.
const Phase = enum { idle, pending, shown, done };
alloc: std.mem.Allocator,
path: []const u8,
/// The listening socket and the identity of the file it was bound to.
/// The id is what keeps `retire` from unlinking a SUCCESSOR's socket:
/// the name has this client's pid in it, and a pid comes round again.
bound: serve_mod.Bound,
/// How `stop` reaches a thread parked in `poll`. Closing the listening
/// fd under an accept is not defined to wake it; a byte here is.
stop_r: std.posix.fd_t,
stop_w: std.posix.fd_t,
thread: ?std.Thread = null,
wake: Hooks,
mu: std.Thread.Mutex = .{},
cv: std.Thread.Condition = .{},
running: bool = true,
phase: Phase = .idle,
prompt: Prompt = .{},
answer_buf: [answer_max]u8 = undefined,
answer_len: usize = 0,
was_declined: bool = false,
ring: [decline_ring]std.posix.pid_t = @splat(0),
ring_at: usize = 0,
/// A field rather than the constant, `HandoffTarget.deadline_ms`'s
/// shape: a test that has to prove the deadline exists must not spend
/// it.
request_ms: i32 = request_ms_default,
/// `runtime_dir` is where the socket goes; the pid in the name is what
/// makes it this client's and not another's.
pub fn start(alloc: std.mem.Allocator, runtime_dir: []const u8, wake: Hooks) !*Listener {
const path = try std.fmt.allocPrint(
alloc,
"{s}/mux-ask-{d}.sock",
.{ runtime_dir, client_os.getpid() },
);
errdefer alloc.free(path);
// Every wall that died by signal — a closed terminal window, a kill
// — left its socket here, and `retire` never ran for it. This wall
// is the first since to look, and asks the OS which owners are gone.
xdg.reapDeadPid(runtime_dir, "mux-ask-");
// `clobber_own`: a client that died without unlinking left a file,
// and a pid comes round again. Nothing else may own this name — it
// has our pid in it. CLOEXEC because this process spawns the ssh
// that the prompt is FOR, and every command run inside a session
// below it; none of them may hold the socket that answers.
var bound = try serve_mod.bind(path, .{
.policy = .clobber_own,
.backlog = backlog,
.cloexec = true,
});
errdefer _ = bound.close(path);
const bell = try std.posix.pipe2(.{ .CLOEXEC = true });
errdefer {
std.posix.close(bell[0]);
std.posix.close(bell[1]);
}
const self = try alloc.create(Listener);
errdefer alloc.destroy(self);
self.* = .{
.alloc = alloc,
.path = path,
.bound = bound,
.stop_r = bell[0],
.stop_w = bell[1],
.wake = wake,
};
self.thread = try std.Thread.spawn(.{}, acceptLoop, .{self});
return self;
}
/// Unlinks and joins. A helper still waiting is answered with a decline,
/// because an ssh blocked on a socket nobody will ever read is a dial
/// that never ends.
pub fn stop(self: *Listener) void {
self.retire();
if (self.thread) |t| t.join();
std.posix.close(self.bound.fd);
std.posix.close(self.stop_r);
std.posix.close(self.stop_w);
self.alloc.free(self.path);
self.alloc.destroy(self);
}
/// The half of `stop` a process about to `exit` may run.
pub fn retire(self: *Listener) void {
// The name leaves the filesystem and a waiting helper is declined;
// nothing is joined, closed or freed. The wall ends in `std.posix.exit`
// with detached pumps live, one of which may be inside `declined` on
// this object — a free there is a use-after-free.
self.mu.lock();
self.running = false;
self.cv.broadcast();
self.mu.unlock();
_ = std.posix.write(self.stop_w, "x") catch {};
// The guard without the close: a successor Listener in a process
// that got our pid back binds this same name, and unlinking by name
// here would take ITS socket and leave that client's ssh prompting
// at nothing.
_ = self.bound.unlinkIfOurs(self.path);
}
/// The keyboard's side. Copies the pending prompt out and marks it
/// shown; false when there is nothing waiting.
pub fn take(self: *Listener, out: *Prompt) bool {
self.mu.lock();
defer self.mu.unlock();
if (self.phase != .pending) return false;
out.* = self.prompt;
self.phase = .shown;
return true;
}
pub fn answer(self: *Listener, text: []const u8) void {
self.finish(text, false);
}
pub fn decline(self: *Listener) void {
self.finish("", true);
}
fn finish(self: *Listener, text: []const u8, declining: bool) void {
self.mu.lock();
defer self.mu.unlock();
// Not `.pending`: an answer for a prompt nobody showed is a driver
// bug, and unblocking the helper with it would hide the bug behind
// a working login.
if (self.phase != .shown) return;
const n = @min(text.len, answer_max);
@memcpy(self.answer_buf[0..n], text[0..n]);
self.answer_len = n;
self.was_declined = declining;
// pid 0 is the failed walk, and remembering it would park every
// tile whose attribution failed.
if (declining and self.prompt.ssh_pid != 0) {
self.ring[self.ring_at % decline_ring] = self.prompt.ssh_pid;
self.ring_at += 1;
// The dial ends HERE, before the helper is released: a refused
// askpass is not a refused login to OpenSSH. A helper that exits
// non-zero is read as the EMPTY password, so ssh tries it and asks
// again up to `NumberOfPasswordPrompts` — one Esc, three prompts.
// Killing our own child makes one Esc one refusal.
std.posix.kill(self.prompt.ssh_pid, std.posix.SIG.TERM) catch {};
}
self.phase = .done;
self.cv.signal();
}
/// The pump's "stop redialing" test: a dial whose prompt the user
/// refused must not come straight back with the same question.
pub fn declined(self: *Listener, ssh_pid: std.posix.pid_t) bool {
if (ssh_pid == 0) return false;
self.mu.lock();
defer self.mu.unlock();
for (self.ring) |p| if (p == ssh_pid) return true;
return false;
}
fn acceptLoop(self: *Listener) void {
while (true) {
var pfds = [_]std.posix.pollfd{
.{ .fd = self.bound.fd, .events = std.posix.POLL.IN, .revents = 0 },
.{ .fd = self.stop_r, .events = std.posix.POLL.IN, .revents = 0 },
};
_ = std.posix.poll(&pfds, -1) catch return;
if (pfds[1].revents != 0) return;
if (pfds[0].revents & std.posix.POLL.IN == 0) continue;
const c = std.posix.accept(self.bound.fd, null, null, std.posix.SOCK.CLOEXEC) catch continue;
self.serve(c);
std.posix.close(c);
self.mu.lock();
const go = self.running;
self.mu.unlock();
if (!go) return;
}
}
fn serve(self: *Listener, c: std.posix.socket_t) void {
const cred = client_os.peerCred(c) orelse return;
// Both checks are `client_os.peerCred`'s to explain.
if (cred.uid != client_os.geteuid()) return;
var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, client_os.getpid(), client_os.parentOf) };
var raw: [prompt_max + 1]u8 = undefined;
// Bounded, because an accept thread parked in `read` is every later
// prompt of this wall parked behind it — and `stop`'s join with it.
const got = readLine(c, &raw, self.request_ms) orelse return;
if (got == 0) return;
p.kind = Kind.ofTag(raw[0]);
// Again on this side: the helper folds before it sends, and this is
// the end that paints. See `foldControl`.
p.text_len = foldControl(&p.text, raw[1..got]);
self.mu.lock();
if (!self.running) {
self.mu.unlock();
return;
}
self.prompt = p;
self.phase = .pending;
self.mu.unlock();
self.wake.wake(self.wake.ctx);
const abandoned = self.awaitAnswer(c);
var reply: [answer_max + 2]u8 = undefined;
var len: usize = 0;
self.mu.lock();
// A stop mid-prompt declines: the popup is gone with the wall that
// painted it, and ssh is owed an answer either way. A peer that has
// gone gets nothing — there is nobody to answer.
const refused = self.was_declined or self.phase != .done;
reply[0] = if (refused) reply_decline else reply_answer;
if (!refused) {
@memcpy(reply[1 .. 1 + self.answer_len], self.answer_buf[0..self.answer_len]);
len = self.answer_len;
}
// The wall's copy dies with the prompt it answered: this buffer
// outlives the box on screen, and a core dump is a file.
@memset(&self.answer_buf, 0);
self.answer_len = 0;
self.phase = .idle;
self.mu.unlock();
reply[1 + len] = '\n';
if (!abandoned) _ = writeAll(c, reply[0 .. len + 2]);
@memset(&reply, 0);
}
/// Blocks until the keyboard answers, the wall stops, or the PEER goes. True
/// when it was the peer: ssh SIGTERMs its notifier helper when the touch
/// lands, and dismissing the leftover box would decline a live dial.
fn awaitAnswer(self: *Listener, c: std.posix.socket_t) bool {
while (true) {
self.mu.lock();
const open = self.running and (self.phase == .pending or self.phase == .shown);
if (!open) {
self.mu.unlock();
return false;
}
self.cv.timedWait(&self.mu, watch_ns) catch {};
const still = self.running and (self.phase == .pending or self.phase == .shown);
self.mu.unlock();
if (!still) return false;
if (!peerGone(c)) continue;
self.mu.lock();
if (self.phase == .pending or self.phase == .shown) self.phase = .idle;
self.mu.unlock();
// The box on screen is showing a question nobody is waiting on.
self.wake.wake(self.wake.ctx);
return true;
}
}
/// Whether the box on screen still has an ssh behind it.
pub fn showing(self: *Listener) bool {
// The keyboard asks every pass: `serve` puts the phase back to idle
// on a hangup, and the popup has to follow it.
self.mu.lock();
defer self.mu.unlock();
return self.phase == .pending or self.phase == .shown;
}
};
/// The helper's end: `mux askpass`. One line out, one back, and the answer on
/// `out` — ssh's own stdin-side pipe, so a byte here that is not the answer is a
/// byte ssh tries to log in with. Every failure is exit 1 with NOTHING written,
/// which ssh reads as a refused prompt.
pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 {
// Through `dial`, so `MUX_ASKPASS_SOCK` naming something that is not a
// socket is this function's exit 1 on both systems rather than a panic
// inside ssh's password helper on one of them.
const stream = dial.dial(sock) catch return 1;
defer stream.close();
var line: [prompt_max + 2]u8 = undefined;
line[0] = @intFromEnum(kind);
// The wire is one line each way, and ssh's own prompts are multi-line
// on some builds (the host-key question). Folded, not split: the popup
// wraps it to the width anyway — and the same fold is what stops a
// server-authored prompt carrying an escape sequence onto the screen.
const n = foldControl(line[1 .. prompt_max + 1], prompt);
line[n + 1] = '\n';
if (!writeAll(stream.handle, line[0 .. n + 2])) return 1;
var reply: [answer_max + 2]u8 = undefined;
// No deadline here, unlike the listener's read: this wait IS the user
// reading the question, and ssh is content to wait on its helper.
const got = readLine(stream.handle, &reply, -1) orelse return 1;
if (got == 0 or reply[0] != reply_answer) return 1;
const wrote = writeAll(out_fd, reply[1..got]) and writeAll(out_fd, "\n");
// The helper is a whole process holding one password. It exits in a
// microsecond, but zeroing is one line and a core dump is a file.
@memset(&reply, 0);
return if (wrote) 0 else 1;
}
/// One prompt's bytes, made safe to paint: every control byte becomes a
/// space. Returns how many were written.
pub fn foldControl(dst: []u8, src: []const u8) usize {
// `handoff.Reason`'s rule on ssh's other channel: a prompt is painted INSIDE
// a wall's alternate screen, so an escape in one moves a cursor or fakes a
// row in somebody's tile. The text is not always ssh's — a
// keyboard-interactive prompt is the SERVER's wording, unsanitized. Run on
// BOTH ends, because the end that paints is the end that must not trust.
const n = @min(src.len, dst.len);
for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch;
return n;
}
/// Bytes up to the first '\n', which is dropped; anything past `buf.len` is
/// dropped too. Null is an EOF with nothing at all — a peer that hung up
/// before saying anything, which is not a prompt and not an answer.
fn readLine(fd: std.posix.fd_t, buf: []u8, timeout_ms: i32) ?usize {
var len: usize = 0;
var seen = false;
while (true) {
if (timeout_ms >= 0) {
var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
const ready = std.posix.poll(&pfd, timeout_ms) catch return null;
if (ready == 0) return null;
}
var one: [1]u8 = undefined;
const n = std.posix.read(fd, &one) catch return null;
if (n == 0) return if (seen) len else null;
seen = true;
if (one[0] == '\n') return len;
if (len < buf.len) {
buf[len] = one[0];
len += 1;
}
}
}
/// Whether the peer has closed its end. A zero-length PEEK is the only
/// answer that means it: `POLL.IN` also fires on bytes we never asked for.
fn peerGone(c: std.posix.socket_t) bool {
var pfd = [_]std.posix.pollfd{.{ .fd = c, .events = std.posix.POLL.IN, .revents = 0 }};
const ready = std.posix.poll(&pfd, 0) catch return true;
if (ready == 0) return false;
if (pfd[0].revents & (std.posix.POLL.ERR | std.posix.POLL.NVAL) != 0) return true;
var b: [1]u8 = undefined;
const n = std.posix.recv(c, &b, std.posix.MSG.PEEK) catch return true;
return n == 0;
}
fn writeAll(fd: std.posix.fd_t, bytes: []const u8) bool {
var off: usize = 0;
while (off < bytes.len) {
const n = std.posix.write(fd, bytes[off..]) catch return false;
if (n == 0) return false;
off += n;
}
return true;
}
/// How far up the tree the walk goes. ssh execs its helper directly, so
/// production is one step; the slack is for a shell in between.
const ancestor_max = 8;
/// The ssh THIS process spawned that is behind `peer`: the ancestor whose
/// parent is us.
fn dialOwner(
peer: std.posix.pid_t,
me: std.posix.pid_t,
parent: *const fn (std.posix.pid_t) std.posix.pid_t,
) std.posix.pid_t {
// Not simply the helper's parent: under ProxyJump the INNER ssh inherits
// `SSH_ASKPASS` and prompts through it, so the chain is helper → inner ssh →
// outer ssh → us. An attribution that breaks on one extra fork returns 0
// exactly when a wall needs a name. The INVARIANT is the far end: the ssh a
// dial spawned is a child of this process, and nothing else is.
var at = peer;
var steps: usize = 0;
while (at > 0 and steps < ancestor_max) : (steps += 1) {
const up = parent(at);
if (up == me) return at;
at = up;
}
return 0;
}
// ---- tests ----
const testtmp = @import("testtmp");
/// A wake that counts, so a test can assert the doorbell rang at all.
const Counter = struct {
n: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
fn bump(ctx: *anyopaque) void {
const self: *Counter = @ptrCast(@alignCast(ctx));
_ = self.n.fetchAdd(1, .release);
}
fn hooks(self: *Counter) Hooks {
return .{ .ctx = self, .wake = bump };
}
};
/// Waits for the accept thread to park a prompt, so no test spins on a
/// race it cannot see. Returns false on the 2 s budget.
fn awaitPrompt(l: *Listener, out: *Prompt) bool {
var waited: usize = 0;
while (waited < 2000) : (waited += 5) {
if (l.take(out)) return true;
std.Thread.sleep(5 * std.time.ns_per_ms);
}
return false;
}
const HelperRun = struct {
code: u8 = 0,
out_path: []const u8,
prompt: []const u8,
sock: []const u8,
kind: Kind = .secret,
fn go(self: *HelperRun) void {
const f = std.fs.cwd().createFile(self.out_path, .{}) catch return;
defer f.close();
self.code = helperMain(self.prompt, self.sock, self.kind, f.handle);
}
};
fn helperStdout(alloc: std.mem.Allocator, path: []const u8) ![]u8 {
return std.fs.cwd().readFileAlloc(alloc, path, 4096);
}
test "askpass.Listener: a helper's line reaches take, and answer reaches the helper" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
var run: HelperRun = .{
.out_path = out_path,
.prompt = "box's password: ",
.sock = l.path,
};
const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
try std.testing.expectEqualStrings("box's password: ", p.slice());
l.answer("s3cret");
th.join();
const got = try helperStdout(alloc, out_path);
defer alloc.free(got);
try std.testing.expectEqualStrings("s3cret\n", got);
try std.testing.expectEqual(@as(u8, 0), run.code);
try std.testing.expect(counter.n.load(.acquire) >= 1);
}
test "askpass.Listener: decline gives the helper exit 1 and no stdout" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path };
const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
l.decline();
th.join();
const got = try helperStdout(alloc, out_path);
defer alloc.free(got);
// ssh logs in with whatever this fd carried, so "nothing" is the claim,
// not "something short".
try std.testing.expectEqualStrings("", got);
try std.testing.expectEqual(@as(u8, 1), run.code);
}
test "askpass.Listener: an EMPTY answer is an answer, not a decline" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path };
const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
// Enter on an empty line. A key with no passphrase asks anyway, and the
// user who answers nothing has answered.
l.answer("");
th.join();
const got = try helperStdout(alloc, out_path);
defer alloc.free(got);
try std.testing.expectEqualStrings("\n", got);
try std.testing.expectEqual(@as(u8, 0), run.code);
}
test "askpass.Listener: two helpers are served one at a time, in accept order" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
const out_a = try std.fmt.allocPrint(alloc, "{s}/a", .{tmp.path()});
defer alloc.free(out_a);
var first: HelperRun = .{ .out_path = out_a, .prompt = "first: ", .sock = l.path };
const th_a = try std.Thread.spawn(.{}, HelperRun.go, .{&first});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
try std.testing.expectEqualStrings("first: ", p.slice());
const out_b = try std.fmt.allocPrint(alloc, "{s}/b", .{tmp.path()});
defer alloc.free(out_b);
var second: HelperRun = .{ .out_path = out_b, .prompt = "second: ", .sock = l.path };
const th_b = try std.Thread.spawn(.{}, HelperRun.go, .{&second});
// The second helper is connected and waiting in the backlog, and the
// popup on screen is still the first one's: a second `take` here would
// be a popup that changed its question under the user's fingers.
std.Thread.sleep(100 * std.time.ns_per_ms);
var q: Prompt = .{};
try std.testing.expect(!l.take(&q));
l.answer("one");
th_a.join();
try std.testing.expect(awaitPrompt(l, &q));
try std.testing.expectEqualStrings("second: ", q.slice());
l.answer("two");
th_b.join();
const a = try helperStdout(alloc, out_a);
defer alloc.free(a);
const b = try helperStdout(alloc, out_b);
defer alloc.free(b);
try std.testing.expectEqualStrings("one\n", a);
try std.testing.expectEqualStrings("two\n", b);
}
/// A process tree as a table, so the walk can be driven over shapes no
/// test could arrange with real forks.
const FakeTree = struct {
// helper 100 <- sh 99 <- sh 98 <- ssh 97 <- us 7 <- init 1
const rows = [_][2]std.posix.pid_t{
.{ 100, 99 }, .{ 99, 98 }, .{ 98, 97 }, .{ 97, 7 }, .{ 7, 1 }, .{ 1, 0 },
};
fn parent(pid: std.posix.pid_t) std.posix.pid_t {
for (rows) |r| if (r[0] == pid) return r[1];
return 0;
}
// A cycle no real tree has, for the bound below.
fn ring(pid: std.posix.pid_t) std.posix.pid_t {
return if (pid == 1) 2 else 1;
}
};
test "askpass: a helper two shells below the ssh we spawned is still that ssh's" {
// The plain case is one step; a ProxyJump is two, and a test's stand-in adds
// a shell. An attribution that breaks on one extra fork answers 0 exactly
// where a wall needs a name — so the walk climbs to OUR child.
try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent));
// The direct case, unchanged.
try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent));
// A peer that is not ours at all: someone else's process on the same
// box. Answering a pid here would park a tile on a stranger's decline.
try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(100, 4242, FakeTree.parent));
// A tree that loops must end the walk rather than the process.
try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring));
}
test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
var run: HelperRun = .{ .out_path = out_path, .prompt = "who: ", .sock = l.path };
const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
// The helper here is a THREAD, so the peer IS this process and no
// ancestor of it is our child. 0, and the prompt is still answered:
// attribution is a convenience, never a condition for answering.
try std.testing.expectEqual(@as(std.posix.pid_t, 0), p.ssh_pid);
l.answer("x");
th.join();
const got = try helperStdout(alloc, out_path);
defer alloc.free(got);
try std.testing.expectEqualStrings("x\n", got);
}
test "askpass.Listener: a decline ends the dial's ssh, it does not just refuse the prompt" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
// A live process standing in for the dial's ssh, because the claim is
// about the OS and not about a flag: an Esc that only refused the
// prompt would leave OpenSSH trying the empty password and asking
// again, three times against a real sshd.
var ssh = std.process.Child.init(&.{ "/bin/sleep", "30" }, alloc);
try ssh.spawn();
l.mu.lock();
l.prompt.ssh_pid = ssh.id;
l.phase = .shown;
l.mu.unlock();
l.decline();
const term = try ssh.wait();
switch (term) {
.Signal => |sig| try std.testing.expectEqual(@as(u32, std.posix.SIG.TERM), sig),
else => {
std.debug.print("the declined dial's ssh was not signalled: {any}\n", .{term});
return error.TestUnexpectedResult;
},
}
}
test "askpass.Listener: a declined pid parks, an untouched one does not" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
// Driven through the same door the pump asks at, with the prompt state
// set by hand: the ring is what a redial reads, and the pid it is keyed
// on comes from a dial, not from this socket.
l.mu.lock();
l.prompt.ssh_pid = 4242;
l.phase = .shown;
l.mu.unlock();
l.decline();
try std.testing.expect(l.declined(4242));
try std.testing.expect(!l.declined(4243));
// A dial whose attribution failed is not every dial: pid 0 must never
// park a tile.
try std.testing.expect(!l.declined(0));
}
test "askpass.Listener: a peer that says nothing does not park the next prompt" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
// Registered FIRST so it runs LAST: the silent peer below must be
// closed before this join, or a broken deadline wedges the runner
// instead of failing — and a wedged test step prints nothing at all.
defer l.stop();
l.request_ms = 150;
// Connected, and then nothing: no tag, no text, no newline. The accept
// thread serves one connection to completion, so without a deadline
// this is every later prompt of this wall parked behind one peer.
const mute = try std.net.connectUnixSocket(l.path);
defer mute.close();
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
var run: HelperRun = .{ .out_path = out_path, .prompt = "after: ", .sock = l.path };
const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
try std.testing.expectEqualStrings("after: ", p.slice());
l.answer("x");
th.join();
}
test "askpass.Listener: a helper that hangs up closes the box behind it" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
// The FIDO notifier's shape: ssh runs the helper to say "touch your
// key" and SIGTERMs it the moment the touch lands. A box left standing
// after that is one the user must dismiss for no reason — and
// dismissing it would record a LIVE dial's pid as declined.
const peer = try std.net.connectUnixSocket(l.path);
_ = try peer.write("nConfirm user presence for key ED25519-SK\n");
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
try std.testing.expectEqual(Kind.notice, p.kind);
try std.testing.expect(l.showing());
peer.close();
var waited: usize = 0;
while (waited < 3000 and l.showing()) : (waited += 25)
std.Thread.sleep(25 * std.time.ns_per_ms);
try std.testing.expect(!l.showing());
// The doorbell rang, which is what makes the keyboard look.
try std.testing.expect(counter.n.load(.acquire) >= 2);
// Nothing was declined: the ssh behind that notifier is alive and its
// dial is still going.
try std.testing.expect(!l.declined(p.ssh_pid));
}
test "askpass.helperMain: a socket nobody is listening on is a refused prompt" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
const sock = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()});
defer alloc.free(sock);
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
const f = try std.fs.cwd().createFile(out_path, .{});
defer f.close();
try std.testing.expectEqual(@as(u8, 1), helperMain("password: ", sock, .secret, f.handle));
const got = try helperStdout(alloc, out_path);
defer alloc.free(got);
try std.testing.expectEqualStrings("", got);
}
test "askpass: a server-authored prompt cannot move a cursor" {
// The rule `handoff.Reason` states for ssh's stderr ("control bytes are
// dropped, so nothing ssh says can move a cursor"), on the channel that
// is not even ssh's own words: a keyboard-interactive prompt is the
// SERVER's text, and it is painted inside a wall's alternate screen.
var buf: [64]u8 = undefined;
const n = foldControl(&buf, "\x1b[2J\x1b[1;1HEnter\x07 code:\x7f ");
try std.testing.expectEqualStrings(" [2J [1;1HEnter code: ", buf[0..n]);
// Every byte survives as SOMETHING: a prompt shortened by a filter is a
// prompt the user reads half of.
try std.testing.expectEqual("\x1b[2J\x1b[1;1HEnter\x07 code:\x7f ".len, n);
// High bytes are not control bytes: a UTF-8 prompt paints.
const m = foldControl(&buf, "clé:");
try std.testing.expectEqualStrings("clé:", buf[0..m]);
}
test "askpass.Listener: control bytes a peer sends direct are folded too" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
// Not through `helperMain`: the socket is reachable by any same-uid
// peer, and the end that paints is the end that must not trust.
const stream = try std.net.connectUnixSocket(l.path);
defer stream.close();
// Hand-written wire: the tag byte, then the text. `q` is no tag ssh
// has, so this also pins the unknown-tag default.
_ = try stream.write("qboom\x1b[31m: \n");
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
try std.testing.expectEqualStrings("boom [31m: ", p.slice());
try std.testing.expectEqual(Kind.secret, p.kind);
l.decline();
}
test "askpass.helperMain: a prompt's newlines fold, so one prompt is one line" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
var run: HelperRun = .{
.out_path = out_path,
// ssh's host-key question arrives with the fingerprint on its own
// line on some builds. Split at the newline, the second half would
// read as the ANSWER to the first.
.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)? ",
.sock = l.path,
};
const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
try std.testing.expect(std.mem.indexOfScalar(u8, p.slice(), '\n') == null);
try std.testing.expect(std.mem.endsWith(u8, p.slice(), "(yes/no)? "));
l.answer("yes");
th.join();
}
test "askpass.Kind: ssh's own variable says what it is asking for, and an unknown value is a secret" {
// The exact signal, in place of matching `assword` against the text: a
// keyboard-interactive prompt is the SERVER's wording, and a substring
// rule paints its answer in the clear whenever the server spells the
// question its own way.
try std.testing.expectEqual(Kind.secret, Kind.of(null));
try std.testing.expectEqual(Kind.confirm, Kind.of("confirm"));
try std.testing.expectEqual(Kind.notice, Kind.of("none"));
// A value neither of ssh's words: the mistake that hides an answer is
// cheaper than the one that paints it.
try std.testing.expectEqual(Kind.secret, Kind.of("something-openssh-adds-in-2027"));
}
test "askpass.Listener: the kind ssh named rides the wire, one byte ahead of the text" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
const l = try Listener.start(alloc, tmp.path(), counter.hooks());
defer l.stop();
const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
defer alloc.free(out_path);
for ([_]struct { k: Kind, text: []const u8 }{
.{ .k = .secret, .text = "box's password: " },
.{ .k = .confirm, .text = "Are you sure you want to continue connecting (yes/no)? " },
.{ .k = .notice, .text = "Confirm user presence for key ED25519-SK SHA256:xyz" },
}) |c| {
var run: HelperRun = .{ .out_path = out_path, .prompt = c.text, .sock = l.path, .kind = c.k };
const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
var p: Prompt = .{};
try std.testing.expect(awaitPrompt(l, &p));
try std.testing.expectEqual(c.k, p.kind);
// The tag is carriage, not content: the text arrives whole.
try std.testing.expectEqualStrings(c.text, p.slice());
l.answer("x");
th.join();
}
}
test "askpass.Listener: retire takes only its own socket, never a successor's at the same pid-named path" {
const alloc = std.testing.allocator;
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var counter: Counter = .{};
// Pid reuse, in one process: the name is `mux-ask-<pid>.sock` in the
// runtime dir, so a second Listener here IS the client that got our pid
// back. The first one's retire runs as the wall exits, after the second
// has bound — and it must not take the name out from under it.
const old_l = try Listener.start(alloc, tmp.path(), counter.hooks());
const successor = try Listener.start(alloc, tmp.path(), counter.hooks());
try std.testing.expectEqualStrings(old_l.path, successor.path);
// Copied: `stop` frees the Listener and its path, and the last assertion
// is about the name AFTER both are gone.
const name = try alloc.dupe(u8, successor.path);
defer alloc.free(name);
old_l.retire();
// Asked of the filesystem, and then of the socket: a file at the name is
// not enough, because the point is that the ssh this successor spawned
// can still reach the client that will answer it.
const st = try std.posix.fstatat(std.posix.AT.FDCWD, successor.path, 0);
try std.testing.expect(std.posix.S.ISSOCK(st.mode));
const probe = try std.net.connectUnixSocket(successor.path);
probe.close();
// retire closes nothing, so the first Listener's own descriptors are
// still ours to release — `stop` does the join and the closes.
old_l.stop();
successor.stop();
try std.testing.expectError(
error.FileNotFound,
std.posix.fstatat(std.posix.AT.FDCWD, name, 0),
);
}
test {
std.testing.refAllDeclsRecursive(@This());
}
test "askpass.Listener: start reaps the socket a wall that died by signal left, not a live wall's" {
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
const dead = try testtmp.deadPid();
var nb: [48]u8 = undefined;
const left = try std.fmt.bufPrint(&nb, "mux-ask-{d}.sock", .{dead});
try tmp.dir.writeFile(.{ .sub_path = left, .data = "" });
try tmp.dir.writeFile(.{ .sub_path = "mux-ask-1.sock", .data = "" });
var c = Counter{};
const l = try Listener.start(std.testing.allocator, tmp.path(), c.hooks());
defer l.stop();
try std.testing.expectError(error.FileNotFound, tmp.dir.access(left, .{}));
try tmp.dir.access("mux-ask-1.sock", .{});
}