u-chan-secrets 3.0.0 → 3.0.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A local MCP server that signs into approved websites with Bitwarden credentials. A privileged service performs credential entry in an isolated browser and transfers filtered session state into a dedicated user Chrome profile.
4
4
 
5
- V3 adds diagnostics, temporary approvals, explicit login outcomes, and a native management window. The source version is `3.0.0`; merging this repository does not publish an npm package or update an installed service.
5
+ V3 adds diagnostics, temporary approvals, explicit login outcomes, and a native management window. The source version is `3.0.2`; merging this repository does not publish an npm package or update an installed service.
6
6
 
7
7
  **Verification:** Windows/macOS build and test CI is separate from installed-service security verification. Native process identity, input isolation, permissions, and installation rehearsals remain [release gates](docs/attack-checklist.md). See [validation evidence](docs/superpowers/research/2026-10-29-v3-validation.md) and [SECURITY.md](SECURITY.md).
8
8
 
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { IpcClient } from './ipc/client.js';
8
8
  import { defaultEndpoint } from './ipc/endpoint.js';
9
9
  import { MAX_FRAME, encode } from './ipc/protocol.js';
10
- import { runDoctor, doctorExitCode, checkPlaywrightNow, chromeState } from './core/doctor.js';
10
+ import { runDoctor, doctorExitCode, checkPlaywrightNow, chromeState, SERVICE_DIAGNOSTIC_TIMEOUT_MS } from './core/doctor.js';
11
11
  import { loadConfig } from './core/config.js';
12
12
  const USAGE = 'usage: u-chan install [--mode <easy|hardcore>] [--phrase <phrase>] | uninstall | unlock | lock | mode <easy|hardcore> | import-v1 [path] | audit [limit] | update | doctor [--json] | manage';
13
13
  /** import-v1 shows one approval window per entry, each open for up to 2 minutes, one after another. */
