feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown

Lock screen (main-window overlay, no second window):
- scrypt password verifier in <userData>/lock.json (per-write salt,
  timingSafeEqual); salt/hash/password never leave the main process
- lock now / idle auto-lock / lock at startup, growing failure cooldown,
  lock flags persisted so a quit-and-relaunch cannot bypass the lock
- locked shell and body portals go inert while sessions keep running;
  menu accelerators (reload, DevTools, zoom) are swallowed while locked
- settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja

Security and stability:
- packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv)
- renderer preload runs with sandbox: true
- unreadable known_hosts store fails closed instead of being overwritten
- connect-time secrets gated by the bookmark's auth method (connectPromptFor)
- ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once
- sysinfo polling is refcounted for split panes (forceStopPolling on close)
- session-log index entries are path-contained; settings store writes
  atomically with EPERM/EBUSY retry
- sync-changelog tolerates CRLF checkouts (was a silent no-op)
- retry ssh2 host-key generation (flaky malformed key, ~1/500)

Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e;
GitHub Actions CI (typecheck + 10 offline tests + build)
This commit is contained in:
Bill committed 2026-09-24 22:16:43 +08:00
1 parent 471f8c3e73
commit 35583b2c15
47 files changed
+3058 -105

No files matched your search

+69 -18
View File
@@ -29,6 +29,22 @@ export type HostKeyCheckResult =
| { status: 'match'; entry: KnownHostEntry }
| { status: 'new' }
| { status: 'changed'; stored: KnownHostEntry }
/**
* The store file exists but cannot be trusted (unreadable, corrupt JSON or
* not the expected shape). Callers must fail closed: accepting here would
* rewrite the store from an empty table and destroy every pinned fingerprint.
*/
| { status: 'unreadable' }
/**
* Load outcome, so "the file was never written" (TOFU from scratch) stays
* distinguishable from "the file is there but we cannot read it" (data loss
* in progress — never pretend the store is empty).
*/
type LoadResult =
| { kind: 'ok'; shape: KnownHostsStoreShape }
| { kind: 'missing' }
| { kind: 'unreadable' }
/** sha256 fingerprint in ssh "SHA256:..." style (no padding) */
export function fingerprintOf(key: Buffer): string {
@@ -38,28 +54,42 @@ export function fingerprintOf(key: Buffer): string {
export class KnownHostsStore {
constructor(private readonly filePath: string) {}
private load(): KnownHostsStoreShape {
private load(): LoadResult {
let raw: string
try {
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
if (raw !== null && typeof raw === 'object') {
const shape = raw as Partial<KnownHostsStoreShape>
raw = readFileSync(this.filePath, 'utf8')
} catch (err) {
// A file that was never created is the normal first-connect case; any
// other read failure (EACCES, EISDIR, ...) means data we cannot see.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' }
return { kind: 'unreadable' }
}
try {
const parsed: unknown = JSON.parse(raw)
if (parsed !== null && typeof parsed === 'object') {
const shape = parsed as Partial<KnownHostsStoreShape>
if (Array.isArray(shape.entries)) {
return {
version: 1,
entries: shape.entries.filter(
(e): e is KnownHostEntry =>
e !== null &&
typeof e === 'object' &&
typeof (e as KnownHostEntry).host === 'string' &&
typeof (e as KnownHostEntry).keyBase64 === 'string'
)
kind: 'ok',
shape: {
version: 1,
entries: shape.entries.filter(
(e): e is KnownHostEntry =>
e !== null &&
typeof e === 'object' &&
typeof (e as KnownHostEntry).host === 'string' &&
typeof (e as KnownHostEntry).keyBase64 === 'string'
)
}
}
}
}
} catch {
// missing / corrupted file -> start fresh
// fall through: JSON.parse failure
}
return { version: 1, entries: [] }
// Parses-but-wrong-shape is treated like corrupt: a file at this path that
// is not the store we wrote is not evidence that nothing was pinned.
return { kind: 'unreadable' }
}
private save(shape: KnownHostsStoreShape): void {
@@ -68,9 +98,18 @@ export class KnownHostsStore {
/**
* Compare the live host key against the stored entry for (host, port).
* Returns `unreadable` when the store exists but cannot be read — never a
* `new` verdict, which would invite overwriting the pin data on accept.
*/
check(host: string, port: number, key: Buffer): HostKeyCheckResult {
const entries = this.load().entries.filter((e) => e.host === host && e.port === port)
const loaded = this.load()
if (loaded.kind === 'unreadable') return { status: 'unreadable' }
// A file that was never written is the genuine TOFU case; an unreadable
// one was already handled above and must never degrade to 'new'.
const entries =
loaded.kind === 'ok'
? loaded.shape.entries.filter((e) => e.host === host && e.port === port)
: []
if (entries.length === 0) return { status: 'new' }
const fingerprint = fingerprintOf(key)
@@ -82,9 +121,19 @@ export class KnownHostsStore {
return { status: 'changed', stored }
}
/** Record a new host key (accept of a 'new' or 'changed' prompt). */
/**
* Record a new host key (accept of a 'new' or 'changed' prompt).
*
* Throws when the store is currently unreadable: rewriting the file from a
* table we failed to load would wipe every other pinned fingerprint.
*/
accept(host: string, port: number, key: Buffer, fingerprint: string): KnownHostEntry {
const shape = this.load()
const loaded = this.load()
if (loaded.kind === 'unreadable') {
throw new Error(`known hosts store unreadable, refusing to overwrite: ${this.filePath}`)
}
const shape: KnownHostsStoreShape =
loaded.kind === 'ok' ? loaded.shape : { version: 1, entries: [] }
const entry: KnownHostEntry = {
id: randomUUID(),
host,
@@ -99,8 +148,10 @@ export class KnownHostsStore {
return entry
}
/** Empty when the store is unreadable: callers must not treat that as "no pins". */
list(): KnownHostEntry[] {
return [...this.load().entries]
const loaded = this.load()
return loaded.kind === 'ok' ? [...loaded.shape.entries] : []
}
}