diff --git a/README.md b/README.md index 0ee1ac4..be95969 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ Creates a new SSH client instance. * `retryDuration` (number, optional): Time (msec) to wait between each retry. Defaults to `1000`. * `retryableExitCodes` (number[], optional): Additional rsync exit codes to treat as retryable for `.send()`/`.recv()`. See [Retry behavior for `.send()` / `.recv()`](#retry-behavior-for-send--recv) below. * `replaceRetryableExitCodes` (boolean, optional): If `true`, `retryableExitCodes` replaces the built-in retryable exit code list instead of adding to it. + * `useAgent` (boolean, optional): Authenticate key-based hosts through an ssh-agent instead of replaying the passphrase on every connection. Defaults to `true` when `keyFile` is set. Set `false` to force the legacy behavior. See [SSH agent authentication](#ssh-agent-authentication) below. + * `identityAgent` (string, optional): Path to an existing ssh-agent socket to authenticate through (emitted as `-oIdentityAgent`). When set, the wrapper uses that agent as-is and manages nothing. + * `agentKeyTTL` (number, optional): Lifetime in seconds applied to keys the wrapper adds to an agent (`ssh-add -t`). Defaults to `3600`. * And more... see `lib/index.js` for all available options. ### `.exec(cmd, [timeout], [outputCallback], [rcfile], [prependCmd])` @@ -178,3 +181,24 @@ Checks if a connection to the remote host can be established. ### `.disconnect()` Closes the master SSH connection to the remote host. + +### `.dispose()` +Removes this host's key from the wrapper-managed (or caller-supplied) ssh-agent and then closes the master SSH connection. It does **not** kill the shared agent process, since other hosts or processes may still be using it. Safe to call repeatedly. + +* Returns: `Promise` + +### SSH agent authentication + +When `keyFile` is set, key-based authentication goes through an **ssh-agent** by default (`useAgent: true`). The passphrase (if any) is consumed once to `ssh-add` the key; every subsequent connection and every master-connection rebuild after a `ControlPersist` expiry then authenticates non-interactively, so the wrapper never has to hold a replayable secret. + +How the agent is chosen, per host, at connect time: + +1. If `identityAgent` is set, that socket is used as-is. +2. Otherwise, if `SSH_AUTH_SOCK` is present in the environment, that agent is used (the key is `ssh-add`ed into it with a `-t` lifetime, so it does not linger there indefinitely). +3. Otherwise, on POSIX, the wrapper spawns one shared `ssh-agent` per user, at a fixed socket under `$XDG_RUNTIME_DIR` / `/run/user/` / `/tmp` (overridable with `SSH_CLIENT_WRAPPER_AGENT_DIR`). It is reused by later runs and is not killed on exit; keys expire from it via `agentKeyTTL` (default 1 h). + +If no agent can be used, or `ssh-add` fails, the wrapper silently falls back to the legacy behavior of answering the passphrase prompt on each connection. Set `useAgent: false` to force that path. + +**Keyless / browser agents:** with no `keyFile`, set `identityAgent` to the socket of an already-populated agent (for example [`bssh-agent`](https://github.com/so5/browser-ssh-agent), which keeps the private key in a browser tab) and the wrapper will authenticate through it. + +**Windows:** an already-running agent (the OpenSSH Authentication Agent service, or a supplied `identityAgent`) is used, but the wrapper never spawns one; without a reachable agent it falls back to the legacy path. diff --git a/lib/agent.js b/lib/agent.js new file mode 100644 index 0000000..d4b08f4 --- /dev/null +++ b/lib/agent.js @@ -0,0 +1,524 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { setTimeout as setTimeoutPromise } from "node:timers/promises"; +import Debug from "debug"; +import { spawn } from "node-pty-prebuilt-multiarch"; +import { rePhPrompt } from "./util.js"; + +const debug = Debug("sshClientWrapper:debug:agent"); +const debugVerbose = Debug("sshClientWrapper:verbose:agent"); + +const DEFAULT_TTL = 3600; +const ADDKEY_TIMEOUT_SEC = 60; +const CHILD_TIMEOUT_MS = 15000; +const LOCK_TIMEOUT_MS = 15000; +const LOCK_STALE_MS = 60000; +const MAX_BAD_PASSPHRASE = 3; +const MAX_SOCKET_PATH = 103; + +//sentinel candidate meaning "run ssh-add with the inherited environment and let +//OpenSSH use its default agent (e.g. the Windows OpenSSH Authentication Agent pipe)" +const AGENT_DEFAULT = "__scw_default_agent__"; + +//injection seam for tests +const _internal = { + spawn, + execFileP: promisify(execFile) +}; + +let sharedSock = null; +let sharedInFlight = null; + +/** + * promisified sleep + * @param {number} ms - milliseconds to wait + * @returns {Promise} - resolves after ms + */ +function sleep(ms) { + return setTimeoutPromise(ms); +} + +/** + * check if path points to a regular file + * @param {string} p - path to check + * @returns {boolean} - true if p is an existing regular file + */ +function isFile(p) { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} + +/** + * check if path points to an existing directory + * @param {string} p - path to check + * @returns {boolean} - true if p is an existing directory + */ +function dirExists(p) { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** + * build an env object pointing ssh-add/ssh-agent at a specific socket + * @param {string} sock - agent socket path, or AGENT_DEFAULT to inherit unchanged + * @returns {object} - environment object for the child process + */ +function sockEnv(sock) { + if (sock === AGENT_DEFAULT) { + return { ...process.env }; + } + return { ...process.env, SSH_AUTH_SOCK: sock }; +} + +/** + * whether this platform can spawn and manage a shared ssh-agent + * @returns {boolean} - true on POSIX platforms with a uid + */ +function canSpawnSharedAgent() { + return process.platform !== "win32" && typeof process.getuid === "function"; +} + +/** + * decide whether key-based auth for this host should go through an ssh-agent + * @param {object} hostInfo - hostinfo object + * @returns {boolean} - true to use the agent path, false for the legacy pty path + */ +function shouldUseAgent(hostInfo) { + if (hostInfo.useAgent === false) { + return false; + } + if (typeof hostInfo.identityAgent === "string" && hostInfo.identityAgent !== "") { + return true; + } + if (hostInfo.useAgent === true) { + return true; + } + return typeof hostInfo.keyFile === "string" && isFile(hostInfo.keyFile); +} + +/** + * resolve a mode-0700, uid-owned directory to hold the shared agent socket + * @returns {string} - directory path + */ +function resolveWellKnownDir() { + const uid = process.getuid(); + const candidates = []; + + if (process.env.SSH_CLIENT_WRAPPER_AGENT_DIR) { + candidates.push(process.env.SSH_CLIENT_WRAPPER_AGENT_DIR); + } + if (process.env.XDG_RUNTIME_DIR) { + candidates.push(path.join(process.env.XDG_RUNTIME_DIR, "ssh-client-wrapper")); + } + if (dirExists(`/run/user/${uid}`)) { + candidates.push(`/run/user/${uid}/ssh-client-wrapper`); + } + candidates.push(path.join(os.tmpdir(), `scw-${uid}`), `/tmp/scw-${uid}`); + + for (const dir of candidates) { + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); + const st = fs.statSync(dir); + const shortEnough = path.join(dir, "agent.sock").length <= MAX_SOCKET_PATH; + if (st.uid === uid && (st.mode & 0o077) === 0 && shortEnough) { + return dir; + } + } catch (e) { + debugVerbose(`candidate agent dir ${dir} rejected: ${e.message}`); + } + } + const err = new Error("no usable directory for the shared ssh-agent socket"); + err.code = "NO_AGENT_DIR"; + throw err; +} + +/** + * check that a pre-existing socket file is owned by us and not group/world accessible + * @param {string} sockPath - socket path to check + * @returns {boolean} - true if the socket is safe to reuse + */ +function socketIsTrusted(sockPath) { + try { + const st = fs.statSync(sockPath); + return st.uid === process.getuid() && (st.mode & 0o077) === 0; + } catch { + return false; + } +} + +/** + * take a cross-process advisory lock on the agent directory + * @param {string} dir - directory to lock + * @returns {Promise} - resolves once the lock is held + */ +async function acquireLock(dir) { + const lock = path.join(dir, "agent.lock"); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + for (;;) { + try { + fs.mkdirSync(lock); + return; + } catch (e) { + if (e.code !== "EEXIST") { + throw e; + } + try { + if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) { + fs.rmdirSync(lock); + continue; + } + } catch { + continue; + } + if (Date.now() > deadline) { + const err = new Error("timeout acquiring ssh-agent lock"); + err.code = "LOCK_TIMEOUT"; + throw err; + } + await sleep(100); + } + } +} + +/** + * release the advisory lock on the agent directory + * @param {string} dir - directory to unlock + * @returns {void} + */ +function releaseLock(dir) { + try { + fs.rmdirSync(path.join(dir, "agent.lock")); + } catch { + //nothing to release + } +} + +/** + * probe an agent socket with `ssh-add -l` + * @param {string} sock - agent socket path (or AGENT_DEFAULT) + * @returns {Promise} - 0 has identities, 1 reachable but empty, 2 unreachable + */ +async function probeAgent(sock) { + try { + await _internal.execFileP("ssh-add", ["-l"], { env: sockEnv(sock), timeout: CHILD_TIMEOUT_MS }); + return 0; + } catch (e) { + return e?.code === 1 ? 1 : 2; + } +} + +/** + * resolve a passphrase value from a hostInfo passphrase callback/string + * @param {string | Function | undefined} ph - passphrase or callback + * @returns {Promise} - the passphrase, or null when unavailable + */ +async function resolvePassphrase(ph) { + if (typeof ph === "string") { + return ph; + } + if (typeof ph === "function") { + const v = await ph(); + return typeof v === "string" ? v : null; + } + return null; +} + +/** + * add a key to an agent via `ssh-add`, answering the passphrase prompt from a pty + * @param {string} sock - agent socket path (or AGENT_DEFAULT) + * @param {string} keyFile - private key path + * @param {string | Function | undefined} phCallback - passphrase or callback + * @param {number} ttl - ssh-add -t lifetime in seconds + * @returns {Promise} - resolves on success, rejects with err.code on failure + */ +function addKey(sock, keyFile, phCallback, ttl) { + return new Promise((resolve, reject)=>{ + const pty = _internal.spawn("ssh-add", ["-t", String(ttl), keyFile], { + windowsHide: true, + env: sockEnv(sock) + }); + let settled = false; + let badTries = 0; + let timer = null; + + const finish = (fn, arg)=>{ + if (settled) { + return; + } + settled = true; + if (timer !== null) { + clearTimeout(timer); + } + try { + pty.kill(); + } catch { + //already gone + } + fn(arg); + }; + const fail = (code, message)=>{ + const err = new Error(message); + err.code = code; + finish(reject, err); + }; + + timer = setTimeout(()=>{ + fail("ADDKEY_TIMEOUT", `ssh-add did not finish within ${ADDKEY_TIMEOUT_SEC} sec`); + }, ADDKEY_TIMEOUT_SEC * 1000); + + pty.onData((data)=>{ + const output = data.toString(); + debugVerbose(output); + + if (/Could not open a connection to your authentication agent|Error connecting to agent/.test(output)) { + fail("NO_AGENT", "no ssh-agent is reachable"); + return; + } + if (rePhPrompt.test(output)) { + resolvePassphrase(phCallback) + .then((v)=>{ + if (v === null) { + fail("NO_PASSPHRASE", "key is encrypted but no passphrase is available"); + return; + } + pty.write(`${v}\n`); + }) + .catch((e)=>{ + finish(reject, e); + }); + return; + } + if (/Bad passphrase|incorrect passphrase/i.test(output)) { + badTries += 1; + if (badTries >= MAX_BAD_PASSPHRASE || typeof phCallback !== "function") { + fail("BAD_PASSPHRASE", "bad passphrase for private key"); + } + return; + } + if (/Identity added/.test(output)) { + finish(resolve); + } + }); + + pty.onExit(({ exitCode })=>{ + if (settled) { + return; + } + if (exitCode === 0) { + finish(resolve); + return; + } + fail("ADDKEY_FAILED", `ssh-add exited with ${exitCode}`); + }); + }); +} + +/** + * remove a key from an agent (best effort) + * @param {string} sock - agent socket path (or AGENT_DEFAULT) + * @param {string} keyFile - private key path + * @returns {Promise} - always resolves + */ +async function removeKey(sock, keyFile) { + try { + await _internal.execFileP("ssh-add", ["-d", keyFile], { env: sockEnv(sock), timeout: CHILD_TIMEOUT_MS }); + } catch { + //best effort only + } +} + +/** + * spawn (or find) the per-user shared ssh-agent and return its socket path + * @returns {Promise} - socket path of a live shared agent + */ +async function spawnSharedAgent() { + const dir = resolveWellKnownDir(); + const sock = path.join(dir, "agent.sock"); + + await acquireLock(dir); + + try { + if (fs.existsSync(sock)) { + if (!socketIsTrusted(sock)) { + const err = new Error(`refusing to use untrusted agent socket ${sock}`); + err.code = "UNTRUSTED_SOCKET"; + throw err; + } + if (await probeAgent(sock) !== 2) { + return sock; + } + try { + fs.unlinkSync(sock); + } catch { + //someone else may have cleaned it up + } + } + debug(`spawning shared ssh-agent at ${sock}`); + await _internal.execFileP("ssh-agent", ["-a", sock], { timeout: CHILD_TIMEOUT_MS }); + return sock; + } finally { + releaseLock(dir); + } +} + +/** + * get the shared agent socket, deduplicating concurrent callers in this process + * @returns {Promise} - socket path of a live shared agent + */ +async function getSharedAgent() { + if (sharedSock && socketIsTrusted(sharedSock) && await probeAgent(sharedSock) !== 2) { + return sharedSock; + } + if (!sharedInFlight) { + sharedInFlight = spawnSharedAgent() + .then((s)=>{ + sharedSock = s; + return s; + }) + .finally(()=>{ + sharedInFlight = null; + }); + } + return sharedInFlight; +} + +/** + * mark hostInfo as agent-ready + * @param {object} hostInfo - hostinfo object + * @param {string} sock - resolved agent socket (or AGENT_DEFAULT) + * @returns {void} + */ +function setReady(hostInfo, sock) { + hostInfo.managedAgentSock = sock === AGENT_DEFAULT ? undefined : sock; + hostInfo._agentEnsuredAt = Date.now(); +} + +/** + * ensure the private key is loaded into some reachable agent (two-step algorithm) + * @param {object} hostInfo - hostinfo object + * @param {string | null} candidate - preferred socket, AGENT_DEFAULT, or null + * @param {number} ttl - ssh-add -t lifetime in seconds + * @returns {Promise} - resolves once loaded (or rejects to trigger legacy fallback) + */ +async function ensureKeyLoaded(hostInfo, candidate, ttl) { + if (candidate !== null) { + try { + await addKey(candidate, hostInfo.keyFile, hostInfo.passphrase, ttl); + setReady(hostInfo, candidate); + return; + } catch (e) { + if (e.code !== "NO_AGENT") { + throw e; + } + debug("no agent at the candidate socket; will spawn a shared one"); + } + } + if (!canSpawnSharedAgent()) { + const err = new Error("no reachable ssh-agent and this platform cannot spawn one"); + err.code = "NO_AGENT"; + throw err; + } + const sock = await getSharedAgent(); + await addKey(sock, hostInfo.keyFile, hostInfo.passphrase, ttl); + setReady(hostInfo, sock); +} + +/** + * for a keyFile-less host, use an already-populated agent if one is reachable + * @param {object} hostInfo - hostinfo object + * @param {string | null} candidate - preferred socket, AGENT_DEFAULT, or null + * @returns {Promise} - resolves; leaves hostInfo on the legacy path if unusable + */ +async function ensurePreloadedAgent(hostInfo, candidate) { + if (candidate === null || candidate === AGENT_DEFAULT) { + return; + } + if (await probeAgent(candidate) === 0) { + setReady(hostInfo, candidate); + } +} + +/** + * effective ssh-add -t lifetime for a host + * @param {object} hostInfo - hostinfo object + * @returns {number} - lifetime in seconds + */ +function effectiveTtl(hostInfo) { + return Number.isFinite(hostInfo.agentKeyTTL) && hostInfo.agentKeyTTL > 0 + ? Math.floor(hostInfo.agentKeyTTL) + : DEFAULT_TTL; +} + +/** + * pick the agent socket to try for a host + * @param {object} hostInfo - hostinfo object + * @returns {string | null | undefined} - socket path, AGENT_DEFAULT, null to spawn a shared agent, or undefined to give up + */ +function resolveAgentCandidate(hostInfo) { + if (typeof hostInfo.identityAgent === "string" && hostInfo.identityAgent !== "") { + return hostInfo.identityAgent; + } + if (typeof process.env.SSH_AUTH_SOCK === "string" && process.env.SSH_AUTH_SOCK !== "") { + return process.env.SSH_AUTH_SOCK; + } + if (canSpawnSharedAgent()) { + return null; + } + return process.platform === "win32" ? AGENT_DEFAULT : undefined; +} + +/** + * make sure this host can authenticate through an ssh-agent, if it should + * @param {object} hostInfo - hostinfo object (mutated: managedAgentSock, _agentEnsuredAt) + * @returns {Promise} - resolves; on any recoverable problem hostInfo is left on the legacy path + */ +async function ensureAgentForHost(hostInfo) { + if (!shouldUseAgent(hostInfo)) { + return; + } + const ttl = effectiveTtl(hostInfo); + + if (typeof hostInfo._agentEnsuredAt === "number" && Date.now() - hostInfo._agentEnsuredAt < ttl * 500) { + return; + } + const candidate = resolveAgentCandidate(hostInfo); + + if (candidate === undefined) { + return; + } + const hasKey = typeof hostInfo.keyFile === "string" && isFile(hostInfo.keyFile); + + try { + await (hasKey ? ensureKeyLoaded(hostInfo, candidate, ttl) : ensurePreloadedAgent(hostInfo, candidate)); + } catch (e) { + if (e.code === "BAD_PASSPHRASE") { + throw e; + } + debug(`agent setup did not complete (${e.code || e.message}); using legacy pty auth`); + delete hostInfo.managedAgentSock; + delete hostInfo._agentEnsuredAt; + } +} + +export { + shouldUseAgent, + canSpawnSharedAgent, + ensureAgentForHost, + removeKey, + probeAgent, + addKey, + resolveWellKnownDir, + socketIsTrusted, + _internal +}; diff --git a/lib/index.d.ts b/lib/index.d.ts index f4640c5..990ff92 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -3,12 +3,13 @@ declare module "ssh-client-wrapper" { host: string; user: string; port?: number; - password?: string | (() => string); - passphrase?: string | (() => string); + password?: string | (() => string | Promise); + passphrase?: string | (() => string | Promise); keyFile?: string; noStrictHostKeyChecking?: boolean; ControlPersist?: number; ConnectTimeout?: number; + ControlPersistDir?: string; maxRetry?: number; retryDuration?: number; retryableExitCodes?: number[]; @@ -16,6 +17,10 @@ declare module "ssh-client-wrapper" { rcfile?: string; prependCmd?: string; sshOpt?: string[]; + useAgent?: boolean; + identityAgent?: string; + agentKeyTTL?: number; + reauthRequired?: () => void; }; class SshClientWrapper { @@ -60,7 +65,15 @@ declare module "ssh-client-wrapper" { replaceRetryableExitCodes?: boolean ): Promise; canConnect(timeout?: number): Promise; + remoteToRemoteCopy( + src: string[], + dstHostInfo: HostInfo, + dst: string, + opt?: string[], + timeout?: number + ): Promise; disconnect(): Promise; + dispose(): Promise; } export default SshClientWrapper; diff --git a/lib/index.js b/lib/index.js index 551eb09..6e6b4bb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,6 +3,7 @@ import path from "path"; import Debug from "debug"; import * as sshExecModule from "./sshExec.js"; import * as rsyncExecModule from "./rsyncExec.js"; +import * as agentModule from "./agent.js"; import { isArrayOfString, isArrayOfInteger, sanityCheck } from "./util.js"; const debug = Debug("sshClientWrapper:debug:interface"); @@ -35,6 +36,10 @@ const debugVerbose = Debug("sshClientWrapper:verbose:interface"); * @param {string} hostInfo.rcfile - rcfile path which will be sourced before executing actual command * @param {string} hostInfo.prependCmd - command string which will be executed before executing actual command * @param {string[]} hostInfo.sshOpt - additional options for ssh + * @param {boolean} hostInfo.useAgent - authenticate key-based hosts through an ssh-agent (default: true when keyFile is set). On win32 an already-running agent is used but none is spawned. Set false to force the legacy pty passphrase-replay path. + * @param {string} hostInfo.identityAgent - path to an existing ssh-agent socket to use (emitted as -oIdentityAgent); the wrapper manages nothing when this is set + * @param {Integer} hostInfo.agentKeyTTL - lifetime in seconds for keys added to the agent (ssh-add -t); default 3600 + * @param {Function} hostInfo.reauthRequired - called when a fresh master connection hits an auth prompt despite the agent (unused by current consumers) */ const logAndReject = (message)=>{ @@ -42,12 +47,14 @@ const logAndReject = (message)=>{ return Promise.reject(new Error(message)); }; +const defaultDeps = { ...sshExecModule, ...rsyncExecModule, ...agentModule }; + /** * Facade class. * @class */ class SshClientWrapper { - constructor(hostInfo, deps = { ...sshExecModule, ...rsyncExecModule }) { + constructor(hostInfo, deps = defaultDeps) { debug("constructor called for", hostInfo.host); debugVerbose("hostInfo=", hostInfo); this.hostInfo = { ...hostInfo }; @@ -281,6 +288,22 @@ class SshClientWrapper { debug(`disconnect from ${this.hostInfo.host} called`); this.deps.disconnect(this.hostInfo); } + + /** + * Remove this host's key from the wrapper-managed / external agent and tear down the + * master session. Does NOT kill the shared agent process (other hosts or processes may + * be using it). Safe to call repeatedly. (Unused by current consumers.) + * @returns {Promise} - resolves when teardown is done + */ + async dispose() { + debug(`dispose for ${this.hostInfo.host} called`); + if (this.hostInfo.managedAgentSock && this.hostInfo.keyFile) { + await this.deps.removeKey(this.hostInfo.managedAgentSock, this.hostInfo.keyFile); + } + delete this.hostInfo.managedAgentSock; + delete this.hostInfo._agentEnsuredAt; + await this.deps.disconnect(this.hostInfo); + } } export default SshClientWrapper; diff --git a/lib/sshExec.js b/lib/sshExec.js index 777a73b..a590586 100644 --- a/lib/sshExec.js +++ b/lib/sshExec.js @@ -1,8 +1,9 @@ import crypto from "crypto"; import { setTimeout as setTimeoutPromise } from "timers/promises"; import Debug from "debug"; -import { sendPty, sshCmd, getSshOption, sanityCheck, watchDogTimer } from "./util.js"; +import { sendPty, sshCmd, getSshOption, sanityCheck, watchDogTimer, rePwPrompt, rePhPrompt } from "./util.js"; import { fork, createMasterPty, sshLoginCallback } from "./fork.js"; +import { shouldUseAgent, ensureAgentForHost } from "./agent.js"; const debug = Debug("sshClientWrapper:debug:sshExec"); const debugVerbose = Debug("sshClientWrapper:verbose:sshExec"); @@ -147,6 +148,22 @@ async function existsMaster(hostInfo, argTimeout) { export async function connect(hostInfo, timeout = 60) { debug("connect called"); + if (shouldUseAgent(hostInfo)) { + try { + await ensureAgentForHost(hostInfo); + } catch (e) { + if (e.code === "BAD_PASSPHRASE") { + throw e; + } + debug("agent setup failed; falling back to legacy pty auth", e); + delete hostInfo.managedAgentSock; + delete hostInfo._agentEnsuredAt; + } + } else { + delete hostInfo.managedAgentSock; + delete hostInfo._agentEnsuredAt; + } + if (await existsMaster(hostInfo, timeout)) { debugVerbose("master connection exists"); return; @@ -201,6 +218,22 @@ export async function connect(hostInfo, timeout = 60) { }); hostInfo.masterPty.onData((data)=>{ const output = data.toString(); + + if (shouldUseAgent(hostInfo) && (rePwPrompt.test(output) || rePhPrompt.test(output))) { + if (typeof hostInfo.reauthRequired === "function") { + try { + hostInfo.reauthRequired(); + } catch { + //a consumer hook must never break the connection + } + } + if (hostInfo.password == null && hostInfo.passphrase == null) { + const err = new Error("agent authentication failed and no secret is available for fallback"); + err.code = "REAUTH_REQUIRED"; + reject(err); + return; + } + } sshLoginCallback(output, hostInfo.masterPty, hostInfo.password, hostInfo.passphrase, debugSendMasterPty) .catch(reject); }); diff --git a/lib/util.js b/lib/util.js index a0ccb43..9b6a735 100644 --- a/lib/util.js +++ b/lib/util.js @@ -53,7 +53,10 @@ const hostInfoSchema = { type: "array", minItems: 1, items: { type: "string", pattern: "\\S+", transform: ["trim"] } - } + }, + useAgent: { type: "boolean" }, + identityAgent: { type: "string", pattern: String.raw`\S+`, transform: ["trim"] }, + agentKeyTTL: { type: "number", minimum: 1 } }, required: ["host"] }; @@ -61,13 +64,15 @@ const hostInfoSchema = { const stringOptions = [ "user", "keyFile", - "ControlPersistDir" + "ControlPersistDir", + "identityAgent" ]; const numberOptions = [ "ControlPersist", "ConnectTimeout", "maxRetry", - "retryDuration" + "retryDuration", + "agentKeyTTL" ]; const validate = ajv.compile(hostInfoSchema); @@ -133,6 +138,11 @@ export const sanityCheck = (hostInfo)=>{ } } } + if (typeof hostInfo.identityAgent === "string" && /\s/.test(hostInfo.identityAgent)) { + const err = new Error(`invalid identityAgent specified ${hostInfo.identityAgent}`); + err.hostInfo = hostInfo; + throw err; + } if (["string", "function"].includes(typeof password)) { hostInfo.password = password; } @@ -211,7 +221,13 @@ export const getSshOption = (hostInfo, withoutDestination)=>{ if (hostInfo.keyFile) { args.push("-A"); } - + + //route auth through a wrapper-managed or caller-supplied ssh-agent when one is in play + const agentSock = hostInfo.managedAgentSock || hostInfo.identityAgent; + if (typeof agentSock === "string" && agentSock !== "") { + args.push(`-oIdentityAgent=${agentSock}`); + } + const controlPersist = Number.isInteger(hostInfo.ControlPersist) && hostInfo.ControlPersist >= 0 ? hostInfo.ControlPersist : "180"; const controlPersistDir = getControlPersistDir(hostInfo); args.push("-oControlMaster=auto"); diff --git a/test/agent.js b/test/agent.js new file mode 100644 index 0000000..a0d02e6 --- /dev/null +++ b/test/agent.js @@ -0,0 +1,279 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.on("unhandledRejection", console.dir); + +//setup test framework +import * as chai from "chai"; +import { expect } from "chai"; +import sinon from "sinon"; +import sinonChai from "sinon-chai"; +import chaiAsPromised from "chai-as-promised"; +chai.use(sinonChai); +chai.use(chaiAsPromised); + +//testee +import { + shouldUseAgent, + canSpawnSharedAgent, + probeAgent, + addKey, + socketIsTrusted, + resolveWellKnownDir, + ensureAgentForHost, + _internal +} from "../lib/agent.js"; + +const origSpawn = _internal.spawn; +const origExecFileP = _internal.execFileP; + +/** + * build a controllable fake pty + * @returns {object} - fake pty with emitData/emitExit drivers + */ +function makeFakePty() { + const dataCbs = []; + const exitCbs = []; + return { + onData: (cb)=>{ + dataCbs.push(cb); + }, + onExit: (cb)=>{ + exitCbs.push(cb); + }, + write: sinon.spy(), + kill: sinon.spy(), + emitData: (s)=>{ + for (const cb of dataCbs) { + cb(Buffer.from(s)); + } + }, + emitExit: (exitCode)=>{ + for (const cb of exitCbs) { + cb({ exitCode }); + } + } + }; +} + +describe("test for agent", ()=>{ + let tmpDir; + beforeEach(()=>{ + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "scw-agent-test-")); + }); + afterEach(()=>{ + _internal.spawn = origSpawn; + _internal.execFileP = origExecFileP; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("#shouldUseAgent", ()=>{ + it("should be false when useAgent is explicitly false", ()=>{ + expect(shouldUseAgent({ host: "h", useAgent: false, identityAgent: "/s" })).to.be.false; + }); + it("should be true when identityAgent is set", ()=>{ + expect(shouldUseAgent({ host: "h", identityAgent: "/s" })).to.be.true; + }); + it("should be true when useAgent is explicitly true even without a keyFile", ()=>{ + expect(shouldUseAgent({ host: "h", useAgent: true })).to.be.true; + }); + it("should be false with only a non-existent keyFile", ()=>{ + expect(shouldUseAgent({ host: "h", keyFile: path.join(tmpDir, "nope") })).to.be.false; + }); + it("should be true with an existing keyFile", ()=>{ + const kf = path.join(tmpDir, "id"); + fs.writeFileSync(kf, "x"); + expect(shouldUseAgent({ host: "h", keyFile: kf })).to.be.true; + }); + }); + + describe("#canSpawnSharedAgent", ()=>{ + it("should be true on this POSIX test platform", ()=>{ + expect(canSpawnSharedAgent()).to.equal(process.platform !== "win32"); + }); + }); + + describe("#probeAgent", ()=>{ + it("should return 0 when ssh-add -l succeeds", async ()=>{ + _internal.execFileP = sinon.stub().resolves({ stdout: "256 SHA256:x key (ED25519)\n" }); + expect(await probeAgent("/s")).to.equal(0); + }); + it("should return 1 when the agent is reachable but empty", async ()=>{ + const err = new Error("The agent has no identities."); + err.code = 1; + _internal.execFileP = sinon.stub().rejects(err); + expect(await probeAgent("/s")).to.equal(1); + }); + it("should return 2 when the agent is unreachable", async ()=>{ + const err = new Error("Could not open a connection to your authentication agent."); + err.code = 2; + _internal.execFileP = sinon.stub().rejects(err); + expect(await probeAgent("/s")).to.equal(2); + }); + }); + + describe("#addKey", ()=>{ + it("should resolve on 'Identity added' and not touch the passphrase callback", async ()=>{ + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const ph = sinon.stub(); + const p = addKey("/run/scw-test/agent.sock", "/k", ph, 111); + pty.emitData("Identity added: /k (/k)\r\n"); + await p; + expect(ph).to.not.be.called; + expect(_internal.spawn).to.be.calledWithMatch("ssh-add", ["-t", "111", "/k"]); + }); + it("should call the passphrase callback once for an encrypted key", async ()=>{ + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const ph = sinon.stub().resolves("secret"); + const p = addKey("/run/scw-test/agent.sock", "/k", ph, 111); + pty.emitData("Enter passphrase for key '/k': "); + await new Promise((resolve)=>{ + return setImmediate(resolve); + }); + pty.emitData("Identity added: /k\r\n"); + await p; + expect(ph).to.be.calledOnce; + expect(pty.write).to.be.calledWith("secret\n"); + }); + it("should reject with BAD_PASSPHRASE after repeated bad passphrases", async ()=>{ + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const ph = sinon.stub().resolves("wrong"); + const p = addKey("/run/scw-test/agent.sock", "/k", ph, 111); + pty.emitData("Bad passphrase, try again for '/k': "); + pty.emitData("Bad passphrase, try again for '/k': "); + pty.emitData("Bad passphrase, try again for '/k': "); + await expect(p).to.be.rejectedWith(/bad passphrase/i); + const e = await p.catch((err)=>{ + return err; + }); + expect(e.code).to.equal("BAD_PASSPHRASE"); + }); + it("should reject with NO_AGENT when ssh-add cannot reach an agent", async ()=>{ + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const p = addKey("/run/scw-test/agent.sock", "/k", undefined, 111); + pty.emitData("Could not open a connection to your authentication agent.\r\n"); + const e = await p.catch((err)=>{ + return err; + }); + expect(e.code).to.equal("NO_AGENT"); + }); + it("should reject with NO_PASSPHRASE when the key is encrypted and no secret is available", async ()=>{ + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const p = addKey("/run/scw-test/agent.sock", "/k", undefined, 111); + pty.emitData("Enter passphrase for key '/k': "); + const e = await p.catch((err)=>{ + return err; + }); + expect(e.code).to.equal("NO_PASSPHRASE"); + }); + it("should reject with ADDKEY_FAILED on a non-zero exit with no recognised message", async ()=>{ + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const p = addKey("/run/scw-test/agent.sock", "/k", undefined, 111); + pty.emitExit(2); + const e = await p.catch((err)=>{ + return err; + }); + expect(e.code).to.equal("ADDKEY_FAILED"); + }); + }); + + describe("#socketIsTrusted", ()=>{ + it("should be true for a uid-owned mode-0600 file", ()=>{ + const f = path.join(tmpDir, "s"); + fs.writeFileSync(f, "", { mode: 0o600 }); + fs.chmodSync(f, 0o600); + expect(socketIsTrusted(f)).to.be.true; + }); + it("should be false for a group-readable file", ()=>{ + const f = path.join(tmpDir, "s2"); + fs.writeFileSync(f, "", { mode: 0o600 }); + fs.chmodSync(f, 0o640); + expect(socketIsTrusted(f)).to.be.false; + }); + it("should be false for a missing path", ()=>{ + expect(socketIsTrusted(path.join(tmpDir, "missing"))).to.be.false; + }); + }); + + describe("#resolveWellKnownDir", ()=>{ + it("should honour SSH_CLIENT_WRAPPER_AGENT_DIR", ()=>{ + const orig = process.env.SSH_CLIENT_WRAPPER_AGENT_DIR; + process.env.SSH_CLIENT_WRAPPER_AGENT_DIR = path.join(tmpDir, "agentdir"); + + try { + const dir = resolveWellKnownDir(); + expect(dir).to.equal(path.join(tmpDir, "agentdir")); + expect(fs.statSync(dir).isDirectory()).to.be.true; + } finally { + if (orig === undefined) { + delete process.env.SSH_CLIENT_WRAPPER_AGENT_DIR; + } else { + process.env.SSH_CLIENT_WRAPPER_AGENT_DIR = orig; + } + } + }); + }); + + describe("#ensureAgentForHost", ()=>{ + it("should do nothing when the host should not use an agent", async ()=>{ + const hostInfo = { host: "h", keyFile: path.join(tmpDir, "nope") }; + await ensureAgentForHost(hostInfo); + expect(hostInfo).to.not.have.property("managedAgentSock"); + expect(hostInfo).to.not.have.property("_agentEnsuredAt"); + }); + it("should adopt a populated identityAgent for a keyFile-less host", async ()=>{ + _internal.execFileP = sinon.stub().resolves({ stdout: "256 SHA256:x k (ED25519)\n" }); + const hostInfo = { host: "h", identityAgent: "/run/agent.sock" }; + await ensureAgentForHost(hostInfo); + expect(hostInfo.managedAgentSock).to.equal("/run/agent.sock"); + }); + it("should stay on the legacy path when identityAgent has no identities (keyFile-less)", async ()=>{ + const err = new Error("empty"); + err.code = 1; + _internal.execFileP = sinon.stub().rejects(err); + const hostInfo = { host: "h", identityAgent: "/run/agent.sock" }; + await ensureAgentForHost(hostInfo); + expect(hostInfo).to.not.have.property("managedAgentSock"); + }); + it("should load a keyFile into a reachable identityAgent", async ()=>{ + const kf = path.join(tmpDir, "id"); + fs.writeFileSync(kf, "x"); + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const hostInfo = { host: "h", keyFile: kf, identityAgent: "/run/agent.sock", agentKeyTTL: 42 }; + const p = ensureAgentForHost(hostInfo); + await new Promise((resolve)=>{ + return setImmediate(resolve); + }); + pty.emitData("Identity added: " + kf + "\r\n"); + await p; + expect(hostInfo.managedAgentSock).to.equal("/run/agent.sock"); + expect(hostInfo._agentEnsuredAt).to.be.a("number"); + expect(_internal.spawn).to.be.calledWithMatch("ssh-add", ["-t", "42", kf]); + }); + it("should propagate BAD_PASSPHRASE instead of falling back", async ()=>{ + const kf = path.join(tmpDir, "id2"); + fs.writeFileSync(kf, "x"); + const pty = makeFakePty(); + _internal.spawn = sinon.stub().returns(pty); + const hostInfo = { host: "h", keyFile: kf, identityAgent: "/run/agent.sock", passphrase: ()=>{ + return Promise.resolve("wrong"); + } }; + const p = ensureAgentForHost(hostInfo); + await new Promise((resolve)=>{ + return setImmediate(resolve); + }); + pty.emitData("Bad passphrase, try again"); + pty.emitData("Bad passphrase, try again"); + pty.emitData("Bad passphrase, try again"); + await expect(p).to.be.rejectedWith(/bad passphrase/i); + }); + }); +}); diff --git a/test/getSshOption.js b/test/getSshOption.js index 4a1d343..6a72821 100644 --- a/test/getSshOption.js +++ b/test/getSshOption.js @@ -46,4 +46,21 @@ describe("test for getSshOption", ()=>{ expect(sshOpts[2]).to.equal("-oControlPath=/tmp/ssh-client-wrapper-%r@%h:%p"); expect(sshOpts[3]).to.equal("-oControlPersist=180"); }); + it("should emit -oIdentityAgent for a wrapper-managed agent socket", ()=>{ + const hostInfo = { ...defaultValues, managedAgentSock: "/run/scw/agent.sock" }; + expect(getSshOption(hostInfo)).to.include("-oIdentityAgent=/run/scw/agent.sock"); + }); + it("should emit -oIdentityAgent for a caller-supplied identityAgent", ()=>{ + const hostInfo = { ...defaultValues, identityAgent: "/run/user/1000/keyring/ssh" }; + expect(getSshOption(hostInfo)).to.include("-oIdentityAgent=/run/user/1000/keyring/ssh"); + }); + it("should prefer managedAgentSock over identityAgent", ()=>{ + const hostInfo = { ...defaultValues, managedAgentSock: "/run/managed.sock", identityAgent: "/run/external.sock" }; + const sshOpts = getSshOption(hostInfo); + expect(sshOpts).to.include("-oIdentityAgent=/run/managed.sock"); + expect(sshOpts).to.not.include("-oIdentityAgent=/run/external.sock"); + }); + it("should not emit -oIdentityAgent when no agent is in play", ()=>{ + expect(getSshOption(defaultValues).join(" ")).to.not.match(/IdentityAgent/); + }); }); diff --git a/test/interface.js b/test/interface.js index e6da0e8..b2e281c 100644 --- a/test/interface.js +++ b/test/interface.js @@ -20,6 +20,7 @@ const expectStub = sinon.stub(); const send = sinon.stub(); const recv = sinon.stub(); const remoteToRemoteCopy = sinon.stub(); +const removeKey = sinon.stub(); const deps = { sshExec, @@ -28,7 +29,8 @@ const deps = { expect: expectStub, send, recv, - remoteToRemoteCopy + remoteToRemoteCopy, + removeKey }; describe("test for interface", ()=>{ @@ -51,6 +53,26 @@ describe("test for interface", ()=>{ expect(new SshClientWrapper({ host: "hoge" }, deps)).to.have.property("remoteToRemoteCopy"); expect(new SshClientWrapper({ host: "hoge" }, deps)).to.have.property("canConnect"); expect(new SshClientWrapper({ host: "hoge" }, deps)).to.have.property("disconnect"); + expect(new SshClientWrapper({ host: "hoge" }, deps)).to.have.property("dispose"); + }); + it("should carry the new agent options through unchanged", ()=>{ + const ssh = new SshClientWrapper({ + host: "hoge", + useAgent: false, + identityAgent: "/run/agent.sock", + agentKeyTTL: 60 + }, deps); + expect(ssh.hostInfo).to.deep.equal({ + host: "hoge", + ControlPersist: 180, + maxRetry: 3, + retryDuration: 1000, + masterPty: null, + rsyncVersion: null, + useAgent: false, + identityAgent: "/run/agent.sock", + agentKeyTTL: 60 + }); }); }); describe("test for public method", ()=>{ @@ -71,6 +93,22 @@ describe("test for interface", ()=>{ recv.reset(); remoteToRemoteCopy.reset(); expectStub.reset(); + removeKey.reset(); + }); + describe("test for dispose", ()=>{ + it("should only disconnect when no agent key is loaded", async ()=>{ + await ssh.dispose(); + expect(removeKey).to.not.be.called; + expect(disconnect).to.be.calledWith(hostInfo); + }); + it("should removeKey then disconnect when an agent key is loaded", async ()=>{ + ssh.hostInfo.managedAgentSock = "/run/scw/agent.sock"; + ssh.hostInfo.keyFile = "/home/u/.ssh/id_ed25519"; + await ssh.dispose(); + expect(removeKey).to.be.calledWith("/run/scw/agent.sock", "/home/u/.ssh/id_ed25519"); + expect(removeKey).to.be.calledBefore(disconnect); + expect(ssh.hostInfo).to.not.have.property("managedAgentSock"); + }); }); describe("test for exec", ()=>{ it("should call sshExec with cmd", ()=>{ diff --git a/test/sanityCheck.js b/test/sanityCheck.js index 637d16a..147d334 100644 --- a/test/sanityCheck.js +++ b/test/sanityCheck.js @@ -152,4 +152,33 @@ describe("test for sanityCheck", ()=>{ }; expect(sanityCheck(testData)).to.deep.equal(testData); }); + it("should coerce string value for useAgent", ()=>{ + expect(sanityCheck({ host, useAgent: "true" })).to.deep.equal({ host, ...defaultValues, useAgent: true }); + expect(sanityCheck({ host, useAgent: 0 })).to.deep.equal({ host, ...defaultValues, useAgent: false }); + }); + it("should throw error if useAgent is not boolean-coercible", ()=>{ + expect(sanityCheck.bind(null, { host, useAgent: "notabool" })).to.throw(/invalid useAgent/); + }); + it("should not inject a default for useAgent", ()=>{ + expect(sanityCheck({ host, useAgent: false })).to.deep.equal({ host, ...defaultValues, useAgent: false }); + }); + it("should trim identityAgent and remove it when empty", ()=>{ + expect(sanityCheck({ host, identityAgent: " /run/agent.sock " })).to.deep.equal({ + host, + ...defaultValues, + identityAgent: "/run/agent.sock" + }); + expect(sanityCheck({ host, identityAgent: " " })).to.deep.equal({ host, ...defaultValues }); + }); + it("should throw error if identityAgent contains whitespace", ()=>{ + expect(sanityCheck.bind(null, { host, identityAgent: "/foo bar/agent.sock" })).to.throw(/invalid identityAgent/); + }); + it("should coerce agentKeyTTL and drop out-of-range values", ()=>{ + expect(sanityCheck({ host, agentKeyTTL: "3600" })).to.deep.equal({ host, ...defaultValues, agentKeyTTL: 3600 }); + expect(sanityCheck({ host, agentKeyTTL: "0" })).to.deep.equal({ host, ...defaultValues }); + expect(sanityCheck({ host, agentKeyTTL: "-1" })).to.deep.equal({ host, ...defaultValues }); + }); + it("should throw error if agentKeyTTL is not a number", ()=>{ + expect(sanityCheck.bind(null, { host, agentKeyTTL: "abc" })).to.throw(/invalid agentKeyTTL/); + }); }); diff --git a/test/sshExec.js b/test/sshExec.js index 4be6500..2f1df81 100644 --- a/test/sshExec.js +++ b/test/sshExec.js @@ -1,5 +1,5 @@ -import path from "path"; -import { fileURLToPath } from "url"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; process.on("unhandledRejection", console.dir); Error.traceLimit = 100000; @@ -150,4 +150,46 @@ describe("test for ssh execution", function () { return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith("invalid port specified 65536"); }); }); + describe("#ssh-agent authentication", ()=>{ + let h; + before(function () { + if (!hostInfo.keyFile) { + this.skip(); + } + }); + beforeEach(async ()=>{ + await disconnect(hostInfo); + h = { ...hostInfo, masterPty: null }; + delete h.managedAgentSock; + delete h._agentEnsuredAt; + }); + afterEach(async ()=>{ + await disconnect(h); + }); + it("should load the key into an agent and route ssh through it", async ()=>{ + const rt = await sshExec(h, "echo agent-ok", 0, sshout); + expect(rt).to.equal(0); + expect(h.managedAgentSock).to.be.a("string").and.not.equal(""); + }); + it("should not touch the agent when useAgent is false", async ()=>{ + h.useAgent = false; + const rt = await sshExec(h, "echo legacy-ok", 0, sshout); + expect(rt).to.equal(0); + expect(h).to.not.have.property("managedAgentSock"); + }); + it("should consume the passphrase only once across a master reset", async function () { + if (!process.env.TEST_PH) { + this.skip(); + } + const phSpy = sinon.spy(()=>{ + return Promise.resolve(process.env.TEST_PH); + }); + h.passphrase = phSpy; + h.ControlPersist = 1; + await sshExec(h, "echo one", 0, sshout); + await disconnect(h); + await sshExec(h, "echo two", 0, sshout); + expect(phSpy.callCount).to.equal(1); + }); + }); });