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)
161 lines
5.5 KiB
TypeScript
161 lines
5.5 KiB
TypeScript
/**
|
|
* Host key pinning store. Persists to <userData>/ssh_known_hosts.json.
|
|
* No Electron imports here: the file location is injected so the module can be
|
|
* reused by the loopback test harness (and any future main-process unit tests).
|
|
*/
|
|
|
|
import { createHash, randomUUID } from 'crypto'
|
|
import { readFileSync } from 'fs'
|
|
import { writeJson } from './store'
|
|
|
|
export interface KnownHostEntry {
|
|
/** uuid; addedAt is split out so fingerprint clash updates can be precise */
|
|
id: string
|
|
host: string
|
|
port: number
|
|
/** raw ssh wire-format host key (base64, no padding) */
|
|
keyBase64: string
|
|
/** 'SHA256:' + base64(sha256(key)) without padding, OpenSSH style */
|
|
fingerprint: string
|
|
addedAt: number
|
|
}
|
|
|
|
export interface KnownHostsStoreShape {
|
|
version: 1
|
|
entries: KnownHostEntry[]
|
|
}
|
|
|
|
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 {
|
|
return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
|
|
}
|
|
|
|
export class KnownHostsStore {
|
|
constructor(private readonly filePath: string) {}
|
|
|
|
private load(): LoadResult {
|
|
let raw: string
|
|
try {
|
|
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 {
|
|
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 {
|
|
// fall through: JSON.parse failure
|
|
}
|
|
// 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 {
|
|
writeJson(this.filePath, shape)
|
|
}
|
|
|
|
/**
|
|
* 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 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)
|
|
const keyBase64 = key.toString('base64')
|
|
const stored = entries[0]
|
|
if (stored.keyBase64 === keyBase64 || stored.fingerprint === fingerprint) {
|
|
return { status: 'match', entry: stored }
|
|
}
|
|
return { status: 'changed', stored }
|
|
}
|
|
|
|
/**
|
|
* 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 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,
|
|
port,
|
|
keyBase64: key.toString('base64'),
|
|
fingerprint,
|
|
addedAt: Date.now()
|
|
}
|
|
shape.entries = shape.entries.filter((e) => !(e.host === host && e.port === port))
|
|
shape.entries.push(entry)
|
|
this.save(shape)
|
|
return entry
|
|
}
|
|
|
|
/** Empty when the store is unreadable: callers must not treat that as "no pins". */
|
|
list(): KnownHostEntry[] {
|
|
const loaded = this.load()
|
|
return loaded.kind === 'ok' ? [...loaded.shape.entries] : []
|
|
}
|
|
}
|
|
|
|
/** Default location: <userData>/ssh_known_hosts.json */
|
|
export function defaultKnownHostsPath(userDataPath: string): string {
|
|
return `${userDataPath}/ssh_known_hosts.json`
|
|
} |