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.
@@ -12,7 +12,7 @@ import { join } from "node:path";
12
12
  import { z } from "zod";
13
13
  import { apiRequest, startBackgroundRefresh } from "../lib/api-client.js";
14
14
  import { decryptData, hexEncode } from "../lib/crypto.js";
15
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
15
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
16
16
  import { getEncryptionKey, getUserId, getSecretKeyBytes, setEncryptionKey } from "../lib/vault-state.js";
17
17
  import { readPassphrase, unlockWithPassphrase } from "./unlock.js";
18
18
  import * as output from "../lib/output.js";
@@ -50,6 +50,14 @@ function prepareSocket(socketPath) {
50
50
  process.stderr.write(`Error: Socket directory ${dir} is owned by uid ${dirStat.uid}, expected ${uid}\n`);
51
51
  process.exit(1);
52
52
  }
53
+ // mkdirSync's mode applies only to a freshly-created dir; a pre-existing dir
54
+ // (e.g. a custom $XDG_RUNTIME_DIR) may be group/other-accessible. Re-stat the
55
+ // mode and reject anything other than 0700 (matches ssh-agent-socket.ts).
56
+ const dirMode = dirStat.mode & 0o7777;
57
+ if (dirMode !== 0o700) {
58
+ process.stderr.write(`Error: Socket directory ${dir} has mode ${dirMode.toString(8)}, expected 700\n`);
59
+ process.exit(1);
60
+ }
53
61
  // Remove stale socket if present, verify ownership first
