passwd-sso-cli 0.4.50 → 0.4.55

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);
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { apiRequest } from "../lib/api-client.js";
10
10
  import { decryptData } from "../lib/crypto.js";
11
- import { buildPersonalEntryAAD } from "../lib/crypto-aad.js";
11
+ import { buildPersonalEntryAAD, VAULT_TYPE } from "../lib/crypto-aad.js";
12
12
  import { getEncryptionKey, getUserId, isUnlocked } from "../lib/vault-state.js";
13
13
  import { autoUnlockIfNeeded } from "./unlock.js";
14
14
  import { loadKey, clearKeys } from "../lib/ssh-key-agent.js";
@@ -60,7 +60,7 @@ export async function agentCommand(opts) {
60
60
  for (const entry of entries) {
61
61
  try {
62
62
  const aad = entry.aadVersion >= 1 && userId
63
- ? buildPersonalEntryAAD(userId, entry.id)
63
+ ? buildPersonalEntryAAD(userId, entry.id, VAULT_TYPE.BLOB)
64
64
  : undefined;
65
65
  const plaintext = await decryptData(entry.encryptedBlob, encryptionKey, aad);
66
66
  const blob = JSON.parse(plaintext);
@@ -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);
package/dist/index.js CHANGED
File without changes
@@ -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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passwd-sso-cli",
3
- "version": "0.4.50",
3
+ "version": "0.4.55",
4
4
  "description": "CLI for passwd-sso password manager",
5
5
  "type": "module",
6
6
  "license": "MIT",