mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat(pi-ssh): support SSH agent authentication
This commit is contained in:
+7
-6
@@ -15,7 +15,7 @@ The extension does not override Pi's local `read`, `write`, `edit`, `find`, `gre
|
||||
|
||||
## Architecture
|
||||
|
||||
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Remote file operations use SFTP and remote shell commands use an SSH exec channel. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
|
||||
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Authentication can use a private-key file, a password, or an SSH agent socket such as 1Password's agent. Remote file operations use SFTP and remote shell commands use an SSH exec channel. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
|
||||
|
||||
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host.
|
||||
|
||||
@@ -35,9 +35,9 @@ From the installed bundle or this repository:
|
||||
The helper:
|
||||
|
||||
1. lets you select concrete aliases from `~/.ssh/config`;
|
||||
2. uses `ssh -G` to resolve HostName, User, Port, and IdentityFile;
|
||||
3. asks whether the selected host uses a private key or password;
|
||||
4. asks for a private-key passphrase when needed;
|
||||
2. uses `ssh -G` to resolve HostName, User, Port, IdentityFile, and IdentityAgent;
|
||||
3. asks whether the selected host uses an SSH agent, private key, or password;
|
||||
4. validates the selected agent socket or asks for a private-key passphrase when needed;
|
||||
5. obtains and displays the server's SHA256 host-key fingerprint;
|
||||
6. connects with `ssh2` to verify authentication;
|
||||
7. optionally assigns a display label and group;
|
||||
@@ -68,12 +68,13 @@ On POSIX systems the directory is mode `700` and both files are mode `600`. `hos
|
||||
|
||||
This is deliberately a **casual-disclosure boundary**, not protection against compromise of the local account: anyone who can read both files can decrypt the vault. Encryption prevents the host configuration and passwords from being exposed by accidentally viewing or copying `hosts.enc` alone.
|
||||
|
||||
The encrypted payload contains host endpoints, pinned host-key fingerprints, groups, and either:
|
||||
The encrypted payload contains host endpoints, pinned host-key fingerprints, groups, and one of:
|
||||
|
||||
- an SSH agent socket path;
|
||||
- a private-key path plus optional passphrase; or
|
||||
- the server password.
|
||||
|
||||
Private-key contents are not copied into the vault.
|
||||
Private-key contents are not copied into the vault. Agent-backed private keys remain inside the agent (including 1Password); pi-ssh stores only the socket path and asks `ssh2` to authenticate through it.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
import {
|
||||
effectiveIdentityAgent,
|
||||
effectiveValue,
|
||||
effectiveValues,
|
||||
listDirectSshAliases,
|
||||
@@ -106,11 +107,19 @@ async function importHost(config, alias) {
|
||||
const identityFiles = effectiveValues(effective, "identityfile")
|
||||
.map((path) => path.replace(/^"|"$/g, ""))
|
||||
.filter((path) => existsSync(expandUserPath(path)));
|
||||
const defaultMode = identityFiles.length > 0 ? "key" : "password";
|
||||
const modeInput = (await question(`Authentication [key/password] (${defaultMode}): `)).toLowerCase();
|
||||
const agentSocket = effectiveIdentityAgent(effective);
|
||||
const agentAvailable = agentSocket && (agentSocket === "pageant" || existsSync(expandUserPath(agentSocket)));
|
||||
const defaultMode = agentAvailable ? "agent" : identityFiles.length > 0 ? "key" : "password";
|
||||
const modeInput = (await question(`Authentication [agent/key/password] (${defaultMode}): `)).toLowerCase();
|
||||
const mode = modeInput || defaultMode;
|
||||
let auth;
|
||||
if (mode === "key") {
|
||||
if (mode === "agent") {
|
||||
const socketPath = await question(`SSH agent socket${agentSocket ? ` (${agentSocket})` : ""}: `) || agentSocket;
|
||||
if (!socketPath || (socketPath !== "pageant" && !existsSync(expandUserPath(socketPath)))) {
|
||||
throw new Error(`SSH agent socket does not exist: ${socketPath ?? ""}`);
|
||||
}
|
||||
auth = { type: "agent", socketPath };
|
||||
} else if (mode === "key") {
|
||||
const suggested = identityFiles[0] ?? "";
|
||||
const identityFile = await question(`Private key path${suggested ? ` (${suggested})` : ""}: `) || suggested;
|
||||
if (!identityFile || !existsSync(expandUserPath(identityFile))) throw new Error(`private key does not exist: ${identityFile}`);
|
||||
@@ -123,7 +132,7 @@ async function importHost(config, alias) {
|
||||
if (!password) throw new Error("server password cannot be empty");
|
||||
auth = { type: "password", password, method: "auto" };
|
||||
} else {
|
||||
throw new Error("authentication must be key or password");
|
||||
throw new Error("authentication must be agent, key, or password");
|
||||
}
|
||||
|
||||
console.log("Obtaining SSH host key fingerprint...");
|
||||
|
||||
+13
-2
@@ -13,7 +13,12 @@ export interface PrivateKeyAuthConfig {
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig;
|
||||
export interface AgentAuthConfig {
|
||||
type: "agent";
|
||||
socketPath: string;
|
||||
}
|
||||
|
||||
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig | AgentAuthConfig;
|
||||
|
||||
export interface SshHostConfig {
|
||||
label?: string;
|
||||
@@ -108,7 +113,13 @@ function validateAuth(value: unknown, name: string): SshAuthConfig {
|
||||
...(auth.passphrase === undefined ? {} : { passphrase: nonEmptyString(auth.passphrase, `${name}.passphrase`) }),
|
||||
};
|
||||
}
|
||||
throw new Error(`${name}.type must be password or private-key`);
|
||||
if (auth.type === "agent") {
|
||||
return {
|
||||
type: "agent",
|
||||
socketPath: nonEmptyString(auth.socketPath, `${name}.socketPath`),
|
||||
};
|
||||
}
|
||||
throw new Error(`${name}.type must be password, private-key, or agent`);
|
||||
}
|
||||
|
||||
function validateHost(value: unknown, name: string): SshHostConfig {
|
||||
|
||||
@@ -37,6 +37,18 @@ export function effectiveValues(config: EffectiveSshConfig, key: string): string
|
||||
return config.get(key.toLowerCase()) ?? [];
|
||||
}
|
||||
|
||||
export function effectiveIdentityAgent(
|
||||
config: EffectiveSshConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const configured = effectiveValue(config, "identityagent")?.replace(/^"|"$/g, "");
|
||||
if (!configured || configured.toLowerCase() === "none") return undefined;
|
||||
if (configured === "SSH_AUTH_SOCK" || configured === "$SSH_AUTH_SOCK" || configured === "${SSH_AUTH_SOCK}") {
|
||||
return env.SSH_AUTH_SOCK;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
export function listDirectSshAliases(configPath = join(homedir(), ".ssh", "config")): string[] {
|
||||
if (!existsSync(configPath)) return [];
|
||||
const aliases: string[] = [];
|
||||
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ declare module "ssh2" {
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: Buffer | string;
|
||||
agent?: string;
|
||||
passphrase?: string;
|
||||
tryKeyboard?: boolean;
|
||||
readyTimeout?: number;
|
||||
|
||||
@@ -128,9 +128,11 @@ function buildConnectConfig(host: SshHostConfig): ConnectConfig {
|
||||
if (host.auth.type === "password") {
|
||||
config.password = host.auth.password;
|
||||
config.tryKeyboard = host.auth.method !== "password";
|
||||
} else {
|
||||
} else if (host.auth.type === "private-key") {
|
||||
config.privateKey = readFileSync(expandUserPath(host.auth.identityFile));
|
||||
if (host.auth.passphrase) config.passphrase = host.auth.passphrase;
|
||||
} else {
|
||||
config.agent = host.auth.socketPath === "pageant" ? "pageant" : expandUserPath(host.auth.socketPath);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { validatePiSshConfig } from "../src/config.ts";
|
||||
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
|
||||
import { effectiveIdentityAgent, effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
|
||||
import { fingerprintHostKey } from "../src/ssh2-transport.ts";
|
||||
|
||||
test("parses the effective ssh -G output including repeated identity files", () => {
|
||||
@@ -21,7 +21,19 @@ test("parses the effective ssh -G output including repeated identity files", ()
|
||||
assert.deepEqual(effectiveValues(parsed, "identityfile"), ["~/.ssh/first", "~/.ssh/second"]);
|
||||
});
|
||||
|
||||
test("validates password and private-key host definitions", () => {
|
||||
test("resolves IdentityAgent socket paths and SSH_AUTH_SOCK references", () => {
|
||||
assert.equal(
|
||||
effectiveIdentityAgent(parseSshG("identityagent /Users/test/.1password/agent.sock"), {}),
|
||||
"/Users/test/.1password/agent.sock",
|
||||
);
|
||||
assert.equal(
|
||||
effectiveIdentityAgent(parseSshG("identityagent $SSH_AUTH_SOCK"), { SSH_AUTH_SOCK: "/tmp/agent.sock" }),
|
||||
"/tmp/agent.sock",
|
||||
);
|
||||
assert.equal(effectiveIdentityAgent(parseSshG("identityagent none"), { SSH_AUTH_SOCK: "/tmp/agent.sock" }), undefined);
|
||||
});
|
||||
|
||||
test("validates password, private-key, and agent host definitions", () => {
|
||||
const password = validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
@@ -37,6 +49,20 @@ test("validates password and private-key host definitions", () => {
|
||||
});
|
||||
assert.equal(password.hosts.build.auth.type, "password");
|
||||
|
||||
const agent = validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
build: {
|
||||
hostName: "build.example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "agent", socketPath: "~/.1password/agent.sock" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(agent.hosts.build.auth, { type: "agent", socketPath: "~/.1password/agent.sock" });
|
||||
|
||||
assert.throws(() => validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
@@ -44,11 +70,11 @@ test("validates password and private-key host definitions", () => {
|
||||
hostName: "build.example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password", password: "" },
|
||||
auth: { type: "agent", socketPath: "" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||
},
|
||||
},
|
||||
}), /password/);
|
||||
}), /socketPath/);
|
||||
});
|
||||
|
||||
test("formats SSH host keys as pinned SHA256 fingerprints", () => {
|
||||
@@ -68,4 +94,6 @@ test("configuration import validates remote HOME and cwd with framed probes", as
|
||||
assert.match(source, /probeRemotePath\(transport, "home"\)/);
|
||||
assert.match(source, /probeRemotePath\(transport, "cwd"\)/);
|
||||
assert.doesNotMatch(source, /transport\.capture\(/);
|
||||
assert.match(source, /Authentication \[agent\/key\/password\]/);
|
||||
assert.match(source, /effectiveIdentityAgent\(effective\)/);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||
@@ -235,6 +235,18 @@ test("loads private-key authentication and forwards the passphrase", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("forwards SSH agent authentication through ssh2", async () => {
|
||||
const host: SshHostConfig = {
|
||||
...passwordHost(),
|
||||
auth: { type: "agent", socketPath: "~/.1password/agent.sock" },
|
||||
};
|
||||
const { fake, transport } = await connectedTransport(undefined, host);
|
||||
assert.equal(fake.connectConfig?.agent, join(homedir(), ".1password", "agent.sock"));
|
||||
assert.equal(fake.connectConfig?.password, undefined);
|
||||
assert.equal(fake.connectConfig?.privateKey, undefined);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
|
||||
const sftp = new FakeSftp();
|
||||
sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));
|
||||
|
||||
Reference in New Issue
Block a user