u-chan-secrets 5.0.0 → 5.1.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 +233 -91
- package/dist/cli.js +115 -19
- package/dist/client/claude-setup.js +97 -0
- package/dist/client/codex-setup.js +6 -5
- package/dist/client/discord.js +67 -0
- package/dist/client/github.js +28 -0
- package/dist/client/setup.js +40 -6
- package/dist/client/update-package.js +166 -0
- package/dist/client/update.js +138 -0
- package/dist/client/windows-command.js +54 -0
- package/dist/core/browser.js +0 -5
- package/dist/core/config.js +1 -0
- package/dist/core/doctor.js +5 -1
- package/dist/core/vault.js +110 -5
- package/dist/discord/http.js +84 -0
- package/dist/discord/policy.js +47 -0
- package/dist/discord/service.js +349 -0
- package/dist/discord/store.js +85 -0
- package/dist/github/http.js +93 -0
- package/dist/github/policy.js +24 -0
- package/dist/github/service.js +185 -0
- package/dist/github/store.js +54 -0
- package/dist/index.js +1 -1
- package/dist/ipc/client.js +74 -6
- package/dist/ipc/server.js +38 -3
- package/dist/mcp/server.js +25 -2
- package/dist/platform/windows/assets/prompt-src.js +160 -38
- package/dist/platform/windows/doctor.js +2 -2
- package/dist/platform/windows/install.js +4 -0
- package/dist/service/control.js +47 -5
- package/dist/service/doctor.js +3 -0
- package/dist/service/main.js +34 -17
- package/dist/service/manage.js +20 -16
- package/dist/service/state.js +28 -0
- package/dist/service/update/coordinator.js +171 -0
- package/dist/service/update/files.js +47 -0
- package/dist/service/update/gate.js +21 -0
- package/dist/service/update/journal.js +157 -0
- package/dist/service/update/native.js +50 -0
- package/dist/service/update/network.js +28 -0
- package/dist/service/update/protocol.js +28 -0
- package/dist/service/update/release.js +102 -0
- package/dist/service/update/worker-main.js +116 -0
- package/dist/service/update/worker.js +58 -0
- package/package.json +1 -1
- package/scripts/debug-install.bat +69 -69
- package/scripts/desktop-browser.ps1 +110 -103
- package/scripts/login-bw.bat +21 -21
- package/scripts/migrate.bat +4 -4
- package/scripts/start-browser.ps1 +33 -33
- package/scripts/windows-security/desktop-matrix.mjs +54 -54
|
@@ -303,6 +303,7 @@ namespace UChan.Platform.Windows.Assets
|
|
|
303
303
|
if (args[i] == "--domains") domains = ParseLines(args[i + 1]);
|
|
304
304
|
return RunManagement(title, phrase, lines, domains);
|
|
305
305
|
}
|
|
306
|
+
if (mode == "password") return RunPasswordDialog(title, phrase, lines);
|
|
306
307
|
|
|
307
308
|
Form form = new Form();
|
|
308
309
|
form.Text = title;
|
|
@@ -311,7 +312,7 @@ namespace UChan.Platform.Windows.Assets
|
|
|
311
312
|
form.StartPosition = FormStartPosition.CenterScreen;
|
|
312
313
|
form.MaximizeBox = false;
|
|
313
314
|
form.MinimizeBox = false;
|
|
314
|
-
form.ClientSize = new Size(
|
|
315
|
+
form.ClientSize = new Size(760, 520);
|
|
315
316
|
|
|
316
317
|
// Anti-phishing phrase banner
|
|
317
318
|
Panel banner = new Panel();
|
|
@@ -335,35 +336,16 @@ namespace UChan.Platform.Windows.Assets
|
|
|
335
336
|
contentPanel.Padding = new Padding(16);
|
|
336
337
|
contentPanel.AutoScroll = true;
|
|
337
338
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
TextBox txtPassword = null;
|
|
351
|
-
if (mode == "password")
|
|
352
|
-
{
|
|
353
|
-
Label pwdLabel = new Label();
|
|
354
|
-
pwdLabel.Text = "Master Password:";
|
|
355
|
-
pwdLabel.Location = new Point(16, yOffset + 8);
|
|
356
|
-
pwdLabel.Size = new Size(428, 20);
|
|
357
|
-
contentPanel.Controls.Add(pwdLabel);
|
|
358
|
-
yOffset += 30;
|
|
359
|
-
|
|
360
|
-
txtPassword = new TextBox();
|
|
361
|
-
txtPassword.Location = new Point(16, yOffset);
|
|
362
|
-
txtPassword.Size = new Size(428, 24);
|
|
363
|
-
txtPassword.UseSystemPasswordChar = true;
|
|
364
|
-
txtPassword.MaxLength = 512;
|
|
365
|
-
contentPanel.Controls.Add(txtPassword);
|
|
366
|
-
}
|
|
339
|
+
TextBox details = new TextBox();
|
|
340
|
+
details.Multiline = true;
|
|
341
|
+
details.ReadOnly = true;
|
|
342
|
+
details.WordWrap = true;
|
|
343
|
+
details.ScrollBars = ScrollBars.Vertical;
|
|
344
|
+
details.MaxLength = 65536;
|
|
345
|
+
details.Location = new Point(16, 60);
|
|
346
|
+
details.Size = new Size(728, 330);
|
|
347
|
+
details.Text = string.Join(Environment.NewLine, lines.ToArray());
|
|
348
|
+
contentPanel.Controls.Add(details);
|
|
367
349
|
|
|
368
350
|
// Bottom panel for countdown & buttons
|
|
369
351
|
Panel bottomPanel = new Panel();
|
|
@@ -418,10 +400,6 @@ namespace UChan.Platform.Windows.Assets
|
|
|
418
400
|
form.Shown += (s, e) =>
|
|
419
401
|
{
|
|
420
402
|
timer.Start();
|
|
421
|
-
if (txtPassword != null)
|
|
422
|
-
{
|
|
423
|
-
txtPassword.Focus();
|
|
424
|
-
}
|
|
425
403
|
};
|
|
426
404
|
|
|
427
405
|
DialogResult result = form.ShowDialog();
|
|
@@ -429,16 +407,160 @@ namespace UChan.Platform.Windows.Assets
|
|
|
429
407
|
|
|
430
408
|
if (result == DialogResult.OK)
|
|
431
409
|
{
|
|
432
|
-
if (mode == "password" && txtPassword != null)
|
|
433
|
-
{
|
|
434
|
-
Console.Write(txtPassword.Text);
|
|
435
|
-
}
|
|
436
410
|
return 0;
|
|
437
411
|
}
|
|
438
412
|
|
|
439
413
|
return 1;
|
|
440
414
|
}
|
|
441
415
|
|
|
416
|
+
private static int RunPasswordDialog(string title, string phrase, List<string> lines)
|
|
417
|
+
{
|
|
418
|
+
using (Form form = new Form())
|
|
419
|
+
using (Timer timer = new Timer())
|
|
420
|
+
{
|
|
421
|
+
bool unlock = lines.Count == 1 && lines[0] == "Unlock the vault";
|
|
422
|
+
form.Text = title;
|
|
423
|
+
form.Font = new Font("Segoe UI", 10f);
|
|
424
|
+
form.AutoScaleDimensions = new SizeF(96f, 96f);
|
|
425
|
+
form.AutoScaleMode = AutoScaleMode.Dpi;
|
|
426
|
+
form.StartPosition = FormStartPosition.CenterScreen;
|
|
427
|
+
form.FormBorderStyle = FormBorderStyle.FixedDialog;
|
|
428
|
+
form.MaximizeBox = false;
|
|
429
|
+
form.MinimizeBox = false;
|
|
430
|
+
form.TopMost = true;
|
|
431
|
+
form.BackColor = SystemColors.Window;
|
|
432
|
+
form.ForeColor = SystemColors.WindowText;
|
|
433
|
+
form.ClientSize = new Size(520, 360);
|
|
434
|
+
form.Padding = new Padding(24);
|
|
435
|
+
|
|
436
|
+
TableLayoutPanel layout = new TableLayoutPanel();
|
|
437
|
+
layout.Dock = DockStyle.Top;
|
|
438
|
+
layout.AutoSize = true;
|
|
439
|
+
layout.ColumnCount = 1;
|
|
440
|
+
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
|
441
|
+
layout.RowCount = 7;
|
|
442
|
+
for (int row = 0; row < layout.RowCount; row++) layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
443
|
+
|
|
444
|
+
Label heading = new Label();
|
|
445
|
+
heading.Text = unlock ? "Unlock vault" : "Confirm action";
|
|
446
|
+
heading.Font = new Font("Segoe UI", 18f, FontStyle.Bold);
|
|
447
|
+
heading.AutoSize = true;
|
|
448
|
+
heading.Margin = new Padding(0, 0, 0, 16);
|
|
449
|
+
|
|
450
|
+
Label verification = new Label();
|
|
451
|
+
verification.Text = "Verification phrase: " + phrase;
|
|
452
|
+
verification.AutoSize = true;
|
|
453
|
+
verification.MaximumSize = new Size(472, 0);
|
|
454
|
+
verification.Dock = DockStyle.Fill;
|
|
455
|
+
verification.Padding = new Padding(12);
|
|
456
|
+
verification.Margin = new Padding(0, 0, 0, 16);
|
|
457
|
+
verification.BackColor = SystemInformation.HighContrast ? SystemColors.Info : Color.FromArgb(235, 243, 255);
|
|
458
|
+
verification.ForeColor = SystemInformation.HighContrast ? SystemColors.InfoText : Color.FromArgb(16, 44, 87);
|
|
459
|
+
|
|
460
|
+
TextBox details = new TextBox();
|
|
461
|
+
details.Multiline = true;
|
|
462
|
+
details.ReadOnly = true;
|
|
463
|
+
details.WordWrap = true;
|
|
464
|
+
details.ScrollBars = lines.Count > 1 ? ScrollBars.Vertical : ScrollBars.None;
|
|
465
|
+
details.BorderStyle = BorderStyle.None;
|
|
466
|
+
details.BackColor = SystemColors.Window;
|
|
467
|
+
details.ForeColor = SystemColors.WindowText;
|
|
468
|
+
details.Text = string.Join(Environment.NewLine, lines.ToArray());
|
|
469
|
+
details.Height = Math.Min(144, Math.Max(32, lines.Count * 22));
|
|
470
|
+
details.Dock = DockStyle.Fill;
|
|
471
|
+
details.Margin = new Padding(0, 0, 0, 16);
|
|
472
|
+
details.AccessibleName = "Requested action";
|
|
473
|
+
details.TabStop = false;
|
|
474
|
+
|
|
475
|
+
Label passwordLabel = new Label();
|
|
476
|
+
passwordLabel.Text = "Master password";
|
|
477
|
+
passwordLabel.AutoSize = true;
|
|
478
|
+
passwordLabel.Margin = new Padding(0, 0, 0, 8);
|
|
479
|
+
|
|
480
|
+
TextBox txtPassword = new TextBox();
|
|
481
|
+
txtPassword.UseSystemPasswordChar = true;
|
|
482
|
+
txtPassword.MaxLength = 512;
|
|
483
|
+
txtPassword.Dock = DockStyle.Fill;
|
|
484
|
+
txtPassword.Margin = new Padding(0, 0, 0, 12);
|
|
485
|
+
txtPassword.AccessibleName = "Master password";
|
|
486
|
+
txtPassword.TabIndex = 0;
|
|
487
|
+
|
|
488
|
+
Label hint = new Label();
|
|
489
|
+
hint.Text = "Check your verification phrase before entering your password.";
|
|
490
|
+
hint.AutoSize = true;
|
|
491
|
+
hint.MaximumSize = new Size(472, 0);
|
|
492
|
+
hint.Margin = new Padding(0, 0, 0, 24);
|
|
493
|
+
|
|
494
|
+
TableLayoutPanel footer = new TableLayoutPanel();
|
|
495
|
+
footer.Dock = DockStyle.Fill;
|
|
496
|
+
footer.AutoSize = true;
|
|
497
|
+
footer.ColumnCount = 3;
|
|
498
|
+
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
|
499
|
+
footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
|
500
|
+
footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
|
501
|
+
footer.Margin = new Padding(0);
|
|
502
|
+
Label countdown = new Label();
|
|
503
|
+
countdown.AutoSize = true;
|
|
504
|
+
countdown.Anchor = AnchorStyles.Left;
|
|
505
|
+
countdown.Margin = new Padding(0, 0, 12, 0);
|
|
506
|
+
countdown.Text = "Closes in 2:00";
|
|
507
|
+
|
|
508
|
+
Button cancel = new Button();
|
|
509
|
+
cancel.Text = "Cancel";
|
|
510
|
+
cancel.Size = new Size(96, 36);
|
|
511
|
+
cancel.Margin = new Padding(0, 0, 8, 0);
|
|
512
|
+
cancel.DialogResult = DialogResult.Cancel;
|
|
513
|
+
cancel.TabIndex = 2;
|
|
514
|
+
Button submit = new Button();
|
|
515
|
+
submit.Text = unlock ? "Unlock" : "Confirm";
|
|
516
|
+
submit.Size = new Size(104, 36);
|
|
517
|
+
submit.Margin = new Padding(0);
|
|
518
|
+
submit.DialogResult = DialogResult.OK;
|
|
519
|
+
submit.Enabled = false;
|
|
520
|
+
submit.TabIndex = 1;
|
|
521
|
+
submit.FlatStyle = FlatStyle.Flat;
|
|
522
|
+
submit.FlatAppearance.BorderSize = 1;
|
|
523
|
+
submit.UseVisualStyleBackColor = false;
|
|
524
|
+
submit.BackColor = SystemColors.Control;
|
|
525
|
+
submit.ForeColor = SystemColors.GrayText;
|
|
526
|
+
txtPassword.TextChanged += delegate {
|
|
527
|
+
submit.Enabled = txtPassword.Text.Length > 0;
|
|
528
|
+
submit.BackColor = submit.Enabled ? (SystemInformation.HighContrast ? SystemColors.Highlight : Color.FromArgb(29, 78, 216)) : SystemColors.Control;
|
|
529
|
+
submit.ForeColor = submit.Enabled ? (SystemInformation.HighContrast ? SystemColors.HighlightText : Color.White) : SystemColors.GrayText;
|
|
530
|
+
};
|
|
531
|
+
footer.Controls.Add(countdown, 0, 0);
|
|
532
|
+
footer.Controls.Add(cancel, 1, 0);
|
|
533
|
+
footer.Controls.Add(submit, 2, 0);
|
|
534
|
+
layout.Controls.Add(heading, 0, 0);
|
|
535
|
+
layout.Controls.Add(verification, 0, 1);
|
|
536
|
+
layout.Controls.Add(details, 0, 2);
|
|
537
|
+
layout.Controls.Add(passwordLabel, 0, 3);
|
|
538
|
+
layout.Controls.Add(txtPassword, 0, 4);
|
|
539
|
+
layout.Controls.Add(hint, 0, 5);
|
|
540
|
+
layout.Controls.Add(footer, 0, 6);
|
|
541
|
+
form.Controls.Add(layout);
|
|
542
|
+
form.AcceptButton = submit;
|
|
543
|
+
form.CancelButton = cancel;
|
|
544
|
+
form.Load += delegate {
|
|
545
|
+
layout.PerformLayout();
|
|
546
|
+
form.ClientSize = new Size(form.ClientSize.Width, layout.GetPreferredSize(new Size(layout.Width, 0)).Height + form.Padding.Vertical);
|
|
547
|
+
};
|
|
548
|
+
int remainingSeconds = 120;
|
|
549
|
+
timer.Interval = 1000;
|
|
550
|
+
timer.Tick += delegate {
|
|
551
|
+
remainingSeconds--;
|
|
552
|
+
countdown.Text = "Closes in " + (remainingSeconds / 60).ToString() + ":" + (remainingSeconds % 60).ToString("00");
|
|
553
|
+
if (remainingSeconds <= 0) { form.DialogResult = DialogResult.Cancel; form.Close(); }
|
|
554
|
+
};
|
|
555
|
+
form.Shown += delegate { txtPassword.Focus(); timer.Start(); };
|
|
556
|
+
DialogResult result = form.ShowDialog();
|
|
557
|
+
timer.Stop();
|
|
558
|
+
if (result == DialogResult.OK) Console.Write(txtPassword.Text);
|
|
559
|
+
txtPassword.Clear();
|
|
560
|
+
return result == DialogResult.OK ? 0 : 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
442
564
|
private static int RunManagement(string title, string phrase, List<string> lines, List<string> domains)
|
|
443
565
|
{
|
|
444
566
|
using (Form form = new Form())
|
|
@@ -2,8 +2,8 @@ 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 const checkWindowsUac = () => commandState('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
4
4
|
"$p=Get-ItemProperty -LiteralPath 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System'; if($p.EnableLUA -eq 1 -and $p.ConsentPromptBehaviorAdmin -eq 2 -and $p.PromptOnSecureDesktop -eq 1){'SAFE'}else{'UNSAFE'}"], s => s.trim() === 'SAFE');
|
|
5
|
-
export function checkWindowsPermissions(home) {
|
|
5
|
+
export function checkWindowsPermissions(home, recursive = true) {
|
|
6
6
|
const encoded = Buffer.from(home, 'utf8').toString('base64');
|
|
7
|
-
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'`;
|
|
7
|
+
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)${recursive ? '+@(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'`;
|
|
8
8
|
return commandState('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], s => s.trim() === 'SAFE', 60000);
|
|
9
9
|
}
|
|
@@ -92,6 +92,10 @@ export async function installWindowsService(opts = {}) {
|
|
|
92
92
|
const pkgRoot = opts.packageRoot ?? fileURLToPath(new URL('../../../', import.meta.url));
|
|
93
93
|
await fs.cp(path.join(pkgRoot, 'dist'), path.join(stagedProgram, 'dist'), { recursive: true });
|
|
94
94
|
await fs.cp(path.join(pkgRoot, 'package.json'), path.join(stagedProgram, 'package.json'));
|
|
95
|
+
await fs.mkdir(path.join(stagedProgram, 'scripts'));
|
|
96
|
+
for (const file of ['start-browser.ps1', 'desktop-browser.ps1']) {
|
|
97
|
+
await fs.copyFile(path.join(pkgRoot, 'scripts', file), path.join(stagedProgram, 'scripts', file));
|
|
98
|
+
}
|
|
95
99
|
const srcNm = path.join(pkgRoot, 'node_modules');
|
|
96
100
|
const destNm = path.join(stagedProgram, 'node_modules');
|
|
97
101
|
{
|
package/dist/service/control.js
CHANGED
|
@@ -3,18 +3,22 @@ import { LOGIN_MSG } from '../core/vault.js';
|
|
|
3
3
|
import { redact } from '../core/redact.js';
|
|
4
4
|
import { UserFacingError } from '../core/types.js';
|
|
5
5
|
import { GrantCoordinator } from './grants.js';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
6
7
|
const BLOCKED = 'too many wrong passwords — try again in 15 minutes';
|
|
7
8
|
const TITLE = 'U-Chan Secrets';
|
|
8
9
|
/** Commands from the `u-chan` CLI. Audit entries never contain passwords or sessions. */
|
|
9
10
|
export class ControlService {
|
|
10
11
|
d;
|
|
11
12
|
grants;
|
|
13
|
+
unlockPending;
|
|
14
|
+
lockEpoch = 0;
|
|
15
|
+
agentUnlockAfter = -Infinity;
|
|
12
16
|
constructor(d) {
|
|
13
17
|
this.d = d;
|
|
14
18
|
this.grants = d.grants ?? new GrantCoordinator(d.allowlist);
|
|
15
19
|
}
|
|
16
20
|
async capabilities() {
|
|
17
|
-
return { ok: true, message: JSON.stringify({ protocol: 3, features: ['doctor', 'success_conditions', 'grants', 'verified_session_transfer'], serviceVersion: this.d.serviceVersion }) };
|
|
21
|
+
return { ok: true, message: JSON.stringify({ protocol: 3, features: ['doctor', 'success_conditions', 'grants', 'verified_session_transfer', 'agent_unlock', ...(this.d.githubSupported ? ['github_broker'] : []), ...(this.d.discordSupported ? ['discord_broker', 'discord_discovery'] : []), ...(this.d.updateBoot ? ['coordinated_update'] : [])], serviceVersion: this.d.serviceVersion, ...(this.d.updateBoot ? { release: this.d.updateBoot } : {}) }) };
|
|
18
22
|
}
|
|
19
23
|
async diagnose() {
|
|
20
24
|
try {
|
|
@@ -63,15 +67,53 @@ export class ControlService {
|
|
|
63
67
|
limiter.fail();
|
|
64
68
|
return { ok: false, message: 'wrong password' };
|
|
65
69
|
}
|
|
66
|
-
unlock()
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
requestUnlock() { return this.unlock(true); }
|
|
71
|
+
unlock(agentRequested = false) {
|
|
72
|
+
if (this.unlockPending)
|
|
73
|
+
return this.unlockPending;
|
|
74
|
+
const epoch = this.lockEpoch;
|
|
75
|
+
let prompted = false;
|
|
76
|
+
const pending = this.guarded('unlock', async () => {
|
|
77
|
+
const status = await this.d.vault.status();
|
|
78
|
+
if (epoch !== this.lockEpoch)
|
|
79
|
+
return { ok: false, message: 'unlock cancelled by lock' };
|
|
80
|
+
if (status === 'unauthenticated')
|
|
69
81
|
return { ok: false, message: LOGIN_MSG };
|
|
70
|
-
|
|
82
|
+
if (status === 'unlocked')
|
|
83
|
+
return { ok: true, message: 'already unlocked' };
|
|
84
|
+
if (status !== 'locked')
|
|
85
|
+
return { ok: false, message: 'vault unavailable; run u-chan doctor' };
|
|
86
|
+
if (agentRequested) {
|
|
87
|
+
if (this.d.limiter.blocked())
|
|
88
|
+
return { ok: false, message: BLOCKED };
|
|
89
|
+
const now = this.d.now?.() ?? performance.now();
|
|
90
|
+
if (now < this.agentUnlockAfter)
|
|
91
|
+
return { ok: false, message: 'unlock prompt cooldown; wait 30 seconds or use u-chan unlock manually' };
|
|
92
|
+
this.agentUnlockAfter = now + 30000;
|
|
93
|
+
}
|
|
94
|
+
prompted = !this.d.limiter.blocked();
|
|
95
|
+
const refused = await this.checkedPassword(['Unlock the vault'], async (pw) => {
|
|
96
|
+
if (epoch !== this.lockEpoch)
|
|
97
|
+
throw new UserFacingError('unlock cancelled by lock');
|
|
98
|
+
const unlocked = await this.d.vault.unlock(pw);
|
|
99
|
+
if (epoch !== this.lockEpoch) {
|
|
100
|
+
await this.d.vault.lock();
|
|
101
|
+
throw new UserFacingError('unlock cancelled by lock');
|
|
102
|
+
}
|
|
103
|
+
return unlocked;
|
|
104
|
+
});
|
|
71
105
|
return refused ?? { ok: true, message: 'unlocked' };
|
|
106
|
+
}).finally(() => {
|
|
107
|
+
if (prompted)
|
|
108
|
+
this.agentUnlockAfter = (this.d.now?.() ?? performance.now()) + 30000;
|
|
109
|
+
if (this.unlockPending === pending)
|
|
110
|
+
this.unlockPending = undefined;
|
|
72
111
|
});
|
|
112
|
+
this.unlockPending = pending;
|
|
113
|
+
return pending;
|
|
73
114
|
}
|
|
74
115
|
lock() {
|
|
116
|
+
this.lockEpoch++;
|
|
75
117
|
return this.guarded('lock', async () => {
|
|
76
118
|
await this.d.vault.lock();
|
|
77
119
|
return { ok: true, message: 'locked' };
|
package/dist/service/doctor.js
CHANGED
|
@@ -8,6 +8,9 @@ export async function diagnoseService(home, vault, platform, cdpEndpoint) {
|
|
|
8
8
|
const paths = [join(home, 'program', 'dist', 'service', 'main.js'), join(home, 'program', 'node_modules'),
|
|
9
9
|
platform === 'win32' ? join(home, 'bin', 'node.exe') : process.execPath,
|
|
10
10
|
platform === 'win32' ? join(home, 'bin', 'bw.exe') : join(home, 'bin', 'bw')];
|
|
11
|
+
if (platform === 'win32')
|
|
12
|
+
for (const file of ['start-browser.ps1', 'desktop-browser.ps1'])
|
|
13
|
+
paths.push(join(home, 'program', 'scripts', file));
|
|
11
14
|
const [assets, permissions, status, browser, browserKind] = await Promise.all([
|
|
12
15
|
bounded(async () => {
|
|
13
16
|
await privateBrowserPath(home);
|
package/dist/service/main.js
CHANGED
|
@@ -18,7 +18,11 @@ import { IsolatedLogin } from './isolated-login.js';
|
|
|
18
18
|
import { PolicyApprover } from './policy-approver.js';
|
|
19
19
|
import { SettingsStore } from './settings.js';
|
|
20
20
|
import { NpmPackageVerifier } from './update/verifier.js';
|
|
21
|
-
import {
|
|
21
|
+
import { UpdateCoordinator } from './update/coordinator.js';
|
|
22
|
+
import { UpdateGate } from './update/gate.js';
|
|
23
|
+
import { readBootIdentity } from './update/files.js';
|
|
24
|
+
import { productionJournal, secureUpdateDirectory, verifyUpdateActive, launchUpdateWorker } from './update/native.js';
|
|
25
|
+
import { boundedRegistryFetch } from './update/network.js';
|
|
22
26
|
import { diagnoseService } from './doctor.js';
|
|
23
27
|
import { GrantCoordinator } from './grants.js';
|
|
24
28
|
import { ensureV3State } from './state.js';
|
|
@@ -26,6 +30,13 @@ import { ManagementService } from './manage.js';
|
|
|
26
30
|
import { privateBrowserPath } from '../platform/runtime-assets.js';
|
|
27
31
|
import { safeVersion } from '../core/doctor.js';
|
|
28
32
|
import { inspectServiceDesktopBrowser } from '../platform/windows/desktop-browser.js';
|
|
33
|
+
import { DiscordStore } from '../discord/store.js';
|
|
34
|
+
import { DiscordService } from '../discord/service.js';
|
|
35
|
+
import { DiscordHttp } from '../discord/http.js';
|
|
36
|
+
import { backupDiscordState } from './state.js';
|
|
37
|
+
import { GithubStore, backupGithubPolicy } from '../github/store.js';
|
|
38
|
+
import { GithubService } from '../github/service.js';
|
|
39
|
+
import { GithubHttp } from '../github/http.js';
|
|
29
40
|
export function prepareSocketEndpoint(endpoint, platform = process.platform) {
|
|
30
41
|
if (platform !== 'win32') {
|
|
31
42
|
const dir = dirname(endpoint);
|
|
@@ -61,6 +72,12 @@ export async function runService(platform = process.platform, env = process.env)
|
|
|
61
72
|
const allowlist = new AllowlistStore(cfg.allowlistPath, () => stateReady ??= ensureV3State(cfg.home).catch(e => { stateReady = undefined; throw e; }));
|
|
62
73
|
const grants = new GrantCoordinator(allowlist);
|
|
63
74
|
const audit = new FileAuditLog(cfg.auditPath);
|
|
75
|
+
let discordStateReady;
|
|
76
|
+
const discordStore = new DiscordStore(cfg.discordAllowlistPath, () => discordStateReady ??= backupDiscordState(cfg.home).catch(e => { discordStateReady = undefined; throw e; }));
|
|
77
|
+
const discord = new DiscordService({ platform, store: discordStore, vault, approver, audit, http: new DiscordHttp(), queue: work => grants.run(work) });
|
|
78
|
+
let githubStateReady;
|
|
79
|
+
const githubStore = new GithubStore(join(cfg.home, 'github-allowlist.json'), () => githubStateReady ??= ensureV3State(cfg.home).then(() => backupGithubPolicy(cfg.home, secureUpdateDirectory)).catch(error => { githubStateReady = undefined; throw error; }));
|
|
80
|
+
const github = new GithubService({ platform, store: githubStore, vault, approver, audit, http: new GithubHttp(), queue: work => grants.run(work) });
|
|
64
81
|
const endpoint = loadConfig(env, platform).cdpEndpoint;
|
|
65
82
|
const validateTarget = platform === 'win32' ? async (browser = 'chrome') => {
|
|
66
83
|
const owner = (await settings.get()).authorizedSid;
|
|
@@ -76,27 +93,27 @@ export async function runService(platform = process.platform, env = process.env)
|
|
|
76
93
|
currentVersion = safeVersion(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version) ?? 'unknown';
|
|
77
94
|
}
|
|
78
95
|
catch { }
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
};
|
|
85
|
-
const
|
|
86
|
-
verifier,
|
|
87
|
-
restarter,
|
|
88
|
-
programDir: cfg.programDir,
|
|
89
|
-
stagingDir: cfg.stagingDir,
|
|
90
|
-
currentVersion,
|
|
91
|
-
});
|
|
92
|
-
const diagnose = async () => ({ ...await diagnoseService(cfg.home, vault, platform, loadConfig(env, platform).cdpEndpoint), serviceVersion: safeVersion(currentVersion) });
|
|
93
|
-
const control = new ControlService({ vault, approver, limiter, settings, allowlist, grants, audit, platform, updater, diagnose, serviceVersion: safeVersion(currentVersion), manage: () => manager.open() });
|
|
96
|
+
const updateBoot = await readBootIdentity(cfg.programDir), updateGate = new UpdateGate();
|
|
97
|
+
const coordinator = new UpdateCoordinator({ home: cfg.home, boot: updateBoot, platform, verifier: new NpmPackageVerifier({ fetcher: boundedRegistryFetch }), journal: productionJournal(cfg.home), gate: updateGate,
|
|
98
|
+
secure: secureUpdateDirectory, verifyActive: path => verifyUpdateActive(cfg.home, path), launch: job => launchUpdateWorker(cfg.home, job), audit: result => audit.record({ action: 'update', domain: '', result }) });
|
|
99
|
+
await coordinator.refreshGate();
|
|
100
|
+
const updater = { update: async () => ({ ok: false, message: 'Use a coordinated-update desktop client; legacy service-only update is disabled' }) };
|
|
101
|
+
const diagnose = async () => ({ ...await diagnoseService(cfg.home, vault, platform, loadConfig(env, platform).cdpEndpoint), serviceVersion: safeVersion(currentVersion), discordPolicy: await discordStore.list().then(() => 'pass', () => 'fail'), discordBroker: platform === 'win32' ? 'available' : 'unsupported' });
|
|
102
|
+
const control = new ControlService({ vault, approver, limiter, settings, allowlist, grants, audit, platform, updater, diagnose, serviceVersion: safeVersion(currentVersion), manage: () => manager.open(), discordSupported: platform === 'win32', githubSupported: platform === 'win32', updateBoot });
|
|
94
103
|
const manager = new ManagementService({
|
|
95
104
|
store: allowlist, grants, vault, audit, diagnose, phrase: async () => (await settings.get()).phrase,
|
|
96
105
|
show: req => adapters.prompt.manage ? adapters.prompt.manage(req) : Promise.reject(new Error('unsupported')),
|
|
97
106
|
lock: () => control.lock(), unlock: () => control.unlock(), revoke: domain => core.revoke(domain),
|
|
107
|
+
discordStore, discordRevoke: alias => discord.discordRevoke(alias),
|
|
98
108
|
});
|
|
99
109
|
const api = {
|
|
110
|
+
requestUnlock: () => control.requestUnlock(), githubRequestAllow: proposal => github.githubRequestAllow(proposal), githubListAllowed: () => github.githubListAllowed(), githubRead: (alias, request) => github.githubRead(alias, request), githubCreate: (alias, operation) => github.githubCreate(alias, operation), githubRevoke: alias => github.githubRevoke(alias),
|
|
111
|
+
planUpdate: () => coordinator.planUpdate(), beginUpdate: target => coordinator.beginUpdate(target), updateStatus: id => coordinator.updateStatus(id), activateUpdate: id => coordinator.activateUpdate(id), confirmUpdateReady: id => coordinator.confirmUpdateReady(id),
|
|
112
|
+
discordRequestAllow: proposal => discord.discordRequestAllow(proposal),
|
|
113
|
+
discordListAllowed: () => discord.discordListAllowed(),
|
|
114
|
+
discordInspect: (alias, memberId, page) => discord.discordInspect(alias, memberId, page),
|
|
115
|
+
discordApply: (alias, operations) => discord.discordApply(alias, operations),
|
|
116
|
+
discordRevoke: alias => discord.discordRevoke(alias),
|
|
100
117
|
capabilities: () => control.capabilities(),
|
|
101
118
|
diagnose: () => control.diagnose(),
|
|
102
119
|
manage: () => control.manage(),
|
|
@@ -112,7 +129,7 @@ export async function runService(platform = process.platform, env = process.env)
|
|
|
112
129
|
audit: (limit) => control.audit(limit),
|
|
113
130
|
update: () => control.update(),
|
|
114
131
|
};
|
|
115
|
-
const server = await startIpcServer(cfg.endpoint, api, { authorize: adapters.authorize });
|
|
132
|
+
const server = await startIpcServer(cfg.endpoint, api, { authorize: adapters.authorize, admission: updateGate, beforeRequest: () => coordinator.refreshGate(), afterResponse: (method, params) => coordinator.afterResponse(method, String(params.id ?? '')) });
|
|
116
133
|
console.error(`U-Chan service listening on ${cfg.endpoint}`);
|
|
117
134
|
return server;
|
|
118
135
|
}
|
package/dist/service/manage.js
CHANGED
|
@@ -21,10 +21,21 @@ export class ManagementService {
|
|
|
21
21
|
this.d.store.list(), bounded(() => this.d.vault.status(), 'unavailable'),
|
|
22
22
|
bounded(() => this.d.audit.readTail(10), []), bounded(this.d.diagnose, null, SERVICE_DIAGNOSTIC_TIMEOUT_MS),
|
|
23
23
|
]);
|
|
24
|
-
const
|
|
24
|
+
const website = entries.filter(e => e.domain === normalizeDomain(e.domain)).map(e => {
|
|
25
|
+
let label = e.expiresAt ? 'until ' + e.expiresAt : e.remainingUses !== undefined ? e.remainingUses + ' attempts remaining' : 'permanent';
|
|
26
|
+
try {
|
|
27
|
+
this.d.grants.check(e);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
label = e.remainingUses === 0 ? 'exhausted' : 'expired';
|
|
31
|
+
}
|
|
32
|
+
return { target: e.domain, line: clean(e.domain) + ' — ' + label };
|
|
33
|
+
});
|
|
34
|
+
const discord = (await this.d.discordStore?.list() ?? []).map(e => ({ target: 'discord:' + e.alias, line: `Discord: ${e.alias} — server ${e.guildId} — metadata reads; protected approval for every write` }));
|
|
35
|
+
const all = [...website, ...discord];
|
|
25
36
|
page = Math.min(page, Math.max(0, Math.ceil(all.length / 20) - 1));
|
|
26
37
|
const grants = all.slice(page * 20, page * 20 + 20);
|
|
27
|
-
const lines = ['Vault: ' + status, lastAction, 'Grant page ' + (page + 1) + ': ' + grants.length + ' of ' +
|
|
38
|
+
const lines = ['Vault: ' + status, lastAction, 'Grant page ' + (page + 1) + ': ' + grants.length + ' of ' + all.length + ' shown. Use Previous/Next for more.'];
|
|
28
39
|
lines.push('Service version: ' + (safeVersion(checks?.serviceVersion) ?? 'unknown'), 'Service browser: ' + (['pass', 'fail'].includes(checks?.browser ?? '') ? checks?.browser : 'unknown'));
|
|
29
40
|
lines.push('Observed desktop browser: ' + (checks?.browserKind === 'edge' ? 'Edge' : checks?.browserKind === 'chrome' ? 'Chrome' : 'unknown'));
|
|
30
41
|
if (status === 'locked')
|
|
@@ -33,24 +44,16 @@ export class ManagementService {
|
|
|
33
44
|
lines.push('Next step: run u-chan doctor; repair from a trusted administrator terminal.');
|
|
34
45
|
if (checks?.browser !== 'pass')
|
|
35
46
|
lines.push('Next step: start the selected dedicated browser and run u-chan setup.');
|
|
36
|
-
for (const e of grants)
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
this.d.grants.check(e);
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
label = e.remainingUses === 0 ? 'exhausted' : 'expired';
|
|
43
|
-
}
|
|
44
|
-
lines.push(clean(e.domain) + ' — ' + label);
|
|
45
|
-
}
|
|
47
|
+
for (const e of grants)
|
|
48
|
+
lines.push(e.line);
|
|
46
49
|
lines.push('Revocation stops future logins. It does not log out existing browser sessions.', 'Revocation waits for any earlier login to finish.', 'Diagnostics: assets ' + (checks?.assets ?? 'unknown') + '; permissions ' + (checks?.permissions ?? 'unknown'), 'Recent activity:');
|
|
47
50
|
for (const e of events.slice(-10)) {
|
|
48
51
|
const ts = Number.isFinite(Date.parse(e.ts)) ? new Date(e.ts).toISOString() : 'unknown time';
|
|
49
|
-
const action = ['login', 'request_allow', 'revoke', 'unlock', 'lock', 'mode', 'import', 'update'].includes(e.action) ? e.action : 'event';
|
|
52
|
+
const action = ['login', 'request_allow', 'revoke', 'unlock', 'lock', 'mode', 'import', 'update', 'discord_allow', 'discord_read', 'discord_write', 'discord_revoke'].includes(e.action) ? e.action : 'event';
|
|
50
53
|
const result = e.result.startsWith('failed') ? 'failed' : e.result === 'denied' ? 'denied' : e.result.startsWith('approved') ? 'approved' : 'recorded';
|
|
51
54
|
lines.push(ts + ' ' + action + ' ' + clean(e.domain).slice(0, 253) + ' ' + result);
|
|
52
55
|
}
|
|
53
|
-
return { title: 'U-Chan Secrets — Manage', phrase: await this.d.phrase(), lines: lines.filter(Boolean), domains: grants.map(e => e.
|
|
56
|
+
return { title: 'U-Chan Secrets — Manage', phrase: await this.d.phrase(), lines: lines.filter(Boolean), domains: grants.map(e => e.target) };
|
|
54
57
|
});
|
|
55
58
|
// Each native window closes before a protected password prompt can open.
|
|
56
59
|
const action = await this.d.show(snapshot);
|
|
@@ -71,8 +74,9 @@ export class ManagementService {
|
|
|
71
74
|
result = await this.d.lock();
|
|
72
75
|
else if (action.action === 'unlock')
|
|
73
76
|
result = await this.d.unlock();
|
|
74
|
-
else if (action.action === 'revoke' && typeof action.domain === 'string' && snapshot.domains?.includes(action.domain))
|
|
75
|
-
result = await this.d.revoke(action.domain);
|
|
77
|
+
else if (action.action === 'revoke' && typeof action.domain === 'string' && snapshot.domains?.includes(action.domain)) {
|
|
78
|
+
result = action.domain.startsWith('discord:') && this.d.discordRevoke ? await this.d.discordRevoke(action.domain.slice(8)) : await this.d.revoke(action.domain);
|
|
79
|
+
}
|
|
76
80
|
else
|
|
77
81
|
return { ok: false, message: 'invalid management action or target' };
|
|
78
82
|
lastAction = result.ok ? 'Last action completed.' : 'Last action failed. Run u-chan doctor for guidance.';
|
package/dist/service/state.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chmod, cp, lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { UserFacingError } from '../core/types.js';
|
|
4
5
|
import { setAdminOnlyAcl } from '../platform/windows/acl.js';
|
|
5
6
|
import { checkWindowsPermissions } from '../platform/windows/doctor.js';
|
|
@@ -86,3 +87,30 @@ export async function ensureV3State(home, secureDirectory = protect) {
|
|
|
86
87
|
await writeFile(tmp, JSON.stringify({ schema: 3, minimumServiceMajor: 3 }), { mode: 0o600 });
|
|
87
88
|
await rename(tmp, join(home, 'schema.json'));
|
|
88
89
|
}
|
|
90
|
+
/** A protected metadata snapshot before the broker's first state mutation in this process. */
|
|
91
|
+
export async function backupDiscordState(home, secureDirectory = protect) {
|
|
92
|
+
await ensureV3State(home, secureDirectory);
|
|
93
|
+
await secureDirectory(home);
|
|
94
|
+
const parent = join(home, '#0 backup');
|
|
95
|
+
await mkdir(parent, { mode: 0o700, recursive: true });
|
|
96
|
+
await secureDirectory(parent);
|
|
97
|
+
const backup = join(parent, 'discord-before-' + randomUUID());
|
|
98
|
+
await mkdir(backup, { mode: 0o700 });
|
|
99
|
+
await secureDirectory(backup);
|
|
100
|
+
for (const name of ['discord-allowlist.json', 'allowlist.json', 'settings.json', 'schema.json']) {
|
|
101
|
+
const source = join(home, name);
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = await lstat(source);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (e.code === 'ENOENT')
|
|
108
|
+
continue;
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
112
|
+
throw new UserFacingError('protected backup refuses links and non-files');
|
|
113
|
+
await cp(source, join(backup, name), { force: false, errorOnExist: true });
|
|
114
|
+
}
|
|
115
|
+
await writeFile(join(backup, 'complete'), 'discord_broker', { mode: 0o600, flag: 'wx' });
|
|
116
|
+
}
|