passwd-sso-cli 0.4.3
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.
- package/dist/commands/agent-decrypt.d.ts +11 -0
- package/dist/commands/agent-decrypt.js +317 -0
- package/dist/commands/agent.d.ts +13 -0
- package/dist/commands/agent.js +116 -0
- package/dist/commands/api-key.d.ts +22 -0
- package/dist/commands/api-key.js +118 -0
- package/dist/commands/decrypt.d.ts +17 -0
- package/dist/commands/decrypt.js +108 -0
- package/dist/commands/env.d.ts +15 -0
- package/dist/commands/env.js +102 -0
- package/dist/commands/export.d.ts +7 -0
- package/dist/commands/export.js +99 -0
- package/dist/commands/generate.d.ts +11 -0
- package/dist/commands/generate.js +45 -0
- package/dist/commands/get.d.ts +8 -0
- package/dist/commands/get.js +73 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.js +66 -0
- package/dist/commands/login.d.ts +4 -0
- package/dist/commands/login.js +45 -0
- package/dist/commands/run.d.ts +12 -0
- package/dist/commands/run.js +97 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +62 -0
- package/dist/commands/totp.d.ts +7 -0
- package/dist/commands/totp.js +57 -0
- package/dist/commands/unlock.d.ts +19 -0
- package/dist/commands/unlock.js +125 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +298 -0
- package/dist/lib/api-client.d.ts +22 -0
- package/dist/lib/api-client.js +145 -0
- package/dist/lib/blocked-keys.d.ts +2 -0
- package/dist/lib/blocked-keys.js +23 -0
- package/dist/lib/clipboard.d.ts +8 -0
- package/dist/lib/clipboard.js +79 -0
- package/dist/lib/config.d.ts +18 -0
- package/dist/lib/config.js +110 -0
- package/dist/lib/crypto-aad.d.ts +5 -0
- package/dist/lib/crypto-aad.js +44 -0
- package/dist/lib/crypto.d.ts +23 -0
- package/dist/lib/crypto.js +148 -0
- package/dist/lib/migrate.d.ts +8 -0
- package/dist/lib/migrate.js +87 -0
- package/dist/lib/openssh-key-parser.d.ts +17 -0
- package/dist/lib/openssh-key-parser.js +273 -0
- package/dist/lib/output.d.ts +10 -0
- package/dist/lib/output.js +36 -0
- package/dist/lib/paths.d.ts +17 -0
- package/dist/lib/paths.js +39 -0
- package/dist/lib/secrets-config.d.ts +31 -0
- package/dist/lib/secrets-config.js +48 -0
- package/dist/lib/ssh-agent-protocol.d.ts +56 -0
- package/dist/lib/ssh-agent-protocol.js +108 -0
- package/dist/lib/ssh-agent-socket.d.ts +20 -0
- package/dist/lib/ssh-agent-socket.js +187 -0
- package/dist/lib/ssh-key-agent.d.ts +54 -0
- package/dist/lib/ssh-key-agent.js +197 -0
- package/dist/lib/totp.d.ts +10 -0
- package/dist/lib/totp.js +31 -0
- package/dist/lib/vault-state.d.ts +15 -0
- package/dist/lib/vault-state.js +37 -0
- package/package.json +56 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decrypt agent — holds vault key in memory, serves decrypt requests via Unix socket.
|
|
3
|
+
* Authorization is checked against the server for every request (no caching).
|
|
4
|
+
*
|
|
5
|
+
* Socket: $XDG_RUNTIME_DIR/passwd-sso/decrypt.sock
|
|
6
|
+
* Protocol: newline-delimited JSON over Unix domain socket
|
|
7
|
+
*/
|
|
8
|
+
export interface DecryptAgentOptions {
|
|
9
|
+
eval?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function decryptAgentCommand(opts: DecryptAgentOptions): Promise<void>;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decrypt agent — holds vault key in memory, serves decrypt requests via Unix socket.
|
|
3
|
+
* Authorization is checked against the server for every request (no caching).
|
|
4
|
+
*
|
|
5
|
+
* Socket: $XDG_RUNTIME_DIR/passwd-sso/decrypt.sock
|
|
6
|
+
* Protocol: newline-delimited JSON over Unix domain socket
|
|
7
|
+
*/
|
|
8
|
+
import { createServer } from "node:net";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { mkdirSync, lstatSync, chmodSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { apiRequest, startBackgroundRefresh } from "../lib/api-client.js";
|
|
14
|
+
import { decryptData, hexEncode } from "../lib/crypto.js";
|
|
15
|
+
import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
|
|
16
|
+
import { getEncryptionKey, getUserId, getSecretKeyBytes, setEncryptionKey } from "../lib/vault-state.js";
|
|
17
|
+
import { readPassphrase, unlockWithPassphrase } from "./unlock.js";
|
|
18
|
+
import * as output from "../lib/output.js";
|
|
19
|
+
// ─── Input Validation Schema ───────────────────────────────────
|
|
20
|
+
const DecryptRequestSchema = z.object({
|
|
21
|
+
entryId: z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/),
|
|
22
|
+
clientId: z.string().startsWith("mcpc_").max(100),
|
|
23
|
+
field: z.enum(["password", "username", "url", "notes", "totp", "title", "_json"]).default("password"),
|
|
24
|
+
});
|
|
25
|
+
// ─── Socket Path ───────────────────────────────────────────────
|
|
26
|
+
function getDecryptSocketPath() {
|
|
27
|
+
const xdg = process.env.XDG_RUNTIME_DIR;
|
|
28
|
+
if (!xdg) {
|
|
29
|
+
process.stderr.write("Error: $XDG_RUNTIME_DIR is not set.\n" +
|
|
30
|
+
"On Linux, this is typically /run/user/<uid>.\n" +
|
|
31
|
+
"Ensure your session manager sets XDG_RUNTIME_DIR (systemd-logind or pam_systemd).\n");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
return join(xdg, "passwd-sso", "decrypt.sock");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Prepare the socket directory and validate ownership before binding.
|
|
38
|
+
*/
|
|
39
|
+
function prepareSocket(socketPath) {
|
|
40
|
+
const dir = join(socketPath, "..");
|
|
41
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
// Verify directory ownership (lstatSync avoids following symlinks)
|
|
43
|
+
const dirStat = lstatSync(dir);
|
|
44
|
+
if (!dirStat.isDirectory()) {
|
|
45
|
+
process.stderr.write(`Error: ${dir} is not a directory (possible symlink attack)\n`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const uid = process.getuid?.();
|
|
49
|
+
if (uid !== undefined && dirStat.uid !== uid) {
|
|
50
|
+
process.stderr.write(`Error: Socket directory ${dir} is owned by uid ${dirStat.uid}, expected ${uid}\n`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
// Remove stale socket if present, verify ownership first
|
|
54
|
+
try {
|
|
55
|
+
const sockStat = lstatSync(socketPath);
|
|
56
|
+
if (uid !== undefined && sockStat.uid !== uid) {
|
|
57
|
+
process.stderr.write(`Error: Stale socket ${socketPath} is owned by uid ${sockStat.uid}, not removing\n`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
unlinkSync(socketPath);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Socket doesn't exist, that's fine
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ─── Decrypt Request Handler ───────────────────────────────────
|
|
67
|
+
async function handleDecryptRequest(req) {
|
|
68
|
+
// Step 1: Check authorization with server (no caching — ensures immediate revocation)
|
|
69
|
+
const checkRes = await apiRequest(`/api/vault/delegation/check?clientId=${req.clientId}&entryId=${req.entryId}`);
|
|
70
|
+
if (!checkRes.ok || !checkRes.data.authorized) {
|
|
71
|
+
const reason = checkRes.data.reason ?? "unauthorized";
|
|
72
|
+
return { ok: false, error: `Not authorized: ${reason}` };
|
|
73
|
+
}
|
|
74
|
+
// Step 2: Fetch the vault entry
|
|
75
|
+
const entryRes = await apiRequest(`/api/passwords/${req.entryId}`);
|
|
76
|
+
if (!entryRes.ok) {
|
|
77
|
+
return { ok: false, error: `Failed to fetch entry: HTTP ${entryRes.status}` };
|
|
78
|
+
}
|
|
79
|
+
const entry = entryRes.data;
|
|
80
|
+
// Defense in depth: verify returned entry matches requested ID
|
|
81
|
+
if (entry.id !== req.entryId) {
|
|
82
|
+
return { ok: false, error: "Entry ID mismatch (server returned unexpected entry)" };
|
|
83
|
+
}
|
|
84
|
+
// Step 3: Decrypt with vault key
|
|
85
|
+
const encryptionKey = getEncryptionKey();
|
|
86
|
+
if (!encryptionKey) {
|
|
87
|
+
return { ok: false, error: "Vault is locked" };
|
|
88
|
+
}
|
|
89
|
+
const userId = getUserId();
|
|
90
|
+
try {
|
|
91
|
+
const aad = entry.aadVersion >= 1 && userId
|
|
92
|
+
? buildPersonalEntryAAD(userId, entry.id)
|
|
93
|
+
: undefined;
|
|
94
|
+
const plaintext = await decryptData(entry.encryptedBlob, encryptionKey, aad);
|
|
95
|
+
const blob = JSON.parse(plaintext);
|
|
96
|
+
// Step 4: Extract requested field or return full JSON
|
|
97
|
+
if (req.field === "_json") {
|
|
98
|
+
return { ok: true, value: JSON.stringify(blob) };
|
|
99
|
+
}
|
|
100
|
+
const fieldValue = blob[req.field];
|
|
101
|
+
if (fieldValue === undefined || fieldValue === null) {
|
|
102
|
+
return { ok: false, error: `Field "${req.field}" not found in entry` };
|
|
103
|
+
}
|
|
104
|
+
const value = typeof fieldValue === "object"
|
|
105
|
+
? JSON.stringify(fieldValue)
|
|
106
|
+
: String(fieldValue);
|
|
107
|
+
return { ok: true, value };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: `Decrypt failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ─── Connection Handler ────────────────────────────────────────
|
|
117
|
+
const MAX_BUFFER_SIZE = 64 * 1024; // 64KB — far more than any valid request
|
|
118
|
+
function handleConnection(socket) {
|
|
119
|
+
let buffer = "";
|
|
120
|
+
socket.on("data", (chunk) => {
|
|
121
|
+
buffer += chunk.toString("utf-8");
|
|
122
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
123
|
+
socket.write(JSON.stringify({ ok: false, error: "Request too large" }) + "\n");
|
|
124
|
+
socket.destroy();
|
|
125
|
+
buffer = "";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Process complete newline-delimited JSON messages
|
|
129
|
+
const lines = buffer.split("\n");
|
|
130
|
+
buffer = lines.pop() ?? ""; // Keep incomplete last line
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
if (!trimmed)
|
|
134
|
+
continue;
|
|
135
|
+
void (async () => {
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
const raw = JSON.parse(trimmed);
|
|
139
|
+
const parsed = DecryptRequestSchema.safeParse(raw);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
response = {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: `Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
response = await handleDecryptRequest(parsed.data);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
response = {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: `Parse error: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
socket.write(JSON.stringify(response) + "\n");
|
|
157
|
+
socket.end();
|
|
158
|
+
})();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
socket.on("error", () => {
|
|
162
|
+
// Silently handle client disconnects
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
export async function decryptAgentCommand(opts) {
|
|
166
|
+
if (process.platform === "win32") {
|
|
167
|
+
process.stderr.write("Error: Decrypt agent is not supported on Windows.\n");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
// Internal daemon mode: receives vault key via IPC from parent
|
|
171
|
+
if (process.env._PSSO_DAEMON === "1") {
|
|
172
|
+
return runDaemonChild();
|
|
173
|
+
}
|
|
174
|
+
const socketPath = getDecryptSocketPath();
|
|
175
|
+
// Prompt for passphrase via TTY — explicitly does NOT use PSSO_PASSPHRASE env
|
|
176
|
+
if (!process.stdin.isTTY) {
|
|
177
|
+
process.stderr.write("Error: No TTY available. The decrypt agent requires a TTY for passphrase input.\n" +
|
|
178
|
+
"Run in an interactive terminal.\n");
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
// In --eval mode, stdout is captured by the shell — write prompt to stderr
|
|
182
|
+
const passphrase = await readPassphrase("Master passphrase: ", { useStderr: opts.eval });
|
|
183
|
+
if (!passphrase) {
|
|
184
|
+
process.stderr.write("Error: Passphrase is required.\n");
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
const unlocked = await unlockWithPassphrase(passphrase);
|
|
188
|
+
if (!unlocked) {
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
if (opts.eval) {
|
|
192
|
+
// Fork as background daemon, pass vault key via IPC
|
|
193
|
+
return forkDaemon(socketPath);
|
|
194
|
+
}
|
|
195
|
+
// Foreground mode
|
|
196
|
+
startForegroundAgent(socketPath);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* --eval mode: fork a detached child, pass vault key via IPC, output eval commands, exit.
|
|
200
|
+
*/
|
|
201
|
+
async function forkDaemon(socketPath) {
|
|
202
|
+
const secretBytes = getSecretKeyBytes();
|
|
203
|
+
if (!secretBytes) {
|
|
204
|
+
process.stderr.write("Error: Secret key bytes not available.\n");
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
// Send secret key bytes (not derived CryptoKey) — child will derive encryption key
|
|
208
|
+
const secretHex = hexEncode(secretBytes);
|
|
209
|
+
const userId = getUserId();
|
|
210
|
+
// Reconstruct args for child: remove --eval, add internal daemon flag.
|
|
211
|
+
// Preserve tsx loader flags (--require, --import) so .ts files work in child.
|
|
212
|
+
const childArgs = [
|
|
213
|
+
...process.execArgv.filter((a) => !a.startsWith("--eval")),
|
|
214
|
+
...process.argv.slice(1).filter((a) => a !== "--eval"),
|
|
215
|
+
];
|
|
216
|
+
const child = spawn(process.execPath, childArgs, {
|
|
217
|
+
detached: true,
|
|
218
|
+
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
|
219
|
+
env: { ...process.env, _PSSO_DAEMON: "1" },
|
|
220
|
+
});
|
|
221
|
+
// Send secret key bytes + userId to child via IPC (child derives encryption key)
|
|
222
|
+
child.send({ secretHex, userId });
|
|
223
|
+
// Wait briefly for child to acknowledge, then output eval commands
|
|
224
|
+
child.on("message", () => {
|
|
225
|
+
console.log(`PSSO_AGENT_SOCK='${socketPath}'; export PSSO_AGENT_SOCK;`);
|
|
226
|
+
console.log(`PSSO_AGENT_PID='${child.pid}'; export PSSO_AGENT_PID;`);
|
|
227
|
+
console.log(`trap 'kill ${child.pid} 2>/dev/null; rm -f ${socketPath}' EXIT;`);
|
|
228
|
+
child.unref();
|
|
229
|
+
child.disconnect();
|
|
230
|
+
process.exit(0);
|
|
231
|
+
});
|
|
232
|
+
// If child exits before acknowledging, report error
|
|
233
|
+
child.on("exit", (code) => {
|
|
234
|
+
process.stderr.write(`Agent child exited unexpectedly with code ${code}\n`);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
});
|
|
237
|
+
// Timeout if child doesn't respond
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
process.stderr.write("Error: Agent child did not respond within 10s.\n");
|
|
240
|
+
child.kill();
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}, 10_000);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Internal: runs as the daemon child process (forked by --eval).
|
|
246
|
+
* Receives vault key via IPC from parent, then starts the agent.
|
|
247
|
+
*/
|
|
248
|
+
function runDaemonChild() {
|
|
249
|
+
return new Promise((resolve) => {
|
|
250
|
+
process.on("message", async (msg) => {
|
|
251
|
+
try {
|
|
252
|
+
// Derive encryption key from secret key bytes
|
|
253
|
+
const { hexDecode, deriveEncryptionKey } = await import("../lib/crypto.js");
|
|
254
|
+
const secretBytes = hexDecode(msg.secretHex);
|
|
255
|
+
const key = await deriveEncryptionKey(secretBytes);
|
|
256
|
+
setEncryptionKey(key, msg.userId ?? undefined);
|
|
257
|
+
// Acknowledge to parent
|
|
258
|
+
process.send("ready");
|
|
259
|
+
// Disconnect IPC (no longer needed)
|
|
260
|
+
if (process.disconnect)
|
|
261
|
+
process.disconnect();
|
|
262
|
+
// Start agent in foreground (this process stays running)
|
|
263
|
+
const socketPath = getDecryptSocketPath();
|
|
264
|
+
startForegroundAgent(socketPath);
|
|
265
|
+
resolve();
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
process.stderr.write(`Daemon init error: ${err}\n`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Start the agent in foreground mode (used by both direct and daemon child).
|
|
276
|
+
*/
|
|
277
|
+
async function startForegroundAgent(socketPath) {
|
|
278
|
+
startBackgroundRefresh();
|
|
279
|
+
prepareSocket(socketPath);
|
|
280
|
+
const server = createServer(handleConnection);
|
|
281
|
+
server.listen(socketPath, () => {
|
|
282
|
+
chmodSync(socketPath, 0o600);
|
|
283
|
+
output.success("Decrypt agent started.");
|
|
284
|
+
output.info(`Socket: ${socketPath}`);
|
|
285
|
+
output.info("In another terminal, run:");
|
|
286
|
+
console.log(` export PSSO_AGENT_SOCK='${socketPath}'`);
|
|
287
|
+
output.info("Press Ctrl+C to stop the agent.");
|
|
288
|
+
});
|
|
289
|
+
server.on("error", (err) => {
|
|
290
|
+
process.stderr.write(`Agent socket error: ${err.message}\n`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
});
|
|
293
|
+
// Clean up on process exit
|
|
294
|
+
const cleanup = () => {
|
|
295
|
+
server.close();
|
|
296
|
+
try {
|
|
297
|
+
unlinkSync(socketPath);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Already cleaned up
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
process.on("exit", cleanup);
|
|
304
|
+
process.on("SIGINT", () => {
|
|
305
|
+
cleanup();
|
|
306
|
+
process.exit(0);
|
|
307
|
+
});
|
|
308
|
+
process.on("SIGTERM", () => {
|
|
309
|
+
cleanup();
|
|
310
|
+
process.exit(0);
|
|
311
|
+
});
|
|
312
|
+
// Keep process running
|
|
313
|
+
await new Promise(() => {
|
|
314
|
+
// Never resolves — process exits via signal handlers
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
//# sourceMappingURL=agent-decrypt.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `passwd-sso agent` — Start an SSH agent backed by vault SSH keys.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* eval $(passwd-sso agent --eval) # Set SSH_AUTH_SOCK
|
|
6
|
+
* ssh-add -l # List vault SSH keys
|
|
7
|
+
* ssh -T git@github.com # Use via agent
|
|
8
|
+
*/
|
|
9
|
+
export interface AgentOptions {
|
|
10
|
+
eval?: boolean;
|
|
11
|
+
decrypt?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function agentCommand(opts: AgentOptions): Promise<void>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `passwd-sso agent` — Start an SSH agent backed by vault SSH keys.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* eval $(passwd-sso agent --eval) # Set SSH_AUTH_SOCK
|
|
6
|
+
* ssh-add -l # List vault SSH keys
|
|
7
|
+
* ssh -T git@github.com # Use via agent
|
|
8
|
+
*/
|
|
9
|
+
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";
|
|
14
|
+
import { loadKey, clearKeys } from "../lib/ssh-key-agent.js";
|
|
15
|
+
import { startAgent, stopAgent } from "../lib/ssh-agent-socket.js";
|
|
16
|
+
import { decryptAgentCommand } from "./agent-decrypt.js";
|
|
17
|
+
import * as output from "../lib/output.js";
|
|
18
|
+
/**
|
|
19
|
+
* Build an SSH public key blob from the stored public key string.
|
|
20
|
+
* The public key is in OpenSSH format: "ssh-ed25519 AAAA... comment"
|
|
21
|
+
*/
|
|
22
|
+
function parsePublicKeyBlob(publicKeyStr) {
|
|
23
|
+
const parts = publicKeyStr.trim().split(/\s+/);
|
|
24
|
+
if (parts.length < 2)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
return Buffer.from(parts[1], "base64");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
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
|
+
}
|
|
41
|
+
const encryptionKey = getEncryptionKey();
|
|
42
|
+
if (!encryptionKey) {
|
|
43
|
+
output.error("Encryption key not available.");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const userId = getUserId();
|
|
47
|
+
// Fetch all SSH_KEY entries from the vault
|
|
48
|
+
const res = await apiRequest("/api/passwords?type=SSH_KEY&include=blob");
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
output.error(`Failed to fetch SSH keys: HTTP ${res.status}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const entries = res.data;
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
output.error("No SSH keys found in vault.");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
// Decrypt and load each SSH key
|
|
59
|
+
let loadedCount = 0;
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
try {
|
|
62
|
+
const aad = entry.aadVersion >= 1 && userId
|
|
63
|
+
? buildPersonalEntryAAD(userId, entry.id)
|
|
64
|
+
: undefined;
|
|
65
|
+
const plaintext = await decryptData(entry.encryptedBlob, encryptionKey, aad);
|
|
66
|
+
const blob = JSON.parse(plaintext);
|
|
67
|
+
if (!blob.privateKey || !blob.publicKey)
|
|
68
|
+
continue;
|
|
69
|
+
const publicKeyBlob = parsePublicKeyBlob(blob.publicKey);
|
|
70
|
+
if (!publicKeyBlob)
|
|
71
|
+
continue;
|
|
72
|
+
await loadKey(entry.id, blob.privateKey, publicKeyBlob, blob.comment ?? blob.title ?? "", blob.passphrase);
|
|
73
|
+
loadedCount++;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
output.warn(`Failed to load SSH key ${entry.id}: ${err instanceof Error ? err.message : "unknown error"}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (loadedCount === 0) {
|
|
80
|
+
output.error("No valid SSH keys could be loaded.");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
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}";`);
|
|
90
|
+
}
|
|
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)`);
|
|
98
|
+
}
|
|
99
|
+
// Keep process running until interrupted
|
|
100
|
+
output.info("Press Ctrl+C to stop the agent.");
|
|
101
|
+
// Handle vault lock → clear keys
|
|
102
|
+
const checkLock = setInterval(() => {
|
|
103
|
+
if (!isUnlocked()) {
|
|
104
|
+
output.warn("Vault locked — clearing agent keys.");
|
|
105
|
+
clearKeys();
|
|
106
|
+
stopAgent();
|
|
107
|
+
clearInterval(checkLock);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
}, 5000);
|
|
111
|
+
// Wait forever (signal handlers in ssh-agent-socket.ts handle cleanup)
|
|
112
|
+
await new Promise(() => {
|
|
113
|
+
// Never resolves — process exits via signal handlers
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=agent.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `passwd-sso api-key` — Manage API keys.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* list — List API keys
|
|
6
|
+
* create — Create a new API key
|
|
7
|
+
* revoke — Revoke an API key
|
|
8
|
+
*/
|
|
9
|
+
export declare function apiKeyListCommand(options?: {
|
|
10
|
+
json?: boolean;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
interface CreateOptions {
|
|
13
|
+
name: string;
|
|
14
|
+
scopes: string[];
|
|
15
|
+
days: number;
|
|
16
|
+
json?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function apiKeyCreateCommand(opts: CreateOptions): Promise<void>;
|
|
19
|
+
export declare function apiKeyRevokeCommand(id: string, options?: {
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `passwd-sso api-key` — Manage API keys.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* list — List API keys
|
|
6
|
+
* create — Create a new API key
|
|
7
|
+
* revoke — Revoke an API key
|
|
8
|
+
*/
|
|
9
|
+
import { apiRequest } from "../lib/api-client.js";
|
|
10
|
+
import * as output from "../lib/output.js";
|
|
11
|
+
export async function apiKeyListCommand(options = {}) {
|
|
12
|
+
const res = await apiRequest("/api/api-keys");
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
output.error(`Failed to list API keys: HTTP ${res.status}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const keys = res.data;
|
|
18
|
+
if (keys.length === 0) {
|
|
19
|
+
if (options.json) {
|
|
20
|
+
output.json([]);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
output.info("No API keys found.");
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (options.json) {
|
|
28
|
+
output.json(keys.map((key) => ({
|
|
29
|
+
id: key.id,
|
|
30
|
+
name: key.name,
|
|
31
|
+
prefix: key.prefix,
|
|
32
|
+
scopes: key.scopes,
|
|
33
|
+
status: key.revokedAt
|
|
34
|
+
? "revoked"
|
|
35
|
+
: new Date(key.expiresAt) < new Date()
|
|
36
|
+
? "expired"
|
|
37
|
+
: "active",
|
|
38
|
+
expiresAt: key.expiresAt,
|
|
39
|
+
createdAt: key.createdAt,
|
|
40
|
+
revokedAt: key.revokedAt,
|
|
41
|
+
lastUsedAt: key.lastUsedAt,
|
|
42
|
+
})));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
const status = key.revokedAt
|
|
48
|
+
? "revoked"
|
|
49
|
+
: new Date(key.expiresAt) < new Date()
|
|
50
|
+
? "expired"
|
|
51
|
+
: "active";
|
|
52
|
+
rows.push([
|
|
53
|
+
key.id,
|
|
54
|
+
key.name,
|
|
55
|
+
key.prefix + "...",
|
|
56
|
+
key.scopes.join(", "),
|
|
57
|
+
status,
|
|
58
|
+
new Date(key.expiresAt).toLocaleDateString(),
|
|
59
|
+
key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : "never",
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
output.table(["ID", "Name", "Prefix", "Scopes", "Status", "Expires", "Last Used"], rows);
|
|
63
|
+
}
|
|
64
|
+
export async function apiKeyCreateCommand(opts) {
|
|
65
|
+
const expiresAt = new Date();
|
|
66
|
+
expiresAt.setDate(expiresAt.getDate() + opts.days);
|
|
67
|
+
const res = await apiRequest("/api/api-keys", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
body: {
|
|
70
|
+
name: opts.name,
|
|
71
|
+
scope: opts.scopes,
|
|
72
|
+
expiresAt: expiresAt.toISOString(),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const err = res.data;
|
|
77
|
+
output.error(`Failed to create API key: ${err.error ?? `HTTP ${res.status}`}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (opts.json) {
|
|
81
|
+
output.json({
|
|
82
|
+
id: res.data.id,
|
|
83
|
+
token: res.data.token,
|
|
84
|
+
name: res.data.name,
|
|
85
|
+
prefix: res.data.prefix,
|
|
86
|
+
scopes: res.data.scopes,
|
|
87
|
+
expiresAt: res.data.expiresAt,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
output.success("API key created:");
|
|
92
|
+
console.log(` Name: ${res.data.name}`);
|
|
93
|
+
console.log(` Scopes: ${res.data.scopes.join(", ")}`);
|
|
94
|
+
console.log(` Expires: ${new Date(res.data.expiresAt).toLocaleDateString()}`);
|
|
95
|
+
console.log();
|
|
96
|
+
output.warn("Copy this token — it will not be shown again:");
|
|
97
|
+
console.log(` ${res.data.token}`);
|
|
98
|
+
}
|
|
99
|
+
export async function apiKeyRevokeCommand(id, options = {}) {
|
|
100
|
+
const res = await apiRequest(`/api/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const err = res.data;
|
|
103
|
+
if (options.json) {
|
|
104
|
+
output.json({ success: false, error: err.error ?? `HTTP ${res.status}` });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
output.error(`Failed to revoke API key: ${err.error ?? `HTTP ${res.status}`}`);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (options.json) {
|
|
112
|
+
output.json({ success: true, id });
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
output.success("API key revoked.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=api-key.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `passwd-sso decrypt <id>` — Thin client that connects to the decrypt agent socket.
|
|
3
|
+
* Sends a decrypt request and prints the result to stdout.
|
|
4
|
+
* Used by Claude Code hooks to decrypt credentials without exposing them to the LLM.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* passwd-sso decrypt <entryId> --field password --mcp-client <mcpc_xxx>
|
|
8
|
+
* passwd-sso decrypt <entryId> --json --mcp-client <mcpc_xxx>
|
|
9
|
+
* passwd-sso decrypt abc123 --field password --mcp-client mcpc_xxx | curl --config - ...
|
|
10
|
+
*
|
|
11
|
+
* --json outputs all decrypted fields as JSON (ignores --field).
|
|
12
|
+
*/
|
|
13
|
+
export declare function decryptCommand(id: string, options: {
|
|
14
|
+
field?: string;
|
|
15
|
+
mcpClient: string;
|
|
16
|
+
json?: boolean;
|
|
17
|
+
}): Promise<void>;
|