summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/hostkey.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
committerAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
commit652010b6c054b69d813e8a2c724d6db039242119 (patch)
treeb70e4dd1591381a6c017c0c02f9502474b1d612d /packages/ssh/src/hostkey.ts
parent350b9b8e247bb1c24f49a884fdade18e44b115eb (diff)
downloaddispatch-652010b6c054b69d813e8a2c724d6db039242119.tar.gz
dispatch-652010b6c054b69d813e8a2c724d6db039242119.zip
feat(ssh): wave 5b — the ssh package (remote ExecBackend over ssh2)
Wave 5b of transparent SSH support. NEW standard extension @dispatch/ssh makes remote execution actually work over SSH, transparently. ssh2 verified to run under Bun (load-bearing decision #1 confirmed: connects to local sshd :22 + execs). - config.ts: ~/.ssh/config reader via ssh-config -> Computer[]/ComputerEntry[] (read-only discovery; resolves hostName/port/user/identityFile/knownHost). - hostkey.ts: known_hosts auto-trust-and-pin (present->verify/reject-on-mismatch, absent->accept+append; the accept-new analog). - errors.ts: pure ssh2/SFTP -> node:fs-style .code error mapping (so tools' existing ENOENT branches work unchanged). - pool.ts: SshConnectionPool (per-alias ssh2.Client, lazy connect, keep-alive, idle reap ~15m); key-only auth from ~/.ssh (config IdentityFile or default id_ed25519/id_rsa); no agent-forwarding, no PTY. - backend.ts: SshExecBackend implements ExecBackend (spawn via client.exec with shell-quoted cwd; fs via SFTP). - service.ts + extension.ts: activate provides BOTH handles the other units consume — remoteExecBackendFactoryHandle (exec-backend: computerId->SshExecBackend) AND computerServiceHandle (transport-http: listComputers/getComputer/getStatus/test). - orchestrator: added packages/ssh to root tsconfig.json refs + bun install. Tests: 45 pass + 6 sshd-integration skipped (it.skipIf(!process.env.SSH_TEST_HOST)). Verified: tsc -b EXIT 0, biome clean, 1690 vitest pass (was 1641, +49). CRs for wave 5c: host-bin registration; CR-5 transport-http barrel re-export; CR-6 usageCount wiring (deferred-ok, defaults to 0). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/ssh/src/hostkey.ts')
-rw-r--r--packages/ssh/src/hostkey.ts148
1 files changed, 148 insertions, 0 deletions
diff --git a/packages/ssh/src/hostkey.ts b/packages/ssh/src/hostkey.ts
new file mode 100644
index 0000000..626b060
--- /dev/null
+++ b/packages/ssh/src/hostkey.ts
@@ -0,0 +1,148 @@
+/**
+ * Host-key trust decision — the `accept-new` (auto-trust-and-pin) analog.
+ *
+ * Per decisions #2/#3: on connect, the ssh2 `hostVerifier` checks whether the
+ * host's key is in `~/.ssh/known_hosts`. If present → verify match (reject on
+ * mismatch, surface "HOST KEY CHANGED" loudly). If absent (first connect) →
+ * accept + append the fingerprint to `known_hosts` (the pin). A future FE
+ * "approve host key" prompt (roadmap) would gate that first accept.
+ *
+ * The DECISION is PURE (input → output, no I/O, zero mocks): given the current
+ * known_hosts text + the host + its key fingerprint, decide accept/reject and
+ * (if pinning) produce the line to append. The I/O — reading/append-writing the
+ * real `~/.ssh/known_hosts` file — lives in the `SshConnectionPool` shell, which
+ * injects the text + applies the append. This keeps the policy unit-testable
+ * against fixture strings (plan §4.4).
+ */
+
+/** Outcome of a host-key check. The shell acts on `accept` + `append`. */
+export interface HostKeyDecision {
+ /** Accept the connection (true) or reject it loudly (false). */
+ readonly accept: boolean;
+ /**
+ * When the host is unseen (first connect), the line to append to
+ * `known_hosts` to pin the key. `undefined` when the host is already known
+ * (no write needed) or when rejecting (do not pin a mismatched key).
+ */
+ readonly append: string | undefined;
+ /** Human-readable reason for logging/the rejection error. */
+ readonly reason: string;
+}
+
+/**
+ * The host-key verification fingerprint. ssh2 hands the verifier the raw host
+ * key Buffer; the shell computes a fingerprint string (e.g. an OpenSSH-style
+ * `SHA256:...` hex) + the key type + the line OpenSSH would write (the `host`
+ * token + base64 key) so the decision is string-comparable against known_hosts.
+ *
+ * Carrying the exact line OpenSSH itself writes keeps `~/.ssh/known_hosts`
+ * interchangeable with the real ssh client (decision #2 — the file is the
+ * shared trust store).
+ */
+export interface HostKeyFingerprint {
+ /** The OpenSSH `known_hosts` line token, e.g. `[localhost]:2222` or `myhost`. */
+ readonly knownHostToken: string;
+ /** The base64-encoded public key (the 2nd field of a known_hosts line). */
+ readonly keyBase64: string;
+ /** Key type label, e.g. `ssh-ed25519` (the 1st field). */
+ readonly keyType: string;
+}
+
+/**
+ * Decide whether to accept a host key, given the current `known_hosts` text.
+ *
+ * The matching mirrors OpenSSH's `known_hosts` semantics at the granularity the
+ * MVP needs: a line whose FIRST field equals `fingerprint.knownHostToken` is a
+ * match for that host. (OpenSSH also supports comma-host + hash + wildcard
+ * tokens; the MVP pins one explicit token per host — the shell writes exactly
+ * the token ssh2 supplied — so a plain first-field compare is correct and
+ * sufficient. A full known_hosts parser is a roadmap item.)
+ *
+ * - **present + key matches** → accept, no append (already pinned).
+ * - **present + key differs** → REJECT ("HOST KEY CHANGED" — never silently
+ * connect; this is the MITM guard).
+ * - **absent (first connect)** → accept + append the pin line.
+ *
+ * Pure: `knownHostsText` + `fingerprint` → `HostKeyDecision`.
+ */
+export function decideHostKey(
+ knownHostsText: string,
+ fingerprint: HostKeyFingerprint,
+): HostKeyDecision {
+ const { knownHostToken, keyBase64, keyType } = fingerprint;
+ const expectedLine = `${knownHostToken} ${keyType} ${keyBase64}`;
+
+ // A line matches THIS host when its first field is the knownHostToken.
+ const existing = findHostLine(knownHostsText, knownHostToken);
+
+ if (existing === undefined) {
+ // Absent → first connect → accept + pin (the accept-new analog).
+ return {
+ accept: true,
+ append: expectedLine,
+ reason: `first connect to "${knownHostToken}": pinning host key`,
+ };
+ }
+
+ // Present → compare the key material (fields 2+3). Ignore leading/trailing
+ // whitespace differences (OpenSSH tolerates these).
+ const normalizedExisting = normalizeLine(existing);
+ if (normalizedExisting === normalizeLine(expectedLine)) {
+ return { accept: true, append: undefined, reason: `host key for "${knownHostToken}" matches` };
+ }
+
+ // Present but DIFFERENT → reject loudly. Do NOT pin (the key changed →
+ // possible MITM; the user must clear the stale line manually).
+ return {
+ accept: false,
+ append: undefined,
+ reason:
+ `HOST KEY CHANGED for "${knownHostToken}" — refusing to connect ` +
+ `(remove the stale entry from ~/.ssh/known_hosts if this change is expected)`,
+ };
+}
+
+/** Find the first known_hosts line whose first field is `token`. Pure. */
+function findHostLine(text: string, token: string): string | undefined {
+ for (const raw of text.split("\n")) {
+ const line = raw.trim();
+ if (line === "" || line.startsWith("#")) continue;
+ // First whitespace-delimited field is the host token (possibly comma-list).
+ const firstSpace = findFirstSpace(line);
+ const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace);
+ // A token may be a comma-separated host list; accept if any element matches.
+ if (
+ firstField
+ .split(",")
+ .map((h) => h.trim())
+ .includes(token)
+ ) {
+ return line;
+ }
+ }
+ return undefined;
+}
+
+/** Normalize a known_hosts line for key-material comparison (host-independent). */
+function normalizeLine(line: string): string {
+ const parts = line.split(/\s+/).filter((p) => p.length > 0);
+ // Drop the first field (host token); compare key-type + base64 key.
+ return parts.slice(1).join(" ");
+}
+
+function findFirstSpace(line: string): number {
+ for (let i = 0; i < line.length; i++) {
+ const ch = line.charCodeAt(i);
+ if (ch === 32 || ch === 9) return i; // space or tab
+ }
+ return -1;
+}
+
+/**
+ * Read whether a host appears in `known_hosts` at all (for the read-only
+ * `Computer.knownHost` view surfaced by `GET /computers`). Pure. Uses the same
+ * first-field matching as `decideHostKey`.
+ */
+export function isKnownHost(knownHostsText: string, token: string): boolean {
+ return findHostLine(knownHostsText, token) !== undefined;
+}