residoo 0.18.0 → 0.20.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 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.18.0",
3
+ "version": "0.20.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
@@ -186,8 +186,8 @@ Watch:
186
186
  same meaning as scan
187
187
  --no-notify skip the OS desktop notification watch fires for
188
188
  each genuinely new finding (macOS via osascript,
189
- Linux via notify-send if installed; no built-in
190
- mechanism on Windows -- disclosed, not attempted).
189
+ Linux via notify-send if installed, Windows via
190
+ System.Windows.Forms.NotifyIcon's balloon-tip API).
191
191
  On by default in human-readable mode: watch's own
192
192
  purpose is alerting you, and a background process
193
193
  nobody is watching a terminal for needs more than
@@ -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), one or more env-var names
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. TRADEOFF: a
342
- keychain-backed vault lives on THIS machine/account
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
- /** The unsealing secret for `unseal`: a keychain-retrieved key, or a typed passphrase. */
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
- if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
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
- module.exports = { main };
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
- // ---- 5. credential-vault file permissions (HOME only, POSIX only) ------
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 is skipped entirely, not
704
- // approximated: Node's fs.Stats.mode on Windows does not reflect NTFS
705
- // ACLs, so a POSIX-style bit check there would be meaningless rather than
706
- // merely imprecise. Project mode is skipped too -- none of these are ever
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 && process.platform !== "win32") {
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
- const { execFileSync } = require("child_process");
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 = { isSupported, unsupportedReason, store, retrieve, remove, CRED_SERVICE };
259
+ module.exports = {
260
+ isSupported, unsupportedReason, store, retrieve, remove, CRED_SERVICE,
261
+ isVaultKeySupported, wrapVaultKeyWindows, unwrapVaultKeyWindows,
262
+ };
package/src/notify.js CHANGED
@@ -8,13 +8,8 @@ const cp = require("child_process");
8
8
  * precedent `keychain.js`'s `security` and `ocr.js`'s `tesseract` already
9
9
  * set), Linux via `notify-send` (commonly present on a desktop session,
10
10
  * NOT guaranteed -- `watch` also runs on headless/server machines with no
11
- * notification daemon at all).
12
- *
13
- * Windows: no built-in, dependency-free mechanism was found that doesn't
14
- * either need an external module (BurntToast) or pop a blocking, modal
15
- * MessageBox in front of a background process -- a disclosed scope limit,
16
- * not silently assumed covered, the same posture `keychain.js` already
17
- * takes for its own Windows refusal.
11
+ * notification daemon at all), Windows via `System.Windows.Forms.NotifyIcon`'s
12
+ * balloon-tip API (see the Windows-specific docstring below).
18
13
  *
19
14
  * Decoration, never the report itself: `watch`'s own `emit()` already
20
15
  * writes every finding to stdout/stderr before this is ever called, so a
@@ -43,11 +38,63 @@ function notifyDesktop(title, message) {
43
38
  const child = cp.spawn("notify-send", [String(title), String(message)], { stdio: "ignore" });
44
39
  child.on("error", () => {});
45
40
  child.unref();
41
+ } else if (process.platform === "win32") {
42
+ notifyWindows(title, message);
46
43
  }
47
- // Windows and anything else: no-op, disclosed above, not attempted.
44
+ // Anything else: no-op, not attempted.
48
45
  } catch {
49
46
  // Never let a notification failure affect the caller.
50
47
  }
51
48
  }
52
49
 
50
+ /**
51
+ * Windows desktop notification via `System.Windows.Forms.NotifyIcon`'s
52
+ * balloon-tip API, shelled out to `powershell.exe` -- an earlier version
53
+ * of this module considered WinRT toast interop
54
+ * (`[Windows.UI.Notifications.ToastNotificationManager]`) instead and
55
+ * declined it after research found a real, disqualifying prerequisite:
56
+ * Microsoft's own docs make a Start-menu shortcut carrying a registered
57
+ * AppUserModelID a hard requirement for ANY desktop app's toast to
58
+ * display at all, explicitly including unpackaged/scripted apps. NotifyIcon
59
+ * has no such requirement -- confirmed against Microsoft's own current API
60
+ * reference (no [Obsolete] marker, listed through the windowsdesktop-10.0/
61
+ * 11.0 monikers) and multiple independently-converging technique
62
+ * write-ups, one of which states plainly it "requires no Start-menu
63
+ * shortcuts, AUMID registration, or external PowerShell modules." Not
64
+ * live-tested against a real Windows install, the same disclosed
65
+ * limitation `keychain.js`'s DPAPI functions and `integrity.js`'s Get-Acl
66
+ * check already carry.
67
+ *
68
+ * Two real caveats, disclosed rather than smoothed over: Windows ignores
69
+ * the millisecond value passed to `ShowBalloonTip` (actual on-screen
70
+ * duration is governed by the user's own accessibility settings, not this
71
+ * script), and the tray icon does NOT self-remove -- every reference
72
+ * implementation found demonstrates disposal via an interactive
73
+ * double-click handler, not an automatic one, which does not exist for a
74
+ * non-interactive script. This is why the script below explicitly
75
+ * `Start-Sleep`s before calling `.Dispose()` itself, inside the SAME
76
+ * spawned process: `notifyDesktop` never blocks its caller (the sleep
77
+ * happens in a detached, `unref()`'d child, exactly like the macOS/Linux
78
+ * branches above), but something has to keep the icon alive long enough
79
+ * to actually be seen before removing it, and nothing outside that one
80
+ * process is positioned to send a follow-up "now dispose" signal.
81
+ */
82
+ function notifyWindows(title, message) {
83
+ const esc = (s) => String(s).replace(/'/g, "''");
84
+ const script =
85
+ "Add-Type -AssemblyName System.Windows.Forms; " +
86
+ "Add-Type -AssemblyName System.Drawing; " +
87
+ "$ni = New-Object System.Windows.Forms.NotifyIcon; " +
88
+ "$ni.Icon = [System.Drawing.SystemIcons]::Information; " +
89
+ `$ni.BalloonTipTitle = '${esc(title)}'; ` +
90
+ `$ni.BalloonTipText = '${esc(message)}'; ` +
91
+ "$ni.Visible = $true; " +
92
+ "$ni.ShowBalloonTip(10000); " +
93
+ "Start-Sleep -Seconds 10; " +
94
+ "$ni.Dispose()";
95
+ const child = cp.spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script], { stdio: "ignore" });
96
+ child.on("error", () => {});
97
+ child.unref();
98
+ }
99
+
53
100
  module.exports = { notifyDesktop };