@@ -27,7 +27,7 @@ export async function main(argv, d) {
27
27
  platform: d.platform,
28
28
  registration: async () => d.platform === 'win32' ? (await import('./platform/windows/doctor.js')).checkWindowsRegistration() : d.platform === 'darwin' ? (await import('./platform/darwin/doctor.js')).checkDarwinRegistration() : 'fail',
29
29
  service: async () => {
30
- const r = await d.connect({ timeoutMs: 5000 }).diagnose();
30
+ const r = await d.connect({ timeoutMs: SERVICE_DIAGNOSTIC_TIMEOUT_MS }).diagnose();
31
31
  if (!r.ok)
32
32
  throw new Error('unavailable');
33
33
  const s = JSON.parse(r.message);
@@ -39,6 +39,8 @@ export async function checkPlaywrightNow(selfPid = process.pid) {
39
39
  return 'unknown';
40
40
  }
41
41
  }
42
+ // Leave room for the service's full native ACL scan and IPC overhead.
43
+ export const SERVICE_DIAGNOSTIC_TIMEOUT_MS = 25000;
42
44
  export async function bounded(probe, fallback, timeoutMs = 5000) {
43
45
  let timer;
44
46
  try {
@@ -54,7 +56,7 @@ export async function bounded(probe, fallback, timeoutMs = 5000) {
54
56
  export async function runDoctor(d) {
55
57
  const [registration, service, chrome, playwright] = await Promise.all([
56
58
  bounded(d.registration, 'unknown'),
57
- bounded(d.service, null),
59
+ bounded(d.service, null, SERVICE_DIAGNOSTIC_TIMEOUT_MS),
58
60
  bounded(d.chrome, 'unknown'),
59
61
  bounded(d.playwright, 'unknown'),
60
62
  ]);
@@ -82,9 +84,9 @@ export function doctorExitCode(report) {
82
84
  const required = report.checks.filter(c => c.required);
83
85
  return required.some(c => c.state === 'fail') ? 1 : required.some(c => c.state === 'unknown') ? 2 : 0;
84
86
  }
85
- export async function commandState(file, args, passes) {
87
+ export async function commandState(file, args, passes, timeoutMs = 5000) {
86
88
  return new Promise(resolve => {
87
- execFile(file, args, { timeout: 5000, windowsHide: true, maxBuffer: 65536 }, (err, stdout) => {
89
+ execFile(file, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 65536 }, (err, stdout) => {
88
90
  resolve(err ? 'unknown' : passes(stdout) ? 'pass' : 'fail');
89
91
  });
90
92
  });
@@ -165,7 +165,7 @@ namespace UChan.Platform.Windows.Assets
165
165
  {
166
166
  writer.AutoFlush = true;
167
167
  Console.SetOut(writer);
168
- return RunProtectedDialog(args);
168
+ return RunDialogOnCleanThread(args);
169
169
  }
170
170
  }
171
171
  catch { return 1; }
@@ -174,6 +174,21 @@ namespace UChan.Platform.Windows.Assets
174
174
  return 1;
175
175
  }
176
176
 
177
+ private static int RunDialogOnCleanThread(string[] args)
178
+ {
179
+ int result = 1;
180
+ // The relay thread can own CLR/COM windows. Desktop assignment requires a clean thread.
181
+ System.Threading.Thread thread = new System.Threading.Thread(delegate()
182
+ {
183
+ try { result = RunProtectedDialog(args); }
184
+ catch { result = 1; }
185
+ });
186
+ thread.SetApartmentState(System.Threading.ApartmentState.STA);
187
+ thread.Start();
188
+ thread.Join();
189
+ return result;
190
+ }
191
+
177
192
  private static int RunProtectedDialog(string[] args)
178
193
  {
179
194
  IntPtr original = OpenInputDesktop(0, false, 0x0100);
@@ -2,6 +2,6 @@ import { commandState } from '../../core/doctor.js';
2
2
  export const checkWindowsRegistration = () => commandState('sc.exe', ['query', 'u-chan-secrets'], s => /SERVICE_NAME:\s*u-chan-secrets/i.test(s));
3
3
  export function checkWindowsPermissions(home) {
4
4
  const encoded = Buffer.from(home, 'utf8').toString('base64');
5
- const script = `$ErrorActionPreference='Stop'; $p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $root=Get-Item -LiteralPath $p -Force; if($root.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $items=@($root)+@(Get-ChildItem -LiteralPath $p -Force -Recurse); foreach($i in $items){if($i.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $acl=$i.GetAccessControl(); $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value; if($owner -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}; foreach($ace in $acl.Access){if($ace.AccessControlType -eq 'Allow' -and $ace.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}} }; 'SAFE'`;
6
- return commandState('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], s => s.trim() === 'SAFE');
5
+ const script = `$ErrorActionPreference='Stop'; $p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $root=Get-Item -LiteralPath $p -Force; if($root.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $items=@($root)+@(Get-ChildItem -LiteralPath $p -Force -Recurse); foreach($i in $items){if($i.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $acl=$i.GetAccessControl(); $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value; if($owner -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}; foreach($ace in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){if($ace.AccessControlType -eq 'Allow' -and $ace.IdentityReference.Value -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}} }; 'SAFE'`;
6
+ return commandState('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], s => s.trim() === 'SAFE', 15000);
7
7
  }
@@ -18,7 +18,7 @@ export async function diagnoseService(home, vault, platform) {
18
18
  }
19
19
  return 'pass';
20
20
  }, 'unknown'),
21
- bounded(() => platform === 'win32' ? checkWindowsPermissions(home) : checkDarwinPermissions(home), 'unknown'),
21
+ bounded(() => platform === 'win32' ? checkWindowsPermissions(home) : checkDarwinPermissions(home), 'unknown', 20000),
22
22
  bounded(() => vault.status(), 'unavailable'),
23
23
  ]);
24
24
  return { assets, permissions, vault: status === 'unlocked' ? 'pass' : status === 'unavailable' ? 'unknown' : status };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-chan-secrets",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "U-Chan Secrets: local MCP server for Bitwarden-backed logins",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,50 @@
1
+ // Run from a trusted administrator terminal after building and installing the service.
2
+ // Exercises SYSTEM relay, desktop entry, restoration and reply transport without a password UI.
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { promisify } from 'node:util'
7
+ import { execFile } from 'node:child_process'
8
+ import { compileCsharp } from '../dist/platform/windows/assets/compiler.js'
9
+ import { PROMPT_HELPER_CSHARP } from '../dist/platform/windows/assets/prompt-src.js'
10
+ import { defaultAdminCheck } from '../dist/platform/windows/install.js'
11
+ import { setAdminOnlyAcl } from '../dist/platform/windows/acl.js'
12
+
13
+ if (process.platform !== 'win32' || !await defaultAdminCheck()) throw new Error('Use a trusted Windows administrator terminal')
14
+ const home = path.join(process.env.ProgramData ?? 'C:\\ProgramData', 'u-chan-secrets')
15
+ const root = path.join(home, '#0 backup', 'desktop-probe-' + randomUUID())
16
+ const task = 'UChanDesktopProbe-' + randomUUID()
17
+ const exec = promisify(execFile)
18
+ const powershell = script => exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true })
19
+ const quote = value => "'" + value.replaceAll("'", "''") + "'"
20
+ let registered = false
21
+ try {
22
+ await fs.mkdir(root)
23
+ await setAdminOnlyAcl(root)
24
+ const trace = path.join(root, 'desktop-trace.txt')
25
+ if (!PROMPT_HELPER_CSHARP.includes('return RunDialog(args);') || !PROMPT_HELPER_CSHARP.includes('SwitchDesktop(original);')) throw new Error('Native probe hooks no longer match; no helper was run')
26
+ const source = PROMPT_HELPER_CSHARP
27
+ .replace('return RunDialog(args);', `File.AppendAllText(@"${trace}", "entered"+Environment.NewLine); return 0;`)
28
+ .replace('SwitchDesktop(original);', `if (SwitchDesktop(original)) File.AppendAllText(@"${trace}", "restored"+Environment.NewLine);`)
29
+ const helper = path.join(root, 'probe.exe')
30
+ await compileCsharp(source, helper, { outputType: 'WindowsApplication', referencedAssemblies: ['System.Windows.Forms.dll', 'System.Drawing.dll'] })
31
+ const output = path.join(root, 'result.json'), runner = path.join(root, 'runner.mjs')
32
+ await fs.writeFile(runner, `import{execFile}from'node:child_process';import fs from'node:fs';execFile(${JSON.stringify(helper)},['--mode','confirm','--phrase','DISPOSABLE-NATIVE-PROBE','--lines','[]'],{timeout:35000,windowsHide:true},(e,out)=>fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({exitCode:e?.code??0,stdoutBytes:Buffer.byteLength(out)})));`)
33
+ await setAdminOnlyAcl(root)
34
+ await powershell(`$ErrorActionPreference='Stop';$a=New-ScheduledTaskAction -Execute ${quote(path.join(home, 'bin', 'node.exe'))} -Argument ${quote('"' + runner + '"')};$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest;$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries;Register-ScheduledTask -TaskName ${quote(task)} -Action $a -Principal $p -Settings $s | Out-Null`)
35
+ registered = true
36
+ await powershell(`Start-ScheduledTask -TaskName ${quote(task)}`)
37
+ const deadline = Date.now() + 60000
38
+ let execution
39
+ while (Date.now() < deadline) {
40
+ try { execution = JSON.parse(await fs.readFile(output, 'utf8')); break }
41
+ catch (e) { if (e.code !== 'ENOENT') throw e }
42
+ await new Promise(resolve => setTimeout(resolve, 500))
43
+ }
44
+ const events = await fs.readFile(trace, 'utf8').catch(() => '')
45
+ const ok = execution?.exitCode === 0 && execution.stdoutBytes === 0 && events.trim() === 'entered\r\nrestored'
46
+ console.log(JSON.stringify({ ok, nativeDryRun: true, desktopEntered: events.includes('entered'), desktopRestored: events.includes('restored'), execution, evidence: root }))
47
+ if (!ok) process.exitCode = 1
48
+ } finally {
49
+ if (registered) await powershell(`Stop-ScheduledTask -TaskName ${quote(task)} -ErrorAction SilentlyContinue;Unregister-ScheduledTask -TaskName ${quote(task)} -Confirm:$false`)
50
+ }