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:
1 parent
471f8c3e73
commit
35583b2c15
47 files changed
+3058
-105
No files matched your search
+69
-18
@@ -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] : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user