54
62
  try {
55
63
  const sockStat = lstatSync(socketPath);
@@ -89,7 +97,7 @@ async function handleDecryptRequest(req) {
89
97
  const userId = getUserId();
90
98
  try {
91
99
  const aad = entry.aadVersion >= 1 && userId
92
- ? buildPersonalEntryAAD(userId, entry.id)
100
+ ? buildPersonalEntryAAD(userId, entry.id, VAULT_TYPE.BLOB)
93
101
  : undefined;
94
102
  const plaintext = await decryptData(entry.encryptedBlob, encryptionKey, aad);
95
103
  const blob = JSON.parse(plaintext);
@@ -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";
11
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
12
- import { getEncryptionKey, getUserId, isUnlocked } from "../lib/vault-state.js";
13
- import { autoUnlockIfNeeded } from "./unlock.js";
16
+ import { decryptData, hexEncode } from "../lib/crypto.js";
17
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.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,37 +40,32 @@ 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
- ? buildPersonalEntryAAD(userId, entry.id)
68
+ ? buildPersonalEntryAAD(userId, entry.id, VAULT_TYPE.BLOB)
64
69
  : undefined;
65
70
  const plaintext = await decryptData(entry.encryptedBlob, encryptionKey, aad);
66
71
  const blob = JSON.parse(plaintext);
@@ -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
@@ -12,7 +12,7 @@ import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
12
12
  import { autoUnlockIfNeeded } from "./unlock.js";
13
13
  import { getToken } from "../lib/api-client.js";
14
14
  import { decryptData } from "../lib/crypto.js";
15
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
15
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
16
16
  import { BLOCKED_KEYS } from "../lib/blocked-keys.js";
17
17
  import * as output from "../lib/output.js";
18
18
  export async function envCommand(opts) {
@@ -72,7 +72,7 @@ export async function envCommand(opts) {
72
72
  const data = (await res.json());
73
73
  let additionalData;
74
74
  if (data.aadVersion && data.aadVersion >= 1 && userId) {
75
- additionalData = buildPersonalEntryAAD(userId, data.id);
75
+ additionalData = buildPersonalEntryAAD(userId, data.id, VAULT_TYPE.BLOB);
76
76
  }
77
77
  const decrypted = await decryptData(data.encryptedBlob, encryptionKey, additionalData);
78
78
  const blob = JSON.parse(decrypted);
@@ -6,7 +6,8 @@ import { existsSync, lstatSync } from "node:fs";
6
6
  import { apiRequest } from "../lib/api-client.js";
7
7
  import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
8
8
  import { decryptData } from "../lib/crypto.js";
9
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
9
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
10
+ import { writeSecretFile } from "../lib/secure-file.js";
10
11
  import * as output from "../lib/output.js";
11
12
  function escapeCSV(value) {
12
13
  if (value.includes(",") || value.includes('"') || value.includes("\n")) {
@@ -50,7 +51,7 @@ export async function exportCommand(options) {
50
51
  for (const entry of entries) {
51
52
  try {
52
53
  const aad = entry.aadVersion >= 1 && userId
53
- ? buildPersonalEntryAAD(userId, entry.id)
54
+ ? buildPersonalEntryAAD(userId, entry.id, VAULT_TYPE.BLOB)
54
55
  : undefined;
55
56
  const plaintext = await decryptData(entry.encryptedBlob, key, aad);
56
57
  decrypted.push(JSON.parse(plaintext));
@@ -65,8 +66,7 @@ export async function exportCommand(options) {
65
66
  if (format === "json") {
66
67
  const out = JSON.stringify(decrypted, null, 2);
67
68
  if (outputPath) {
68
- const { writeFileSync } = await import("node:fs");
69
- writeFileSync(outputPath, out, { encoding: "utf-8", mode: 0o600 });
69
+ writeSecretFile(outputPath, out);
70
70
  output.success(`Exported ${decrypted.length} entries to ${outputPath}`);
71
71
  }
72
72
  else {
@@ -87,8 +87,7 @@ export async function exportCommand(options) {
87
87
  }
88
88
  const csvOut = csvRows.join("\n");
89
89
  if (outputPath) {
90
- const { writeFileSync } = await import("node:fs");
91
- writeFileSync(outputPath, csvOut, { encoding: "utf-8", mode: 0o600 });
90
+ writeSecretFile(outputPath, csvOut);
92
91
  output.success(`Exported ${decrypted.length} entries to ${outputPath}`);
93
92
  }
94
93
  else {
@@ -4,7 +4,7 @@
4
4
  import { apiRequest } from "../lib/api-client.js";
5
5
  import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
6
6
  import { decryptData } from "../lib/crypto.js";
7
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
7
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
8
8
  import { copyToClipboard } from "../lib/clipboard.js";
9
9
  import * as output from "../lib/output.js";
10
10
  export async function getCommand(id, options) {
@@ -22,7 +22,7 @@ export async function getCommand(id, options) {
22
22
  const entry = res.data;
23
23
  try {
24
24
  const aad = entry.aadVersion >= 1 && userId
25
- ? buildPersonalEntryAAD(userId, entry.id)
25
+ ? buildPersonalEntryAAD(userId, entry.id, VAULT_TYPE.BLOB)
26
26
  : undefined;
27
27
  const plaintext = await decryptData(entry.encryptedBlob, key, aad);
28
28
  const blob = JSON.parse(plaintext);
@@ -4,7 +4,7 @@
4
4
  import { apiRequest } from "../lib/api-client.js";
5
5
  import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
6
6
  import { decryptData } from "../lib/crypto.js";
7
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
7
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
8
8
  import * as output from "../lib/output.js";
9
9
  export async function listCommand(options) {
10
10
  const key = getEncryptionKey();
@@ -27,7 +27,7 @@ export async function listCommand(options) {
27
27
  for (const entry of entries) {
28
28
  try {
29
29
  const aad = entry.aadVersion >= 1 && userId
30
- ? buildPersonalEntryAAD(userId, entry.id)
30
+ ? buildPersonalEntryAAD(userId, entry.id, VAULT_TYPE.OVERVIEW)
31
31
  : undefined;
32
32
  const plaintext = await decryptData(entry.encryptedOverview, key, aad);
33
33
  const overview = JSON.parse(plaintext);
@@ -10,7 +10,7 @@ import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
10
10
  import { autoUnlockIfNeeded } from "./unlock.js";
11
11
  import { getToken } from "../lib/api-client.js";
12
12
  import { decryptData } from "../lib/crypto.js";
13
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
13
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
14
14
  import * as output from "../lib/output.js";
15
15
  import { BLOCKED_KEYS } from "../lib/blocked-keys.js";
16
16
  export async function runCommand(opts) {
@@ -74,7 +74,7 @@ export async function runCommand(opts) {
74
74
  const data = (await res.json());
75
75
  let additionalData;
76
76
  if (data.aadVersion && data.aadVersion >= 1 && userId) {
77
- additionalData = buildPersonalEntryAAD(userId, data.id);
77
+ additionalData = buildPersonalEntryAAD(userId, data.id, VAULT_TYPE.BLOB);
78
78
  }
79
79
  const decrypted = await decryptData(data.encryptedBlob, encryptionKey, additionalData);
80
80
  const blob = JSON.parse(decrypted);
@@ -4,7 +4,7 @@
4
4
  import { apiRequest } from "../lib/api-client.js";
5
5
  import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
6
6
  import { decryptData } from "../lib/crypto.js";
7
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
7
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
8
8
  import { generateTOTPCode } from "../lib/totp.js";
9
9
  import { copyToClipboard } from "../lib/clipboard.js";
10
10
  import * as output from "../lib/output.js";
@@ -22,7 +22,7 @@ export async function totpCommand(id, options) {
22
22
  }
23
23
  try {
24
24
  const aad = res.data.aadVersion >= 1 && userId
25
- ? buildPersonalEntryAAD(userId, res.data.id)
25
+ ? buildPersonalEntryAAD(userId, res.data.id, VAULT_TYPE.BLOB)
26
26
  : undefined;
27
27
  const plaintext = await decryptData(res.data.encryptedBlob, key, aad);
28
28
  const blob = JSON.parse(plaintext);
@@ -6,8 +6,9 @@
6
6
  *
7
7
  * Legacy ~/.passwd-sso/ is auto-migrated on first access.
8
8
  */
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, openSync, writeSync, closeSync, unlinkSync, constants as fsConstants, } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, unlinkSync, } from "node:fs";
10
10
  import { getConfigDir, getDataDir, getConfigFilePath, getCredentialsFilePath, } from "./paths.js";
11
+ import { writeSecretFile, readSecretFile } from "./secure-file.js";
11
12
  import { migrateIfNeeded } from "./migrate.js";
12
13
  const DEFAULT_CONFIG = {
13
14
  serverUrl: "",
@@ -49,21 +50,10 @@ export function saveConfig(config) {
49
50
  export function saveCredentials(creds) {
50
51
  ensureDataDir();
51
52
  const dataDir = getDataDir();
52
- const stat = lstatSync(dataDir);
53
- if (stat.isSymbolicLink()) {
53
+ if (lstatSync(dataDir).isSymbolicLink()) {
54
54
  throw new Error("Data directory is a symlink — refusing to write credentials.");
55
55
  }
56
- const credPath = getCredentialsFilePath();
57
- const fd = openSync(credPath, fsConstants.O_WRONLY |
58
- fsConstants.O_CREAT |
59
- fsConstants.O_TRUNC |
60
- (fsConstants.O_NOFOLLOW ?? 0), 0o600);
61
- try {
62
- writeSync(fd, JSON.stringify(creds)); // codeql[js/network-data-written-to-file] OAuth tokens are intentionally persisted for session continuity
63
- }
64
- finally {
65
- closeSync(fd);
66
- }
56
+ writeSecretFile(getCredentialsFilePath(), JSON.stringify(creds));
67
57
  }
68
58
  /**
69
59
  * Load stored credentials. Returns null if the file does not exist,
@@ -73,7 +63,14 @@ export function saveCredentials(creds) {
73
63
  export function loadCredentials() {
74
64
  migrateIfNeeded();
75
65
  try {
76
- const raw = readFileSync(getCredentialsFilePath(), "utf-8").trim();
66
+ // Mirror saveCredentials' symlink hardening on the read side: refuse a
67
+ // symlinked data dir, and open the file with O_NOFOLLOW so a pre-planted
68
+ // symlink at the credentials path cannot redirect the read elsewhere.
69
+ const dataDir = getDataDir();
70
+ if (existsSync(dataDir) && lstatSync(dataDir).isSymbolicLink()) {
71
+ return null;
72
+ }
73
+ const raw = readSecretFile(getCredentialsFilePath()).trim();
77
74
  let parsed;
78
75
  try {
79
76
  parsed = JSON.parse(raw);
@@ -2,4 +2,9 @@
2
2
  * AAD (Additional Authenticated Data) builders for AES-256-GCM encryption.
3
3
  * Ported from src/lib/crypto-aad.ts for CLI compatibility.
4
4
  */
5
- export declare function buildPersonalEntryAAD(userId: string, entryId: string): Uint8Array;
5
+ export declare const VAULT_TYPE: {
6
+ readonly BLOB: "blob";
7
+ readonly OVERVIEW: "overview";
8
+ };
9
+ export type VaultType = (typeof VAULT_TYPE)[keyof typeof VAULT_TYPE];
10
+ export declare function buildPersonalEntryAAD(userId: string, entryId: string, vaultType: VaultType): Uint8Array;
@@ -38,7 +38,13 @@ function buildAADBytes(scope, expectedFieldCount, fields) {
38
38
  }
39
39
  return bytes;
40
40
  }
41
- export function buildPersonalEntryAAD(userId, entryId) {
42
- return buildAADBytes(SCOPE_PERSONAL, 2, [userId, entryId]);
41
+ // Mirror of src/lib/crypto/crypto-aad.ts VAULT_TYPE. Keep in sync —
42
+ // any change to the wire string values would break decrypt interop.
43
+ export const VAULT_TYPE = {
44
+ BLOB: "blob",
45
+ OVERVIEW: "overview",
46
+ };
47
+ export function buildPersonalEntryAAD(userId, entryId, vaultType) {
48
+ return buildAADBytes(SCOPE_PERSONAL, 3, [userId, entryId, vaultType]);
43
49
  }
44
50
  //# sourceMappingURL=crypto-aad.js.map
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() {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Symlink-safe file I/O for secret material (credentials, decrypted exports).
3
+ *
4
+ * O_NOFOLLOW refuses to follow a symlink at the final path component, closing
5
+ * the symlink / check-then-write TOCTOU window that plain writeFileSync/
6
+ * readFileSync leave open. Used wherever the CLI persists or reads secrets.
7
+ *
8
+ * MIRROR: e2e/helpers/secure-file.ts holds the same O_NOFOLLOW logic (the cli
9
+ * package and the e2e tree compile under separate tsconfigs and cannot share a
10
+ * module). Keep the O_NOFOLLOW semantics in sync. This copy adds an `encoding`
11
+ * param + codeql annotation the e2e copy intentionally omits.
12
+ */
13
+ /** Write `data` to `path` with O_NOFOLLOW + the given mode (default 0600). */
14
+ export declare function writeSecretFile(path: string, data: string, mode?: number): void;
15
+ /** Read `path` with O_NOFOLLOW (refuses a symlinked final component). */
16
+ export declare function readSecretFile(path: string, encoding?: BufferEncoding): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Symlink-safe file I/O for secret material (credentials, decrypted exports).
3
+ *
4
+ * O_NOFOLLOW refuses to follow a symlink at the final path component, closing
5
+ * the symlink / check-then-write TOCTOU window that plain writeFileSync/
6
+ * readFileSync leave open. Used wherever the CLI persists or reads secrets.
7
+ *
8
+ * MIRROR: e2e/helpers/secure-file.ts holds the same O_NOFOLLOW logic (the cli
9
+ * package and the e2e tree compile under separate tsconfigs and cannot share a
10
+ * module). Keep the O_NOFOLLOW semantics in sync. This copy adds an `encoding`
11
+ * param + codeql annotation the e2e copy intentionally omits.
12
+ */
13
+ import { openSync, writeSync, closeSync, readFileSync, constants as fsConstants, } from "node:fs";
14
+ /** Write `data` to `path` with O_NOFOLLOW + the given mode (default 0600). */
15
+ export function writeSecretFile(path, data, mode = 0o600) {
16
+ const fd = openSync(path, fsConstants.O_WRONLY |
17
+ fsConstants.O_CREAT |
18
+ fsConstants.O_TRUNC |
19
+ (fsConstants.O_NOFOLLOW ?? 0), mode);
20
+ try {
21
+ // Intentional persistence of the caller's own secrets (OAuth tokens /
22
+ // decrypted export) to a 0600 O_NOFOLLOW file. CodeQL js/http-to-file-access
23
+ // is excluded for this in .github/codeql/codeql-config.yml (inline
24
+ // // codeql[...] suppression is not honored by GitHub code scanning here).
25
+ writeSync(fd, data);
26
+ }
27
+ finally {
28
+ closeSync(fd);
29
+ }
30
+ }
31
+ /** Read `path` with O_NOFOLLOW (refuses a symlinked final component). */
32
+ export function readSecretFile(path, encoding = "utf-8") {
33
+ const fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
34
+ try {
35
+ return readFileSync(fd, encoding);
36
+ }
37
+ finally {
38
+ closeSync(fd);
39
+ }
40
+ }
41
+ //# sourceMappingURL=secure-file.js.map
@@ -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
+ };