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 +1 -1
- package/dist/cli.js +2 -2
- package/dist/core/doctor.js +5 -3
- package/dist/platform/windows/assets/prompt-src.js +16 -1
- package/dist/platform/windows/doctor.js +2 -2
- package/dist/service/doctor.js +1 -1
- package/package.json +1 -1
- package/scripts/verify-windows-prompt-desktop.mjs +50 -0
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.
|
|
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:
|
|
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);
|
package/dist/core/doctor.js
CHANGED
|
@@ -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:
|
|
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
|
|
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.
|
|
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
|
}
|
package/dist/service/doctor.js
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|