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
+68
-2
@@ -20,9 +20,9 @@
|
||||
|
||||
import { app, shell } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync } from 'fs'
|
||||
import { appendFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||
import { loadSettings } from './settingsStore'
|
||||
@@ -236,6 +236,9 @@ export class CommandsStore {
|
||||
typeof (x as SessionLogMeta).file === 'string'
|
||||
) {
|
||||
const meta = x as SessionLogMeta
|
||||
// index.json is data, not trust: a tampered or hand-edited `file`
|
||||
// must never turn logWrite into an arbitrary-path append.
|
||||
if (!this.isLoggableFile(meta.file)) continue
|
||||
this.metasByFile.set(meta.file, meta)
|
||||
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
|
||||
}
|
||||
@@ -245,6 +248,69 @@ export class CommandsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `file` resolves to a regular file inside the logs directory.
|
||||
*
|
||||
* Applied to every entry hydrated from index.json before it can ever reach
|
||||
* logWrite. `..` segments collapse via resolve(); containment is compared
|
||||
* case-insensitively on Windows so drive-letter or name-case spelling cannot
|
||||
* sneak a path past it. Symlinks are followed (realpathSync): an entry that
|
||||
* resolves outside the logs dir is dropped, and a path that exists but is
|
||||
* not a regular file (a directory, a device) is dropped too. A path that is
|
||||
* simply not on disk yet stays eligible — appendFile creates it lazily, and
|
||||
* the directory it would land in is still resolved.
|
||||
*/
|
||||
private isLoggableFile(file: string): boolean {
|
||||
if (file.length === 0) return false
|
||||
const dirReal = this.logsDirReal()
|
||||
let target: string
|
||||
let exists = true
|
||||
try {
|
||||
target = realpathSync(file)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false
|
||||
// Not on disk yet: appendFile would create it, so the directory it would
|
||||
// land in is what decides containment. Judging the literal path alone
|
||||
// would let a symlinked subdirectory point the append outside the dir.
|
||||
target = this.pendingPathReal(file)
|
||||
exists = false
|
||||
}
|
||||
if (exists) {
|
||||
try {
|
||||
if (!statSync(target).isFile()) return false
|
||||
} catch {
|
||||
// Vanished between realpath and stat: appends would recreate it, and
|
||||
// the containment check below already passed for this path.
|
||||
}
|
||||
}
|
||||
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p)
|
||||
const dirN = norm(dirReal)
|
||||
return norm(target).startsWith(dirN + sep)
|
||||
}
|
||||
|
||||
/** Real path of the logs dir; falls back to resolve() when it does not exist yet. */
|
||||
private logsDirReal(): string {
|
||||
try {
|
||||
return realpathSync(this.logsDir)
|
||||
} catch {
|
||||
return resolve(this.logsDir)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a log file that is not on disk yet would actually be written: its
|
||||
* parent resolved through any symlinks, the leaf kept as written (it does not
|
||||
* exist, so it has nothing to resolve). Falls back to the literal resolve()
|
||||
* when the parent is missing as well — the containment check then decides.
|
||||
*/
|
||||
private pendingPathReal(file: string): string {
|
||||
try {
|
||||
return join(realpathSync(dirname(file)), basename(file))
|
||||
} catch {
|
||||
return resolve(file)
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the full log index so listSessionLogs survives restart. */
|
||||
private persistIndex(): void {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
/**
|
||||
* Dev-only environment overrides. A packaged build must ignore these variables
|
||||
* even when they are present in its environment: whoever can inject env vars
|
||||
* into a launch (a wrapper script, a shortcut, malware with user rights) could
|
||||
* otherwise point the renderer — and with it the IPC trust check — at a remote
|
||||
* origin, or redirect the update feed to a hostile server.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vite dev server URL, or undefined when the packaged renderer file must be
|
||||
* loaded. Packaged builds never honor ELECTRON_RENDERER_URL.
|
||||
*/
|
||||
export function devRendererUrl(): string | undefined {
|
||||
if (app.isPackaged) return undefined
|
||||
return process.env['ELECTRON_RENDERER_URL'] || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom update feed URL for development, or undefined to use the production
|
||||
* Gitea feed. Packaged builds never honor OT_UPDATE_URL (OT_UPDATE_TOKEN is
|
||||
* unrelated and still read from the environment in every build).
|
||||
*/
|
||||
export function devUpdateFeedUrl(): string | undefined {
|
||||
if (app.isPackaged) return undefined
|
||||
return process.env['OT_UPDATE_URL'] || undefined
|
||||
}
|
||||
+50
-2
@@ -2,9 +2,11 @@ import { app, BrowserWindow, globalShortcut, net, nativeImage, protocol, shell }
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { devRendererUrl } from './devEnv'
|
||||
import { isTrustedRendererUrl, registerIpc } from './ipc'
|
||||
import { killAllPtys, killPtysByOwner } from './pty'
|
||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
||||
import { getLockController, initLockController } from './lockController'
|
||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
@@ -87,6 +89,11 @@ if (!gotSingleInstanceLock) {
|
||||
// OS-level effects (login item, sleep blocker) must apply even if the
|
||||
// settings dialog is never opened this run.
|
||||
applyStartupSystemSettings(loadSettings())
|
||||
// The lock state has to exist before the window loads, so a lockAtStartup
|
||||
// lock is already in place when the renderer asks for it. It also starts the
|
||||
// idle watcher, which is why it belongs after ready: powerMonitor cannot be
|
||||
// touched before that.
|
||||
initLockController()
|
||||
createWindow()
|
||||
initTray(showOrCreate)
|
||||
// Tray labels are resolved from the dictionary at build time, so the menu has
|
||||
@@ -99,6 +106,31 @@ if (!gotSingleInstanceLock) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Accelerators that must not reach the page while the lock screen is up.
|
||||
*
|
||||
* The overlay is a DOM layer inside the window, so everything the browser
|
||||
* process handles on its own passes straight through it: reloading the renderer
|
||||
* runs Workspace's beforeunload and kills every local/SSH session behind the
|
||||
* overlay, and the zoom / DevTools shortcuts would let the locked screen be
|
||||
* resized or read. These come from Electron's default application menu, whose
|
||||
* keys are matched before any renderer code runs.
|
||||
*
|
||||
* Matching is on `input.key` rather than `input.code`: the key is what the
|
||||
* layout actually produces (Ctrl+Shift+= arrives as '+', Ctrl+Shift+- as '_'),
|
||||
* while the code depends on the physical key.
|
||||
*/
|
||||
function isLockBlockedShortcut(input: Electron.Input): boolean {
|
||||
const key = input.key.toLowerCase()
|
||||
if (key === 'f5') return true
|
||||
if (!input.control) return false
|
||||
if (key === 'r') return true
|
||||
// Zoom: in, out and reset, in both their plain and Shift-shifted spellings.
|
||||
if (key === '=' || key === '+' || key === '-' || key === '_' || key === '0') return true
|
||||
// DevTools. Shift is required so that plain Ctrl+C (copy) keeps working.
|
||||
return input.shift && (key === 'i' || key === 'j' || key === 'c')
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon
|
||||
// (electron-builder embeds build/icon.png), so undefined is fine there.
|
||||
@@ -128,7 +160,21 @@ function createWindow(): void {
|
||||
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
// Keep the default renderer sandbox (preload only touches the electron
|
||||
// IPC bridge, so it does not need Node access).
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
|
||||
// Swallow the menu accelerators that would otherwise act behind the lock
|
||||
// overlay (see isLockBlockedShortcut). A throw in here would break typing
|
||||
// altogether, so the whole guard is defensive.
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
try {
|
||||
if (input.type !== 'keyDown' || !getLockController().isLocked()) return
|
||||
if (isLockBlockedShortcut(input)) event.preventDefault()
|
||||
} catch {
|
||||
/* an input guard must never take the window down with it */
|
||||
}
|
||||
})
|
||||
|
||||
@@ -174,7 +220,9 @@ function createWindow(): void {
|
||||
if (!isTrustedRendererUrl(url)) event.preventDefault()
|
||||
})
|
||||
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
// ELECTRON_RENDERER_URL is honored in dev builds only — a packaged build must
|
||||
// always load the bundled renderer file, no matter what the environment says.
|
||||
const devUrl = devRendererUrl()
|
||||
if (devUrl) {
|
||||
win.loadURL(devUrl)
|
||||
} else {
|
||||
|
||||
+11
-5
@@ -8,9 +8,11 @@ import { pathToFileURL } from 'url'
|
||||
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
||||
import { t } from '../shared/i18n'
|
||||
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||
import { devRendererUrl } from './devEnv'
|
||||
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
|
||||
import { startPolling, stopPolling } from './sysinfo'
|
||||
import { registerSettingsIpc } from './settingsStore'
|
||||
import { registerLockIpc } from './lockController'
|
||||
import { registerSessionStateIpc } from './sessionState'
|
||||
import { normalizeReportedCwd, resolveCwd } from './cwd'
|
||||
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
|
||||
@@ -41,15 +43,17 @@ const RENDERER_FILE = join(__dirname, '../renderer/index.html')
|
||||
|
||||
/**
|
||||
* True only for a document the app itself loaded: the bundled renderer file, or
|
||||
* in dev anything served by the vite dev server. Used both to refuse a
|
||||
* navigation away from the app page and to refuse IPC from a frame that is not
|
||||
* it — a window that navigated elsewhere would still hold this preload bridge,
|
||||
* which is the whole main-process API (`createPty` included).
|
||||
* in dev anything served by the vite dev server (ELECTRON_RENDERER_URL is read
|
||||
* in dev builds only — a packaged build always trusts the production file URL,
|
||||
* never a remote origin). Used both to refuse a navigation away from the app
|
||||
* page and to refuse IPC from a frame that is not it — a window that navigated
|
||||
* elsewhere would still hold this preload bridge, which is the whole
|
||||
* main-process API (`createPty` included).
|
||||
*/
|
||||
export function isTrustedRendererUrl(raw: string): boolean {
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
try {
|
||||
const target = new URL(raw)
|
||||
const devUrl = devRendererUrl()
|
||||
if (devUrl) return target.origin === new URL(devUrl).origin
|
||||
return target.protocol === 'file:' && target.pathname === pathToFileURL(RENDERER_FILE).pathname
|
||||
} catch {
|
||||
@@ -240,6 +244,8 @@ export function registerIpc(): void {
|
||||
|
||||
registerSettingsIpc()
|
||||
registerSessionStateIpc()
|
||||
// ---- lock screen (main owns the state; the renderer only draws the overlay) ----
|
||||
registerLockIpc()
|
||||
|
||||
// Resolve a cd-style argument against the current cwd (platform-aware; only
|
||||
// the main process has node's `path`).
|
||||
|
||||
+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] : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Screen-lock controller.
|
||||
*
|
||||
* The main process owns the lock: it holds the verifier (lockStore) and the one
|
||||
* piece of live state that matters — whether the screen is currently locked.
|
||||
* Nothing here creates a window; the renderer draws the overlay for whatever
|
||||
* window it is and asks these channels for the truth, so a renderer reload (or a
|
||||
* second window, later) can never disagree about being locked.
|
||||
*
|
||||
* Every state change is published on LOCK_STATE_CHANGED, so the overlay appears
|
||||
* the moment the idle watcher fires rather than when something happens to ask.
|
||||
*/
|
||||
|
||||
import { app, ipcMain, powerMonitor } from 'electron'
|
||||
import {
|
||||
Ipc,
|
||||
type LockOperationError,
|
||||
type LockOperationResult,
|
||||
type LockPasswordInput,
|
||||
type LockSettingsState
|
||||
} from '../shared/ipc'
|
||||
import type { LockSettings } from '../shared/settings'
|
||||
import { broadcast } from './broadcast'
|
||||
import {
|
||||
LockStateStore,
|
||||
LockStore,
|
||||
defaultLockPath,
|
||||
defaultLockStatePath,
|
||||
isValidPassword
|
||||
} from './lockStore'
|
||||
import { loadSettings, mutateSettings } from './settingsStore'
|
||||
|
||||
/**
|
||||
* Backoff after each failed verification: the nth failure refuses further
|
||||
* attempts for COOLDOWN_STEPS_MS[n-1], with the last step repeating. A wrong
|
||||
* password therefore costs 1s, 2s, 5s, 10s and then 30s every time.
|
||||
*/
|
||||
const COOLDOWN_STEPS_MS = [1000, 2000, 5000, 10000, 30000]
|
||||
|
||||
/** How often the idle watcher asks the OS how long the user has been away. */
|
||||
const IDLE_POLL_MS = 15_000
|
||||
|
||||
/**
|
||||
* Upper bound applied to a cooldown restored from disk. The longest backoff step
|
||||
* is 30s, so a legitimately stored deadline can never sit further out than that;
|
||||
* anything beyond it means the clock moved backwards, and trusting the file
|
||||
* verbatim would lock the user out until the old deadline came around again.
|
||||
*/
|
||||
const MAX_RESTORED_COOLDOWN_MS = 30_000
|
||||
|
||||
export interface LockControllerOptions {
|
||||
/** injected by tests; defaults to <userData>/lock.json */
|
||||
store?: LockStore
|
||||
/** injected by tests; defaults to <userData>/lock-state.json */
|
||||
stateStore?: LockStateStore
|
||||
/** where the preferences are read from — read per call, so a settings change
|
||||
* takes effect without restarting anything */
|
||||
getLockSettings?: () => LockSettings
|
||||
publish?: (state: LockSettingsState) => void
|
||||
now?: () => number
|
||||
/** system idle time in seconds; injected so tests need no powerMonitor */
|
||||
idleSeconds?: () => number
|
||||
/** turns the lock preferences off once the password is gone; the default
|
||||
* writes them through settingsStore, which broadcasts the change itself */
|
||||
clearLockPreferences?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export class LockController {
|
||||
private readonly store: LockStore
|
||||
private readonly stateStore: LockStateStore
|
||||
private readonly getLockSettings: () => LockSettings
|
||||
private readonly publish: (state: LockSettingsState) => void
|
||||
private readonly now: () => number
|
||||
private readonly idleSeconds: () => number
|
||||
private readonly clearLockPreferences: () => void | Promise<void>
|
||||
|
||||
/** true only while a verifier exists and a lock was requested */
|
||||
private locked = false
|
||||
/** consecutive failed verifications; any success clears them */
|
||||
private failures = 0
|
||||
/** epoch ms until which attempts are refused; 0 = no cooldown */
|
||||
private cooldownUntil = 0
|
||||
private idleTimer?: ReturnType<typeof setInterval>
|
||||
/** tail of the operation queue; see serialize() */
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(options: LockControllerOptions = {}) {
|
||||
this.store = options.store ?? new LockStore(defaultLockPath(app.getPath('userData')))
|
||||
this.stateStore =
|
||||
options.stateStore ?? new LockStateStore(defaultLockStatePath(app.getPath('userData')))
|
||||
this.getLockSettings = options.getLockSettings ?? ((): LockSettings => loadSettings().lock)
|
||||
this.publish =
|
||||
options.publish ?? ((state): void => broadcast(Ipc.LOCK_STATE_CHANGED, state))
|
||||
this.now = options.now ?? ((): number => Date.now())
|
||||
this.idleSeconds = options.idleSeconds ?? ((): number => powerMonitor.getSystemIdleTime())
|
||||
this.clearLockPreferences =
|
||||
options.clearLockPreferences ??
|
||||
((): Promise<void> =>
|
||||
mutateSettings((s) => ({
|
||||
...s,
|
||||
lock: { ...s.lock, enabled: false, lockAtStartup: false }
|
||||
})).then(() => undefined))
|
||||
}
|
||||
|
||||
// ---- state -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write the live flags through to disk. Called after every change rather than
|
||||
* once at quit, because the exits that matter here are the abrupt ones: a
|
||||
* tray exit, a task-manager kill or a crash must not hand back an unlocked
|
||||
* app, and the failure count has to survive with it. The state is three
|
||||
* fields, so a synchronous write per change is not worth debouncing.
|
||||
*
|
||||
* A failed write is a warning and nothing more: losing the flags costs the
|
||||
* user one restart's worth of protection, while failing the operation they
|
||||
* just asked for would be a visible bug.
|
||||
*/
|
||||
private persist(): void {
|
||||
try {
|
||||
this.stateStore.save({
|
||||
locked: this.locked,
|
||||
failures: this.failures,
|
||||
cooldownUntil: this.cooldownUntil
|
||||
})
|
||||
} catch {
|
||||
console.warn('[lock] could not persist the lock state')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the operations one at a time. The gate reads the backoff, then awaits a
|
||||
* ~100ms scrypt verification; two calls arriving together would both clear the
|
||||
* gate and only raise the cooldown once they had both failed, so firing
|
||||
* attempts in parallel would step around the backoff entirely. Serializing
|
||||
* also means only one scrypt runs at a time in the main process.
|
||||
*/
|
||||
private serialize<T>(op: () => Promise<T>): Promise<T> {
|
||||
const run = this.queue.then(op, op)
|
||||
this.queue = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
private remainingCooldown(): number {
|
||||
return Math.max(0, this.cooldownUntil - this.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* What the renderer sees: the stored preferences plus the live lock flags.
|
||||
* Never the verifier — no salt, no hash, no password.
|
||||
*/
|
||||
getState(): LockSettingsState {
|
||||
const settings = this.getLockSettings()
|
||||
const cooldownMs = this.remainingCooldown()
|
||||
return {
|
||||
configured: this.store.isConfigured(),
|
||||
enabled: settings.enabled,
|
||||
autoLockMinutes: settings.autoLockMinutes,
|
||||
lockAtStartup: settings.lockAtStartup,
|
||||
locked: this.locked,
|
||||
...(cooldownMs > 0 ? { cooldownMs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private result(error?: LockOperationError): LockOperationResult {
|
||||
const state = this.getState()
|
||||
return error === undefined ? { ok: true, state } : { ok: false, state, error }
|
||||
}
|
||||
|
||||
/** Change the lock flag and publish, so every renderer follows immediately. */
|
||||
private applyLocked(locked: boolean): void {
|
||||
if (this.locked === locked) return
|
||||
this.locked = locked
|
||||
this.persist()
|
||||
this.publish(this.getState())
|
||||
}
|
||||
|
||||
/** Record a failed verification and (re)start the backoff. */
|
||||
private noteFailure(): void {
|
||||
const step = COOLDOWN_STEPS_MS[Math.min(this.failures, COOLDOWN_STEPS_MS.length - 1)]
|
||||
this.failures += 1
|
||||
this.cooldownUntil = this.now() + step
|
||||
this.persist()
|
||||
}
|
||||
|
||||
private resetFailures(): void {
|
||||
this.failures = 0
|
||||
this.cooldownUntil = 0
|
||||
this.persist()
|
||||
}
|
||||
|
||||
/** Refuse an attempt while the backoff is running. */
|
||||
private gate(): LockOperationResult | null {
|
||||
return this.remainingCooldown() > 0 ? this.result('cooldown') : null
|
||||
}
|
||||
|
||||
/** Passwords only ever travel in; a missing one is simply a mismatch. */
|
||||
private static passwordOf(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
// ---- operations ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set or replace the password. Replacing requires the current one: without
|
||||
* that check, anyone who walked up to an unlocked machine could install their
|
||||
* own password and keep the real user out afterwards.
|
||||
*/
|
||||
async setPassword(input: LockPasswordInput): Promise<LockOperationResult> {
|
||||
return this.serialize(async (): Promise<LockOperationResult> => {
|
||||
const next = input?.newPassword
|
||||
if (!isValidPassword(next)) return this.result('invalid-password')
|
||||
if (this.store.isConfigured()) {
|
||||
const blocked = this.gate()
|
||||
if (blocked) return blocked
|
||||
if (!(await this.store.verify(LockController.passwordOf(input?.currentPassword)))) {
|
||||
this.noteFailure()
|
||||
return this.result('wrong-password')
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.store.setPassword(next)
|
||||
} catch {
|
||||
// Deliberately not logging the error: it can quote the input, and the
|
||||
// password must not reach a log file.
|
||||
console.error('[lock] could not write the lock verifier')
|
||||
return this.result('save-failed')
|
||||
}
|
||||
this.resetFailures()
|
||||
// Whoever set the password knows it, so a freshly configured lock does not
|
||||
// slam shut on them; `locked` is left exactly as it was.
|
||||
this.publish(this.getState())
|
||||
return this.result()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the password. Verifying first is the whole point: otherwise the lock
|
||||
* could be removed by anyone at the keyboard. Clearing also unlocks, because
|
||||
* an unconfigured lock has no way to ask for anything.
|
||||
*/
|
||||
async clearPassword(input: { currentPassword?: string }): Promise<LockOperationResult> {
|
||||
return this.serialize(async (): Promise<LockOperationResult> => {
|
||||
if (!this.store.isConfigured()) {
|
||||
this.applyLocked(false)
|
||||
return this.result()
|
||||
}
|
||||
const blocked = this.gate()
|
||||
if (blocked) return blocked
|
||||
if (!(await this.store.verify(LockController.passwordOf(input?.currentPassword)))) {
|
||||
this.noteFailure()
|
||||
return this.result('wrong-password')
|
||||
}
|
||||
try {
|
||||
this.store.clear()
|
||||
} catch {
|
||||
console.error('[lock] could not remove the lock verifier')
|
||||
return this.result('save-failed')
|
||||
}
|
||||
this.locked = false
|
||||
this.resetFailures()
|
||||
// The preferences go with the password: the settings UI promises that
|
||||
// clearing turns the lock off, and leaving `enabled`/`lockAtStartup` set
|
||||
// would silently re-arm the screen the moment a new password was typed.
|
||||
try {
|
||||
await this.clearLockPreferences()
|
||||
} catch {
|
||||
console.warn('[lock] could not turn the lock preferences off')
|
||||
}
|
||||
this.publish(this.getState())
|
||||
return this.result()
|
||||
})
|
||||
}
|
||||
|
||||
/** Answer the lock screen. */
|
||||
async unlock(input: { password?: string }): Promise<LockOperationResult> {
|
||||
return this.serialize(async (): Promise<LockOperationResult> => {
|
||||
if (!this.store.isConfigured()) {
|
||||
// The verifier is gone (deleted while running): nothing could ever open
|
||||
// the screen again, so it must not stay shut.
|
||||
this.applyLocked(false)
|
||||
return this.result()
|
||||
}
|
||||
if (!this.locked) return this.result()
|
||||
const blocked = this.gate()
|
||||
if (blocked) return blocked
|
||||
if (!(await this.store.verify(LockController.passwordOf(input?.password)))) {
|
||||
this.noteFailure()
|
||||
return this.result('wrong-password')
|
||||
}
|
||||
this.locked = false
|
||||
this.resetFailures()
|
||||
this.publish(this.getState())
|
||||
return this.result()
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the screen is currently shut. Read by the main-process guards that
|
||||
* have to stop input reaching a locked window (see before-input-event). */
|
||||
isLocked(): boolean {
|
||||
return this.locked
|
||||
}
|
||||
|
||||
/** Lock the screen now. Only a configured password can lock — with no
|
||||
* verifier there would be no way back in. */
|
||||
lockNow(): LockSettingsState {
|
||||
if (this.store.isConfigured()) this.applyLocked(true)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
// ---- lifecycle -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply the startup lock and start watching for idleness. Call once the app is
|
||||
* ready: powerMonitor cannot be touched before that.
|
||||
*
|
||||
* The lock flags come back from disk, so a relaunch is not a way out of a lock
|
||||
* that was already up — an idle auto-lock or an explicit lock survives the
|
||||
* quit, exactly like `lockAtStartup` does. `locked` is assigned directly here
|
||||
* (no publish): nothing is listening yet, and the renderer asks for the state
|
||||
* as soon as it loads.
|
||||
*/
|
||||
start(): void {
|
||||
const restored = this.stateStore.load()
|
||||
this.failures = restored.failures
|
||||
// Clamped: see MAX_RESTORED_COOLDOWN_MS.
|
||||
this.cooldownUntil = Math.min(restored.cooldownUntil, this.now() + MAX_RESTORED_COOLDOWN_MS)
|
||||
if (this.store.isConfigured()) {
|
||||
if (this.getLockSettings().lockAtStartup || restored.locked) this.locked = true
|
||||
} else {
|
||||
// No verifier means nothing could ever open the screen again, so the
|
||||
// stored flags must not outlive it (a later password would re-arm a lock
|
||||
// nobody asked for).
|
||||
try {
|
||||
this.stateStore.clear()
|
||||
} catch {
|
||||
console.warn('[lock] could not clear the persisted lock state')
|
||||
}
|
||||
}
|
||||
if (this.idleTimer === undefined) {
|
||||
this.idleTimer = setInterval(() => this.checkIdle(), IDLE_POLL_MS)
|
||||
// Polling for idleness must never hold the process open.
|
||||
this.idleTimer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle auto-lock. Only a configured, enabled lock with a real delay may fire,
|
||||
* and never while the screen is already locked — otherwise every poll would
|
||||
* republish the same state.
|
||||
*/
|
||||
private checkIdle(): void {
|
||||
const settings = this.getLockSettings()
|
||||
if (!settings.enabled || settings.autoLockMinutes <= 0) return
|
||||
if (this.locked || !this.store.isConfigured()) return
|
||||
let idleSeconds: number
|
||||
try {
|
||||
idleSeconds = this.idleSeconds()
|
||||
} catch {
|
||||
// powerMonitor refuses without a session (or on a locked workstation);
|
||||
// "unknown" must read as "not idle" rather than locking the app.
|
||||
return
|
||||
}
|
||||
if (idleSeconds >= settings.autoLockMinutes * 60) this.applyLocked(true)
|
||||
}
|
||||
}
|
||||
|
||||
let controller: LockController | undefined
|
||||
|
||||
export function getLockController(): LockController {
|
||||
if (!controller) controller = new LockController()
|
||||
return controller
|
||||
}
|
||||
|
||||
/** Start the idle watcher and apply the startup lock (call after app ready). */
|
||||
export function initLockController(): LockController {
|
||||
const instance = getLockController()
|
||||
instance.start()
|
||||
return instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the lock channels. Goes through `ipcMain.handle` like every other
|
||||
* channel, so the sender guard installed by registerIpc covers these too.
|
||||
*/
|
||||
export function registerLockIpc(): void {
|
||||
const lock = getLockController()
|
||||
ipcMain.handle(Ipc.LOCK_STATE_GET, () => lock.getState())
|
||||
ipcMain.handle(Ipc.LOCK_SET_PASSWORD, (_event, input: LockPasswordInput) =>
|
||||
lock.setPassword(input ?? {})
|
||||
)
|
||||
ipcMain.handle(Ipc.LOCK_CLEAR_PASSWORD, (_event, input: { currentPassword?: string }) =>
|
||||
lock.clearPassword(input ?? {})
|
||||
)
|
||||
ipcMain.handle(Ipc.LOCK_UNLOCK, (_event, input: { password?: string }) =>
|
||||
lock.unlock(input ?? {})
|
||||
)
|
||||
ipcMain.handle(Ipc.LOCK_NOW, () => lock.lockNow())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Screen-lock stores. The verifier persists to <userData>/lock.json, the live
|
||||
* lock flags (locked / failures / cooldownUntil) to <userData>/lock-state.json.
|
||||
*
|
||||
* No Electron imports here: both file locations are injected, the same way the
|
||||
* known-hosts store does it, so the module can be driven by a plain-Node test.
|
||||
*
|
||||
* What lands on disk is a scrypt verifier, never the password: a random 16-byte
|
||||
* salt plus scrypt(password, salt, 64), both base64. The comparison goes through
|
||||
* timingSafeEqual so a wrong password cannot be narrowed down by response time,
|
||||
* and the password is never logged, echoed back or put in an error message.
|
||||
*
|
||||
* A missing, truncated, wrong-shaped or wrongly-sized verifier file reads as
|
||||
* "not configured": losing the lock is a nuisance, while throwing out of a
|
||||
* startup path would make the app unstartable. The state store is just as
|
||||
* forgiving, for the same reason.
|
||||
*/
|
||||
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'crypto'
|
||||
import { unlinkSync } from 'fs'
|
||||
import { readJson, writeJson } from './store'
|
||||
|
||||
/** Shape written to disk; `version` gates any future migration. */
|
||||
export interface LockStoreShape {
|
||||
version: 1
|
||||
/** random per-install salt, base64 */
|
||||
salt: string
|
||||
/** scrypt(password, salt, KEY_LENGTH), base64 */
|
||||
hash: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const SALT_BYTES = 16
|
||||
const KEY_LENGTH = 64
|
||||
|
||||
/**
|
||||
* scrypt cost parameters, spelled out rather than left to node's defaults so the
|
||||
* verifier cannot be silently re-tuned by a runtime upgrade (an existing hash
|
||||
* has to stay checkable).
|
||||
*/
|
||||
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
|
||||
|
||||
export const MIN_PASSWORD_LENGTH = 4
|
||||
export const MAX_PASSWORD_LENGTH = 128
|
||||
|
||||
/** An empty or absurdly long password is refused rather than hashed. */
|
||||
export function isValidPassword(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length >= MIN_PASSWORD_LENGTH &&
|
||||
value.length <= MAX_PASSWORD_LENGTH
|
||||
)
|
||||
}
|
||||
|
||||
/** Async on purpose: scryptSync would block the main process (which streams PTY
|
||||
* output) for ~100ms on every attempt. */
|
||||
function derive(password: string, salt: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, KEY_LENGTH, SCRYPT_OPTIONS, (err, key) => {
|
||||
if (err) reject(err)
|
||||
else resolve(key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export class LockStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
/** The load path runs on every state query, so the unknown-version warning
|
||||
* must fire once per process rather than once per call. */
|
||||
private static warnedUnknownVersion = false
|
||||
|
||||
/** The stored verifier, or null when unconfigured or unusable. */
|
||||
private load(): LockStoreShape | null {
|
||||
const parsed = readJson<Partial<LockStoreShape>>(this.filePath)
|
||||
if (parsed === null || typeof parsed !== 'object') return null
|
||||
if (parsed.version !== 1) {
|
||||
// Fail-open is deliberate (a lock file nobody can read must not make the
|
||||
// app unstartable), but a future format must not disable the lock
|
||||
// silently: the file exists, says it holds a verifier, and is being
|
||||
// ignored. One warning per process; the message quotes nothing from it.
|
||||
if (!LockStore.warnedUnknownVersion) {
|
||||
LockStore.warnedUnknownVersion = true
|
||||
console.warn(
|
||||
'[lock] lock.json has a version this build does not know; reading it as not configured — the password must be set again'
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (typeof parsed.salt !== 'string' || typeof parsed.hash !== 'string') return null
|
||||
if (typeof parsed.createdAt !== 'number') return null
|
||||
const salt = Buffer.from(parsed.salt, 'base64')
|
||||
const hash = Buffer.from(parsed.hash, 'base64')
|
||||
// A verifier of the wrong size is a corrupt file, not a weak password: it
|
||||
// can never match, so it must not count as "a password is set".
|
||||
if (salt.length !== SALT_BYTES || hash.length !== KEY_LENGTH) return null
|
||||
return { version: 1, salt: parsed.salt, hash: parsed.hash, createdAt: parsed.createdAt }
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.load() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fresh verifier — first set and replace alike, because the salt is
|
||||
* regenerated so the previous hash (and anyone who saw it) becomes worthless.
|
||||
* Throws on an unusable password; the caller maps that to `invalid-password`.
|
||||
*/
|
||||
async setPassword(password: string): Promise<void> {
|
||||
if (!isValidPassword(password)) {
|
||||
throw new Error(
|
||||
`lock password must be ${MIN_PASSWORD_LENGTH}..${MAX_PASSWORD_LENGTH} characters`
|
||||
)
|
||||
}
|
||||
const salt = randomBytes(SALT_BYTES)
|
||||
const hash = await derive(password, salt)
|
||||
const shape: LockStoreShape = {
|
||||
version: 1,
|
||||
salt: salt.toString('base64'),
|
||||
hash: hash.toString('base64'),
|
||||
createdAt: Date.now()
|
||||
}
|
||||
writeJson(this.filePath, shape)
|
||||
}
|
||||
|
||||
/** Constant-time check. False when unconfigured; never throws. */
|
||||
async verify(password: string): Promise<boolean> {
|
||||
const stored = this.load()
|
||||
if (stored === null || typeof password !== 'string') return false
|
||||
const expected = Buffer.from(stored.hash, 'base64')
|
||||
const actual = await derive(password, Buffer.from(stored.salt, 'base64'))
|
||||
// Lengths are equal by construction (load() enforces it); the guard keeps
|
||||
// timingSafeEqual from throwing on a file that changed underneath us.
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
/** Forget the password: the file is deleted, so "not configured" is one state
|
||||
* rather than two (an empty file vs. no file). */
|
||||
clear(): void {
|
||||
try {
|
||||
unlinkSync(this.filePath)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default location: <userData>/lock.json */
|
||||
export function defaultLockPath(userDataPath: string): string {
|
||||
return `${userDataPath}/lock.json`
|
||||
}
|
||||
|
||||
/** The live lock flags, as persisted and as held in memory by the controller. */
|
||||
export interface LockState {
|
||||
locked: boolean
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
}
|
||||
|
||||
/** Shape written to disk; `version` gates any future migration. */
|
||||
interface LockStateShape extends LockState {
|
||||
version: 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence for the lock flags themselves, so quitting and relaunching is not
|
||||
* a way out of a lock that was already up (nor a way to reset the backoff that
|
||||
* was already earned). Only flags live here — never a password, never a hash.
|
||||
*
|
||||
* Like the verifier store, this reads a missing/corrupt/wrong-shaped file as
|
||||
* "nothing was ever locked": the load happens on the startup path, where
|
||||
* throwing would leave the app without a window but still holding the
|
||||
* single-instance lock.
|
||||
*/
|
||||
export class LockStateStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
/** The stored flags, or "unlocked with no failures" for anything unusable. */
|
||||
load(): LockState {
|
||||
const fallback: LockState = { locked: false, failures: 0, cooldownUntil: 0 }
|
||||
const parsed = readJson<Partial<LockStateShape>>(this.filePath)
|
||||
if (parsed === null || typeof parsed !== 'object') return fallback
|
||||
if (parsed.version !== 1) return fallback
|
||||
const { failures, cooldownUntil } = parsed
|
||||
return {
|
||||
locked: parsed.locked === true,
|
||||
failures:
|
||||
typeof failures === 'number' && Number.isInteger(failures) && failures > 0 ? failures : 0,
|
||||
cooldownUntil:
|
||||
typeof cooldownUntil === 'number' && Number.isFinite(cooldownUntil) && cooldownUntil > 0
|
||||
? cooldownUntil
|
||||
: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic write (temp file + rename), so a crash mid-write cannot leave a
|
||||
* half-written file that would read back as "never locked". */
|
||||
save(state: LockState): void {
|
||||
const shape: LockStateShape = { version: 1, ...state }
|
||||
writeJson(this.filePath, shape)
|
||||
}
|
||||
|
||||
/** Forget the flags: the file is deleted, so "not locked" is one state rather
|
||||
* than two (an empty file vs. no file). */
|
||||
clear(): void {
|
||||
try {
|
||||
unlinkSync(this.filePath)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default location: <userData>/lock-state.json */
|
||||
export function defaultLockStatePath(userDataPath: string): string {
|
||||
return `${userDataPath}/lock-state.json`
|
||||
}
|
||||
+33
-7
@@ -8,7 +8,7 @@ import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../s
|
||||
import { broadcast } from './broadcast'
|
||||
import { connectSsh, type SshSessionHandle } from './ssh'
|
||||
import type { HostKeyCheckResult } from './knownHosts'
|
||||
import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo'
|
||||
import { configureSysinfo, registerSysinfoClient, forceStopPolling } from './sysinfo'
|
||||
import { registerSftpClientProvider, closeSftp } from './sftp'
|
||||
import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem'
|
||||
import { pickAdapter } from './shellIntegration'
|
||||
@@ -17,7 +17,7 @@ import { statSync } from 'fs'
|
||||
|
||||
// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the
|
||||
// M3 polling engine without importing src/main/sysinfo.ts separately.
|
||||
export { startPolling, stopPolling } from './sysinfo'
|
||||
export { startPolling, stopPolling, forceStopPolling } from './sysinfo'
|
||||
|
||||
/**
|
||||
* M5 command-store / log-service hooks (injected once by ipc.ts). Mirrors the
|
||||
@@ -308,16 +308,40 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
||||
if (typeof code === 'number') handle.exitCode = code
|
||||
})
|
||||
|
||||
handle.stream.on('close', () => {
|
||||
// One teardown for both terminal events. ssh2 emits 'close' after an 'error'
|
||||
// (and killSession closes the stream directly), so the path must be
|
||||
// idempotent: the flag guarantees PTY_EXIT is broadcast exactly once and the
|
||||
// polling / SFTP / ZMODEM / log cleanups run at most once per session.
|
||||
let sshClosed = false
|
||||
const teardownSshStream = (exitCode: number): void => {
|
||||
if (sshClosed) return
|
||||
sshClosed = true
|
||||
try {
|
||||
stopPolling(handle.id)
|
||||
// Session is gone: no shared poll may survive any remaining panel refs.
|
||||
forceStopPolling(handle.id)
|
||||
closeSftp(handle.id)
|
||||
detachZmodem(handle.id)
|
||||
safeStopLog(handle.id)
|
||||
sessions.delete(handle.id)
|
||||
replayBuffers.delete(handle.id)
|
||||
sshDecoders.delete(handle.id)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: handle.exitCode })
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
}
|
||||
|
||||
handle.stream.on('close', () => {
|
||||
teardownSshStream(handle.exitCode)
|
||||
})
|
||||
|
||||
handle.stream.on('error', (err: Error) => {
|
||||
// 'close' follows an 'error', but rely on the idempotent teardown instead
|
||||
// of waiting for it: a dead stream must release its session slot at once.
|
||||
try {
|
||||
console.warn(`[pty] ssh stream error (${handle.id}): ${err.message}`)
|
||||
if (handle.exitCode === 0) handle.exitCode = 1
|
||||
teardownSshStream(handle.exitCode)
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
@@ -353,7 +377,9 @@ export function resizePty(id: string, cols: number, rows: number): void {
|
||||
}
|
||||
|
||||
function killSession(id: string): void {
|
||||
stopPolling(id)
|
||||
// Forced: the session is being destroyed, so the shared poll must stop even
|
||||
// if split panels still hold references.
|
||||
forceStopPolling(id)
|
||||
closeSftp(id)
|
||||
detachZmodem(id)
|
||||
safeStopLog(id)
|
||||
@@ -398,7 +424,7 @@ export function killPtysByOwner(owner: number): void {
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const id of sessions.keys()) {
|
||||
stopPolling(id)
|
||||
forceStopPolling(id)
|
||||
closeSftp(id)
|
||||
detachZmodem(id)
|
||||
safeStopLog(id)
|
||||
|
||||
+38
-14
@@ -1,13 +1,16 @@
|
||||
import { app, ipcMain, powerSaveBlocker } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import {
|
||||
DEFAULT_HIGHLIGHT_RULES,
|
||||
DEFAULT_SETTINGS,
|
||||
isHighlightCategory,
|
||||
isLockAutoDelay,
|
||||
lockAutoDelayOf,
|
||||
type AppSettings,
|
||||
type HighlightRule,
|
||||
type LockSettings,
|
||||
type SystemSettings,
|
||||
type TerminalSettings
|
||||
} from '../shared/settings'
|
||||
@@ -15,6 +18,7 @@ import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/th
|
||||
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
|
||||
import { sanitizeProfiles } from '../shared/highlightProfiles'
|
||||
import { broadcast } from './broadcast'
|
||||
import { writeJson } from './store'
|
||||
import { applyGlobalShortcut } from './globalShortcuts'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
|
||||
@@ -267,8 +271,31 @@ function sanitizeThemes(value: unknown, warnings: Warnings): TerminalTheme[] {
|
||||
return themes
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the lock block. Every field is forced to its type, so a partial
|
||||
* patch, a hand-edited file or a config written before the block existed all
|
||||
* land on the defaults; the delay must be one of the offered steps, because an
|
||||
* arbitrary number here would silently change the idle-lock schedule.
|
||||
*/
|
||||
function sanitizeLock(value: unknown, errors: string[]): LockSettings {
|
||||
const candidate =
|
||||
value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
if (candidate.autoLockMinutes !== undefined && !isLockAutoDelay(candidate.autoLockMinutes)) {
|
||||
errors.push('lock.autoLockMinutes')
|
||||
}
|
||||
return {
|
||||
enabled: candidate.enabled === true,
|
||||
autoLockMinutes: lockAutoDelayOf(candidate.autoLockMinutes),
|
||||
lockAtStartup: candidate.lockAtStartup === true
|
||||
}
|
||||
}
|
||||
|
||||
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
const lock = sanitizeLock(
|
||||
raw !== null && typeof raw === 'object' ? (raw as { lock?: unknown }).lock : undefined,
|
||||
errors
|
||||
)
|
||||
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
|
||||
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
|
||||
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
|
||||
@@ -345,7 +372,8 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
new Set(rules.map((rule) => rule.id)),
|
||||
(message) => errors.push(message)
|
||||
),
|
||||
system: system as SystemSettings
|
||||
system: system as SystemSettings,
|
||||
lock
|
||||
},
|
||||
errors
|
||||
}
|
||||
@@ -419,7 +447,8 @@ export function loadSettings(): AppSettings {
|
||||
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
|
||||
highlightProfiles: [],
|
||||
system: { ...DEFAULT_SYSTEM }
|
||||
system: { ...DEFAULT_SYSTEM },
|
||||
lock: { ...DEFAULT_SETTINGS.lock }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,11 +469,7 @@ function migrateDefaultsOnce(): void {
|
||||
if (current.terminal.suggestEnabled || current.terminal.historyEnabled) {
|
||||
current.terminal.suggestEnabled = false
|
||||
current.terminal.historyEnabled = false
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
const path = settingsPath()
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf8')
|
||||
renameSync(tmp, path)
|
||||
writeJson(settingsPath(), current)
|
||||
}
|
||||
writeFileSync(flag, '', 'utf8')
|
||||
} catch {
|
||||
@@ -464,11 +489,9 @@ export function mutateSettings(mutate: (settings: AppSettings) => AppSettings):
|
||||
applySystemSettings(merged.system)
|
||||
applyWindowChrome(merged)
|
||||
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
const path = settingsPath()
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
|
||||
renameSync(tmp, path)
|
||||
// writeJson gives the same atomic write as every other store, including
|
||||
// short EPERM/EBUSY retries when Windows holds the destination open.
|
||||
writeJson(settingsPath(), merged)
|
||||
|
||||
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
||||
return merged
|
||||
@@ -492,7 +515,8 @@ export function saveSettings(next: Partial<AppSettings>): Promise<AppSettings> {
|
||||
...current,
|
||||
...next,
|
||||
terminal: { ...current.terminal, ...next.terminal },
|
||||
system: { ...current.system, ...next.system }
|
||||
system: { ...current.system, ...next.system },
|
||||
lock: { ...current.lock, ...next.lock }
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+30
-13
@@ -15,7 +15,6 @@
|
||||
|
||||
import type { SshConnection, SshSecretOverride } from '../shared/connections'
|
||||
import { t } from '../shared/i18n'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
|
||||
@@ -112,14 +111,16 @@ export async function connectSsh(
|
||||
// Called by ssh2 during kex; return undefined => async verdict via verify().
|
||||
const hostVerifier = (hostKey: Buffer, verify: (permitted: boolean) => void): void => {
|
||||
let fingerprint: string
|
||||
let status: 'new' | 'changed' | 'match'
|
||||
let status: HostKeyCheckResult['status']
|
||||
try {
|
||||
fingerprint = fingerprintOf(hostKey)
|
||||
status = deps.knownHosts.check(conn.host, conn.port, hostKey).status
|
||||
} catch (err) {
|
||||
console.error(`[ssh] knownHosts.check threw: ${(err as Error).message}`)
|
||||
fingerprint = fingerprintOf(hostKey)
|
||||
status = 'new'
|
||||
// A throwing store is as untrustworthy as an unreadable one: falling back
|
||||
// to 'new' would let the subsequent accept() rewrite the whole store.
|
||||
status = 'unreadable'
|
||||
}
|
||||
|
||||
if (status === 'match') {
|
||||
@@ -127,6 +128,17 @@ export async function connectSsh(
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'unreadable') {
|
||||
// known_hosts exists but cannot be read (corrupt JSON, access error...).
|
||||
// Accepting would persist the new key into a table we failed to load and
|
||||
// destroy every pinned fingerprint, so fail closed instead of prompting:
|
||||
// no accept offer, the user repairs or deletes the file and retries.
|
||||
verifierErr = t('main.ssh.knownHostsUnreadable')
|
||||
console.error(`[ssh] ${verifierErr}`)
|
||||
verify(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Pause the connect timeout; the user's decision owns this wait.
|
||||
if (connectTimer) clearTimeout(connectTimer)
|
||||
|
||||
@@ -168,13 +180,11 @@ export async function connectSsh(
|
||||
}
|
||||
// Session already established: surface as a session exit and clean up.
|
||||
// Record the failure code on the handle so the stream's later 'close'
|
||||
// (pty.ts) reports the same exit code instead of a bogus 0.
|
||||
// (pty.ts teardown) reports the same exit code instead of a bogus 0 —
|
||||
// the broadcast itself must come only from pty.ts's idempotent teardown;
|
||||
// emitting it here as well would fire PTY_EXIT twice (destroy() closes
|
||||
// the stream, and the stream 'close' handler broadcasts on its own).
|
||||
if (sessionHandle) sessionHandle.exitCode = 1
|
||||
try {
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
try {
|
||||
handshake.destroy()
|
||||
} catch {
|
||||
@@ -228,11 +238,14 @@ export async function connectSsh(
|
||||
// Password auth. A stored password is offered only when the bookmark is
|
||||
// configured for password auth: connectionsStore keeps password_enc when a
|
||||
// bookmark is switched to key/agent auth, and silently falling back to it
|
||||
// would authenticate a weaker method than the user chose. An explicitly
|
||||
// typed connect-time password (secretOverride) is always honoured.
|
||||
// would authenticate a weaker method than the user chose. The same gate
|
||||
// applies to a typed connect-time password (secretOverride): it belongs to
|
||||
// the password flow and must never upgrade a key/agent bookmark into
|
||||
// password auth (a stale ask flag used to route one here via ConnectFlow).
|
||||
const password =
|
||||
secretOverride?.password ??
|
||||
(conn.auth === 'password' ? deps.connections.getSecret(conn, 'password') : undefined)
|
||||
conn.auth === 'password'
|
||||
? (secretOverride?.password ?? deps.connections.getSecret(conn, 'password'))
|
||||
: undefined
|
||||
if (password !== undefined) cfg.password = password
|
||||
|
||||
// Private key auth (keyPath takes precedence over stored keyContent)
|
||||
@@ -246,6 +259,10 @@ export async function connectSsh(
|
||||
: undefined
|
||||
if (privateKey !== undefined) {
|
||||
cfg.privateKey = privateKey
|
||||
// A typed connect-time passphrase (secretOverride) is honoured only
|
||||
// inside this private-key branch — the gate above keeps the password
|
||||
// override out of key auth and this branch keeps the passphrase out of
|
||||
// password auth.
|
||||
const passphrase = secretOverride?.passphrase ?? deps.connections.getSecret(conn, 'passphrase')
|
||||
if (passphrase !== undefined) cfg.passphrase = passphrase
|
||||
}
|
||||
|
||||
+42
-5
@@ -75,16 +75,29 @@ interface PollState {
|
||||
prevAt: number
|
||||
metaSent: boolean
|
||||
timer: NodeJS.Timeout
|
||||
/**
|
||||
* Live renderer subscriptions on this poll. Two MonitorPanels can share one
|
||||
* sessionId (split view); each start/stop pair adjusts the count and only a
|
||||
* count of zero (or a forced stop) tears the poll down.
|
||||
*/
|
||||
refs: number
|
||||
}
|
||||
|
||||
const polls = new Map<string, PollState>()
|
||||
|
||||
/**
|
||||
* Start polling a session. Calling again for the same id stops the previous
|
||||
* poll first (re-entrancy safe).
|
||||
* Start polling a session on behalf of one renderer subscriber.
|
||||
*
|
||||
* The first call creates the poll; further calls for an already-polling id
|
||||
* only bump the reference count (the existing interval keeps running) so a
|
||||
* second panel joining a split view cannot restart or reset the shared poll.
|
||||
*/
|
||||
export function startPolling(id: string, intervalMs = 3000): void {
|
||||
stopPolling(id)
|
||||
const existing = polls.get(id)
|
||||
if (existing) {
|
||||
existing.refs += 1
|
||||
return
|
||||
}
|
||||
const state: PollState = {
|
||||
id,
|
||||
intervalMs,
|
||||
@@ -94,13 +107,34 @@ export function startPolling(id: string, intervalMs = 3000): void {
|
||||
lastSample: undefined,
|
||||
prevAt: 0,
|
||||
metaSent: false,
|
||||
refs: 1,
|
||||
timer: setTimeout(() => pollOnce(id, state), 0)
|
||||
}
|
||||
polls.set(id, state)
|
||||
}
|
||||
|
||||
/** Stop polling a session (no-op when not polling). Also run on session close. */
|
||||
/**
|
||||
* Drop one renderer subscription. The poll itself stops only when the last
|
||||
* reference is gone — any other panel sharing the sessionId keeps it alive.
|
||||
* No-op when nothing is polling (e.g. after a forced stop).
|
||||
*/
|
||||
export function stopPolling(id: string): void {
|
||||
const state = polls.get(id)
|
||||
if (!state) return
|
||||
state.refs -= 1
|
||||
if (state.refs <= 0) haltPolling(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop unconditionally, discarding the reference count. For session
|
||||
* close/kill: after the underlying ssh client is gone nothing must keep
|
||||
* polling, regardless of how many panels still hold references.
|
||||
*/
|
||||
export function forceStopPolling(id: string): void {
|
||||
haltPolling(id)
|
||||
}
|
||||
|
||||
function haltPolling(id: string): void {
|
||||
const state = polls.get(id)
|
||||
if (!state) return
|
||||
state.stopped = true
|
||||
@@ -199,7 +233,10 @@ function handleError(id: string, state: PollState, message: string): void {
|
||||
|
||||
if (state.consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
|
||||
console.warn(`[sysinfo] session ${id} failed ${state.consecutiveFails} polls, stopping`)
|
||||
stopPolling(id)
|
||||
// Engine-side decision: halt the poll outright (not a refcount decrement —
|
||||
// that would leave sibling panels pointing at a dead loop with no timer).
|
||||
// A later renderer stop is then a no-op and a fresh start can re-create it.
|
||||
forceStopPolling(id)
|
||||
return
|
||||
}
|
||||
armNext(id, state)
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ import { autoUpdater } from 'electron-updater'
|
||||
import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
|
||||
import { t } from '../shared/i18n'
|
||||
import { broadcast } from './broadcast'
|
||||
import { devUpdateFeedUrl } from './devEnv'
|
||||
import { loadSettings } from './settingsStore'
|
||||
import { markQuitting } from './tray'
|
||||
|
||||
@@ -32,11 +33,15 @@ function useFeed(feed: 'gitea' | 'github'): void {
|
||||
activeFeed = feed
|
||||
if (feed === 'gitea') {
|
||||
// Domestic feed: always direct — a system proxy only breaks it.
|
||||
// OT_UPDATE_URL overrides the feed in dev builds only; OT_UPDATE_TOKEN is
|
||||
// read from the environment in every build, which is only safe because the
|
||||
// packaged URL is the hardcoded GITEA_FEED — making it configurable again
|
||||
// would turn the token into a credential sent to whatever host it names.
|
||||
void autoUpdater.netSession.setProxy({ mode: 'direct' })
|
||||
const token = process.env.OT_UPDATE_TOKEN
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: process.env.OT_UPDATE_URL || GITEA_FEED,
|
||||
url: devUpdateFeedUrl() ?? GITEA_FEED,
|
||||
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
|
||||
})
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user