passwd-sso-cli 0.4.66 → 0.4.68

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.
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { mkdirSync, lstatSync, chmodSync, unlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { z } from "zod";
13
- import { apiRequest, startBackgroundRefresh } from "../lib/api-client.js";
13
+ import { apiRequest, assertLoggedIn, startBackgroundRefresh } from "../lib/api-client.js";
14
14
  import { decryptData, hexEncode } from "../lib/crypto.js";
15
15
  import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
16
16
  import { getEncryptionKey, getUserId, getSecretKeyBytes, setEncryptionKey } from "../lib/vault-state.js";
@@ -153,7 +153,16 @@ export function handleConnection(socket) {
153
153
  };
154
154
  }
155
155
  else {
156
- response = await handleDecryptRequest(parsed.data);
156
+ try {
157
+ response = await handleDecryptRequest(parsed.data);
158
+ }
159
+ catch (err) {
160
+ // Request-layer failures (server down, revoked login) — not parse errors
161
+ response = {
162
+ ok: false,
163
+ error: `Request failed: ${err instanceof Error ? err.message : "unknown error"}`,
164
+ };
165
+ }
157
166
  }
158
167
  }
159
168
  catch (err) {
@@ -187,6 +196,8 @@ export async function decryptAgentCommand(opts) {
187
196
  "Run in an interactive terminal.\n");
188
197
  process.exit(1);
189
198
  }
199
+ // Fail fast before prompting — the passphrase is useless without a login
200
+ assertLoggedIn();
190
201
  // In --eval mode, stdout is captured by the shell — write prompt to stderr
191
202
  const passphrase = await readPassphrase("Master passphrase: ", { useStderr: opts.eval });
