passwd-sso-cli 0.4.50 → 0.4.56

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,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 {};