u-chan-secrets 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +218 -3
- package/dist/cli.js +195 -0
- package/dist/core/abort.js +30 -0
- package/dist/core/allowlist.js +139 -0
- package/dist/core/audit.js +41 -0
- package/dist/core/browser.js +131 -0
- package/dist/core/config.js +33 -0
- package/dist/core/doctor.js +106 -0
- package/dist/core/login-filler.js +170 -0
- package/dist/core/policy.js +36 -0
- package/dist/core/redact.js +7 -0
- package/dist/core/service.js +167 -0
- package/dist/core/totp.js +72 -0
- package/dist/core/types.js +10 -0
- package/dist/core/vault.js +157 -0
- package/dist/index.js +16 -0
- package/dist/ipc/client.js +79 -0
- package/dist/ipc/endpoint.js +7 -0
- package/dist/ipc/protocol.js +30 -0
- package/dist/ipc/server.js +108 -0
- package/dist/mcp/server.js +72 -0
- package/dist/platform/darwin/authorize.js +37 -0
- package/dist/platform/darwin/doctor.js +13 -0
- package/dist/platform/darwin/lifecycle.js +182 -0
- package/dist/platform/darwin/manage-script.js +33 -0
- package/dist/platform/darwin/peercred.js +69 -0
- package/dist/platform/darwin/plist.js +28 -0
- package/dist/platform/darwin/prompt-script.js +38 -0
- package/dist/platform/darwin/prompt.js +65 -0
- package/dist/platform/index.js +30 -0
- package/dist/platform/runtime-assets.js +37 -0
- package/dist/platform/windows/acl.js +45 -0
- package/dist/platform/windows/assets/auth-src.js +191 -0
- package/dist/platform/windows/assets/compiler.js +45 -0
- package/dist/platform/windows/assets/prompt-src.js +558 -0
- package/dist/platform/windows/assets/service-src.js +148 -0
- package/dist/platform/windows/authorize.js +45 -0
- package/dist/platform/windows/doctor.js +7 -0
- package/dist/platform/windows/index.js +26 -0
- package/dist/platform/windows/install.js +217 -0
- package/dist/platform/windows/migration.js +49 -0
- package/dist/platform/windows/prompt.js +110 -0
- package/dist/platform/windows/service-manager.js +75 -0
- package/dist/platform/windows/uac.js +61 -0
- package/dist/service/attempts.js +29 -0
- package/dist/service/control.js +145 -0
- package/dist/service/doctor.js +25 -0
- package/dist/service/grants.js +89 -0
- package/dist/service/isolated-login.js +108 -0
- package/dist/service/main.js +125 -0
- package/dist/service/manage.js +81 -0
- package/dist/service/policy-approver.js +45 -0
- package/dist/service/session-transfer.js +16 -0
- package/dist/service/settings.js +29 -0
- package/dist/service/state.js +88 -0
- package/dist/service/update/updater.js +137 -0
- package/dist/service/update/verifier.js +191 -0
- package/package.json +33 -6
- package/scripts/debug-install.bat +69 -0
- package/scripts/login-bw.bat +21 -0
- package/scripts/migrate.bat +4 -0
- package/scripts/start-chrome.ps1 +33 -0
- package/scripts/start-chrome.sh +32 -0
- package/scripts/verify-windows-prompt-desktop.mjs +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chu Đức An
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,218 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# U-Chan Secrets V3
|
|
2
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
9
|
+
## What V3 changes
|
|
10
|
+
|
|
11
|
+
- **Diagnose:** `u-chan doctor` explains service, vault, permission, Chrome, and browser-sharing problems without fetching credentials or repairing configuration.
|
|
12
|
+
- **Limit approval:** approve one login attempt, a fixed duration, or permanent future logins.
|
|
13
|
+
- **Confirm outcomes:** an approved URL or selector provides authentication evidence; generic form completion remains explicitly unconfirmed.
|
|
14
|
+
- **Manage access:** `u-chan manage` shows grant pages and recent sanitized activity, with lock, unlock, and revoke actions.
|
|
15
|
+
- **Preserve state:** privileged migration backup, schema/downgrade guards, and state-preserving reinstall replace manual editing of protected files.
|
|
16
|
+
|
|
17
|
+
Windows Hello and Touch ID remain [research only](docs/superpowers/research/2026-10-29-native-approval.md). Linux, general SSO/OAuth, passkeys, and automated CAPTCHA solving are outside V3.
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
```mermaid
|
|
22
|
+
flowchart TD
|
|
23
|
+
AI["Claude Code / other MCP client"] --> MCP["Thin MCP client: user account"]
|
|
24
|
+
MCP --> IPC["Local IPC with caller identity checks"]
|
|
25
|
+
IPC --> SERVICE["Privileged service: SYSTEM / root"]
|
|
26
|
+
SERVICE --> VAULT["Bitwarden CLI and private vault data"]
|
|
27
|
+
SERVICE --> PROMPT["Service-owned native prompts"]
|
|
28
|
+
SERVICE --> BROWSER["Isolated service-owned Chromium"]
|
|
29
|
+
BROWSER -->|"Filtered cookies and localStorage"| CHROME["Dedicated user Chrome"]
|
|
30
|
+
AI --> PW["Playwright MCP"]
|
|
31
|
+
PW -->|"Shared CDP endpoint"| CHROME
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The service owns the allowlist, settings, audit log, runtime, dependencies, Bitwarden executable, and isolated Chromium bundle. The thin client holds no vault credentials. Windows prompts require SYSTEM identity and a separate input desktop; replies cross sessions through a SYSTEM-only named pipe. macOS uses secure password entry and permits hardcore mode only.
|
|
35
|
+
|
|
36
|
+
A Bitwarden session is held in service memory and passed to privileged Bitwarden subprocesses when needed. It is not stored in desktop configuration or a session file. Vault access expires after the configured idle interval (12 hours by default), manual lock, restart, or reboot. This does not claim cryptographic zeroization of JavaScript strings or OS memory.
|
|
37
|
+
|
|
38
|
+
**After session transfer, the AI can use the signed-in website.** Grant expiry and revocation stop future U-Chan logins; they do not invalidate existing Chrome sessions or prevent actions inside those sessions. Revocation waits for an earlier queued login to finish.
|
|
39
|
+
|
|
40
|
+
## Prerequisites
|
|
41
|
+
|
|
42
|
+
- Windows or macOS.
|
|
43
|
+
- Node.js 24+.
|
|
44
|
+
- A native Bitwarden CLI executable: `bw.exe` on Windows or `bw` on macOS. The Windows npm `.cmd` shim is unsupported.
|
|
45
|
+
- Google Chrome for the user-side dedicated profile.
|
|
46
|
+
- The matching Playwright Chromium installation for service staging.
|
|
47
|
+
|
|
48
|
+
Install from a trusted shell before granting an AI unrestricted user-account access. On Windows, use UAC **Always notify** or run the AI from a standard user account. Existing administrator/root compromise is outside the threat model.
|
|
49
|
+
|
|
50
|
+
## Build and install from source
|
|
51
|
+
|
|
52
|
+
Use the checkout for the V3 source version. Unversioned npm installation can select a different published version; the package metadata in this repository is not proof of npm publication.
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
git clone https://github.com/anchuwithdeadline/u-chan-secrets.git
|
|
56
|
+
cd u-chan-secrets
|
|
57
|
+
npm ci
|
|
58
|
+
npm run build
|
|
59
|
+
npx playwright install chromium
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The following setup commands use `node dist/cli.js` from that checkout. After a matching package is globally installed, `u-chan` invokes the same CLI. The globally installed MCP command is `u-chan-secrets`.
|
|
63
|
+
|
|
64
|
+
### Windows
|
|
65
|
+
|
|
66
|
+
From a trusted elevated PowerShell terminal in the checkout:
|
|
67
|
+
|
|
68
|
+
```powershell
|
|
69
|
+
node dist/cli.js install --mode hardcore
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Windows also supports `--mode easy`; omitting the mode uses the Windows installer default, easy. The installer generates a phrase unless one is supplied. Reinstall preserves existing owner/settings and grants rather than applying new mode/phrase arguments over them.
|
|
73
|
+
|
|
74
|
+
For the one-time login to the service's private Bitwarden store, use the copied native executable in that same elevated terminal:
|
|
75
|
+
|
|
76
|
+
```powershell
|
|
77
|
+
$env:BITWARDENCLI_APPDATA_DIR = Join-Path $env:ProgramData 'u-chan-secrets\bw-data'
|
|
78
|
+
Remove-Item Env:\BW_SESSION -ErrorAction SilentlyContinue
|
|
79
|
+
& (Join-Path $env:ProgramData 'u-chan-secrets\bin\bw.exe') login
|
|
80
|
+
Remove-Item Env:\BITWARDENCLI_APPDATA_DIR
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Do not use `scripts/debug-install.bat` as a V3 setup or upgrade path.** That legacy script invokes uninstall, which deletes service data, and exports protected settings into the checkout. Use the CLI installer above.
|
|
84
|
+
|
|
85
|
+
### macOS
|
|
86
|
+
|
|
87
|
+
From the trusted checkout:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sudo "$(command -v node)" dist/cli.js install
|
|
91
|
+
sudo env -u BW_SESSION \
|
|
92
|
+
BITWARDENCLI_APPDATA_DIR="/Library/Application Support/u-chan-secrets/bw-data" \
|
|
93
|
+
"/Library/Application Support/u-chan-secrets/bin/bw" login
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The daemon uses `/Library/Application Support/u-chan-secrets` and `/Library/LaunchDaemons/com.uchan.secrets.plist`. macOS always uses hardcore approval; easy mode is refused.
|
|
97
|
+
|
|
98
|
+
### Register MCP and shared Chrome
|
|
99
|
+
|
|
100
|
+
For a source checkout, register the absolute path to the thin client:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
claude mcp add u-chan-secrets -- node "/absolute/path/to/u-chan-secrets/dist/index.js"
|
|
104
|
+
claude mcp add playwright -- npx @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
If a Playwright MCP registration already exists, replace it with the shared-CDP configuration and restart the AI session. A globally installed matching package can instead register `claude mcp add u-chan-secrets -- u-chan-secrets`.
|
|
108
|
+
|
|
109
|
+
Start the dedicated Chrome profile from the checkout:
|
|
110
|
+
|
|
111
|
+
| Platform | Command |
|
|
112
|
+
|---|---|
|
|
113
|
+
| Windows | `powershell -File scripts\start-chrome.ps1` |
|
|
114
|
+
| macOS | `bash scripts/start-chrome.sh` |
|
|
115
|
+
|
|
116
|
+
Use a normal user terminal for daily operations:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
node dist/cli.js doctor
|
|
120
|
+
node dist/cli.js unlock
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Check the phrase in the genuine protected prompt before entering a password. Never send passwords, TOTP seeds/codes, or vault session keys to an AI conversation.
|
|
124
|
+
|
|
125
|
+
## Daily commands
|
|
126
|
+
|
|
127
|
+
The table uses the globally installed `u-chan` spelling; from source use `node dist/cli.js` with the same arguments.
|
|
128
|
+
|
|
129
|
+
| Command | Purpose |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `doctor [--json]` | Read-only diagnostic report and safe next actions |
|
|
132
|
+
| `manage` | Native status, grant pages, recent activity, lock/unlock, and revocation |
|
|
133
|
+
| `unlock` | Request the protected master-password prompt |
|
|
134
|
+
| `lock` | Forget the service's vault session and request vault lock |
|
|
135
|
+
| `mode <easy\|hardcore>` | Change approval mode; lowering protection requires a protected password check |
|
|
136
|
+
| `audit [limit]` | Read recent service audit records |
|
|
137
|
+
| `import-v1 [path]` | Approve each imported entry in a protected window; imported grants are permanent |
|
|
138
|
+
| `update` | Request a verified compatible package update; some changes require administrator reinstall |
|
|
139
|
+
| `install [--mode <easy\|hardcore>] [--phrase <phrase>]` | Privileged install/reinstall; existing settings are preserved |
|
|
140
|
+
| `uninstall` | Privileged removal of the service **and all its data** |
|
|
141
|
+
|
|
142
|
+
Doctor reports `pass`, `fail`, or `unknown`. Exit 0 means required checks passed; 1 means a required check failed; 2 means required checks remain unknown without a failure. A locked vault is a valid state with an unlock instruction. Unobservable Playwright sharing is advisory; a known mismatch fails doctor. Diagnosis never retrieves credentials, opens an unlock prompt, or changes configuration.
|
|
143
|
+
|
|
144
|
+
Manage shows 20 grants per page. It cannot create or extend a grant. Unlock closes the management dialog before opening the protected password prompt.
|
|
145
|
+
|
|
146
|
+
## MCP tools and approvals
|
|
147
|
+
|
|
148
|
+
The five AI-facing tools remain:
|
|
149
|
+
|
|
150
|
+
| Tool | Behavior |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `status` | Vault state, approval mode, and observable Playwright sharing |
|
|
153
|
+
| `request_allow(domain, login_url, item, selectors?, grant?, success?)` | Request protected approval of the complete entry and duration |
|
|
154
|
+
| `login(domain)` | Check/reserve the grant, sign in privately, and transfer permitted session state |
|
|
155
|
+
| `list_allowed` | Show authorized item/hosts and permanent, timed, expired, or exhausted grant state |
|
|
156
|
+
| `revoke(domain)` | Remove a grant without another approval; waits behind an earlier login |
|
|
157
|
+
|
|
158
|
+
Use the Bitwarden item ID or name supplied by the user; do not guess a vault item. Optional selectors cover username, password, submit, and OTP fields.
|
|
159
|
+
|
|
160
|
+
Example `request_allow` arguments:
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"domain": "example.com",
|
|
165
|
+
"login_url": "https://example.com/login",
|
|
166
|
+
"item": "the Bitwarden item ID supplied by the user",
|
|
167
|
+
"grant": { "kind": "timed", "minutes": 30 },
|
|
168
|
+
"success": { "url": "https://example.com/dashboard" }
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Grant | Meaning |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `{ "kind": "once" }` | One attempt after vault/Chrome preflight; failure after reservation still consumes it |
|
|
175
|
+
| `{ "kind": "timed", "minutes": 30 }` | Future logins until the exact approved UTC expiry; integer range 1–1,440 minutes |
|
|
176
|
+
| `{ "kind": "permanent" }` | Permanent future logins |
|
|
177
|
+
| Omitted | Permanent, preserving existing callers; the protected prompt states this |
|
|
178
|
+
|
|
179
|
+
The AI proposes policy; the human authorizes it through the protected prompt. V3 clients refuse new options against a service that lacks the corresponding capability.
|
|
180
|
+
|
|
181
|
+
Changing identity, hosts, selectors, duration, or success conditions requires a new approval. A timed request that expires while its window is open is refused. Detected backward-clock changes invalidate affected persisted timed grants; renewed approval is required. Clock changes while the service is stopped cannot be reconstructed.
|
|
182
|
+
|
|
183
|
+
Easy mode exists only on Windows. Hardcore approvals require the master password. Protected prompts time out after two minutes. Five incorrect password attempts lock password-checked operations for 15 minutes.
|
|
184
|
+
|
|
185
|
+
### Login results and 2FA
|
|
186
|
+
|
|
187
|
+
A success condition is one exact approved HTTPS URL or a visible-element selector, such as `{ "selector": "#signed-in-account" }`. Choose evidence specific to the authenticated page: `body` or another generic element is not meaningful proof. The service checks the allowed host around evidence evaluation and transfer.
|
|
188
|
+
|
|
189
|
+
Without approved evidence, a generic completed flow may transfer filtered state but returns `ok: false` with `authentication_unconfirmed`. Inspect Chrome before continuing. An unmet configured condition prevents transfer. Other fixed result codes distinguish grant expiry/exhaustion, vault/Chrome availability, human verification, timeout, and transfer failure.
|
|
190
|
+
|
|
191
|
+
The service supports Bitwarden TOTP for recognized one-time-code fields. Passwords, TOTP seeds, and generated codes stay inside the privileged login flow. CAPTCHA, device verification, and unsupported challenges require the user; the AI must not request the underlying credentials.
|
|
192
|
+
|
|
193
|
+
## Upgrade, backup, and rollback
|
|
194
|
+
|
|
195
|
+
Use a trusted administrator reinstall for the first V3 installation; the thin client alone does not upgrade native helpers or private executables. Windows reinstall stops the service, waits for shutdown, stages a fresh private program copy, preserves the old program under protected `#0 backup`, and reuses service registration. Existing settings and grants remain.
|
|
196
|
+
|
|
197
|
+
Before the first grant-state mutation, the service creates a protected `#0 backup/v2-before-v3` snapshot and a `schema.json` marker. The snapshot may contain privileged configuration and encrypted vault data: never copy it into a user-readable checkout. Incomplete backups block mutation rather than being overwritten.
|
|
198
|
+
|
|
199
|
+
Supported V3 tooling refuses V2 downgrade after migration. Historical V2 administrator tools cannot enforce a newer schema guard. A deliberate rollback requires stopping the service, inspecting/restoring the protected pre-V3 state, and acknowledging that old permanent approvals return. Do not uninstall as an upgrade shortcut: uninstall deletes the data.
|
|
200
|
+
|
|
201
|
+
Package updates verify registry signatures and integrity and retain private dependencies. Changed dependencies or native helpers require a trusted administrator reinstall. Expired/exhausted grants and already transferred website sessions are separate from vault lock and upgrade state.
|
|
202
|
+
|
|
203
|
+
For V1 data, use `u-chan import-v1 [path]`: each entry requires protected approval. Retire user-side Bitwarden sessions and the old `bw-session` file through a trusted migration process; never copy them into the service as trusted authorization.
|
|
204
|
+
|
|
205
|
+
## Validation and security
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
npm test
|
|
209
|
+
npm run build
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
[CI](https://github.com/anchuwithdeadline/u-chan-secrets/actions/workflows/ci.yml) runs builds/tests on Windows and macOS. CodeQL checks source separately. Passing these checks does not prove actual SYSTEM/root prompt isolation or certify an installed service.
|
|
213
|
+
|
|
214
|
+
Before release or reliance on the security boundary, perform the [manual attack checklist](docs/attack-checklist.md). See [validation and remaining native checks](docs/superpowers/research/2026-10-29-v3-validation.md), [security policy](SECURITY.md), and [V3 change notes](CHANGELOG.md).
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
[MIT](LICENSE)
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { posix, win32 } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { IpcClient } from './ipc/client.js';
|
|
8
|
+
import { defaultEndpoint } from './ipc/endpoint.js';
|
|
9
|
+
import { MAX_FRAME, encode } from './ipc/protocol.js';
|
|
10
|
+
import { runDoctor, doctorExitCode, checkPlaywrightNow, chromeState, SERVICE_DIAGNOSTIC_TIMEOUT_MS } from './core/doctor.js';
|
|
11
|
+
import { loadConfig } from './core/config.js';
|
|
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
|
+
/** import-v1 shows one approval window per entry, each open for up to 2 minutes, one after another. */
|
|
14
|
+
export const IMPORT_PROMPT_MS = 120000;
|
|
15
|
+
export const IMPORT_MARGIN_MS = 60000;
|
|
16
|
+
export const importTimeoutMs = (count) => count * IMPORT_PROMPT_MS + IMPORT_MARGIN_MS;
|
|
17
|
+
/** V1 kept its allowlist in %APPDATA%\u-chan-secrets (the user's profile). */
|
|
18
|
+
function defaultV1Path(env, platform) {
|
|
19
|
+
const path = platform === 'win32' ? win32 : posix;
|
|
20
|
+
return path.join(env.APPDATA ?? path.join(homedir(), 'AppData', 'Roaming'), 'u-chan-secrets', 'allowlist.json');
|
|
21
|
+
}
|
|
22
|
+
/** Returns the process exit code: 0 on success, 1 on failure or bad usage. */
|
|
23
|
+
export async function main(argv, d) {
|
|
24
|
+
const [cmd, arg, ...rest] = argv;
|
|
25
|
+
let call;
|
|
26
|
+
const doctor = d.doctor ?? (() => runDoctor({
|
|
27
|
+
platform: d.platform,
|
|
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
|
+
service: async () => {
|
|
30
|
+
const r = await d.connect({ timeoutMs: SERVICE_DIAGNOSTIC_TIMEOUT_MS }).diagnose();
|
|
31
|
+
if (!r.ok)
|
|
32
|
+
throw new Error('unavailable');
|
|
33
|
+
const s = JSON.parse(r.message);
|
|
34
|
+
if (!s || typeof s !== 'object')
|
|
35
|
+
throw new Error('invalid diagnostics');
|
|
36
|
+
return s;
|
|
37
|
+
},
|
|
38
|
+
chrome: () => chromeState(loadConfig(d.env, d.platform).cdpEndpoint),
|
|
39
|
+
playwright: async () => {
|
|
40
|
+
if (d.platform !== 'win32')
|
|
41
|
+
return 'unknown';
|
|
42
|
+
const state = await checkPlaywrightNow();
|
|
43
|
+
return state === 'ok' ? 'pass' : state === 'missing' ? 'fail' : 'unknown';
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
const printDoctor = (r) => d.out(r.checks.map(c => `${c.state.toUpperCase()} ${c.id}: ${c.message}${c.nextAction ? ' ' + c.nextAction : ''}`).join('\n'));
|
|
47
|
+
if (cmd === 'doctor' && (arg === undefined || arg === '--json') && rest.length === 0) {
|
|
48
|
+
const r = await doctor();
|
|
49
|
+
if (arg === '--json')
|
|
50
|
+
d.out(JSON.stringify(r));
|
|
51
|
+
else
|
|
52
|
+
printDoctor(r);
|
|
53
|
+
return doctorExitCode(r);
|
|
54
|
+
}
|
|
55
|
+
if (cmd === 'install') {
|
|
56
|
+
let mode;
|
|
57
|
+
let phrase;
|
|
58
|
+
let valid = true;
|
|
59
|
+
const args = arg !== undefined ? [arg, ...rest] : [];
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
if (args[i] === '--mode' && (args[i + 1] === 'easy' || args[i + 1] === 'hardcore')) {
|
|
62
|
+
mode = args[++i];
|
|
63
|
+
}
|
|
64
|
+
else if (args[i] === '--phrase' && args[i + 1] !== undefined) {
|
|
65
|
+
phrase = args[++i];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
valid = false;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!valid) {
|
|
73
|
+
d.err(USAGE);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
call = async () => {
|
|
77
|
+
if (d.install)
|
|
78
|
+
return d.install({ mode, phrase });
|
|
79
|
+
if (d.platform === 'win32') {
|
|
80
|
+
const { installWindowsService } = await import('./platform/windows/install.js');
|
|
81
|
+
return installWindowsService({ mode, phrase, out: d.out, err: d.err });
|
|
82
|
+
}
|
|
83
|
+
if (d.platform === 'darwin') {
|
|
84
|
+
const { macosInstall } = await import('./platform/darwin/lifecycle.js');
|
|
85
|
+
return macosInstall({ mode, phrase, out: d.out, err: d.err });
|
|
86
|
+
}
|
|
87
|
+
return { ok: false, message: `install is not supported on ${d.platform} yet` };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
else if (cmd === 'uninstall' && arg === undefined) {
|
|
91
|
+
call = async () => {
|
|
92
|
+
if (d.uninstall)
|
|
93
|
+
return d.uninstall();
|
|
94
|
+
if (d.platform === 'win32') {
|
|
95
|
+
const { uninstallWindowsService } = await import('./platform/windows/install.js');
|
|
96
|
+
return uninstallWindowsService({ out: d.out, err: d.err });
|
|
97
|
+
}
|
|
98
|
+
if (d.platform === 'darwin') {
|
|
99
|
+
const { macosUninstall } = await import('./platform/darwin/lifecycle.js');
|
|
100
|
+
return macosUninstall({ out: d.out, err: d.err });
|
|
101
|
+
}
|
|
102
|
+
return { ok: false, message: `uninstall is not supported on ${d.platform} yet` };
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
else if (cmd === 'unlock' && arg === undefined) {
|
|
106
|
+
call = () => d.connect({}).unlock();
|
|
107
|
+
}
|
|
108
|
+
else if (cmd === 'lock' && arg === undefined) {
|
|
109
|
+
call = () => d.connect({}).lock();
|
|
110
|
+
}
|
|
111
|
+
else if (cmd === 'mode' && (arg === 'easy' || arg === 'hardcore') && rest.length === 0) {
|
|
112
|
+
call = () => d.connect({}).setMode(arg);
|
|
113
|
+
}
|
|
114
|
+
else if (cmd === 'import-v1' && rest.length === 0) {
|
|
115
|
+
const path = arg ?? defaultV1Path(d.env, d.platform);
|
|
116
|
+
let text;
|
|
117
|
+
try {
|
|
118
|
+
text = await d.readFile(path);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
const code = e?.code;
|
|
122
|
+
d.err(code === 'ENOENT' ? `no V1 allowlist found at ${path}` : `could not read ${path} (${code ?? 'error'})`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
d.err(`${path} is not valid JSON`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
if (!Array.isArray(entries)) {
|
|
134
|
+
d.err(`${path} is not a V1 allowlist (expected a JSON array)`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
// The service drops frames over MAX_FRAME; a worst-case id keeps this estimate above the real frame.
|
|
138
|
+
const frame = encode({ id: Number.MAX_SAFE_INTEGER, method: 'import_v1', params: { entries } });
|
|
139
|
+
if (Buffer.byteLength(frame, 'utf8') > MAX_FRAME) {
|
|
140
|
+
d.err('V1 allowlist is too large to import in one go');
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
const list = entries;
|
|
144
|
+
call = () => d.connect({ timeoutMs: importTimeoutMs(list.length) }).importV1(list);
|
|
145
|
+
}
|
|
146
|
+
else if (cmd === 'audit' && rest.length === 0) {
|
|
147
|
+
let limit;
|
|
148
|
+
if (arg !== undefined) {
|
|
149
|
+
const parsed = Number(arg);
|
|
150
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
151
|
+
d.err('limit must be a positive integer');
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
limit = parsed;
|
|
155
|
+
}
|
|
156
|
+
call = () => d.connect({}).audit(limit);
|
|
157
|
+
}
|
|
158
|
+
else if (cmd === 'update' && arg === undefined) {
|
|
159
|
+
call = () => d.connect({ timeoutMs: 120000 }).update();
|
|
160
|
+
}
|
|
161
|
+
else if (cmd === 'manage' && arg === undefined) {
|
|
162
|
+
call = () => d.connect({ timeoutMs: 15 * 60_000 }).manage();
|
|
163
|
+
}
|
|
164
|
+
if (!call) {
|
|
165
|
+
d.err(USAGE);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
const r = await call();
|
|
169
|
+
if (r.ok)
|
|
170
|
+
d.out(r.message);
|
|
171
|
+
else
|
|
172
|
+
d.err(r.message);
|
|
173
|
+
if (r.ok && cmd === 'install' && !d.install)
|
|
174
|
+
printDoctor(await doctor());
|
|
175
|
+
return r.ok ? 0 : 1;
|
|
176
|
+
}
|
|
177
|
+
const isEntry = (() => {
|
|
178
|
+
try {
|
|
179
|
+
return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
if (isEntry) {
|
|
186
|
+
const code = await main(process.argv.slice(2), {
|
|
187
|
+
connect: (opts) => new IpcClient(defaultEndpoint(process.env, process.platform), opts),
|
|
188
|
+
env: process.env,
|
|
189
|
+
platform: process.platform,
|
|
190
|
+
readFile: (p) => readFile(p, 'utf8'),
|
|
191
|
+
out: (l) => console.log(l),
|
|
192
|
+
err: (l) => console.error(l),
|
|
193
|
+
});
|
|
194
|
+
process.exitCode = code;
|
|
195
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Cancels owned resources before rejecting; late initialization results are disposed. */
|
|
2
|
+
export async function abortable(work, signal, cancel = async () => { }, disposeLate) {
|
|
3
|
+
if (!signal)
|
|
4
|
+
return work;
|
|
5
|
+
let cleanup;
|
|
6
|
+
let reject;
|
|
7
|
+
const stopped = new Promise((_resolve, r) => { reject = r; });
|
|
8
|
+
const abort = () => {
|
|
9
|
+
cleanup ??= Promise.resolve().then(cancel);
|
|
10
|
+
void cleanup.then(() => reject(signal.reason), reject);
|
|
11
|
+
};
|
|
12
|
+
const running = work.then(async (value) => {
|
|
13
|
+
if (signal.aborted) {
|
|
14
|
+
await disposeLate?.(value);
|
|
15
|
+
throw signal.reason;
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
});
|
|
19
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
20
|
+
if (signal.aborted)
|
|
21
|
+
abort();
|
|
22
|
+
try {
|
|
23
|
+
return await Promise.race([running, stopped]);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
signal.removeEventListener('abort', abort);
|
|
27
|
+
if (signal.aborted)
|
|
28
|
+
await cleanup?.catch(() => { });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { UserFacingError } from './types.js';
|
|
4
|
+
import { validOptions } from './policy.js';
|
|
5
|
+
export class AllowlistStore {
|
|
6
|
+
path;
|
|
7
|
+
beforeWrite;
|
|
8
|
+
constructor(path, beforeWrite) {
|
|
9
|
+
this.path = path;
|
|
10
|
+
this.beforeWrite = beforeWrite;
|
|
11
|
+
}
|
|
12
|
+
async list() {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await readFile(this.path, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
if (e.code === 'ENOENT')
|
|
19
|
+
return [];
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
let data;
|
|
23
|
+
try {
|
|
24
|
+
data = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new UserFacingError('allowlist.json is invalid');
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(data))
|
|
30
|
+
throw new UserFacingError('allowlist.json is invalid');
|
|
31
|
+
return data.filter(isValidEntry);
|
|
32
|
+
}
|
|
33
|
+
async get(domain) {
|
|
34
|
+
const d = normalizeDomain(domain);
|
|
35
|
+
if (!d)
|
|
36
|
+
return undefined;
|
|
37
|
+
return (await this.list()).find((e) => e.domain === d);
|
|
38
|
+
}
|
|
39
|
+
async upsert(entry) {
|
|
40
|
+
if (!isValidEntry(entry))
|
|
41
|
+
throw new UserFacingError('invalid grant policy', 'invalid_policy');
|
|
42
|
+
const d = normalizeDomain(entry.domain);
|
|
43
|
+
if (!d)
|
|
44
|
+
throw new UserFacingError('domain is empty or invalid');
|
|
45
|
+
const rest = (await this.list()).filter((e) => e.domain !== d);
|
|
46
|
+
await this.write([...rest, { ...entry, domain: d }]);
|
|
47
|
+
}
|
|
48
|
+
async remove(domain) {
|
|
49
|
+
const d = normalizeDomain(domain);
|
|
50
|
+
if (!d)
|
|
51
|
+
return false;
|
|
52
|
+
const all = await this.list();
|
|
53
|
+
const rest = all.filter((e) => e.domain !== d);
|
|
54
|
+
if (rest.length === all.length)
|
|
55
|
+
return false;
|
|
56
|
+
await this.write(rest);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
async write(entries) {
|
|
60
|
+
await this.beforeWrite?.();
|
|
61
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
62
|
+
const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
|
|
63
|
+
await writeFile(tmp, JSON.stringify(entries, null, 2));
|
|
64
|
+
await rename(tmp, this.path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function isValidEntry(e) {
|
|
68
|
+
if (typeof e !== 'object' || e === null)
|
|
69
|
+
return false;
|
|
70
|
+
const o = e;
|
|
71
|
+
return (typeof o.domain === 'string' &&
|
|
72
|
+
typeof o.loginUrl === 'string' &&
|
|
73
|
+
typeof o.item === 'string' &&
|
|
74
|
+
Array.isArray(o.hosts) &&
|
|
75
|
+
o.hosts.every((h) => typeof h === 'string')
|
|
76
|
+
&& (o.expiresAt === undefined || (typeof o.expiresAt === 'string' && Number.isFinite(Date.parse(o.expiresAt)) && new Date(o.expiresAt).toISOString() === o.expiresAt))
|
|
77
|
+
&& (o.remainingUses === undefined || (Number.isSafeInteger(o.remainingUses) && o.remainingUses >= 0))
|
|
78
|
+
&& (o.grantId === undefined || (typeof o.grantId === 'string' && o.grantId.length > 0 && o.grantId.length <= 100))
|
|
79
|
+
&& (o.success === undefined || validOptions({ success: o.success })));
|
|
80
|
+
}
|
|
81
|
+
/** Strips control, format (e.g. bidi overrides), line- and paragraph-separator characters (CR, LF, tab included). */
|
|
82
|
+
export const clean = (s) => String(s ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '');
|
|
83
|
+
const SELECTOR_KEYS = ['username', 'password', 'submit', 'otp'];
|
|
84
|
+
/** Only the four known keys with non-empty string values, cleaned. */
|
|
85
|
+
export function cleanSelectors(raw) {
|
|
86
|
+
if (typeof raw !== 'object' || raw === null)
|
|
87
|
+
return undefined;
|
|
88
|
+
const o = raw;
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const k of SELECTOR_KEYS) {
|
|
91
|
+
const v = o[k];
|
|
92
|
+
if (typeof v === 'string' && clean(v))
|
|
93
|
+
out[k] = clean(v);
|
|
94
|
+
}
|
|
95
|
+
return Object.keys(out).length ? out : undefined;
|
|
96
|
+
}
|
|
97
|
+
export const selectorsLine = (s) => `Selectors: ${Object.entries(s).map(([k, v]) => `${k}=${v}`).join(', ')}`;
|
|
98
|
+
/** Exact, case-insensitive hostname match over https only. No wildcards. */
|
|
99
|
+
export function isHostAllowed(entry, url) {
|
|
100
|
+
let u;
|
|
101
|
+
try {
|
|
102
|
+
u = new URL(url);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (u.protocol !== 'https:')
|
|
108
|
+
return false;
|
|
109
|
+
if (!Array.isArray(entry.hosts) || !entry.hosts.every((h) => typeof h === 'string'))
|
|
110
|
+
return false;
|
|
111
|
+
return entry.hosts.includes(u.hostname.toLowerCase());
|
|
112
|
+
}
|
|
113
|
+
export function normalizeDomain(input) {
|
|
114
|
+
const s = input.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '').split(/[/?#]/)[0];
|
|
115
|
+
if (!s)
|
|
116
|
+
return '';
|
|
117
|
+
try {
|
|
118
|
+
return new URL(`https://${s}`).hostname;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function buildEntry(domain, loginUrl, item, existing) {
|
|
125
|
+
const d = normalizeDomain(domain);
|
|
126
|
+
if (!d)
|
|
127
|
+
throw new UserFacingError('domain is empty or invalid');
|
|
128
|
+
let u;
|
|
129
|
+
try {
|
|
130
|
+
u = new URL(loginUrl);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new UserFacingError('loginUrl is not a valid URL');
|
|
134
|
+
}
|
|
135
|
+
if (u.protocol !== 'https:')
|
|
136
|
+
throw new UserFacingError('loginUrl must use https');
|
|
137
|
+
const hosts = [...new Set([...(existing?.hosts ?? []), d, u.hostname.toLowerCase()])];
|
|
138
|
+
return { ...existing, domain: d, hosts, loginUrl, item };
|
|
139
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export class FileAuditLog {
|
|
4
|
+
path;
|
|
5
|
+
constructor(path) {
|
|
6
|
+
this.path = path;
|
|
7
|
+
}
|
|
8
|
+
async record(event) {
|
|
9
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
10
|
+
const { action, domain, result } = event;
|
|
11
|
+
await appendFile(this.path, JSON.stringify({ ts: new Date().toISOString(), action, domain, result }) + '\n');
|
|
12
|
+
}
|
|
13
|
+
async readTail(limit = 100) {
|
|
14
|
+
let content;
|
|
15
|
+
try {
|
|
16
|
+
content = await readFile(this.path, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code === 'ENOENT')
|
|
20
|
+
return [];
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
const lines = content.split('\n');
|
|
24
|
+
const records = [];
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(trimmed);
|
|
31
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.ts === 'string' && typeof parsed.action === 'string') {
|
|
32
|
+
records.push(parsed);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// skip corrupt line
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return records.slice(-limit);
|
|
40
|
+
}
|
|
41
|
+
}
|