192
203
  if (!passphrase) {
@@ -12,7 +12,7 @@
12
12
  * substitution returns instead of blocking.
13
13
  */
14
14
  import { spawn } from "node:child_process";
15
- import { apiRequest } from "../lib/api-client.js";
15
+ import { apiRequest, assertLoggedIn } from "../lib/api-client.js";
16
16
  import { decryptData, hexEncode } from "../lib/crypto.js";
17
17
  import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
18
18
  import { getEncryptionKey, getUserId, getSecretKeyBytes, setEncryptionKey, isUnlocked, } from "../lib/vault-state.js";
@@ -100,6 +100,8 @@ export async function agentCommand(opts) {
100
100
  // Unlock the vault in this (parent / foreground) process.
101
101
  if (!(await autoUnlockIfNeeded())) {
102
102
  if (opts.eval && process.stdin.isTTY) {
103
+ // Fail fast before prompting — the passphrase is useless without a login
104
+ assertLoggedIn();
103
105
  // Prompt on stderr so stdout stays clean for `eval $(...)` capture.
104
106
  const passphrase = await readPassphrase("Master passphrase: ", { useStderr: true });
105
107
  if (!passphrase || !(await unlockWithPassphrase(passphrase))) {
@@ -10,12 +10,7 @@ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
10
10
  import { writeSecretFile } from "../lib/secure-file.js";
11
11
  import * as output from "../lib/output.js";
12
12
  import { CLI_API_PATH } from "../lib/api-paths.js";
13
- function escapeCSV(value) {
14
- if (value.includes(",") || value.includes('"') || value.includes("\n")) {
15
- return `"${value.replace(/"/g, '""')}"`;
16
- }
17
- return value;
18
- }
13
+ import { escapeCsvCompat } from "../lib/csv-escape.js";
19
14
  export async function exportCommand(options) {
20
15
  const key = getEncryptionKey();
21
16
  if (!key) {
@@ -81,9 +76,9 @@ export async function exportCommand(options) {
81
76
  csvRows.push(headers.map((h) => {
82
77
  const val = entry[h];
83
78
  if (h === "totp" && val && typeof val === "object") {
84
- return escapeCSV(val.secret ?? "");
79
+ return escapeCsvCompat(val.secret ?? "");
85
80
  }
86
- return escapeCSV(String(val ?? ""));
81
+ return escapeCsvCompat(String(val ?? ""));
87
82
  }).join(","));
88
83
  }
89
84
  const csvOut = csvRows.join("\n");
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Derives the encryption key and stores it in process memory.
5
5
  */
6
- import { apiRequest } from "../lib/api-client.js";
6
+ import { apiRequest, assertLoggedIn } from "../lib/api-client.js";
7
7
  import { hexDecode, deriveWrappingKey, unwrapSecretKey, deriveEncryptionKey, verifyKey, } from "../lib/crypto.js";
8
8
  import { setEncryptionKey, setSecretKeyBytes, isUnlocked } from "../lib/vault-state.js";
9
9
  import * as output from "../lib/output.js";
@@ -100,6 +100,8 @@ export async function unlockCommand() {
100
100
  output.info("Vault is already unlocked.");
101
101
  return;
102
102
  }
103
+ // Fail fast before prompting — the passphrase is useless without a login
104
+ assertLoggedIn();
103
105
  const passphrase = await readPassphrase("Master passphrase: ");
104
106
  if (!passphrase) {
105
107
  output.error("Passphrase is required.");
package/dist/index.js CHANGED
@@ -175,7 +175,14 @@ program
175
175
  process.exit(1);
176
176
  }
177
177
  });
178
- program.parse();
178
+ try {
179
+ await program.parseAsync();
180
+ }
181
+ catch (err) {
182
+ // Render action-handler errors as a clean one-liner — never a stack trace
183
+ output.error(err instanceof Error ? err.message : String(err));
184
+ process.exit(1);
185
+ }
179
186
  // ─── Interactive REPL Mode ──────────────────────────────────────
180
187
  async function interactiveMode() {
181
188
  output.info("Vault unlocked. Type a command or `help` for options. `lock` to exit.");
@@ -8,6 +8,7 @@ export declare function setInsecure(enabled: boolean): void;
8
8
  export declare function getToken(): string | null;
9
9
  export declare function setTokenCache(token: string, expiresAt?: string, refreshToken?: string, clientId?: string): void;
10
10
  export declare function clearTokenCache(): void;
11
+ export declare function assertLoggedIn(): void;
11
12
  export interface ApiResponse<T = unknown> {
12
13
  ok: boolean;
13
14
  status: number;
@@ -47,6 +47,11 @@ export function clearTokenCache() {
47
47
  cachedRefreshToken = null;
48
48
  cachedClientId = null;
49
49
  }
50
+ export function assertLoggedIn() {
51
+ if (!getToken()) {
52
+ throw new Error("Not logged in. Run `passwd-sso login` first.");
53
+ }
54
+ }
50
55
  function getBaseUrl() {
51
56
  const config = loadConfig();
52
57
  if (!config.serverUrl) {
@@ -83,10 +88,8 @@ async function refreshToken() {
83
88
  }
84
89
  }
85
90
  export async function apiRequest(path, options = {}) {
91
+ assertLoggedIn();
86
92
  let token = getToken();
87
- if (!token) {
88
- throw new Error("Not logged in. Run `passwd-sso login` first.");
89
- }
90
93
  if (isTokenExpiringSoon()) {
91
94
  const refreshed = await refreshToken();
92
95
  if (refreshed) {
@@ -13,9 +13,9 @@ export declare function hexEncode(buf: ArrayBuffer | Uint8Array): string;
13
13
  export declare function hexDecode(hex: string): Uint8Array;
14
14
  export declare function deriveWrappingKey(passphrase: string, accountSalt: Uint8Array): Promise<CryptoKey>;
15
15
  export declare function deriveEncryptionKey(secretKey: Uint8Array): Promise<CryptoKey>;
16
- export declare function deriveAuthKey(secretKey: Uint8Array): Promise<CryptoKey>;
16
+ export declare function deriveAuthKeyBytes(secretKey: Uint8Array): Promise<Uint8Array>;
17
17
  export declare function unwrapSecretKey(encrypted: EncryptedData, wrappingKey: CryptoKey): Promise<Uint8Array>;
18
- export declare function computeAuthHash(authKey: CryptoKey): Promise<string>;
18
+ export declare function computeAuthHash(authKeyBytes: Uint8Array): Promise<string>;
19
19
  export declare function verifyKey(encryptionKey: CryptoKey, artifact: EncryptedData): Promise<boolean>;
20
20
  export declare function encryptData(plaintext: string, key: CryptoKey, aad?: Uint8Array): Promise<EncryptedData>;
21
21
  export declare function decryptData(encrypted: EncryptedData, key: CryptoKey, aad?: Uint8Array): Promise<string>;
@@ -65,15 +65,18 @@ export async function deriveEncryptionKey(secretKey) {
65
65
  info: textEncode(HKDF_ENC_INFO),
66
66
  }, hkdfKey, { name: "AES-GCM", length: AES_KEY_LENGTH }, false, ["encrypt", "decrypt"]);
67
67
  }
68
- export async function deriveAuthKey(secretKey) {
69
- const hkdfKey = await crypto.subtle.importKey("raw", toArrayBuffer(secretKey), "HKDF", false, ["deriveKey"]);
70
- return crypto.subtle.deriveKey({
68
+ // Uses deriveBits (not deriveKey) so no extractable CryptoKey is ever created.
69
+ // Output bytes are identical to the previous exportKey("raw", <HMAC key>) form.
70
+ export async function deriveAuthKeyBytes(secretKey) {
71
+ const hkdfKey = await crypto.subtle.importKey("raw", toArrayBuffer(secretKey), "HKDF", false, ["deriveBits"]);
72
+ const bits = await crypto.subtle.deriveBits({
71
73
  name: "HKDF",
72
74
  hash: "SHA-256",
73
75
  // See deriveEncryptionKey for zero-salt risk acceptance rationale
74
76
  salt: new ArrayBuffer(32),
75
77
  info: textEncode(HKDF_AUTH_INFO),
76
- }, hkdfKey, { name: "HMAC", hash: "SHA-256", length: AES_KEY_LENGTH }, true, ["sign"]);
78
+ }, hkdfKey, AES_KEY_LENGTH);
79
+ return new Uint8Array(bits);
77
80
  }
78
81
  // ─── Secret Key Management ─────────────────────────────────────
79
82
  export async function unwrapSecretKey(encrypted, wrappingKey) {
@@ -87,9 +90,8 @@ export async function unwrapSecretKey(encrypted, wrappingKey) {
87
90
  return new Uint8Array(decrypted);
88
91
  }
89
92
  // ─── Auth Hash ────────────────────────────────────────────────
90
- export async function computeAuthHash(authKey) {
91
- const rawKey = await crypto.subtle.exportKey("raw", authKey);
92
- const hash = await crypto.subtle.digest("SHA-256", rawKey);
93
+ export async function computeAuthHash(authKeyBytes) {
94
+ const hash = await crypto.subtle.digest("SHA-256", toArrayBuffer(authKeyBytes));
93
95
  return hexEncode(hash);
94
96
  }
95
97
  // ─── Verification ──────────────────────────────────────────────
@@ -0,0 +1,13 @@
1
+ /** Characters that make a spreadsheet interpret a cell as a formula. */
2
+ export declare const CSV_FORMULA_TRIGGER_RE: RegExp;
3
+ /**
4
+ * Escapes a value for a compatibility CSV format that only quote-wraps cells
5
+ * containing a delimiter/quote/newline (Bitwarden-compatible layout), while
6
+ * still neutralizing formula-injection.
7
+ *
8
+ * RS6 ordering: the `"` → `""` doubling is applied before the formula-prefix
9
+ * decision, and the prefix is added inside the quotes — a formula-triggering
10
+ * cell is always quote-wrapped so the leading `'` cannot be stripped by a bare
11
+ * unquoted value.
12
+ */
13
+ export declare function escapeCsvCompat(value: string): string;
@@ -0,0 +1,41 @@
1
+ // Shared CSV formula-injection (CSV injection) neutralization for the CLI.
2
+ //
3
+ // Mirrors src/lib/format/csv-escape.ts byte-for-byte behavior. The CLI is a
4
+ // separate ESM package and cannot import the app module, so the trigger set
5
+ // and escape logic are duplicated here; the parity test in
6
+ // cli/src/__tests__/unit/csv-escape.test.ts pins them to the same cases as the
7
+ // app twin so the two implementations cannot drift silently.
8
+ //
9
+ // A cell whose value begins with one of `= + - @ \t \r` is interpreted as a
10
+ // formula by Excel / Google Sheets / LibreOffice when the exported file is
11
+ // opened, enabling data exfiltration (HYPERLINK / WEBSERVICE) or command
12
+ // execution (DDE). Prefixing such a cell with a single quote makes the
13
+ // spreadsheet treat it as literal text.
14
+ //
15
+ // Leading whitespace (spaces, tabs, newlines) before the trigger char is also
16
+ // caught (`\s*` prefix) — spreadsheets still evaluate ` =1+1` as a formula,
17
+ // so a value with leading whitespace and a trigger char needs the same guard.
18
+ /** Characters that make a spreadsheet interpret a cell as a formula. */
19
+ export const CSV_FORMULA_TRIGGER_RE = /^\s*[=+\-@\t\r]/;
20
+ /**
21
+ * Escapes a value for a compatibility CSV format that only quote-wraps cells
22
+ * containing a delimiter/quote/newline (Bitwarden-compatible layout), while
23
+ * still neutralizing formula-injection.
24
+ *
25
+ * RS6 ordering: the `"` → `""` doubling is applied before the formula-prefix
26
+ * decision, and the prefix is added inside the quotes — a formula-triggering
27
+ * cell is always quote-wrapped so the leading `'` cannot be stripped by a bare
28
+ * unquoted value.
29
+ */
30
+ export function escapeCsvCompat(value) {
31
+ const needsQuote = value.includes(",") ||
32
+ value.includes('"') ||
33
+ value.includes("\n") ||
34
+ CSV_FORMULA_TRIGGER_RE.test(value);
35
+ if (!needsQuote)
36
+ return value;
37
+ const escaped = value.replace(/"/g, '""');
38
+ const prefixed = CSV_FORMULA_TRIGGER_RE.test(escaped) ? `'${escaped}` : escaped;
39
+ return `"${prefixed}"`;
40
+ }
41
+ //# sourceMappingURL=csv-escape.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passwd-sso-cli",
3
- "version": "0.4.66",
3
+ "version": "0.4.68",
4
4
  "description": "CLI for passwd-sso password manager",
5
5
  "type": "module",
6
6
  "license": "MIT",