residoo 0.18.0 → 0.19.0
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/README.md +7 -0
- package/package.json +1 -1
- package/src/cli.js +81 -12
- package/src/integrity.js +83 -6
- package/src/keychain.js +116 -9
package/README.md
CHANGED
|
@@ -175,6 +175,13 @@ gap, none catches every case, and all are re-includable with
|
|
|
175
175
|
certainty — true of every tool in this category, including the well-
|
|
176
176
|
established ones.
|
|
177
177
|
|
|
178
|
+
- **No mobile app.** Researched, not assumed: residoo's file-scanning
|
|
179
|
+
approach cannot port to stock iOS under Apple's own sandboxing model,
|
|
180
|
+
and every alternative mechanism checked (a keyboard extension, a local
|
|
181
|
+
VPN content filter) has a specific, disqualifying problem. See
|
|
182
|
+
[docs/platform-scope.md](docs/platform-scope.md) for the full technical
|
|
183
|
+
verdict and what would change it.
|
|
184
|
+
|
|
178
185
|
## Install
|
|
179
186
|
|
|
180
187
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CloudRoam (https://cloudroam.io)",
|
package/src/cli.js
CHANGED
|
@@ -217,7 +217,15 @@ MCP:
|
|
|
217
217
|
Cred:
|
|
218
218
|
residoo cred set <name> --env <ENV_VAR_NAME> [--env <ENV_VAR_NAME_2> ...]
|
|
219
219
|
store a live credential in the OS keychain
|
|
220
|
-
(macOS/Linux only
|
|
220
|
+
(macOS/Linux only -- genuinely unsupported on
|
|
221
|
+
Windows, not just undone yet: this needs a NAMED
|
|
222
|
+
store you can write once and read back later,
|
|
223
|
+
and Windows has none reachable without an extra
|
|
224
|
+
dependency. Different from --seal --keychain
|
|
225
|
+
below, which Windows DOES support via DPAPI --
|
|
226
|
+
that one only ever needs to wrap/unwrap a key
|
|
227
|
+
living inside its own vault directory, never a
|
|
228
|
+
named lookup), one or more env-var names
|
|
221
229
|
mapped to hidden-typed values. Interactive TTY
|
|
222
230
|
only, no scripted entry: a live credential is
|
|
223
231
|
more sensitive than a vault passphrase and
|
|
@@ -338,10 +346,19 @@ Seal options (used with scan):
|
|
|
338
346
|
in the OS keychain instead of a typed passphrase.
|
|
339
347
|
Nothing to remember, and the key's strength no longer
|
|
340
348
|
depends on passphrase choice. macOS today; Linux when
|
|
341
|
-
secret-tool (libsecret) is installed
|
|
342
|
-
|
|
349
|
+
secret-tool (libsecret) is installed; Windows via
|
|
350
|
+
DPAPI (a different mechanism, not a Windows Credential
|
|
351
|
+
Manager entry -- cmdkey.exe is confirmed write-only,
|
|
352
|
+
it can never read a stored password back out). TRADEOFF:
|
|
353
|
+
a keychain-backed vault lives on THIS machine/account
|
|
343
354
|
only, unlike a passphrase, it is not portable to
|
|
344
|
-
another machine.
|
|
355
|
+
another machine. On Windows specifically, the DPAPI-
|
|
356
|
+
wrapped key travels INSIDE the vault directory itself
|
|
357
|
+
(there's no separate OS-level store to put it in,
|
|
358
|
+
unlike macOS/Linux) -- still undecryptable without
|
|
359
|
+
being logged in as the same Windows user on the same
|
|
360
|
+
machine, but a real, disclosed difference from macOS/
|
|
361
|
+
Linux's fully separate keychain entry.
|
|
345
362
|
--vault-dir <dir> where to create the vault (default: ./residoo-vault-<stamp>)
|
|
346
363
|
--upload-cloudroam ALSO upload the sealed vault to CloudRoam. One of two
|
|
347
364
|
opt-in features that touch the network (--verify
|
|
@@ -402,31 +419,70 @@ async function getPassphrase({ confirmNew }) {
|
|
|
402
419
|
* sealFindings/deriveKey path a typed passphrase would use — scrypt on a
|
|
403
420
|
* full 256-bit-entropy input is harmless extra defense, and reusing that
|
|
404
421
|
* already-tested path means no change to sealcrypto.js/sealvault.js at all.
|
|
422
|
+
*
|
|
423
|
+
* `winKeyBlob` is Windows' equivalent of `vaultId`: there is no OS-level
|
|
424
|
+
* named credential store on Windows (see keychain.js's own
|
|
425
|
+
* wrapVaultKeyWindows docstring for exactly why, and why this is a
|
|
426
|
+
* DIFFERENT mechanism from `vaultId`'s macOS/Linux keychain.store(), not a
|
|
427
|
+
* Windows branch inside it), so instead of a lookup id, the caller gets
|
|
428
|
+
* back the actual DPAPI-wrapped key blob to persist itself once the vault
|
|
429
|
+
* directory exists — the one place on Windows with a rule-compliant
|
|
430
|
+
* reason to write it.
|
|
405
431
|
*/
|
|
406
432
|
async function resolveSealSecret(args) {
|
|
407
433
|
if (!args.includes("--keychain")) {
|
|
408
|
-
return { passphrase: await getPassphrase({ confirmNew: true }), vaultId: null };
|
|
434
|
+
return { passphrase: await getPassphrase({ confirmNew: true }), vaultId: null, winKeyBlob: null };
|
|
409
435
|
}
|
|
436
|
+
const passphrase = crypto.randomBytes(32).toString("base64");
|
|
410
437
|
const keychain = require("./keychain");
|
|
438
|
+
if (process.platform === "win32") {
|
|
439
|
+
if (!keychain.isVaultKeySupported()) throw new Error("--keychain: DPAPI is not available on this system.");
|
|
440
|
+
return { passphrase, vaultId: null, winKeyBlob: keychain.wrapVaultKeyWindows(passphrase) };
|
|
441
|
+
}
|
|
411
442
|
if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
|
|
412
443
|
const vaultId = crypto.randomUUID();
|
|
413
|
-
const passphrase = crypto.randomBytes(32).toString("base64");
|
|
414
444
|
keychain.store(vaultId, passphrase);
|
|
415
|
-
return { passphrase, vaultId };
|
|
445
|
+
return { passphrase, vaultId, winKeyBlob: null };
|
|
416
446
|
}
|
|
417
447
|
|
|
418
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* The unsealing secret for `unseal`: a keychain-retrieved key, or a typed
|
|
450
|
+
* passphrase. Checked by which MARKER FILE is actually present in the
|
|
451
|
+
* vault directory, not by the current machine's platform — a vault sealed
|
|
452
|
+
* on Windows carries `.keychain-key-win`, one sealed on macOS/Linux
|
|
453
|
+
* carries `.keychain-id`, and trying to unseal one on the wrong kind of
|
|
454
|
+
* machine should say so clearly rather than silently mis-routing.
|
|
455
|
+
*/
|
|
419
456
|
async function resolveUnsealSecret(args, vaultDir) {
|
|
420
457
|
if (!args.includes("--keychain")) return getPassphrase({ confirmNew: false });
|
|
421
458
|
const keychain = require("./keychain");
|
|
422
|
-
|
|
459
|
+
const winKeyPath = path.join(vaultDir, ".keychain-key-win");
|
|
423
460
|
const idPath = path.join(vaultDir, ".keychain-id");
|
|
461
|
+
|
|
462
|
+
if (fs.existsSync(winKeyPath)) {
|
|
463
|
+
if (!keychain.isVaultKeySupported()) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`This vault was sealed with --keychain on Windows (DPAPI-backed). DPAPI keys are tied to that ` +
|
|
466
|
+
`Windows user account and cannot be unwrapped on ${process.platform} — unseal it on that same Windows machine.`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
return keychain.unwrapVaultKeyWindows(fs.readFileSync(winKeyPath, "utf-8").trim());
|
|
471
|
+
} catch {
|
|
472
|
+
throw new Error(
|
|
473
|
+
"Could not decrypt this vault's key via Windows DPAPI. It may have been sealed under a different " +
|
|
474
|
+
"Windows user account or moved to a different machine — a keychain-backed vault is not portable."
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
424
479
|
if (!fs.existsSync(idPath)) {
|
|
425
480
|
throw new Error(
|
|
426
|
-
`No .keychain-id marker in ${vaultDir}: this vault was not sealed with --keychain, ` +
|
|
481
|
+
`No .keychain-id or .keychain-key-win marker in ${vaultDir}: this vault was not sealed with --keychain, ` +
|
|
427
482
|
`or the marker file was moved separately from the vault. Try unsealing without --keychain.`
|
|
428
483
|
);
|
|
429
484
|
}
|
|
485
|
+
if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
|
|
430
486
|
const vaultId = fs.readFileSync(idPath, "utf-8").trim();
|
|
431
487
|
try {
|
|
432
488
|
return keychain.retrieve(vaultId);
|
|
@@ -467,7 +523,7 @@ async function runSeal(result, args) {
|
|
|
467
523
|
|
|
468
524
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
469
525
|
const vaultDir = argValue(args, "--vault-dir") || path.resolve(`residoo-vault-${stamp}`);
|
|
470
|
-
const { passphrase, vaultId } = await resolveSealSecret(args);
|
|
526
|
+
const { passphrase, vaultId, winKeyBlob } = await resolveSealSecret(args);
|
|
471
527
|
|
|
472
528
|
process.stdout.write(`\nSealing ${filesWithFindings.length} file(s) with findings into ${vaultDir}\n`);
|
|
473
529
|
const { entries } = await sealFindings({
|
|
@@ -479,6 +535,13 @@ async function runSeal(result, args) {
|
|
|
479
535
|
// keychain lookup, never the key itself and never anything about what the
|
|
480
536
|
// vault contains.
|
|
481
537
|
if (vaultId) fs.writeFileSync(path.join(vaultDir, ".keychain-id"), vaultId, { mode: 0o600 });
|
|
538
|
+
// Windows' equivalent marker -- unlike .keychain-id, this DOES hold
|
|
539
|
+
// sensitive material (the DPAPI-wrapped key itself, not a lookup id),
|
|
540
|
+
// which is exactly the disclosed security-property trade-off
|
|
541
|
+
// keychain.js's wrapVaultKeyWindows docstring names: on Windows the
|
|
542
|
+
// wrapped key travels with the vault, decryptable only by the same
|
|
543
|
+
// Windows user account, rather than living in a fully separate store.
|
|
544
|
+
if (winKeyBlob) fs.writeFileSync(path.join(vaultDir, ".keychain-key-win"), winKeyBlob, { mode: 0o600 });
|
|
482
545
|
const totalPlain = entries.reduce((s, e) => s + e.plainBytes, 0);
|
|
483
546
|
const totalSealed = entries.reduce((s, e) => s + e.sealedBytes, 0);
|
|
484
547
|
process.stdout.write(
|
|
@@ -1188,4 +1251,10 @@ async function main(argv) {
|
|
|
1188
1251
|
return failOnFind && (secretGate || integrityWarnCount(integrity) > 0) ? 1 : 0;
|
|
1189
1252
|
}
|
|
1190
1253
|
|
|
1191
|
-
|
|
1254
|
+
// resolveSealSecret/resolveUnsealSecret are exported alongside `main`
|
|
1255
|
+
// specifically so tests/smoke.js can exercise their Windows (DPAPI) branch
|
|
1256
|
+
// directly: a spawned subprocess (this file's usual CLI-testing precedent)
|
|
1257
|
+
// always runs on the REAL host platform, so it can never exercise
|
|
1258
|
+
// win32-only logic on a non-Windows build machine -- calling these two
|
|
1259
|
+
// in-process, with process.platform mocked, is the only way to.
|
|
1260
|
+
module.exports = { main, resolveSealSecret, resolveUnsealSecret };
|
package/src/integrity.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const os = require("os");
|
|
6
|
+
// Not destructured: kept as `cp.execFileSync(...)` at every call site so a
|
|
7
|
+
// test can monkey-patch `require("child_process").execFileSync` (the same
|
|
8
|
+
// shared module object) the way tests/smoke.js's notify.js tests already
|
|
9
|
+
// do -- a destructured `const { execFileSync } = ...` would copy the
|
|
10
|
+
// reference at import time and never see a later patch.
|
|
11
|
+
const cp = require("child_process");
|
|
6
12
|
const { PATTERNS, redact } = require("./patterns");
|
|
7
13
|
|
|
8
14
|
/**
|
|
@@ -677,7 +683,59 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
677
683
|
}
|
|
678
684
|
}
|
|
679
685
|
|
|
680
|
-
//
|
|
686
|
+
// Windows NTFS ACL check for one credential-vault file, the win32
|
|
687
|
+
// counterpart to the POSIX `stat.mode` check below -- Node's fs.Stats.mode
|
|
688
|
+
// on Windows does not reflect NTFS ACLs at all, so this shells out to
|
|
689
|
+
// PowerShell's Get-Acl instead, the same "invoke the OS's own tool"
|
|
690
|
+
// pattern keychain.js already uses for macOS's `security` and Linux's
|
|
691
|
+
// `secret-tool`. Verified against Microsoft's own Get-Acl documentation
|
|
692
|
+
// (learn.microsoft.com/powershell/module/microsoft.powershell.security/get-acl)
|
|
693
|
+
// and cross-checked against independent write-ups, not live-tested
|
|
694
|
+
// against a real Windows install -- disclosed the same way copilot-cli.js's
|
|
695
|
+
// own header discloses "corroborated but unverified against a real
|
|
696
|
+
// install" for a source built without one available. icacls was
|
|
697
|
+
// considered and rejected: research found it has no `/findsid` switch
|
|
698
|
+
// and its text output is not reliably parseable, unlike Get-Acl's typed
|
|
699
|
+
// object model.
|
|
700
|
+
//
|
|
701
|
+
// Every field is projected through an explicit PSCustomObject (never the
|
|
702
|
+
// raw IdentityReference/FileSystemRights/AccessControlType objects) so
|
|
703
|
+
// JSON serialization is forced to plain strings regardless of how those
|
|
704
|
+
// .NET types would otherwise serialize -- and the result is wrapped in an
|
|
705
|
+
// array via `-InputObject` (not piped) specifically because ConvertTo-Json
|
|
706
|
+
// collapses a single-item collection into a bare object rather than a
|
|
707
|
+
// one-element array when piped, a well-known PowerShell gotcha that would
|
|
708
|
+
// otherwise break parsing on the (common) case of a file with exactly one
|
|
709
|
+
// relevant ACE.
|
|
710
|
+
//
|
|
711
|
+
// English-locale principal names only (Everyone, BUILTIN\Users, NT
|
|
712
|
+
// AUTHORITY\Authenticated Users) -- a disclosed, not silent, gap: a
|
|
713
|
+
// non-English Windows install localizes these names (e.g. "Jeder" for
|
|
714
|
+
// Everyone in German) and would not match here, the same kind of named
|
|
715
|
+
// scope limit ibanValid's per-country-length gap and the OCR module's
|
|
716
|
+
// English-only wordlist already carry elsewhere in this project.
|
|
717
|
+
function checkWindowsCredentialAcl(file) {
|
|
718
|
+
const escaped = file.replace(/'/g, "''");
|
|
719
|
+
const script =
|
|
720
|
+
"$ErrorActionPreference='Stop'; " +
|
|
721
|
+
`$acl = Get-Acl -LiteralPath '${escaped}'; ` +
|
|
722
|
+
"$rules = @($acl.Access | ForEach-Object { [PSCustomObject]@{ " +
|
|
723
|
+
"Identity = $_.IdentityReference.Value; Rights = $_.FileSystemRights.ToString(); Type = $_.AccessControlType.ToString() } }); " +
|
|
724
|
+
"ConvertTo-Json -InputObject $rules -Compress";
|
|
725
|
+
const out = cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" });
|
|
726
|
+
let rules;
|
|
727
|
+
try { rules = JSON.parse(out); } catch { return null; }
|
|
728
|
+
if (!Array.isArray(rules)) rules = [rules];
|
|
729
|
+
const BROAD_PRINCIPAL = /^(everyone|builtin\\users|nt authority\\authenticated users|users)$/i;
|
|
730
|
+
const READ_CAPABLE_RIGHTS = /fullcontrol|modify|read/i;
|
|
731
|
+
const hits = rules.filter((r) =>
|
|
732
|
+
r && typeof r.Identity === "string" && typeof r.Type === "string" && typeof r.Rights === "string" &&
|
|
733
|
+
r.Type === "Allow" && BROAD_PRINCIPAL.test(r.Identity.trim()) && READ_CAPABLE_RIGHTS.test(r.Rights));
|
|
734
|
+
if (hits.length === 0) return { tooOpen: false };
|
|
735
|
+
return { tooOpen: true, detail: hits.map((h) => `${h.Identity} (${h.Rights})`).join(", ") };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---- 5. credential-vault file permissions (HOME only, POSIX and win32) -
|
|
681
739
|
// GitGuardian's "State of Secrets Sprawl 2026" (blog.gitguardian.com,
|
|
682
740
|
// published 2026-03-17) found 24,008 unique secrets in MCP-related config
|
|
683
741
|
// files on public GitHub, 8.8% of them still live -- these files
|
|
@@ -700,13 +758,14 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
700
758
|
// naive backup restore, or a shared-volume container mount can each
|
|
701
759
|
// silently widen a file's mode without the tool that wrote it ever
|
|
702
760
|
// knowing, the same way SSH itself checks id_rsa's permissions rather
|
|
703
|
-
// than trusting whoever created it. Windows
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
761
|
+
// than trusting whoever created it. Windows gets its own NTFS-ACL-based
|
|
762
|
+
// check (checkWindowsCredentialAcl above), not a POSIX-bit approximation
|
|
763
|
+
// -- Node's fs.Stats.mode on Windows does not reflect NTFS ACLs at all,
|
|
764
|
+
// so pretending otherwise would be meaningless, not merely imprecise.
|
|
765
|
+
// Project mode is skipped on every platform -- none of these are ever
|
|
707
766
|
// project-scoped files by any vendor's own design, so there is no
|
|
708
767
|
// project-relative equivalent to check.
|
|
709
|
-
if (!projectMode
|
|
768
|
+
if (!projectMode) {
|
|
710
769
|
const geminiDir = process.env.GEMINI_CLI_HOME
|
|
711
770
|
? path.join(process.env.GEMINI_CLI_HOME, ".gemini")
|
|
712
771
|
: path.join(home, ".gemini");
|
|
@@ -739,6 +798,24 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
739
798
|
}
|
|
740
799
|
continue;
|
|
741
800
|
}
|
|
801
|
+
|
|
802
|
+
if (process.platform === "win32") {
|
|
803
|
+
let acl = null;
|
|
804
|
+
try { acl = checkWindowsCredentialAcl(file); } catch { acl = null; }
|
|
805
|
+
if (!acl) {
|
|
806
|
+
mark(file, "unreadable");
|
|
807
|
+
add("warn", "unreadable-config", file, "credential file exists but its NTFS permissions could not be checked (Get-Acl failed): unverified, not clean");
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
mark(file, "checked");
|
|
811
|
+
if (acl.tooOpen) {
|
|
812
|
+
const shown = display(file);
|
|
813
|
+
add("warn", "insecure-credential-permissions", file,
|
|
814
|
+
`${note}; NTFS permissions grant ${acl.detail} access to a live credential on this machine. Fix: right-click "${shown}" > Properties > Security > Advanced, and remove any group other than your own account and the system/administrators.`);
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
|
|
742
819
|
mark(file, "checked");
|
|
743
820
|
const openBits = stat.mode & 0o077;
|
|
744
821
|
if (openBits !== 0) {
|
package/src/keychain.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Not destructured: kept as `cp.execFileSync(...)` at every call site so a
|
|
4
|
+
// test can monkey-patch `require("child_process").execFileSync` (the same
|
|
5
|
+
// shared module object) the way tests/smoke.js's Windows-path tests do for
|
|
6
|
+
// both this file and integrity.js -- a destructured `const { execFileSync
|
|
7
|
+
// } = ...` would copy the reference at import time and never see a patch.
|
|
8
|
+
const cp = require("child_process");
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* OS-native secure credential storage for `scan --seal --keychain` and
|
|
@@ -64,7 +69,7 @@ function isSupported() {
|
|
|
64
69
|
if (process.platform === "darwin") return true;
|
|
65
70
|
if (process.platform === "linux") {
|
|
66
71
|
try {
|
|
67
|
-
execFileSync("which", ["secret-tool"], { stdio: "ignore" });
|
|
72
|
+
cp.execFileSync("which", ["secret-tool"], { stdio: "ignore" });
|
|
68
73
|
return true;
|
|
69
74
|
} catch {
|
|
70
75
|
return false;
|
|
@@ -78,9 +83,108 @@ function unsupportedReason() {
|
|
|
78
83
|
if (process.platform === "linux") {
|
|
79
84
|
return "secret-tool (libsecret) is not installed. Install it (e.g. \"apt install libsecret-tools\" or \"dnf install libsecret\") or omit --keychain to use a passphrase instead.";
|
|
80
85
|
}
|
|
86
|
+
if (process.platform === "win32") {
|
|
87
|
+
// Specifically about THIS by-name store/retrieve/remove API (residoo
|
|
88
|
+
// cred's only use of it) -- `--seal --keychain` works on Windows via a
|
|
89
|
+
// different, vault-relative mechanism (wrapVaultKeyWindows/
|
|
90
|
+
// unwrapVaultKeyWindows below), so this message must not read as "no
|
|
91
|
+
// --keychain support at all on Windows," which would be wrong.
|
|
92
|
+
return "residoo cred needs a named credential store, and Windows has none reachable without an extra dependency " +
|
|
93
|
+
"(cmdkey.exe can store a credential but never reads its password back -- confirmed against Microsoft's own docs). " +
|
|
94
|
+
"--seal --keychain works on Windows through a different mechanism; this specific store is what's unavailable.";
|
|
95
|
+
}
|
|
81
96
|
return `--keychain is not supported on ${process.platform} yet. Omit --keychain to use a passphrase instead.`;
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True when wrapVaultKeyWindows/unwrapVaultKeyWindows (below) can run --
|
|
101
|
+
* Windows only, since DPAPI is a Windows-specific API. Deliberately
|
|
102
|
+
* SEPARATE from isSupported() above: that function is about the by-name
|
|
103
|
+
* store()/retrieve()/remove() API (false on Windows -- no OS-level named
|
|
104
|
+
* credential store is reachable there without an extra dependency, since
|
|
105
|
+
* cmdkey.exe is confirmed write/list-only, never returning a stored
|
|
106
|
+
* password). This one is about the vault-relative wrap/unwrap pair, which
|
|
107
|
+
* exists only because `--seal --keychain` has a legitimate place (the
|
|
108
|
+
* vault directory itself) to put a DPAPI-wrapped blob -- `residoo cred`
|
|
109
|
+
* has no such place and does not use this.
|
|
110
|
+
*/
|
|
111
|
+
function isVaultKeySupported() {
|
|
112
|
+
return process.platform === "win32";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Windows-only DPAPI (Data Protection API) wrap/unwrap for `--seal
|
|
117
|
+
* --keychain`'s vault key, via a PowerShell shell-out -- the same
|
|
118
|
+
* "invoke the OS's own tool" pattern as macOS's `security` and Linux's
|
|
119
|
+
* `secret-tool` above, but a materially different SHAPE, and why these
|
|
120
|
+
* two functions exist separately from store()/retrieve() rather than as a
|
|
121
|
+
* Windows branch inside them.
|
|
122
|
+
*
|
|
123
|
+
* Windows has no OS-level "store this under a name, fetch it back by that
|
|
124
|
+
* name later" service the way macOS Keychain/secret-tool do. Verified
|
|
125
|
+
* directly, not assumed: `cmdkey.exe` (Windows Credential Manager's own
|
|
126
|
+
* CLI) can WRITE a generic credential but Microsoft's own documentation
|
|
127
|
+
* states plainly "Passwords are not displayed after they're stored," and
|
|
128
|
+
* `/list` only ever surfaces target names and usernames -- cmdkey is
|
|
129
|
+
* write/list-only, and cannot serve a store-then-retrieve flow at all.
|
|
130
|
+
* DPAPI (`System.Security.Cryptography.ProtectedData`, confirmed reachable
|
|
131
|
+
* from stock PowerShell 5.1 via `Add-Type -AssemblyName System.Security`
|
|
132
|
+
* with zero extra installs -- learn.microsoft.com/dotnet/standard/security/
|
|
133
|
+
* how-to-use-data-protection) is the real built-in alternative, but it is
|
|
134
|
+
* a stateless encrypt/decrypt PRIMITIVE, not a named registry: something
|
|
135
|
+
* still has to decide where the encrypted bytes are persisted.
|
|
136
|
+
*
|
|
137
|
+
* That is why wrapVaultKeyWindows only WRAPS a secret and hands the
|
|
138
|
+
* encrypted blob straight back to its caller -- this module never decides
|
|
139
|
+
* where it lives. `--seal --keychain` (cli.js's resolveSealSecret/
|
|
140
|
+
* resolveUnsealSecret) is the one caller with a rule-compliant place to
|
|
141
|
+
* put it: the vault directory itself, already within `--seal`'s own
|
|
142
|
+
* carve-out under CONTRIBUTING.md's hard rule (residoo writes nothing
|
|
143
|
+
* outside `~/.residoo/rotations.json` and an explicit `--seal`). `residoo
|
|
144
|
+
* cred` stores a long-lived credential with no vault of its own, so there
|
|
145
|
+
* is no rule-compliant place to write a DPAPI blob for it -- it remains
|
|
146
|
+
* genuinely unsupported on Windows (isSupported() above, unchanged), not
|
|
147
|
+
* worked around by squeezing it through this pair too.
|
|
148
|
+
*
|
|
149
|
+
* Disclosed security-property difference, not glossed over: on macOS/
|
|
150
|
+
* Linux, the key lives in a genuinely separate OS-managed store, entirely
|
|
151
|
+
* absent from the vault directory -- copying just the vault gets an
|
|
152
|
+
* attacker nothing at all. On Windows, the wrapped blob travels WITH the
|
|
153
|
+
* vault directory (inside it), so copying the whole vault also copies the
|
|
154
|
+
* blob. DPAPI's CurrentUser-scope encryption still means that blob is
|
|
155
|
+
* only decryptable by the same Windows user account on the same Windows
|
|
156
|
+
* installation -- not portable, the same end-user guarantee --keychain
|
|
157
|
+
* already documents -- but a threat model where an attacker can read the
|
|
158
|
+
* vault directory's files without being able to run code as that same
|
|
159
|
+
* user gets less protection here than macOS/Linux's physically-separate
|
|
160
|
+
* store provides. Verified against Microsoft's own DPAPI documentation;
|
|
161
|
+
* not live-tested against a real Windows install.
|
|
162
|
+
*/
|
|
163
|
+
function wrapVaultKeyWindows(secret) {
|
|
164
|
+
if (process.platform !== "win32") throw new Error("wrapVaultKeyWindows is Windows-only.");
|
|
165
|
+
const escaped = String(secret).replace(/'/g, "''");
|
|
166
|
+
const script =
|
|
167
|
+
"$ErrorActionPreference='Stop'; " +
|
|
168
|
+
"Add-Type -AssemblyName System.Security; " +
|
|
169
|
+
`$bytes = [System.Text.Encoding]::UTF8.GetBytes('${escaped}'); ` +
|
|
170
|
+
"$enc = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); " +
|
|
171
|
+
"[Convert]::ToBase64String($enc)";
|
|
172
|
+
return cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" }).trim();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The other half of wrapVaultKeyWindows -- see its docstring for the full design. */
|
|
176
|
+
function unwrapVaultKeyWindows(blob) {
|
|
177
|
+
if (process.platform !== "win32") throw new Error("unwrapVaultKeyWindows is Windows-only.");
|
|
178
|
+
const escaped = String(blob).replace(/'/g, "''");
|
|
179
|
+
const script =
|
|
180
|
+
"$ErrorActionPreference='Stop'; " +
|
|
181
|
+
"Add-Type -AssemblyName System.Security; " +
|
|
182
|
+
`$enc = [Convert]::FromBase64String('${escaped}'); ` +
|
|
183
|
+
"$dec = [Security.Cryptography.ProtectedData]::Unprotect($enc, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); " +
|
|
184
|
+
"[System.Text.Encoding]::UTF8.GetString($dec)";
|
|
185
|
+
return cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" }).trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
84
188
|
/**
|
|
85
189
|
* Store `secret` (a string) under `account` in the OS keychain, for later
|
|
86
190
|
* retrieve(account). `keychainFile`, macOS only, is an escape hatch used
|
|
@@ -97,13 +201,13 @@ function store(account, secret, keychainFile, service = SERVICE) {
|
|
|
97
201
|
const kf = keychainFile || testKeychainFile();
|
|
98
202
|
const args = ["add-generic-password", "-a", account, "-s", service, "-w", secret, "-U"];
|
|
99
203
|
if (kf) args.push(kf);
|
|
100
|
-
execFileSync("security", args, { stdio: "ignore" });
|
|
204
|
+
cp.execFileSync("security", args, { stdio: "ignore" });
|
|
101
205
|
return;
|
|
102
206
|
}
|
|
103
207
|
if (process.platform === "linux") {
|
|
104
208
|
// secret-tool reads the secret from stdin, never a CLI argument, so it
|
|
105
209
|
// never appears in a process listing or shell history.
|
|
106
|
-
execFileSync("secret-tool", [
|
|
210
|
+
cp.execFileSync("secret-tool", [
|
|
107
211
|
"store", "--label", "residoo sealed vault key", "service", service, "account", account,
|
|
108
212
|
], { input: secret, stdio: ["pipe", "ignore", "ignore"] });
|
|
109
213
|
return;
|
|
@@ -120,10 +224,10 @@ function retrieve(account, keychainFile, service = SERVICE) {
|
|
|
120
224
|
const kf = keychainFile || testKeychainFile();
|
|
121
225
|
const args = ["find-generic-password", "-a", account, "-s", service, "-w"];
|
|
122
226
|
if (kf) args.push(kf);
|
|
123
|
-
return execFileSync("security", args, { encoding: "utf8" }).trim();
|
|
227
|
+
return cp.execFileSync("security", args, { encoding: "utf8" }).trim();
|
|
124
228
|
}
|
|
125
229
|
if (process.platform === "linux") {
|
|
126
|
-
return execFileSync("secret-tool", [
|
|
230
|
+
return cp.execFileSync("secret-tool", [
|
|
127
231
|
"lookup", "service", service, "account", account,
|
|
128
232
|
], { encoding: "utf8" }).trim();
|
|
129
233
|
}
|
|
@@ -142,14 +246,17 @@ function remove(account, keychainFile, service = SERVICE) {
|
|
|
142
246
|
const kf = keychainFile || testKeychainFile();
|
|
143
247
|
const args = ["delete-generic-password", "-a", account, "-s", service];
|
|
144
248
|
if (kf) args.push(kf);
|
|
145
|
-
execFileSync("security", args, { stdio: "ignore" });
|
|
249
|
+
cp.execFileSync("security", args, { stdio: "ignore" });
|
|
146
250
|
return;
|
|
147
251
|
}
|
|
148
252
|
if (process.platform === "linux") {
|
|
149
|
-
execFileSync("secret-tool", ["clear", "service", service, "account", account], { stdio: "ignore" });
|
|
253
|
+
cp.execFileSync("secret-tool", ["clear", "service", service, "account", account], { stdio: "ignore" });
|
|
150
254
|
return;
|
|
151
255
|
}
|
|
152
256
|
throw new Error(unsupportedReason());
|
|
153
257
|
}
|
|
154
258
|
|
|
155
|
-
module.exports = {
|
|
259
|
+
module.exports = {
|
|
260
|
+
isSupported, unsupportedReason, store, retrieve, remove, CRED_SERVICE,
|
|
261
|
+
isVaultKeySupported, wrapVaultKeyWindows, unwrapVaultKeyWindows,
|
|
262
|
+
};
|