passwd-sso-cli 0.4.55 → 0.4.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,14 @@
2
2
  * `passwd-sso agent` — Start an SSH agent backed by vault SSH keys.
3
3
  *
4
4
  * Usage:
5
- * eval $(passwd-sso agent --eval) # Set SSH_AUTH_SOCK
5
+ * eval $(passwd-sso agent --eval) # fork a detached daemon, set SSH_AUTH_SOCK
6
6
  * ssh-add -l # List vault SSH keys
7
7
  * ssh -T git@github.com # Use via agent
8
+ *
9
+ * Without --eval the agent runs in the foreground (Ctrl+C to stop). With
10
+ * --eval it forks a detached child holding the vault key (mirrors the decrypt
11
+ * agent) and the parent prints the export commands and exits, so command
12
+ * substitution returns instead of blocking.
8
13
  */
9
14
  export interface AgentOptions {
10
15
  eval?: boolean;
@@ -2,19 +2,29 @@
2
2
  * `passwd-sso agent` — Start an SSH agent backed by vault SSH keys.
3
3
  *
4
4
  * Usage:
5
- * eval $(passwd-sso agent --eval) # Set SSH_AUTH_SOCK
5
+ * eval $(passwd-sso agent --eval) # fork a detached daemon, set SSH_AUTH_SOCK
6
6
  * ssh-add -l # List vault SSH keys
7
7
  * ssh -T git@github.com # Use via agent
8
+ *
9
+ * Without --eval the agent runs in the foreground (Ctrl+C to stop). With
10
+ * --eval it forks a detached child holding the vault key (mirrors the decrypt
11
+ * agent) and the parent prints the export commands and exits, so command
12
+ * substitution returns instead of blocking.
8
13
  */
14
+ import { spawn } from "node:child_process";
9
15
  import { apiRequest } from "../lib/api-client.js";
10
- import { decryptData } from "../lib/crypto.js";
16
+ import { decryptData, hexEncode } from "../lib/crypto.js";
11
17
  import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
12
- import { getEncryptionKey, getUserId, isUnlocked } from "../lib/vault-state.js";
13
- import { autoUnlockIfNeeded } from "./unlock.js";
18
+ import { getEncryptionKey, getUserId, getSecretKeyBytes, setEncryptionKey, isUnlocked, } from "../lib/vault-state.js";
19
+ import { autoUnlockIfNeeded, readPassphrase, unlockWithPassphrase, } from "./unlock.js";
14
20
  import { loadKey, clearKeys } from "../lib/ssh-key-agent.js";
15
- import { startAgent, stopAgent } from "../lib/ssh-agent-socket.js";
21
+ import { startAgent, stopAgent, setAgentDeps } from "../lib/ssh-agent-socket.js";
22
+ import { authorizeSign } from "../lib/ssh-sign-authorizer.js";
23
+ import { confirmSign } from "../lib/ssh-confirm.js";
16
24
  import { decryptAgentCommand } from "./agent-decrypt.js";
17
25
  import * as output from "../lib/output.js";
26
+ /** Env flag marking the forked daemon child process. */
27
+ const SSH_DAEMON_ENV = "_PSSO_SSH_DAEMON";
18
28
  /**
19
29
  * Build an SSH public key blob from the stored public key string.
20
30
  * The public key is in OpenSSH format: "ssh-ed25519 AAAA... comment"
@@ -30,34 +40,29 @@ function parsePublicKeyBlob(publicKeyStr) {
30
40
  return null;
31
41
  }
32
42
  }
33
- export async function agentCommand(opts) {
34
- if (opts.decrypt) {
35
- return decryptAgentCommand({ eval: opts.eval });
36
- }
37
- if (!await autoUnlockIfNeeded()) {
38
- output.error("Vault is locked. Run `passwd-sso unlock` first, or set PSSO_PASSPHRASE.");
39
- process.exit(1);
40
- }
43
+ /**
44
+ * Fetch every SSH_KEY entry, decrypt it, and load it into the in-memory
45
+ * agent. Returns the number of keys loaded. Exits the process on a fatal
46
+ * condition (no encryption key, fetch failure, no keys, none loadable).
47
+ */
48
+ async function loadSshKeys() {
41
49
  const encryptionKey = getEncryptionKey();
42
50
  if (!encryptionKey) {
43
51
  output.error("Encryption key not available.");
44
52
  process.exit(1);
45
53
  }
46
54
  const userId = getUserId();
47
- // Fetch all SSH_KEY entries from the vault
48
55
  const res = await apiRequest("/api/passwords?type=SSH_KEY&include=blob");
49
56
  if (!res.ok) {
50
57
  output.error(`Failed to fetch SSH keys: HTTP ${res.status}`);
51
58
  process.exit(1);
52
59
  }
53
- const entries = res.data;
54
- if (entries.length === 0) {
60
+ if (res.data.length === 0) {
55
61
  output.error("No SSH keys found in vault.");
56
62
  process.exit(1);
57
63
  }
58
- // Decrypt and load each SSH key
59
64
  let loadedCount = 0;
60
- for (const entry of entries) {
65
+ for (const entry of res.data) {
61
66
  try {
62
67
  const aad = entry.aadVersion >= 1 && userId
63
68
  ? buildPersonalEntryAAD(userId, entry.id, VAULT_TYPE.BLOB)
@@ -69,7 +74,7 @@ export async function agentCommand(opts) {
69
74
  const publicKeyBlob = parsePublicKeyBlob(blob.publicKey);
70
75
  if (!publicKeyBlob)
71
76
  continue;
72
- await loadKey(entry.id, blob.privateKey, publicKeyBlob, blob.comment ?? blob.title ?? "", blob.passphrase);
77
+ await loadKey(entry.id, blob.privateKey, publicKeyBlob, blob.comment ?? blob.title ?? "", blob.passphrase, entry.requireReprompt);
73
78
  loadedCount++;
74
79
  }
75
80
  catch (err) {
@@ -80,23 +85,44 @@ export async function agentCommand(opts) {
80
85
  output.error("No valid SSH keys could be loaded.");
81
86
  process.exit(1);
82
87
  }
83
- // Start the agent socket
84
- const socketPath = startAgent();
85
- if (opts.eval) {
86
- // Output shell commands to set SSH_AUTH_SOCK
87
- // The user runs: eval $(passwd-sso agent --eval)
88
- console.log(`SSH_AUTH_SOCK=${socketPath}; export SSH_AUTH_SOCK;`);
89
- console.log(`echo "Agent pid ${process.pid}";`);
88
+ return loadedCount;
89
+ }
90
+ export async function agentCommand(opts) {
91
+ if (opts.decrypt) {
92
+ return decryptAgentCommand({ eval: opts.eval });
93
+ }
94
+ // Internal daemon child (forked by --eval): receives the vault key via IPC.
95
+ if (process.env[SSH_DAEMON_ENV] === "1") {
96
+ return runDaemonChild();
97
+ }
98
+ // Unlock the vault in this (parent / foreground) process.
99
+ if (!(await autoUnlockIfNeeded())) {
100
+ if (opts.eval && process.stdin.isTTY) {
101
+ // Prompt on stderr so stdout stays clean for `eval $(...)` capture.
102
+ const passphrase = await readPassphrase("Master passphrase: ", { useStderr: true });
103
+ if (!passphrase || !(await unlockWithPassphrase(passphrase))) {
104
+ output.error("Vault unlock failed.");
105
+ process.exit(1);
106
+ }
107
+ }
108
+ else {
109
+ output.error("Vault is locked. Run `passwd-sso unlock` first, or set PSSO_PASSPHRASE.");
110
+ process.exit(1);
111
+ }
90
112
  }
91
- else {
92
- output.success(`SSH agent started with ${loadedCount} key(s).`);
93
- output.info(`Socket: ${socketPath}`);
94
- output.info("Set SSH_AUTH_SOCK:");
95
- console.log(` export SSH_AUTH_SOCK=${socketPath}`);
96
- output.info("Or use:");
97
- console.log(` eval $(passwd-sso agent --eval)`);
113
+ if (opts.eval) {
114
+ return forkDaemon();
98
115
  }
99
- // Keep process running until interrupted
116
+ // Foreground mode: load keys and serve in this process.
117
+ const loadedCount = await loadSshKeys();
118
+ setAgentDeps({ authorizeSign, confirmSign });
119
+ const socketPath = startAgent();
120
+ output.success(`SSH agent started with ${loadedCount} key(s).`);
121
+ output.info(`Socket: ${socketPath}`);
122
+ output.info("Set SSH_AUTH_SOCK:");
123
+ console.log(` export SSH_AUTH_SOCK=${socketPath}`);
124
+ output.info("Or use:");
125
+ console.log(` eval $(passwd-sso agent --eval)`);
100
126
  output.info("Press Ctrl+C to stop the agent.");
101
127
  // Handle vault lock → clear keys
102
128
  const checkLock = setInterval(() => {
@@ -113,4 +139,81 @@ export async function agentCommand(opts) {
113
139
  // Never resolves — process exits via signal handlers
114
140
  });
115
141
  }
142
+ /**
143
+ * --eval mode: fork a detached child holding the vault key, print the
144
+ * SSH_AUTH_SOCK export commands, and exit so `eval $(...)` returns. Mirrors
145
+ * the decrypt agent's forkDaemon (agent-decrypt.ts).
146
+ */
147
+ async function forkDaemon() {
148
+ const secretBytes = getSecretKeyBytes();
149
+ if (!secretBytes) {
150
+ output.error("Secret key bytes not available.");
151
+ process.exit(1);
152
+ }
153
+ // Send the raw secret bytes (not the derived CryptoKey) — the child derives
154
+ // the encryption key itself. Zero the source array after hex-encoding.
155
+ const secretHex = hexEncode(secretBytes);
156
+ secretBytes.fill(0);
157
+ const userId = getUserId();
158
+ // Reconstruct args for the child: drop --eval, preserve tsx loader flags.
159
+ const childArgs = [
160
+ ...process.execArgv.filter((a) => !a.startsWith("--eval")),
161
+ ...process.argv.slice(1).filter((a) => a !== "--eval"),
162
+ ];
163
+ const child = spawn(process.execPath, childArgs, {
164
+ detached: true,
165
+ stdio: ["ignore", "ignore", "inherit", "ipc"],
166
+ env: { ...process.env, [SSH_DAEMON_ENV]: "1" },
167
+ });
168
+ child.send({ secretHex, userId });
169
+ // Child reports the socket path once it is serving keys.
170
+ child.on("message", (msg) => {
171
+ console.log(`SSH_AUTH_SOCK='${msg.socketPath}'; export SSH_AUTH_SOCK;`);
172
+ console.log(`SSH_AGENT_PID='${child.pid}'; export SSH_AGENT_PID;`);
173
+ console.log(`trap 'kill ${child.pid} 2>/dev/null; rm -f ${msg.socketPath}' EXIT;`);
174
+ child.unref();
175
+ child.disconnect();
176
+ process.exit(0);
177
+ });
178
+ // Child exited before acknowledging (e.g., no loadable keys) → surface it.
179
+ child.on("exit", (code) => {
180
+ process.stderr.write(`Agent child exited unexpectedly with code ${code}\n`);
181
+ process.exit(1);
182
+ });
183
+ setTimeout(() => {
184
+ process.stderr.write("Error: Agent child did not respond within 10s.\n");
185
+ child.kill();
186
+ process.exit(1);
187
+ }, 10_000);
188
+ }
189
+ /**
190
+ * Internal daemon child: receives the vault secret via IPC, derives the
191
+ * encryption key, loads the SSH keys, starts the socket, and reports the
192
+ * socket path back to the parent. The listening socket keeps the process
193
+ * alive after the IPC channel is disconnected.
194
+ */
195
+ function runDaemonChild() {
196
+ return new Promise((resolve) => {
197
+ process.on("message", async (msg) => {
198
+ try {
199
+ const { hexDecode, deriveEncryptionKey } = await import("../lib/crypto.js");
200
+ const secretBytes = hexDecode(msg.secretHex);
201
+ const key = await deriveEncryptionKey(secretBytes);
202
+ secretBytes.fill(0);
203
+ setEncryptionKey(key, msg.userId ?? undefined);
204
+ await loadSshKeys(); // exits(1) on no loadable keys → parent reports via "exit"
205
+ setAgentDeps({ authorizeSign, confirmSign });
206
+ const socketPath = startAgent();
207
+ process.send({ socketPath });
208
+ if (process.disconnect)
209
+ process.disconnect();
210
+ resolve();
211
+ }
212
+ catch (err) {
213
+ process.stderr.write(`Daemon init error: ${err instanceof Error ? err.message : String(err)}\n`);
214
+ process.exit(1);
215
+ }
216
+ });
217
+ });
218
+ }
116
219
  //# sourceMappingURL=agent.js.map
package/dist/index.js CHANGED
File without changes
package/dist/lib/oauth.js CHANGED
@@ -9,7 +9,7 @@ import { createServer, } from "node:http";
9
9
  import { spawn } from "node:child_process";
10
10
  const CALLBACK_TIMEOUT_MS = 120_000; // 2 minutes
11
11
  const CLI_CLIENT_NAME = "passwd-sso-cli";
12
- const CLI_SCOPES = "credentials:list credentials:use vault:status vault:unlock-data passwords:read passwords:write";
12
+ const CLI_SCOPES = "credentials:list credentials:use vault:status vault:unlock-data passwords:read passwords:write ssh:sign";
13
13
  const MCP_TOKEN_ENDPOINT = "/api/mcp/token";
14
14
  // ─── PKCE helpers ─────────────────────────────────────────────────────────────
15
15
  export function generateCodeVerifier() {
@@ -2,15 +2,20 @@
2
2
  * SSH agent protocol constants and framing helpers.
3
3
  *
4
4
  * Implements the subset of the SSH agent protocol needed for key listing
5
- * and signing operations. Reference: draft-miller-ssh-agent.
5
+ * and signing operations. Reference: RFC 9987 (SSH Agent Protocol).
6
6
  */
7
7
  /** Client → Agent */
8
8
  export declare const SSH2_AGENTC_REQUEST_IDENTITIES = 11;
9
9
  export declare const SSH2_AGENTC_SIGN_REQUEST = 13;
10
+ export declare const SSH_AGENTC_REMOVE_ALL_IDENTITIES = 19;
11
+ export declare const SSH_AGENTC_EXTENSION = 27;
10
12
  /** Agent → Client */
11
13
  export declare const SSH2_AGENT_FAILURE = 5;
14
+ export declare const SSH_AGENT_SUCCESS = 6;
12
15
  export declare const SSH2_AGENT_IDENTITIES_ANSWER = 12;
13
16
  export declare const SSH2_AGENT_SIGN_RESPONSE = 14;
17
+ export declare const SSH_AGENT_EXTENSION_FAILURE = 28;
18
+ export declare const SSH_AGENT_EXTENSION_RESPONSE = 29;
14
19
  export declare const SSH_AGENT_RSA_SHA2_256 = 2;
15
20
  export declare const SSH_AGENT_RSA_SHA2_512 = 4;
16
21
  /**
@@ -54,3 +59,23 @@ export declare function buildIdentitiesAnswer(keys: {
54
59
  * Build an SSH2_AGENT_SIGN_RESPONSE.
55
60
  */
56
61
  export declare function buildSignResponse(signature: Buffer): Buffer;
62
+ /**
63
+ * Build an SSH_AGENT_SUCCESS response (RFC 9987 §4.1).
64
+ */
65
+ export declare function buildSuccess(): Buffer;
66
+ /**
67
+ * Build an SSH_AGENT_EXTENSION_RESPONSE (RFC 9987 §4.7).
68
+ *
69
+ * @param payload The extension-specific response payload bytes
70
+ */
71
+ export declare function buildExtensionResponse(payload: Buffer): Buffer;
72
+ /**
73
+ * Parse an SSH_AGENTC_EXTENSION message buffer.
74
+ *
75
+ * @param msgBuf The full message body (msgBuf[0] is the SSH_AGENTC_EXTENSION type byte).
76
+ * @returns The extension name (utf-8) and the remaining bytes after the name string.
77
+ */
78
+ export declare function readExtensionRequest(msgBuf: Buffer): {
79
+ extName: string;
80
+ rest: Buffer;
81
+ };
@@ -2,16 +2,21 @@
2
2
  * SSH agent protocol constants and framing helpers.
3
3
  *
4
4
  * Implements the subset of the SSH agent protocol needed for key listing
5
- * and signing operations. Reference: draft-miller-ssh-agent.
5
+ * and signing operations. Reference: RFC 9987 (SSH Agent Protocol).
6
6
  */
7
7
  // ─── Message types ────────────────────────────────────────────
8
8
  /** Client → Agent */
9
9
  export const SSH2_AGENTC_REQUEST_IDENTITIES = 11;
10
10
  export const SSH2_AGENTC_SIGN_REQUEST = 13;
11
+ export const SSH_AGENTC_REMOVE_ALL_IDENTITIES = 19;
12
+ export const SSH_AGENTC_EXTENSION = 27;
11
13
  /** Agent → Client */
12
14
  export const SSH2_AGENT_FAILURE = 5;
15
+ export const SSH_AGENT_SUCCESS = 6;
13
16
  export const SSH2_AGENT_IDENTITIES_ANSWER = 12;
14
17
  export const SSH2_AGENT_SIGN_RESPONSE = 14;
18
+ export const SSH_AGENT_EXTENSION_FAILURE = 28;
19
+ export const SSH_AGENT_EXTENSION_RESPONSE = 29;
15
20
  // ─── Signature algorithm flags (SSH2_AGENTC_SIGN_REQUEST flags field) ─
16
21
  export const SSH_AGENT_RSA_SHA2_256 = 2;
17
22
  export const SSH_AGENT_RSA_SHA2_512 = 4;
@@ -105,4 +110,36 @@ export function buildSignResponse(signature) {
105
110
  sigString.copy(body, 1);
106
111
  return frameMessage(body);
107
112
  }
113
+ /**
114
+ * Build an SSH_AGENT_SUCCESS response (RFC 9987 §4.1).
115
+ */
116
+ export function buildSuccess() {
117
+ const body = Buffer.alloc(1);
118
+ body[0] = SSH_AGENT_SUCCESS;
119
+ return frameMessage(body);
120
+ }
121
+ /**
122
+ * Build an SSH_AGENT_EXTENSION_RESPONSE (RFC 9987 §4.7).
123
+ *
124
+ * @param payload The extension-specific response payload bytes
125
+ */
126
+ export function buildExtensionResponse(payload) {
127
+ const body = Buffer.alloc(1 + payload.length);
128
+ body[0] = SSH_AGENT_EXTENSION_RESPONSE;
129
+ payload.copy(body, 1);
130
+ return frameMessage(body);
131
+ }
132
+ /**
133
+ * Parse an SSH_AGENTC_EXTENSION message buffer.
134
+ *
135
+ * @param msgBuf The full message body (msgBuf[0] is the SSH_AGENTC_EXTENSION type byte).
136
+ * @returns The extension name (utf-8) and the remaining bytes after the name string.
137
+ */
138
+ export function readExtensionRequest(msgBuf) {
139
+ // msgBuf[0] = type byte (SSH_AGENTC_EXTENSION); extension name starts at offset 1
140
+ const { data, nextOffset } = readString(msgBuf, 1);
141
+ const extName = data.toString("utf-8");
142
+ const rest = msgBuf.subarray(nextOffset);
143
+ return { extName, rest };
144
+ }
108
145
  //# sourceMappingURL=ssh-agent-protocol.js.map
@@ -1,9 +1,49 @@
1
1
  /**
2
2
  * SSH agent Unix domain socket server.
3
3
  *
4
- * Creates a Unix socket that implements the SSH agent protocol,
5
- * serving keys from the passwd-sso vault.
4
+ * Creates a Unix socket that implements the SSH agent protocol (RFC 9987),
5
+ * serving keys from the passwd-sso vault. Supports:
6
+ * - REQUEST_IDENTITIES (11)
7
+ * - SIGN_REQUEST (13) — with per-signature server authorization
8
+ * - REMOVE_ALL_IDENTITIES (19)
9
+ * - EXTENSION (27): query, session-bind@openssh.com
6
10
  */
11
+ import type { Socket } from "node:net";
12
+ import type { SessionBinding } from "./ssh-session-bind.js";
13
+ import { authorizeSign as defaultAuthorizeSign } from "./ssh-sign-authorizer.js";
14
+ import { confirmSign as defaultConfirmSign } from "./ssh-confirm.js";
15
+ /** Per-connection state: the verified session-bind from session-bind@openssh.com */
16
+ export type ConnectionContext = {
17
+ binding: SessionBinding | null;
18
+ };
19
+ type AuthorizeFn = typeof defaultAuthorizeSign;
20
+ type ConfirmFn = typeof defaultConfirmSign;
21
+ /**
22
+ * Inject authorize/confirm dependencies.
23
+ * Must be called before the first connection. Mirrors the setEncryptionKey pattern.
24
+ */
25
+ export declare function setAgentDeps(deps: {
26
+ authorizeSign?: AuthorizeFn;
27
+ confirmSign?: ConfirmFn;
28
+ }): void;
29
+ /**
30
+ * Handle a single SSH agent protocol message.
31
+ * Exported for unit testing (mirrors agent-decrypt.ts).
32
+ */
33
+ export declare function handleMessage(msgBuf: Buffer, ctx: ConnectionContext): Promise<Buffer>;
34
+ /**
35
+ * Handle a connected client socket with sequential, in-order message processing.
36
+ *
37
+ * RFC 9987 requires replies in the same order as requests. The async sign path
38
+ * (authorize + optional confirm) would allow a second reply to overtake the
39
+ * first if handled concurrently. We prevent this with a single-in-flight drain:
40
+ * the data handler enqueues complete frames into a per-connection buffer, then
41
+ * the drain loop processes one frame at a time, awaiting each reply before
42
+ * dequeuing the next.
43
+ *
44
+ * Exported for unit testing (mirrors agent-decrypt.ts).
45
+ */
46
+ export declare function handleConnection(socket: Socket): void;
7
47
  /**
8
48
  * Start the SSH agent socket server.
9
49
  *
@@ -18,3 +58,4 @@ export declare function stopAgent(): void;
18
58
  * Get the current socket path, or null if not running.
19
59
  */
20
60
  export declare function getSocketPath(): string | null;
61
+ export {};
@@ -1,54 +1,39 @@
1
1
  /**
2
2
  * SSH agent Unix domain socket server.
3
3
  *
4
- * Creates a Unix socket that implements the SSH agent protocol,
5
- * serving keys from the passwd-sso vault.
4
+ * Creates a Unix socket that implements the SSH agent protocol (RFC 9987),
5
+ * serving keys from the passwd-sso vault. Supports:
6
+ * - REQUEST_IDENTITIES (11)
7
+ * - SIGN_REQUEST (13) — with per-signature server authorization
8
+ * - REMOVE_ALL_IDENTITIES (19)
9
+ * - EXTENSION (27): query, session-bind@openssh.com
6
10
  */
7
11
  import { createServer } from "node:net";
8
12
  import { mkdirSync, lstatSync, chmodSync, unlinkSync } from "node:fs";
9
13
  import { join } from "node:path";
10
- import { readUint32, readString, SSH2_AGENTC_REQUEST_IDENTITIES, SSH2_AGENTC_SIGN_REQUEST, buildFailure, buildIdentitiesAnswer, buildSignResponse, } from "./ssh-agent-protocol.js";
11
- import { getLoadedKeys, findKeyByBlob, signData } from "./ssh-key-agent.js";
12
- let server = null;
13
- let socketPath = null;
14
+ import { readUint32, readString, encodeString, SSH2_AGENTC_REQUEST_IDENTITIES, SSH2_AGENTC_SIGN_REQUEST, SSH_AGENTC_REMOVE_ALL_IDENTITIES, SSH_AGENTC_EXTENSION, buildFailure, buildSuccess, buildIdentitiesAnswer, buildSignResponse, buildExtensionResponse, readExtensionRequest, } from "./ssh-agent-protocol.js";
15
+ import { getLoadedKeys, findKeyByBlob, signData, clearKeys, } from "./ssh-key-agent.js";
16
+ import { parseSessionBind, verifySessionBind, fingerprintPublicKey, } from "./ssh-session-bind.js";
17
+ import { authorizeSign as defaultAuthorizeSign } from "./ssh-sign-authorizer.js";
18
+ import { confirmSign as defaultConfirmSign } from "./ssh-confirm.js";
19
+ let _authorizeSign = defaultAuthorizeSign;
20
+ let _confirmSign = defaultConfirmSign;
14
21
  /**
15
- * Get the socket directory path.
16
- * Prefers $XDG_RUNTIME_DIR/passwd-sso/, falls back to /tmp/passwd-sso-<uid>/
22
+ * Inject authorize/confirm dependencies.
23
+ * Must be called before the first connection. Mirrors the setEncryptionKey pattern.
17
24
  */
18
- function getSocketDir() {
19
- const xdg = process.env.XDG_RUNTIME_DIR;
20
- if (xdg)
21
- return join(xdg, "passwd-sso");
22
- const uid = process.getuid?.();
23
- if (uid === undefined) {
24
- throw new Error("Cannot determine user ID for socket directory");
25
- }
26
- return join("/tmp", `passwd-sso-${uid}`);
27
- }
28
- /**
29
- * Create the socket directory with proper permissions.
30
- * Validates ownership and mode to prevent symlink attacks.
31
- */
32
- function ensureSocketDir(dir) {
33
- mkdirSync(dir, { recursive: true, mode: 0o700 });
34
- // Verify ownership and permissions (TOCTOU mitigation — lstatSync to avoid following symlinks)
35
- const stat = lstatSync(dir);
36
- if (!stat.isDirectory()) {
37
- throw new Error(`Socket path ${dir} is not a directory (possible symlink attack)`);
38
- }
39
- const uid = process.getuid?.();
40
- if (uid !== undefined && stat.uid !== uid) {
41
- throw new Error(`Socket directory ${dir} is owned by uid ${stat.uid}, expected ${uid}`);
42
- }
43
- const mode = stat.mode & 0o7777;
44
- if (mode !== 0o700) {
45
- throw new Error(`Socket directory ${dir} has mode ${mode.toString(8)}, expected 700`);
46
- }
25
+ export function setAgentDeps(deps) {
26
+ if (deps.authorizeSign !== undefined)
27
+ _authorizeSign = deps.authorizeSign;
28
+ if (deps.confirmSign !== undefined)
29
+ _confirmSign = deps.confirmSign;
47
30
  }
31
+ // ─── Message handler ───────────────────────────────────────────
48
32
  /**
49
33
  * Handle a single SSH agent protocol message.
34
+ * Exported for unit testing (mirrors agent-decrypt.ts).
50
35
  */
51
- function handleMessage(msgBuf) {
36
+ export async function handleMessage(msgBuf, ctx) {
52
37
  if (msgBuf.length < 1)
53
38
  return buildFailure();
54
39
  const msgType = msgBuf[0];
@@ -67,56 +52,168 @@ function handleMessage(msgBuf) {
67
52
  const { data: keyBlob, nextOffset: afterKey } = readString(msgBuf, offset);
68
53
  offset = afterKey;
69
54
  // Read data to sign
70
- const { data, nextOffset: afterData } = readString(msgBuf, offset);
55
+ const { data: dataToSign, nextOffset: afterData } = readString(msgBuf, offset);
71
56
  offset = afterData;
72
57
  // Read flags
73
- const flags = offset + 4 <= msgBuf.length
74
- ? readUint32(msgBuf, offset)
75
- : 0;
76
- // Find the key
58
+ const flags = offset + 4 <= msgBuf.length ? readUint32(msgBuf, offset) : 0;
59
+ // Resolve key
77
60
  const key = findKeyByBlob(keyBlob);
78
61
  if (!key)
79
62
  return buildFailure();
80
- // Perform signing
81
- const signature = signData(key, data, flags);
63
+ // Per-key confirmation gate
64
+ if (key.requireReprompt) {
65
+ const label = key.comment || key.entryId;
66
+ const allowed = await _confirmSign(label);
67
+ if (!allowed)
68
+ return buildFailure();
69
+ }
70
+ // Per-signature server authorization
71
+ const fingerprint = fingerprintPublicKey(key.publicKeyBlob);
72
+ const authorized = await _authorizeSign({
73
+ keyId: key.entryId,
74
+ fingerprint,
75
+ binding: ctx.binding,
76
+ });
77
+ if (!authorized)
78
+ return buildFailure();
79
+ // Perform signing locally
80
+ const signature = signData(key, dataToSign, flags);
82
81
  return buildSignResponse(signature);
83
82
  }
84
83
  catch {
85
84
  return buildFailure();
86
85
  }
87
86
  }
87
+ case SSH_AGENTC_REMOVE_ALL_IDENTITIES: {
88
+ clearKeys();
89
+ return buildSuccess();
90
+ }
91
+ case SSH_AGENTC_EXTENSION: {
92
+ const { extName, rest } = readExtensionRequest(msgBuf);
93
+ if (extName === "query") {
94
+ // Advertise supported extension names as a sequence of SSH strings.
95
+ // OpenSSH PROTOCOL.agent: query response body = concatenated string(name) entries.
96
+ const payload = Buffer.concat([
97
+ encodeString("query"),
98
+ encodeString("session-bind@openssh.com"),
99
+ ]);
100
+ return buildExtensionResponse(payload);
101
+ }
102
+ if (extName === "session-bind@openssh.com") {
103
+ try {
104
+ const parsed = parseSessionBind(rest);
105
+ if (!verifySessionBind(parsed))
106
+ return buildFailure();
107
+ ctx.binding = {
108
+ hostKeyFingerprint: fingerprintPublicKey(parsed.hostKeyBlob),
109
+ forwarded: parsed.isForwarding,
110
+ };
111
+ return buildSuccess();
112
+ }
113
+ catch {
114
+ return buildFailure();
115
+ }
116
+ }
117
+ // Unknown extension — RFC 9987 requires an empty SSH_AGENT_FAILURE.
118
+ return buildFailure();
119
+ }
88
120
  default:
89
121
  return buildFailure();
90
122
  }
91
123
  }
124
+ // ─── Connection handler ────────────────────────────────────────
92
125
  /**
93
- * Handle data from a connected client.
94
- * SSH agent protocol uses length-prefixed messages.
126
+ * Handle a connected client socket with sequential, in-order message processing.
127
+ *
128
+ * RFC 9987 requires replies in the same order as requests. The async sign path
129
+ * (authorize + optional confirm) would allow a second reply to overtake the
130
+ * first if handled concurrently. We prevent this with a single-in-flight drain:
131
+ * the data handler enqueues complete frames into a per-connection buffer, then
132
+ * the drain loop processes one frame at a time, awaiting each reply before
133
+ * dequeuing the next.
134
+ *
135
+ * Exported for unit testing (mirrors agent-decrypt.ts).
95
136
  */
96
- function handleConnection(socket) {
137
+ export function handleConnection(socket) {
138
+ const ctx = { binding: null };
97
139
  let buffer = Buffer.alloc(0);
98
- socket.on("data", (chunk) => {
99
- buffer = Buffer.concat([buffer, chunk]);
100
- // Process complete messages
101
- while (buffer.length >= 4) {
102
- const msgLen = readUint32(buffer, 0);
103
- // Reject absurdly large messages (DoS protection)
104
- if (msgLen > 256 * 1024) {
105
- socket.destroy();
106
- return;
140
+ let isProcessing = false;
141
+ async function drain() {
142
+ if (isProcessing)
143
+ return;
144
+ isProcessing = true;
145
+ try {
146
+ while (buffer.length >= 4) {
147
+ const msgLen = readUint32(buffer, 0);
148
+ // Reject absurdly large messages before dequeuing (DoS protection)
149
+ if (msgLen > 256 * 1024) {
150
+ socket.destroy();
151
+ buffer = Buffer.alloc(0);
152
+ return;
153
+ }
154
+ if (buffer.length < 4 + msgLen)
155
+ break; // Need more data
156
+ const msgBuf = buffer.subarray(4, 4 + msgLen);
157
+ buffer = buffer.subarray(4 + msgLen);
158
+ let reply;
159
+ try {
160
+ reply = await handleMessage(msgBuf, ctx);
161
+ }
162
+ catch {
163
+ reply = buildFailure();
164
+ }
165
+ socket.write(reply);
107
166
  }
108
- if (buffer.length < 4 + msgLen)
109
- break; // Need more data
110
- const msgBuf = buffer.subarray(4, 4 + msgLen);
111
- buffer = buffer.subarray(4 + msgLen);
112
- const response = handleMessage(msgBuf);
113
- socket.write(response);
114
167
  }
168
+ finally {
169
+ isProcessing = false;
170
+ }
171
+ }
172
+ socket.on("data", (chunk) => {
173
+ buffer = Buffer.concat([buffer, chunk]);
174
+ void drain();
115
175
  });
116
176
  socket.on("error", () => {
117
177
  // Silently handle client disconnects
118
178
  });
119
179
  }
180
+ // ─── Server lifecycle ──────────────────────────────────────────
181
+ let server = null;
182
+ let socketPath = null;
183
+ /**
184
+ * Get the socket directory path.
185
+ * Prefers $XDG_RUNTIME_DIR/passwd-sso/, falls back to /tmp/passwd-sso-<uid>/
186
+ */
187
+ function getSocketDir() {
188
+ const xdg = process.env.XDG_RUNTIME_DIR;
189
+ if (xdg)
190
+ return join(xdg, "passwd-sso");
191
+ const uid = process.getuid?.();
192
+ if (uid === undefined) {
193
+ throw new Error("Cannot determine user ID for socket directory");
194
+ }
195
+ return join("/tmp", `passwd-sso-${uid}`);
196
+ }
197
+ /**
198
+ * Create the socket directory with proper permissions.
199
+ * Validates ownership and mode to prevent symlink attacks.
200
+ */
201
+ function ensureSocketDir(dir) {
202
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
203
+ // Verify ownership and permissions (TOCTOU mitigation — lstatSync to avoid following symlinks)
204
+ const stat = lstatSync(dir);
205
+ if (!stat.isDirectory()) {
206
+ throw new Error(`Socket path ${dir} is not a directory (possible symlink attack)`);
207
+ }
208
+ const uid = process.getuid?.();
209
+ if (uid !== undefined && stat.uid !== uid) {
210
+ throw new Error(`Socket directory ${dir} is owned by uid ${stat.uid}, expected ${uid}`);
211
+ }
212
+ const mode = stat.mode & 0o7777;
213
+ if (mode !== 0o700) {
214
+ throw new Error(`Socket directory ${dir} has mode ${mode.toString(8)}, expected 700`);
215
+ }
216
+ }
120
217
  /**
121
218
  * Start the SSH agent socket server.
122
219
  *
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Per-signature confirmation gate for SSH keys with requireReprompt = true.
3
+ *
4
+ * When a key entry requires confirmation, this module prompts the user on the
5
+ * controlling terminal before each signature. If no TTY is available the
6
+ * request is denied (fail-closed) with a logged explanation.
7
+ *
8
+ * Precedent: unlock.ts uses process.stdin.isTTY for the same TTY-detection
9
+ * pattern. Prompt and isTTY are injectable via deps so tests need no real TTY.
10
+ */
11
+ /**
12
+ * Optional dependency overrides, primarily for testing.
13
+ */
14
+ export interface ConfirmDeps {
15
+ /** Override for process.stdin.isTTY */
16
+ isTTY?: boolean;
17
+ /** Override for the readline prompt function */
18
+ prompt?: (question: string) => Promise<string>;
19
+ }
20
+ /**
21
+ * Ask the user to confirm an SSH signing operation on the controlling TTY.
22
+ *
23
+ * Returns true only when the user explicitly answers "y" or "yes"
24
+ * (case-insensitive). Any other answer, Ctrl-C, or absence of a TTY → false.
25
+ */
26
+ export declare function confirmSign(keyLabel: string, deps?: ConfirmDeps): Promise<boolean>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Per-signature confirmation gate for SSH keys with requireReprompt = true.
3
+ *
4
+ * When a key entry requires confirmation, this module prompts the user on the
5
+ * controlling terminal before each signature. If no TTY is available the
6
+ * request is denied (fail-closed) with a logged explanation.
7
+ *
8
+ * Precedent: unlock.ts uses process.stdin.isTTY for the same TTY-detection
9
+ * pattern. Prompt and isTTY are injectable via deps so tests need no real TTY.
10
+ */
11
+ import { createInterface } from "node:readline";
12
+ /**
13
+ * Ask the user to confirm an SSH signing operation on the controlling TTY.
14
+ *
15
+ * Returns true only when the user explicitly answers "y" or "yes"
16
+ * (case-insensitive). Any other answer, Ctrl-C, or absence of a TTY → false.
17
+ */
18
+ export async function confirmSign(keyLabel, deps) {
19
+ const hasTTY = deps?.isTTY ?? process.stdin.isTTY;
20
+ if (!hasTTY) {
21
+ process.stderr.write(`SSH signing with "${keyLabel}" requires confirmation but no TTY is available. ` +
22
+ "Run the agent in the foreground (not via eval) to allow per-key confirmation.\n");
23
+ return false;
24
+ }
25
+ const promptFn = deps?.prompt ?? defaultPrompt;
26
+ const answer = await promptFn(`Allow SSH signing with "${keyLabel}"? [y/N] `);
27
+ const trimmed = answer.trim().toLowerCase();
28
+ return trimmed === "y" || trimmed === "yes";
29
+ }
30
+ /**
31
+ * Default readline-based prompt over process.stdin / process.stdout.
32
+ * Mirrors the pattern used in unlock.ts (readPassphrase).
33
+ */
34
+ function defaultPrompt(question) {
35
+ return new Promise((resolve) => {
36
+ const rl = createInterface({
37
+ input: process.stdin,
38
+ output: process.stdout,
39
+ });
40
+ rl.question(question, (answer) => {
41
+ rl.close();
42
+ resolve(answer);
43
+ });
44
+ });
45
+ }
46
+ //# sourceMappingURL=ssh-confirm.js.map
@@ -8,6 +8,8 @@ import type { KeyObject } from "node:crypto";
8
8
  export interface LoadedSshKey {
9
9
  /** Entry ID from the vault */
10
10
  entryId: string;
11
+ /** Whether each signature requires explicit user confirmation */
12
+ requireReprompt: boolean;
11
13
  /** PEM-encoded private key */
12
14
  pem: string;
13
15
  /** Optional passphrase for encrypted keys */
@@ -24,9 +26,9 @@ export interface LoadedSshKey {
24
26
  /**
25
27
  * Load a PEM private key into memory.
26
28
  *
27
- * @returns The public key blob for identity listing
29
+ * @returns The loaded key record
28
30
  */
29
- export declare function loadKey(entryId: string, pem: string, publicKeyBlob: Buffer, comment: string, passphrase?: string): Promise<LoadedSshKey>;
31
+ export declare function loadKey(entryId: string, pem: string, publicKeyBlob: Buffer, comment: string, passphrase?: string, requireReprompt?: boolean): Promise<LoadedSshKey>;
30
32
  /**
31
33
  * Get all loaded keys for identity listing.
32
34
  */
@@ -11,9 +11,9 @@ const loadedKeys = new Map();
11
11
  /**
12
12
  * Load a PEM private key into memory.
13
13
  *
14
- * @returns The public key blob for identity listing
14
+ * @returns The loaded key record
15
15
  */
16
- export async function loadKey(entryId, pem, publicKeyBlob, comment, passphrase) {
16
+ export async function loadKey(entryId, pem, publicKeyBlob, comment, passphrase, requireReprompt) {
17
17
  let keyObject;
18
18
  try {
19
19
  keyObject = createPrivateKey({
@@ -26,8 +26,11 @@ export async function loadKey(entryId, pem, publicKeyBlob, comment, passphrase)
26
26
  keyObject = await parseOpenSshPrivateKey(pem, passphrase);
27
27
  }
28
28
  const keyType = detectKeyType(keyObject);
29
+ // Non-boolean requireReprompt (serializer regression) defaults deny-side to true.
30
+ const reprompt = typeof requireReprompt === "boolean" ? requireReprompt : true;
29
31
  const loaded = {
30
32
  entryId,
33
+ requireReprompt: reprompt,
31
34
  pem,
32
35
  passphrase,
33
36
  publicKeyBlob,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * SSH session-bind@openssh.com extension parser and verifier.
3
+ *
4
+ * Parses and cryptographically verifies the session-bind extension payload
5
+ * so the agent can record the host-key fingerprint and forwarding flag for
6
+ * audit metadata without trusting unauthenticated client assertions.
7
+ *
8
+ * Reference: RFC 9987 §4.7 + OpenSSH PROTOCOL.agent
9
+ */
10
+ import type { KeyObject } from "node:crypto";
11
+ /** Verified session-binding metadata, usable as audit context. */
12
+ export type SessionBinding = {
13
+ /** SHA256 fingerprint of the host public key ("SHA256:<base64nopad>") */
14
+ hostKeyFingerprint: string;
15
+ /** True if the agent is operating over a forwarded connection */
16
+ forwarded: boolean;
17
+ };
18
+ /**
19
+ * Parse the payload of a session-bind@openssh.com extension message.
20
+ *
21
+ * The contents (after the extension name string) are:
22
+ * string hostkey (SSH wire-format public key blob)
23
+ * string session-identifier
24
+ * string signature (SSH wire-format signature blob)
25
+ * bool is_forwarding (1 byte: 0x01 = true, 0x00 = false)
26
+ *
27
+ * @param rest Bytes immediately following the extension name string
28
+ */
29
+ export declare function parseSessionBind(rest: Buffer): {
30
+ hostKeyBlob: Buffer;
31
+ sessionId: Buffer;
32
+ signature: Buffer;
33
+ isForwarding: boolean;
34
+ };
35
+ type ParsedPublicKey = {
36
+ key: KeyObject;
37
+ keyType: string;
38
+ };
39
+ /**
40
+ * Convert an SSH wire-format public key blob into a Node.js KeyObject.
41
+ *
42
+ * Supported types:
43
+ * - ssh-ed25519
44
+ * - ssh-rsa
45
+ * - ecdsa-sha2-nistp256 / nistp384 / nistp521
46
+ *
47
+ * Throws an Error for unsupported key types so the caller (verifySessionBind)
48
+ * can catch it and return false.
49
+ */
50
+ export declare function sshWirePublicKeyToKeyObject(blob: Buffer): ParsedPublicKey;
51
+ /**
52
+ * Verify that the session-bind signature is valid.
53
+ *
54
+ * The host proves session control by signing the session identifier with the
55
+ * host private key. This binds the agent connection to a specific SSH session
56
+ * and host, preventing forwarded-agent hijack attacks.
57
+ *
58
+ * Algorithm binding (RFC 9987 §3.2): the signature algorithm name embedded in
59
+ * the signature blob must be consistent with the host-key type. Inconsistent
60
+ * algorithm names are rejected to prevent downgrade attacks.
61
+ *
62
+ * Returns false (never throws) on any parse error, unsupported type, or
63
+ * invalid signature.
64
+ */
65
+ export declare function verifySessionBind(parsed: {
66
+ hostKeyBlob: Buffer;
67
+ sessionId: Buffer;
68
+ signature: Buffer;
69
+ isForwarding: boolean;
70
+ }): boolean;
71
+ /**
72
+ * Compute the standard OpenSSH SHA256 fingerprint of a public key blob.
73
+ *
74
+ * Format: "SHA256:<base64-no-padding>" (matches `ssh-keygen -l -E sha256` output).
75
+ */
76
+ export declare function fingerprintPublicKey(blob: Buffer): string;
77
+ export {};
@@ -0,0 +1,272 @@
1
+ /**
2
+ * SSH session-bind@openssh.com extension parser and verifier.
3
+ *
4
+ * Parses and cryptographically verifies the session-bind extension payload
5
+ * so the agent can record the host-key fingerprint and forwarding flag for
6
+ * audit metadata without trusting unauthenticated client assertions.
7
+ *
8
+ * Reference: RFC 9987 §4.7 + OpenSSH PROTOCOL.agent
9
+ */
10
+ import { createPublicKey, createVerify, verify as cryptoVerify, createHash, } from "node:crypto";
11
+ import { readString } from "./ssh-agent-protocol.js";
12
+ // ─── Parsing ──────────────────────────────────────────────────
13
+ /**
14
+ * Parse the payload of a session-bind@openssh.com extension message.
15
+ *
16
+ * The contents (after the extension name string) are:
17
+ * string hostkey (SSH wire-format public key blob)
18
+ * string session-identifier
19
+ * string signature (SSH wire-format signature blob)
20
+ * bool is_forwarding (1 byte: 0x01 = true, 0x00 = false)
21
+ *
22
+ * @param rest Bytes immediately following the extension name string
23
+ */
24
+ export function parseSessionBind(rest) {
25
+ let offset = 0;
26
+ const { data: hostKeyBlob, nextOffset: o1 } = readString(rest, offset);
27
+ offset = o1;
28
+ const { data: sessionId, nextOffset: o2 } = readString(rest, offset);
29
+ offset = o2;
30
+ const { data: signature, nextOffset: o3 } = readString(rest, offset);
31
+ offset = o3;
32
+ if (offset >= rest.length) {
33
+ throw new Error("session-bind payload too short: missing is_forwarding byte");
34
+ }
35
+ const isForwarding = rest[offset] !== 0;
36
+ return { hostKeyBlob, sessionId, signature, isForwarding };
37
+ }
38
+ /**
39
+ * Convert an SSH wire-format public key blob into a Node.js KeyObject.
40
+ *
41
+ * Supported types:
42
+ * - ssh-ed25519
43
+ * - ssh-rsa
44
+ * - ecdsa-sha2-nistp256 / nistp384 / nistp521
45
+ *
46
+ * Throws an Error for unsupported key types so the caller (verifySessionBind)
47
+ * can catch it and return false.
48
+ */
49
+ export function sshWirePublicKeyToKeyObject(blob) {
50
+ const { data: keyTypeBuf, nextOffset } = readString(blob, 0);
51
+ const keyType = keyTypeBuf.toString("utf-8");
52
+ switch (keyType) {
53
+ case "ssh-ed25519": {
54
+ const { data: pubPoint } = readString(blob, nextOffset);
55
+ if (pubPoint.length !== 32) {
56
+ throw new Error(`Invalid Ed25519 public key length: ${pubPoint.length}`);
57
+ }
58
+ const key = createPublicKey({
59
+ key: {
60
+ kty: "OKP",
61
+ crv: "Ed25519",
62
+ x: base64url(pubPoint),
63
+ },
64
+ format: "jwk",
65
+ });
66
+ return { key, keyType };
67
+ }
68
+ case "ssh-rsa": {
69
+ // SSH RSA wire format: string(e), string(n) — both big-endian mpints
70
+ const { data: e, nextOffset: o1 } = readString(blob, nextOffset);
71
+ const { data: n } = readString(blob, o1);
72
+ const key = createPublicKey({
73
+ key: {
74
+ kty: "RSA",
75
+ n: base64url(stripLeadingZero(n)),
76
+ e: base64url(stripLeadingZero(e)),
77
+ },
78
+ format: "jwk",
79
+ });
80
+ return { key, keyType };
81
+ }
82
+ case "ecdsa-sha2-nistp256":
83
+ case "ecdsa-sha2-nistp384":
84
+ case "ecdsa-sha2-nistp521": {
85
+ // SSH ECDSA wire format: string(curve-name), string(Q point 0x04||x||y)
86
+ const { data: curveNameBuf, nextOffset: o1 } = readString(blob, nextOffset);
87
+ const curveName = curveNameBuf.toString("utf-8");
88
+ const { data: qPoint } = readString(blob, o1);
89
+ const curveMap = {
90
+ "nistp256": { crv: "P-256", size: 32 },
91
+ "nistp384": { crv: "P-384", size: 48 },
92
+ "nistp521": { crv: "P-521", size: 66 },
93
+ };
94
+ const curve = curveMap[curveName];
95
+ if (!curve) {
96
+ throw new Error(`Unsupported ECDSA curve: ${curveName}`);
97
+ }
98
+ if (qPoint[0] !== 0x04) {
99
+ throw new Error("Only uncompressed ECDSA points are supported");
100
+ }
101
+ const x = qPoint.subarray(1, 1 + curve.size);
102
+ const y = qPoint.subarray(1 + curve.size, 1 + 2 * curve.size);
103
+ const key = createPublicKey({
104
+ key: {
105
+ kty: "EC",
106
+ crv: curve.crv,
107
+ x: base64url(x),
108
+ y: base64url(y),
109
+ },
110
+ format: "jwk",
111
+ });
112
+ return { key, keyType };
113
+ }
114
+ default:
115
+ throw new Error(`Unsupported SSH public key type: ${keyType}`);
116
+ }
117
+ }
118
+ // ─── Signature verification ────────────────────────────────────
119
+ /**
120
+ * Verify that the session-bind signature is valid.
121
+ *
122
+ * The host proves session control by signing the session identifier with the
123
+ * host private key. This binds the agent connection to a specific SSH session
124
+ * and host, preventing forwarded-agent hijack attacks.
125
+ *
126
+ * Algorithm binding (RFC 9987 §3.2): the signature algorithm name embedded in
127
+ * the signature blob must be consistent with the host-key type. Inconsistent
128
+ * algorithm names are rejected to prevent downgrade attacks.
129
+ *
130
+ * Returns false (never throws) on any parse error, unsupported type, or
131
+ * invalid signature.
132
+ */
133
+ export function verifySessionBind(parsed) {
134
+ try {
135
+ const { key, keyType } = sshWirePublicKeyToKeyObject(parsed.hostKeyBlob);
136
+ // Parse the SSH signature blob: string(algo-name) + string(raw-sig)
137
+ const { data: algoNameBuf, nextOffset } = readString(parsed.signature, 0);
138
+ const algoName = algoNameBuf.toString("utf-8");
139
+ const { data: rawSig } = readString(parsed.signature, nextOffset);
140
+ // Algorithm-binding check: reject mismatched algo/key pairs
141
+ if (!isAlgoConsistentWithKeyType(algoName, keyType)) {
142
+ return false;
143
+ }
144
+ return verifySshSignature(key, keyType, algoName, rawSig, parsed.sessionId);
145
+ }
146
+ catch {
147
+ // Any parse or crypto error → treat as invalid
148
+ return false;
149
+ }
150
+ }
151
+ // ─── Fingerprint ──────────────────────────────────────────────
152
+ /**
153
+ * Compute the standard OpenSSH SHA256 fingerprint of a public key blob.
154
+ *
155
+ * Format: "SHA256:<base64-no-padding>" (matches `ssh-keygen -l -E sha256` output).
156
+ */
157
+ export function fingerprintPublicKey(blob) {
158
+ const digest = createHash("sha256").update(blob).digest("base64");
159
+ // Remove trailing "=" padding to match OpenSSH output
160
+ const base64noPad = digest.replace(/=+$/, "");
161
+ return `SHA256:${base64noPad}`;
162
+ }
163
+ // ─── Internal helpers ─────────────────────────────────────────
164
+ /**
165
+ * Check that a signature algorithm name is consistent with the host key type.
166
+ *
167
+ * Prevents an attacker from substituting a weaker algorithm (e.g. ssh-rsa/SHA1)
168
+ * for a key that supports stronger hashes, or from mixing algorithms across
169
+ * key families entirely.
170
+ */
171
+ function isAlgoConsistentWithKeyType(algoName, keyType) {
172
+ switch (keyType) {
173
+ case "ssh-ed25519":
174
+ return algoName === "ssh-ed25519";
175
+ case "ssh-rsa":
176
+ // RSA host key: require SHA-2 signatures only. Modern OpenSSH (7.8+) emits
177
+ // rsa-sha2-256/512 for session-bind even with ssh-rsa host keys; the legacy
178
+ // ssh-rsa (SHA-1) signature algorithm is rejected (no SHA-1 in the trust path).
179
+ return algoName === "rsa-sha2-256" || algoName === "rsa-sha2-512";
180
+ case "ecdsa-sha2-nistp256":
181
+ return algoName === "ecdsa-sha2-nistp256";
182
+ case "ecdsa-sha2-nistp384":
183
+ return algoName === "ecdsa-sha2-nistp384";
184
+ case "ecdsa-sha2-nistp521":
185
+ return algoName === "ecdsa-sha2-nistp521";
186
+ default:
187
+ return false;
188
+ }
189
+ }
190
+ /**
191
+ * Dispatch to the correct crypto.verify call based on algorithm.
192
+ */
193
+ function verifySshSignature(key, keyType, algoName, rawSig, data) {
194
+ switch (keyType) {
195
+ case "ssh-ed25519":
196
+ // Ed25519: no hash (null), raw 64-byte signature
197
+ return cryptoVerify(null, data, key, rawSig);
198
+ case "ssh-rsa": {
199
+ const hash = algoName === "rsa-sha2-512" ? "sha512" : "sha256";
200
+ const verifier = createVerify(hash);
201
+ verifier.update(data);
202
+ return verifier.verify(key, rawSig);
203
+ }
204
+ case "ecdsa-sha2-nistp256":
205
+ case "ecdsa-sha2-nistp384":
206
+ case "ecdsa-sha2-nistp521": {
207
+ const hashMap = {
208
+ "ecdsa-sha2-nistp256": "sha256",
209
+ "ecdsa-sha2-nistp384": "sha384",
210
+ "ecdsa-sha2-nistp521": "sha512",
211
+ };
212
+ const hash = hashMap[keyType];
213
+ // SSH ECDSA raw sig: string(r) || string(s) — convert to DER for Node crypto
214
+ const derSig = sshEcdsaToDer(rawSig);
215
+ const verifier = createVerify(hash);
216
+ verifier.update(data);
217
+ return verifier.verify(key, derSig);
218
+ }
219
+ default:
220
+ return false;
221
+ }
222
+ }
223
+ /**
224
+ * Convert an SSH ECDSA signature (string(r) || string(s)) to DER format.
225
+ *
226
+ * SSH format: uint32(rLen) || r || uint32(sLen) || s
227
+ * DER format: SEQUENCE { INTEGER r, INTEGER s }
228
+ *
229
+ * This is the inverse of derToSshEcdsa in ssh-key-agent.ts.
230
+ */
231
+ function sshEcdsaToDer(sshSig) {
232
+ const { data: r, nextOffset } = readString(sshSig, 0);
233
+ const { data: s } = readString(sshSig, nextOffset);
234
+ // DER definite-length encoding: short form (<128) is one byte; long form
235
+ // (>=128) is 0x80|n followed by n big-endian length bytes. P-521 signatures
236
+ // push the SEQUENCE length past 127, so short-form-only encoding is invalid.
237
+ const encodeLen = (len) => {
238
+ if (len < 0x80)
239
+ return Buffer.from([len]);
240
+ const bytes = [];
241
+ let v = len;
242
+ while (v > 0) {
243
+ bytes.unshift(v & 0xff);
244
+ v >>= 8;
245
+ }
246
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
247
+ };
248
+ // DER-encode each integer: 0x02 + length + value (with leading 0x00 if MSB set)
249
+ const encodeInt = (n) => {
250
+ const needsPad = n[0] !== undefined && (n[0] & 0x80) !== 0;
251
+ const value = needsPad ? Buffer.concat([Buffer.from([0x00]), n]) : n;
252
+ return Buffer.concat([Buffer.from([0x02]), encodeLen(value.length), value]);
253
+ };
254
+ const rDer = encodeInt(r);
255
+ const sDer = encodeInt(s);
256
+ const seq = Buffer.concat([rDer, sDer]);
257
+ return Buffer.concat([Buffer.from([0x30]), encodeLen(seq.length), seq]);
258
+ }
259
+ function base64url(buf) {
260
+ return Buffer.from(buf)
261
+ .toString("base64")
262
+ .replace(/\+/g, "-")
263
+ .replace(/\//g, "_")
264
+ .replace(/=+$/, "");
265
+ }
266
+ function stripLeadingZero(buf) {
267
+ let i = 0;
268
+ while (i < buf.length - 1 && buf[i] === 0)
269
+ i++;
270
+ return i > 0 ? buf.subarray(i) : buf;
271
+ }
272
+ //# sourceMappingURL=ssh-session-bind.js.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Per-signature server authorization for the SSH agent.
3
+ *
4
+ * Before each signature, the agent POSTs to /api/vault/ssh/sign-authorize to
5
+ * authorize the operation and emit an audit event server-side. The result is
6
+ * never cached — one authorize call per signature ensures immediate revocation
7
+ * and a complete audit trail.
8
+ *
9
+ * Threat-model note: this is an honest-agent audit/revocation control. A
10
+ * compromised agent process that already holds the decrypted private key can
11
+ * always sign locally; per-sign authorize cannot prevent that. The value is
12
+ * audit completeness and immediate policy enforcement for an honest agent.
13
+ */
14
+ import type { SessionBinding } from "./ssh-session-bind.js";
15
+ /**
16
+ * Authorize a single SSH signing operation against the server.
17
+ *
18
+ * Returns true only on HTTP 200 with `authorized === true`.
19
+ * Any other status, network error, or malformed response → false (fail-closed).
20
+ */
21
+ export declare function authorizeSign(args: {
22
+ keyId: string;
23
+ fingerprint: string;
24
+ binding: SessionBinding | null;
25
+ }): Promise<boolean>;
26
+ /**
27
+ * Reset the one-time scope hint guard.
28
+ * Exposed for testing only — do not call from production code.
29
+ *
30
+ * @internal
31
+ */
32
+ export declare function _resetScopeHintForTest(): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Per-signature server authorization for the SSH agent.
3
+ *
4
+ * Before each signature, the agent POSTs to /api/vault/ssh/sign-authorize to
5
+ * authorize the operation and emit an audit event server-side. The result is
6
+ * never cached — one authorize call per signature ensures immediate revocation
7
+ * and a complete audit trail.
8
+ *
9
+ * Threat-model note: this is an honest-agent audit/revocation control. A
10
+ * compromised agent process that already holds the decrypted private key can
11
+ * always sign locally; per-sign authorize cannot prevent that. The value is
12
+ * audit completeness and immediate policy enforcement for an honest agent.
13
+ */
14
+ import { apiRequest } from "./api-client.js";
15
+ import * as output from "./output.js";
16
+ // One-time hint guard: print "re-run `passwd-sso login`" at most once per run.
17
+ let scopeHintEmitted = false;
18
+ /**
19
+ * Authorize a single SSH signing operation against the server.
20
+ *
21
+ * Returns true only on HTTP 200 with `authorized === true`.
22
+ * Any other status, network error, or malformed response → false (fail-closed).
23
+ */
24
+ export async function authorizeSign(args) {
25
+ const { keyId, fingerprint, binding } = args;
26
+ try {
27
+ const body = { keyId, fingerprint };
28
+ if (binding) {
29
+ body.host = {
30
+ hostKeyFingerprint: binding.hostKeyFingerprint,
31
+ forwarded: binding.forwarded,
32
+ };
33
+ }
34
+ const res = await apiRequest("/api/vault/ssh/sign-authorize", { method: "POST", body });
35
+ if (res.ok && res.status === 200 && res.data.authorized === true) {
36
+ return true;
37
+ }
38
+ // On scope-deny (401/403 with reason "unauthorized"), print a one-time hint.
39
+ if ((res.status === 401 || res.status === 403) && res.data.reason === "unauthorized") {
40
+ if (!scopeHintEmitted) {
41
+ scopeHintEmitted = true;
42
+ output.warn("Re-run `passwd-sso login` to grant SSH signing (ssh:sign scope).");
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+ catch (err) {
48
+ output.warn(`SSH sign authorize failed: ${err instanceof Error ? err.message : "network error"}`);
49
+ return false;
50
+ }
51
+ }
52
+ /**
53
+ * Reset the one-time scope hint guard.
54
+ * Exposed for testing only — do not call from production code.
55
+ *
56
+ * @internal
57
+ */
58
+ export function _resetScopeHintForTest() {
59
+ scopeHintEmitted = false;
60
+ }
61
+ //# sourceMappingURL=ssh-sign-authorizer.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passwd-sso-cli",
3
- "version": "0.4.55",
3
+ "version": "0.4.57",
4
4
  "description": "CLI for passwd-sso password manager",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,7 @@
48
48
  "@types/node": "^22.15.3",
49
49
  "tsx": "^4.19.4",
50
50
  "typescript": "^5.9.0-beta",
51
- "vitest": "^4.0.18"
51
+ "vitest": "^4.1.8"
52
52
  },
53
53
  "overrides": {
54
54
  "postcss": ">=8.5.10"