diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31906c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: typecheck + test + build (windows) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + # npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 10 个离线测试 + - name: Test + run: npm test + + # 仅验证构建通过;不做 electron-builder 打包、不发布、不传 artifact + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index 9126426..70feaaa 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ research/ .sftp-dl-* tests/.commands-store.cjs +tests/.known-hosts.cjs +tests/.lock-store.cjs +tests/.lock-controller.cjs tests/.settings-store.cjs tests/.session-e2e.cjs tests/.hl-split-smoke.cjs diff --git a/AGENTS.md b/AGENTS.md index 57fa24a..0cd7f69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,9 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - 开发:`npm run dev`(主进程改动不热重建,需重启) - dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置 - 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`) -- 测试:`npm test`(先 `node tests/build-bundles.cjs` 重建 esbuild bundle,再依次跑可离线运行的 7 个测试;真实服务器测试需 JD_* 凭据,不在此列) -- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap) +- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 10 个测试:ssh-loopback、commands-store、settings-store、lock-store、lock-controller、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) +- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 10 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次),产物在 `release/`(msi + exe + latest.yml + blockmap) + - GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布 - 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist` ## 仓库与远程 @@ -52,6 +53,19 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧 - 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效 +## 锁屏 + +- 只使用**主窗口内的不透明遮罩**(`src/renderer/src/lock/LockScreen.tsx` + `lock.css` 的 `.lock-screen`,z-index 4000),**不创建第二个 Electron 窗口**。锁定时 `App.tsx` 把 `.app-root` 设为 `inert` 并**保持挂载**——卸载会杀掉遮罩后面的本地/SSH 会话与传输列表;antd portal(Modal/Dropdown/Tooltip)挂在 `document.body` 上、不在 `#root` 内,锁定时**要把 body 下 `#root` 以外的子节点也设为 `inert`**,否则键盘 Tab 仍能走进遮罩后面的浮层 +- 密码 verifier 在 `/lock.json`(`src/main/lockStore.ts` 的 `LockStore`):scrypt(N=16384,r=8,p=1) + 每次写入重新生成的 16 字节 salt + `timingSafeEqual`,**不存明文**;缺 `version: 1`、salt/hash 尺寸不符一律当「未配置」(宁失效也不崩启动路径),但 `version` 不认识会**每进程 warn 一次**——将来改格式不许静默失锁 +- 锁状态由主进程独占(`src/main/lockController.ts`):`LockSettingsState` 只有 configured/enabled/autoLockMinutes/lockAtStartup/locked/cooldownMs,**salt/hash/密码永不出主进程**,渲染层从不自行判定锁定 +- 锁标志落盘在 `/lock-state.json`(`LockStateStore`),**locked/failures/cooldownUntil 每次变化立即写**,所以托盘退出、任务管理器强杀、崩溃后重启仍然是锁的——`lockAtStartup` 只是额外一层。没有 verifier 时启动会删掉该文件;从磁盘恢复的 `cooldownUntil` 夹紧到 `now+30s`,防系统时间回拨导致永久锁死 +- 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;`setPassword`/`clearPassword`/`unlock` 走内部串行队列(`serialize`),否则并发调用会同时通过闸门绕过冷却 +- 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不 +- **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装 +- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。渲染层另有一道 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`) +- 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行) +- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动) + ## 关键词高亮 - 预设规则表在 `src/shared/settings.ts` 的 `DEFAULT_HIGHLIGHT_RULES`(22 条),引擎在 `src/renderer/src/terminal/highlightEngine.ts`;规则按 priority 升序应用,**先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过** diff --git a/scripts/sync-changelog.cjs b/scripts/sync-changelog.cjs index 93b8ea0..a635e58 100644 --- a/scripts/sync-changelog.cjs +++ b/scripts/sync-changelog.cjs @@ -51,13 +51,27 @@ const sync = ({ notes, changelog, header, required }) => { return } - const section = `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}\n\n${body}\n` + // The header pattern must match the file's own newlines: a checkout with + // core.autocrlf=true leaves these files CRLF, and an `\n`-only pattern then + // matches nothing — the write below becomes a silent no-op that still logs + // success (exactly what bit the v1.0.17 sync). The inserted section follows + // the file's dominant ending so it does not create mixed line endings. + const nl = existing.includes('\r\n') ? '\r\n' : '\n' + const section = + `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}${nl}${nl}` + + `${body.split('\n').join(nl)}${nl}` // Function replacement, not a string: a notes body containing `$&`, `$1`, `` $` `` // or `$'` would otherwise be expanded by String.replace and corrupt the merge. const updated = existing - ? existing.replace(new RegExp(`^(${header}\\n\\n)`), (_m, head) => `${head}${section}\n`) - : `${header}\n\n${section}` + ? existing.replace(new RegExp(`^(${header}\\r?\\n\\r?\\n)`), (_m, head) => `${head}${section}${nl}`) + : `${header}${nl}${nl}${section}` fs.writeFileSync(changelogPath, updated, 'utf8') + // Belt and braces: the header replace is the only thing that places the + // section, so prove it landed instead of trusting the pattern. + if (!fs.readFileSync(changelogPath, 'utf8').includes(`## v${pkgVersion} `)) { + console.error(`${changelog}: header pattern did not match — section was NOT inserted`) + process.exit(1) + } console.log(`${changelog}: added v${pkgVersion} (${body.split('\n').length} lines)`) } diff --git a/src/main/commands.ts b/src/main/commands.ts index e03221a..5fceb27 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -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 { diff --git a/src/main/devEnv.ts b/src/main/devEnv.ts new file mode 100644 index 0000000..3d32b97 --- /dev/null +++ b/src/main/devEnv.ts @@ -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 +} diff --git a/src/main/index.ts b/src/main/index.ts index 6be4c4e..da238af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7414390..48af590 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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`). diff --git a/src/main/knownHosts.ts b/src/main/knownHosts.ts index 7ced5c9..6392333 100644 --- a/src/main/knownHosts.ts +++ b/src/main/knownHosts.ts @@ -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 + 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 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] : [] } } diff --git a/src/main/lockController.ts b/src/main/lockController.ts new file mode 100644 index 0000000..78fbb78 --- /dev/null +++ b/src/main/lockController.ts @@ -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 /lock.json */ + store?: LockStore + /** injected by tests; defaults to /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 +} + +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 + + /** 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 + /** tail of the operation queue; see serialize() */ + private queue: Promise = 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 => + 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(op: () => Promise): Promise { + 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 { + return this.serialize(async (): Promise => { + 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 { + return this.serialize(async (): Promise => { + 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 { + return this.serialize(async (): Promise => { + 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()) +} diff --git a/src/main/lockStore.ts b/src/main/lockStore.ts new file mode 100644 index 0000000..649168e --- /dev/null +++ b/src/main/lockStore.ts @@ -0,0 +1,217 @@ +/** + * Screen-lock stores. The verifier persists to /lock.json, the live + * lock flags (locked / failures / cooldownUntil) to /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 { + 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>(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 { + 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 { + 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: /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>(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: /lock-state.json */ +export function defaultLockStatePath(userDataPath: string): string { + return `${userDataPath}/lock-state.json` +} diff --git a/src/main/pty.ts b/src/main/pty.ts index 98218be..5b7a40d 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -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) diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index b20fb4a..9527e26 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -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) : {} + 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): Promise { ...current, ...next, terminal: { ...current.terminal, ...next.terminal }, - system: { ...current.system, ...next.system } + system: { ...current.system, ...next.system }, + lock: { ...current.lock, ...next.lock } })) } diff --git a/src/main/ssh.ts b/src/main/ssh.ts index 08b4129..22a5bd2 100644 --- a/src/main/ssh.ts +++ b/src/main/ssh.ts @@ -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 } diff --git a/src/main/sysinfo.ts b/src/main/sysinfo.ts index 7195586..b5351dd 100644 --- a/src/main/sysinfo.ts +++ b/src/main/sysinfo.ts @@ -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() /** - * 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) diff --git a/src/main/updater.ts b/src/main/updater.ts index 7b8c0cd..c947a1d 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 { diff --git a/src/preload/index.ts b/src/preload/index.ts index 4e7a688..fa2dcee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,6 +3,7 @@ import { Ipc } from '../shared/ipc' import type { AppSettings } from '../shared/settings' import type { AppApi } from '../shared/api' import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, SessionSnapshot, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc' +import type { LockOperationResult, LockPasswordInput, LockSettingsState } from '../shared/ipc' import type { HostKeyAction, HostKeyPromptEvent, @@ -113,6 +114,18 @@ const api: AppApi = { return () => ipcRenderer.removeListener(Ipc.SETTINGS_CHANGED, listener) }, + getLockState: () => ipcRenderer.invoke(Ipc.LOCK_STATE_GET), + setLockPassword: (input: LockPasswordInput) => ipcRenderer.invoke(Ipc.LOCK_SET_PASSWORD, input), + clearLockPassword: (input: { currentPassword?: string }) => + ipcRenderer.invoke(Ipc.LOCK_CLEAR_PASSWORD, input), + unlockLock: (input: { password?: string }) => ipcRenderer.invoke(Ipc.LOCK_UNLOCK, input), + lockNow: () => ipcRenderer.invoke(Ipc.LOCK_NOW), + onLockStateChanged: (cb: (state: LockSettingsState) => void) => { + const listener = (_: unknown, state: LockSettingsState): void => cb(state) + ipcRenderer.on(Ipc.LOCK_STATE_CHANGED, listener) + return () => ipcRenderer.removeListener(Ipc.LOCK_STATE_CHANGED, listener) + }, + listFonts: () => ipcRenderer.invoke(Ipc.FONTS_LIST), listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 84efcb8..83091d7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,9 +7,11 @@ import jaJP from 'antd/locale/ja_JP' import Workspace from '@renderer/workspace/Workspace' import { SettingsDialog } from '@renderer/settings/SettingsDialog' import { TransferPanel } from '@renderer/sftp/TransferPanel' +import { LockScreen } from '@renderer/lock/LockScreen' import { useSettingsStore } from '@renderer/settings/store' import { getThemeById } from '@shared/theme' import { DEFAULT_LANGUAGE, syncLanguage, type Language } from '@shared/i18n' +import type { LockSettingsState } from '@shared/ipc' import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome' import appIconUrl from '../../../build/icon.png' @@ -44,11 +46,81 @@ export default function App(): React.JSX.Element { const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor) const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) const [settingsOpen, setSettingsOpen] = useState(false) + /** Secure default: main may already hold a startup lock before this IPC + * resolves, so the shell must not become interactive while unknown. `null` + * means "not answered yet" and is kept distinct from "locked": the overlay + * is drawn only once main has spoken, otherwise every start would flash a + * lock panel — even for users who never configured a password. */ + const [lockState, setLockState] = useState(null) useEffect(() => { void hydrate() }, [hydrate]) + // Lock state: pulled once (it may already be locked at startup) and then kept + // in sync from main, which owns the state — the renderer never decides. + useEffect(() => { + let alive = true + void window.api.getLockState().then( + (state: LockSettingsState) => { + if (alive) setLockState(state) + }, + (err: unknown) => { + console.error('[lock] getLockState failed', err) + // Stay recoverable: fall back to "locked with no verifier" so the panel + // (and its input) is reachable. Main unlocks an unconfigured app on the + // first attempt, so the user is never stuck on the boot layer. + if (alive) { + setLockState({ + configured: false, + enabled: false, + autoLockMinutes: 0, + lockAtStartup: false, + locked: true + }) + } + } + ) + const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state)) + return () => { + alive = false + off() + } + }, []) + + // Unknown counts as locked: main owns the state and may already be locked. + const locked = lockState === null || lockState.locked + + // Locking is app-wide: close antd portals that live outside `.app-root` + // (the settings modal otherwise stays focusable behind the opaque mask), + // and expose the state to window-level hotkey listeners — they see 'true' + // during the unknown window as well, which is the point. + useEffect(() => { + document.documentElement.dataset.locked = locked ? 'true' : 'false' + if (locked) setSettingsOpen(false) + // Portals (antd modals, dropdowns, tooltips) attach to document.body, + // outside the inert `#root` subtree, so a keyboard user could still Tab + // into whatever happened to be open when the lock engaged. Inert every + // other body child while locked; the overlay itself lives inside #root, + // above the inert `.app-root`, and stays reachable. + const appRoot = document.getElementById('root') + const inerted: Element[] = [] + if (locked) { + for (const el of Array.from(document.body.children)) { + if (el === appRoot) continue + try { + el.setAttribute('inert', '') + inerted.push(el) + } catch { + // a node that refuses the attribute must not break the lock + } + } + } + return () => { + for (const el of inerted) el.removeAttribute('inert') + } + }, [locked]) + // Interface language: t() reads the module-level language at render time, so // sync it before the tree renders — an effect would paint one frame late. // Silent on purpose: notifying subscribers during a render is what React @@ -71,12 +143,26 @@ export default function App(): React.JSX.Element { return ( -
- - setSettingsOpen(true)} /> - setSettingsOpen(false)} /> - -
+ <> + {/* Locked: the shell goes inert (no focus, no pointer, hidden from + assistive tech) but stays mounted — unmounting it would kill the + local/SSH sessions and the transfer list behind the overlay. + `inert` is listed before aria-hidden so focus leaves the subtree + before the browser checks for a focused descendant. */} +
+ + setSettingsOpen(true)} /> + setSettingsOpen(false)} /> + +
+ {/* Unknown state paints the opaque layer alone: the shell stays hidden + and no panel is shown, so startup cannot flash a lock screen. */} + {lockState === null ? ( +
+ ) : locked ? ( + + ) : null} + ) } diff --git a/src/renderer/src/lock/LockScreen.tsx b/src/renderer/src/lock/LockScreen.tsx new file mode 100644 index 0000000..e8488ed --- /dev/null +++ b/src/renderer/src/lock/LockScreen.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from 'react' +import { LockOutlined } from '@ant-design/icons' +import { Button, Input } from 'antd' +import type { InputRef } from 'antd' +import { t } from '@shared/i18n' +import type { LockOperationError, LockSettingsState } from '@shared/ipc' +import './lock.css' + +/** Message key per failure, so every LockOperationError reads as its own line. */ +const ERROR_KEYS: Record = { + 'invalid-password': 'settings.lock.error.invalidPassword', + 'wrong-password': 'settings.lock.error.wrongPassword', + cooldown: 'settings.lock.error.cooldown', + 'save-failed': 'settings.lock.error.saveFailed' +} + +/** + * Failure text for a `LockOperationResult.error`. A `cooldown` error is shown + * with the remaining seconds when main reports them, and with the generic + * wording otherwise — never as "retry in 0 seconds". + */ +export function lockErrorText(error: LockOperationError, cooldownMs?: number): string { + if (error === 'cooldown') { + const seconds = cooldownMs !== undefined && cooldownMs > 0 ? Math.ceil(cooldownMs / 1000) : 0 + return seconds > 0 + ? t('settings.lock.error.cooldown', { n: seconds }) + : t('settings.lock.error.cooldownGeneric') + } + return t(ERROR_KEYS[error]) +} + +export interface LockScreenProps { + state: LockSettingsState + /** publish the state main returned, so App drops the overlay once unlocked */ + onStateChange: (state: LockSettingsState) => void +} + +/** + * Lock overlay for the main window. + * + * App.tsx renders it as a *sibling* of the app shell rather than inside it: the + * shell stays mounted (and inert) underneath, so PTY/SSH sessions keep running + * and the workspace is not torn down by a lock. The layer is opaque, so no + * terminal output is visible through it. + * + * Deliberately minimal — no session content, no bypass button, and the password + * never leaves this component: it is cleared after every attempt and is not + * logged anywhere. + */ +export function LockScreen({ state, onStateChange }: LockScreenProps): React.JSX.Element { + const [password, setPassword] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + /** local deadline, so the countdown keeps ticking between main's broadcasts */ + const [cooldownUntil, setCooldownUntil] = useState(0) + const [now, setNow] = useState(() => Date.now()) + const inputRef = useRef(null) + + const cooldownMs = state.cooldownMs ?? 0 + useEffect(() => { + setCooldownUntil(cooldownMs > 0 ? Date.now() + cooldownMs : 0) + setNow(Date.now()) + }, [cooldownMs]) + + useEffect(() => { + if (cooldownUntil <= 0) return + const id = window.setInterval(() => setNow(Date.now()), 250) + return () => window.clearInterval(id) + }, [cooldownUntil]) + + const remainingMs = Math.max(0, cooldownUntil - now) + const cooling = remainingMs > 0 + + // Focus follows usability: on mount and again when a cooldown runs out, so + // the lock can be answered by typing without reaching for the mouse. + useEffect(() => { + if (!cooling) inputRef.current?.focus() + }, [cooling]) + + const submit = async (): Promise => { + if (busy || cooling || password.length === 0) return + const attempt = password + setBusy(true) + setError(null) + try { + const result = await window.api.unlockLock({ password: attempt }) + setPassword('') + onStateChange(result.state) + if (!result.ok) setError(result.error ?? 'wrong-password') + } catch (err) { + console.error('[lock] unlock request failed', err) + setError('save-failed') + } finally { + setBusy(false) + } + } + + const message = cooling + ? lockErrorText('cooldown', remainingMs) + : error + ? lockErrorText(error, cooldownMs) + : '' + + return ( + + ) +} diff --git a/src/renderer/src/lock/lock.css b/src/renderer/src/lock/lock.css new file mode 100644 index 0000000..46157c9 --- /dev/null +++ b/src/renderer/src/lock/lock.css @@ -0,0 +1,80 @@ +/* ============================================================ + Lock overlay (lock/LockScreen.tsx) + Opaque on purpose: nothing of the workspace underneath may + show through. Colors come from the chrome variables, so the + overlay follows the active terminal theme. + ============================================================ */ + +/* Opaque layer shared by the lock screen and the boot layer that App.tsx paints + while the lock state is still unknown (first IPC round trip). Keeping one rule + means the boot layer cannot drift away from the overlay's stacking level. */ +.lock-screen, +.lock-screen-boot { + position: fixed; + inset: 0; + /* above the workspace rail (960) and every antd layer: modal 1000, + message 1010, notification 1050 */ + z-index: 4000; + background: var(--chrome-bg-deep, #101418); + color: var(--chrome-fg, #cccccc); +} + +/* Only the panel-bearing overlay centres anything; the boot layer is empty. */ +.lock-screen { + display: flex; + align-items: center; + justify-content: center; +} + +.lock-screen-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + width: 340px; + max-width: 80vw; + padding: 30px 28px 22px; + border: 1px solid var(--chrome-border, #2d2d2d); + border-radius: 10px; + background: var(--chrome-bg, #181818); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + text-align: center; +} + +.lock-screen-icon { + font-size: 32px; + line-height: 1; + color: var(--tab-accent, #3fb950); +} + +.lock-screen-title { + font-size: 17px; + font-weight: 600; +} + +.lock-screen-desc { + font-size: 12px; + margin-bottom: 4px; + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent); +} + +.lock-screen-input, +.lock-screen-button { + width: 100%; +} + +/* Reserved height: the message appears and disappears without moving the + button, so a failed attempt does not shift the input out from under the + pointer. */ +.lock-screen-message { + min-height: 18px; + font-size: 12px; + line-height: 18px; + color: #f85149; +} + +.lock-screen-hint { + font-size: 11px; + line-height: 1.5; + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 38%, transparent); +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 87881f6..bce9b74 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,6 +2,7 @@ import ReactDOM from 'react-dom/client' import { useEffect, type ReactNode } from 'react' import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd' import type { AppApi } from '@shared/api' +import type { LockSettingsState } from '@shared/ipc' import { DEFAULT_SETTINGS } from '@shared/settings' import { getThemeById } from '@shared/theme' import App from './App' @@ -19,7 +20,14 @@ import './global.css' setInterval(() => { const now = performance.now() const lag = now - lastTick - 2000 - if (lag > 3000) console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`) + // Chromium throttles timers in a hidden page down to roughly one per + // minute (intensive throttling), which this watchdog would otherwise + // report as a ~58s "stall" on every tick while the window sits in the + // tray. A hidden window also has nothing user-visible to freeze, so the + // report is skipped until the page is visible again. + if (lag > 3000 && !document.hidden) { + console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`) + } lastTick = now }, 2000) } @@ -29,6 +37,16 @@ import './global.css' if (typeof window !== 'undefined' && !window.api) { const noop = (): void => undefined const stubId = (): string => `stub-${Math.random().toString(36).slice(2)}` + /** Browser stub: never configured and never locked, so the lock overlay + * stays out of the way of layout work done in a plain vite page. */ + const stubLockState = (over?: Partial): LockSettingsState => ({ + configured: false, + enabled: false, + autoLockMinutes: 0, + lockAtStartup: false, + locked: false, + ...over + }) const api: AppApi = { appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }), createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }), @@ -111,7 +129,16 @@ if (typeof window !== 'undefined' && !window.api) { getSessionState: async () => null, saveSessionState: async () => null, resolveCwd: async () => null, - reportCwd: async () => null + reportCwd: async () => null, + getLockState: async () => stubLockState(), + setLockPassword: async (input) => ({ + ok: true, + state: stubLockState({ configured: (input.newPassword ?? '').length > 0 }) + }), + clearLockPassword: async () => ({ ok: true, state: stubLockState() }), + unlockLock: async () => ({ ok: true, state: stubLockState() }), + lockNow: async () => stubLockState(), + onLockStateChanged: () => noop } ;(window as unknown as { api: AppApi }).api = api } @@ -130,6 +157,15 @@ function FontHotkeyListener(): null { useEffect(() => { const onKeyDown = (e: KeyboardEvent): void => { + // The lock overlay leaves Workspace mounted; window-capture hotkeys must + // not mutate font size on a shell hidden behind it. preventDefault matters + // here: returning alone lets the chord reach Electron's default menu + // accelerators (zoomIn/zoomOut/resetZoom), which rescale the whole UI + // behind the opaque mask and make Chromium persist that zoom per origin. + if (document.documentElement.dataset.locked === 'true') { + e.preventDefault() + return + } if (!e.ctrlKey || e.metaKey) return const target = e.target as HTMLElement | null const isXtermHelper = diff --git a/src/renderer/src/settings/LockSettingsTab.tsx b/src/renderer/src/settings/LockSettingsTab.tsx new file mode 100644 index 0000000..23f4a5f --- /dev/null +++ b/src/renderer/src/settings/LockSettingsTab.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState } from 'react' +import { Button, Input, Popconfirm, Select, Switch, Tag } from 'antd' +import { t } from '@shared/i18n' +import type { LockOperationResult, LockSettingsState } from '@shared/ipc' +import { LOCK_AUTO_DELAYS, type LockAutoDelay } from '@shared/settings' +import { lockErrorText } from '@renderer/lock/LockScreen' +import { SettingRow } from './fields' +import { useSettingsStore } from './store' + +type Feedback = { kind: 'ok' | 'error'; text: string } | null + +/** + * 锁屏设置页。 + * + * Two sources, on purpose: the switches live in `settings.lock` (persisted + * through the settings store, so they follow the normal save/rollback path), + * while "is a password configured / is the screen locked right now" comes from + * main (`lock.json` holds the verifier, never settings). The password fields + * are write-only: they are sent once and cleared, main never echoes them back. + */ +export function LockSettingsTab(): React.JSX.Element { + const lock = useSettingsStore((s) => s.settings.lock) + const updateLock = useSettingsStore((s) => s.updateLock) + const [lockState, setLockState] = useState(null) + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [busy, setBusy] = useState(false) + const [feedback, setFeedback] = useState(null) + + useEffect(() => { + let alive = true + void window.api.getLockState().then( + (state: LockSettingsState) => { + if (alive) setLockState(state) + }, + (err: unknown) => console.error('[lock] getLockState failed', err) + ) + // Keeps `configured` honest when the lock engages or main clears the + // verifier (e.g. an unlock attempt failed and a cooldown started). + const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state)) + return () => { + alive = false + off() + } + }, []) + + const configured = lockState?.configured === true + const usable = configured && lock.enabled + + const clearFields = (): void => { + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + } + + /** Run a lock operation and fold its result into the local state + feedback. */ + const run = async (op: () => Promise, okText: string): Promise => { + setBusy(true) + setFeedback(null) + try { + const result = await op() + setLockState(result.state) + if (result.ok) { + clearFields() + setFeedback({ kind: 'ok', text: okText }) + } else { + setFeedback({ + kind: 'error', + text: lockErrorText(result.error ?? 'save-failed', result.state.cooldownMs) + }) + } + } catch (err) { + console.error('[lock] operation failed', err) + setFeedback({ kind: 'error', text: lockErrorText('save-failed') }) + } finally { + setBusy(false) + } + } + + const savePassword = (): void => { + if (newPassword.length === 0) { + setFeedback({ kind: 'error', text: t('settings.lock.password.empty') }) + return + } + if (newPassword !== confirmPassword) { + setFeedback({ kind: 'error', text: t('settings.lock.password.mismatch') }) + return + } + void run( + () => + window.api.setLockPassword(configured ? { currentPassword, newPassword } : { newPassword }), + t('settings.lock.password.saved') + ) + } + + const clearPassword = (): void => { + if (currentPassword.length === 0) { + setFeedback({ kind: 'error', text: t('settings.lock.password.empty') }) + return + } + void run( + () => window.api.clearLockPassword({ currentPassword }), + t('settings.lock.password.cleared') + ) + } + + /** lockNow answers with the state directly, not with an operation result. */ + const lockNow = async (): Promise => { + setBusy(true) + setFeedback(null) + try { + setLockState(await window.api.lockNow()) + } catch (err) { + console.error('[lock] lockNow failed', err) + setFeedback({ kind: 'error', text: lockErrorText('save-failed') }) + } finally { + setBusy(false) + } + } + + const autoLockOptions = LOCK_AUTO_DELAYS.map((minutes: LockAutoDelay) => ({ + value: minutes, + label: + minutes === 0 + ? t('settings.lock.autoLockOff') + : t('settings.lock.autoLockMinutes', { n: minutes }) + })) + + return ( +
+
+ {t('settings.lock.password.title')} + {t('settings.lock.password.desc')} + + {configured + ? t('settings.lock.password.configured') + : t('settings.lock.password.notConfigured')} + +
+ + {configured && ( + setCurrentPassword(e.target.value)} + /> + } + /> + )} + setNewPassword(e.target.value)} + /> + } + /> + setConfirmPassword(e.target.value)} + /> + } + /> +
+ + {configured && ( + + + + )} +
+
+ {feedback?.text ?? ''} +
+
{t('settings.lock.password.forgot')}
+ + void updateLock({ enabled: checked })} + /> + } + /> + void updateLock({ autoLockMinutes: value })} + options={autoLockOptions} + /> + } + /> + void updateLock({ lockAtStartup: checked })} + /> + } + /> + void lockNow()}> + {t('settings.lock.lockNow')} + + } + /> +
+ ) +} diff --git a/src/renderer/src/settings/SettingsDialog.tsx b/src/renderer/src/settings/SettingsDialog.tsx index 7007595..95a7e21 100644 --- a/src/renderer/src/settings/SettingsDialog.tsx +++ b/src/renderer/src/settings/SettingsDialog.tsx @@ -7,6 +7,7 @@ import { useSettingsStore } from './store' import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs' import { ThemeSettingsTab } from './ThemeSettingsTab' import { HighlightTab } from './HighlightTab' +import { LockSettingsTab } from './LockSettingsTab' import { AboutTab } from './AboutTab' import { ThemeEditor } from '../theme/editor/ThemeEditor' import './settings.css' @@ -91,6 +92,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS ) }, { key: 'system', label: t('settings.tabs.system'), children: }, + { key: 'lock', label: t('settings.tabs.lock'), children: }, { key: 'about', label: t('settings.tabs.about'), children: } ] diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css index 58e7f1f..748403b 100644 --- a/src/renderer/src/settings/settings.css +++ b/src/renderer/src/settings/settings.css @@ -571,6 +571,43 @@ line-height: 1.6; } +/* ---------- lock tab (settings/LockSettingsTab.tsx) ---------- */ + +.settings-lock-input { + width: 220px; +} + +.settings-lock-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin: 8px 8px 0; +} + +/* Reserved height, so a feedback line appearing does not push the rows below + it around. */ +.settings-lock-feedback { + min-height: 18px; + margin: 6px 8px 0; + font-size: 12px; + line-height: 18px; +} + +.settings-lock-feedback.is-ok { + color: #3fb950; +} + +.settings-lock-feedback.is-error { + color: #f85149; +} + +.settings-lock-note { + margin: 4px 8px 16px; + font-size: 12px; + line-height: 1.6; + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent); +} + /* ---------- shortcut recorder (press-to-record input) ---------- */ .shortcut-input { diff --git a/src/renderer/src/settings/store.ts b/src/renderer/src/settings/store.ts index 641db5c..0655ed6 100644 --- a/src/renderer/src/settings/store.ts +++ b/src/renderer/src/settings/store.ts @@ -1,4 +1,4 @@ -import type { AppSettings, SystemSettings, TerminalSettings } from '@shared/settings' +import type { AppSettings, LockSettings, SystemSettings, TerminalSettings } from '@shared/settings' import { DEFAULT_SETTINGS } from '@shared/settings' import type { TerminalTheme } from '@shared/theme' import { getThemeById } from '@shared/theme' @@ -19,6 +19,8 @@ export interface SettingsState { setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise /** shallow-merge into settings.system and persist */ updateSystem: (partial: Partial) => Promise + /** shallow-merge into settings.lock and persist */ + updateLock: (partial: Partial) => Promise } /** unsubscriber for the cross-window SETTINGS_CHANGED listener; held module-level so hydrate() is idempotent */ @@ -68,6 +70,8 @@ async function persist( if (terminal) patch.terminal = terminal as TerminalSettings const system = diffGroup(prev.system, written.system) if (system) patch.system = system as SystemSettings + const lock = diffGroup(prev.lock, written.lock) + if (lock) patch.lock = lock as LockSettings await window.api.saveSettings(patch) } catch (err) { console.error(`[settings] ${label} failed, rolling back`, err) @@ -131,6 +135,12 @@ export const useSettingsStore = create((set, get) => ({ const prev = get().settings const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } } await persist(get, set, next, prev, 'updateSystem') + }, + + updateLock: async (partial) => { + const prev = get().settings + const next: AppSettings = { ...prev, lock: { ...prev.lock, ...partial } } + await persist(get, set, next, prev, 'updateLock') } })) diff --git a/src/renderer/src/workspace/ConnectFlow.tsx b/src/renderer/src/workspace/ConnectFlow.tsx index 0cc9865..7689dc4 100644 --- a/src/renderer/src/workspace/ConnectFlow.tsx +++ b/src/renderer/src/workspace/ConnectFlow.tsx @@ -1,16 +1,17 @@ import { useState } from 'react' import { Button, Input, Modal } from 'antd' import { LoadingOutlined } from '@ant-design/icons' -import type { SshConnection, SshSecretOverride } from '@shared/connections' +import { connectPromptFor, type SshConnection, type SshSecretOverride } from '@shared/connections' import { t } from '@shared/i18n' /** * Connect-time gateways for one SSH connection attempt. * * `phase` drives which dialog shows: - * - 'secret' → secret prompt modal (password when `askPasswordAtConnect`, - * passphrase when `askPassphraseAtConnect`). This is the only - * connect-time dialog that can be cancelled, because + * - 'secret' → secret prompt modal (kind chosen by the shared + * `connectPromptFor`: password for ask-at-connect password + * auth, passphrase for ask-at-connect key auth). This is the + * only connect-time dialog that can be cancelled, because * openSession cannot be aborted before it resolves. * - 'connecting' → an uncancellable "连接中…" modal while openSession flies. * @@ -34,14 +35,15 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP const [password, setPassword] = useState('') const [passphrase, setPassphrase] = useState('') + const prompt = conn != null && phase !== 'connecting' ? connectPromptFor(conn) : null const stage: ConnectStage = conn == null ? 'idle' : phase === 'connecting' ? 'connecting' - : conn.askPasswordAtConnect + : prompt === 'password' ? 'password' - : conn.askPassphraseAtConnect + : prompt === 'passphrase' ? 'passphrase' : 'connecting' diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index 2649647..49b5b8f 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -25,7 +25,7 @@ import type { } from 'dockview-react' import 'dockview-react/dist/styles/dockview.css' -import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections' +import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections' import { t } from '@shared/i18n' import { ConnectionSidebar } from '../connections/ConnectionSidebar' import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar' @@ -660,6 +660,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX */ useEffect(() => { const handler = (e: KeyboardEvent): void => { + // Tab cycling must not move an invisible workspace while the lock + // overlay covers the main window. + if (document.documentElement.dataset.locked === 'true') return const ctrl = e.ctrlKey const code = e.code if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return @@ -764,12 +767,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const handleConnectRequest = useCallback( (conn: SshConnection): void => { if (connectInFlightRef.current > 0) return - // Ask-at-connect connections go through the secret prompt first; saved - // credentials must connect IMMEDIATELY — routing them through ConnectFlow - // would only ever *render* a "connecting" spinner without firing openSession. - const needsPassword = conn.auth === 'password' && conn.askPasswordAtConnect - const needsPassphrase = conn.auth === 'privateKey' && conn.askPassphraseAtConnect - if (needsPassword || needsPassphrase) { + // Ask-at-connect connections go through the secret prompt first (the + // prompt kind comes from the shared `connectPromptFor`, so a stale ask + // flag from a switched auth method can never pop the wrong dialog); + // saved credentials must connect IMMEDIATELY — routing them through + // ConnectFlow would only ever *render* a "connecting" spinner without + // firing openSession. + const prompt = connectPromptFor(conn) + if (prompt !== null) { setRequestedConn(conn) return } diff --git a/src/shared/api.ts b/src/shared/api.ts index 31ce523..8950bec 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -10,6 +10,7 @@ import type { } from './ipc' import type { AppSettings } from './settings' import type { ReleaseNote, UpdateState } from './ipc' +import type { LockOperationResult, LockPasswordInput, LockSettingsState } from './ipc' import type { HostKeyAction, HostKeyPromptEvent, @@ -100,6 +101,17 @@ export interface AppApi { saveSettings(next: Partial): Promise onSettingsChanged(cb: (s: AppSettings) => void): () => void + // ---- lock screen (main-window overlay; main owns the lock state) ---- + getLockState(): Promise + /** set or replace the password; verifies currentPassword when one exists */ + setLockPassword(input: LockPasswordInput): Promise + /** remove the password; verifies currentPassword when one exists */ + clearLockPassword(input: { currentPassword?: string }): Promise + /** answer the lock screen; resets the failure cooldown on success */ + unlockLock(input: { password?: string }): Promise + lockNow(): Promise + onLockStateChanged(cb: (state: LockSettingsState) => void): () => void + // ---- fonts ---- listFonts(): Promise diff --git a/src/shared/connections.ts b/src/shared/connections.ts index c6cfe87..c9b76ce 100644 --- a/src/shared/connections.ts +++ b/src/shared/connections.ts @@ -67,6 +67,28 @@ export interface SshSecretOverride { passphrase?: string } +/** The secret kind a connection must prompt for before connecting. */ +export type ConnectPromptKind = 'password' | 'passphrase' + +/** + * Which secret dialog (if any) a connect attempt for `conn` must show first. + * + * Single source of truth for Workspace's `handleConnectRequest` and + * ConnectFlow's stage selection: both used to derive it independently and the + * renderer-only check on ConnectFlow's side ignored `auth`, so a key-auth + * bookmark with a stale `askPasswordAtConnect` flag (left over from an edit + * that switched auth methods) popped a password dialog. + * + * Each ask* flag only counts for the auth method it belongs to; `null` means + * saved credentials exist and the connection must start immediately (existing + * UX — such connections must never route through the secret prompt). + */ +export function connectPromptFor(conn: SshConnection): ConnectPromptKind | null { + if (conn.auth === 'password' && conn.askPasswordAtConnect) return 'password' + if (conn.auth === 'privateKey' && conn.askPassphraseAtConnect) return 'passphrase' + return null +} + export interface SessionOpenOptions { kind: 'local' | 'ssh' /** ssh only */ diff --git a/src/shared/i18n/dicts/en/main.ts b/src/shared/i18n/dicts/en/main.ts index 2ec71b8..c53363f 100644 --- a/src/shared/i18n/dicts/en/main.ts +++ b/src/shared/i18n/dicts/en/main.ts @@ -17,6 +17,7 @@ const main: Record = { 'main.ssh.connectTimeout': 'Connection timed out ({host}:{port})', 'main.ssh.saveFingerprintFailed': 'Failed to save the host fingerprint: {detail}', + 'main.ssh.knownHostsUnreadable': 'The known-hosts file (ssh_known_hosts.json) exists but could not be read. The connection was refused to protect the pinned fingerprints. Repair or delete the file and try again.', 'main.ssh.hostKeyRejected': 'The user rejected the host key', 'main.ssh.connectFailed': 'Connection failed {host}:{port}: {detail}', 'main.ssh.shellOpenFailed': 'Could not open the SSH shell ({host}:{port}): {detail}', diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index de18883..1ba6c68 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -7,6 +7,7 @@ const settings: Record = { 'settings.tabs.highlight': 'Highlighting', 'settings.tabs.theme': 'Themes', 'settings.tabs.system': 'System', + 'settings.tabs.lock': 'Lock', 'settings.tabs.about': 'About', 'settings.resizeHandle': 'Drag to resize', 'settings.preview': 'Preview', @@ -100,6 +101,52 @@ const settings: Record = { 'settings.system.shortcutPlaceholder': 'Click and press a shortcut, leave empty to disable', 'settings.system.shortcutConflict': 'This shortcut is already used by the app (Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn). Please choose another.', + 'settings.lock.password.title': 'Lock password', + 'settings.lock.password.desc': 'A password is required before the lock can be enabled', + 'settings.lock.password.configured': 'Set', + 'settings.lock.password.notConfigured': 'Not set', + 'settings.lock.password.current': 'Current password', + 'settings.lock.password.currentPlaceholder': 'Enter the current password', + 'settings.lock.password.new': 'New password', + 'settings.lock.password.newPlaceholder': 'Enter a new password', + 'settings.lock.password.confirm': 'Confirm new password', + 'settings.lock.password.confirmPlaceholder': 'Repeat the new password', + 'settings.lock.password.set': 'Set password', + 'settings.lock.password.change': 'Change password', + 'settings.lock.password.clear': 'Remove password', + 'settings.lock.password.clearTitle': 'Remove the lock password?', + 'settings.lock.password.clearDesc': + 'The lock is turned off along with it. The current password is required to confirm.', + 'settings.lock.password.mismatch': 'The two new passwords do not match', + 'settings.lock.password.empty': 'Enter a password', + 'settings.lock.password.saved': 'Password saved', + 'settings.lock.password.cleared': 'Lock password removed', + 'settings.lock.password.forgot': + 'A forgotten lock password cannot be recovered from any cloud or backdoor: the only way back in is deleting this machine\u2019s lock data and setting a new one.', + 'settings.lock.enabled': 'Enable lock', + 'settings.lock.enabledDesc': + 'Lock the main window when idle or at startup; unlocking needs the password above', + 'settings.lock.needPassword': 'Set a lock password first', + 'settings.lock.autoLock': 'Lock when idle', + 'settings.lock.autoLockDesc': 'Lock once the system has been idle this long (0 = never)', + 'settings.lock.autoLockOff': 'Off', + 'settings.lock.autoLockMinutes': '{n} min', + 'settings.lock.lockAtStartup': 'Lock at startup', + 'settings.lock.lockAtStartupDesc': 'Ask for the password right after every launch', + 'settings.lock.lockNow': 'Lock now', + 'settings.lock.lockNowDesc': 'Lock the main window immediately (sessions keep running)', + 'settings.lock.title': 'Locked', + 'settings.lock.screenDesc': 'Enter the lock password to unlock', + 'settings.lock.passwordPlaceholder': 'Password', + 'settings.lock.unlock': 'Unlock', + 'settings.lock.unlocking': 'Unlocking…', + 'settings.lock.screenForgot': + 'Forgot it? The lock password cannot be recovered \u2014 the only way out is deleting this machine\u2019s lock data.', + 'settings.lock.error.invalidPassword': 'Password is invalid (empty or too short)', + 'settings.lock.error.wrongPassword': 'Wrong password', + 'settings.lock.error.cooldown': 'Too many attempts \u2014 try again in {n}s', + 'settings.lock.error.cooldownGeneric': 'Too many attempts \u2014 try again later', + 'settings.lock.error.saveFailed': 'Save failed, please retry', 'settings.resetTerminal': 'Reset terminal settings', 'settings.resetTerminalTitle': 'Reset terminal settings?', 'settings.resetTerminalDesc': 'All terminal settings — font, cursor, rendering and theme — will be restored to defaults.', diff --git a/src/shared/i18n/dicts/ja/main.ts b/src/shared/i18n/dicts/ja/main.ts index e49df22..13b72d2 100644 --- a/src/shared/i18n/dicts/ja/main.ts +++ b/src/shared/i18n/dicts/ja/main.ts @@ -17,6 +17,7 @@ const main: Record = { 'main.ssh.connectTimeout': '接続がタイムアウトしました ({host}:{port})', 'main.ssh.saveFingerprintFailed': 'ホスト鍵のフィンガープリントを保存できませんでした: {detail}', + 'main.ssh.knownHostsUnreadable': '既知ホストファイル (ssh_known_hosts.json) が存在しますが読み取れないため、保存済みフィンガープリントを保護するために接続を拒否しました。ファイルを修復または削除してから再試行してください。', 'main.ssh.hostKeyRejected': 'ユーザーがホスト鍵を拒否しました', 'main.ssh.connectFailed': '接続に失敗しました {host}:{port}: {detail}', 'main.ssh.shellOpenFailed': 'SSH シェルを開けません ({host}:{port}): {detail}', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index 3e205e3..3d2d129 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -7,6 +7,7 @@ const settings: Record = { 'settings.tabs.highlight': 'ハイライト', 'settings.tabs.theme': 'テーマ', 'settings.tabs.system': 'システム', + 'settings.tabs.lock': 'ロック', 'settings.tabs.about': 'このアプリについて', 'settings.resizeHandle': 'ドラッグしてサイズ変更', 'settings.preview': 'プレビュー', @@ -97,6 +98,52 @@ const settings: Record = { 'settings.system.shortcutPlaceholder': 'クリックしてショートカットを押す。空欄で無効', 'settings.system.shortcutConflict': 'このショートカットはアプリ内で既に使用されています(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn)。別のものを選んでください。', + 'settings.lock.password.title': 'ロックパスワード', + 'settings.lock.password.desc': 'パスワードを設定するとロックを有効にできます', + 'settings.lock.password.configured': '設定済み', + 'settings.lock.password.notConfigured': '未設定', + 'settings.lock.password.current': '現在のパスワード', + 'settings.lock.password.currentPlaceholder': '現在のパスワードを入力', + 'settings.lock.password.new': '新しいパスワード', + 'settings.lock.password.newPlaceholder': '新しいパスワードを入力', + 'settings.lock.password.confirm': '新しいパスワード(確認)', + 'settings.lock.password.confirmPlaceholder': 'もう一度入力してください', + 'settings.lock.password.set': 'パスワードを設定', + 'settings.lock.password.change': 'パスワードを変更', + 'settings.lock.password.clear': 'パスワードを削除', + 'settings.lock.password.clearTitle': 'ロックパスワードを削除しますか?', + 'settings.lock.password.clearDesc': + '削除するとロックも無効になります。確認のため現在のパスワードが必要です。', + 'settings.lock.password.mismatch': '新しいパスワードが一致しません', + 'settings.lock.password.empty': 'パスワードを入力してください', + 'settings.lock.password.saved': 'パスワードを保存しました', + 'settings.lock.password.cleared': 'ロックパスワードを削除しました', + 'settings.lock.password.forgot': + 'パスワードを忘れてもクラウドから復元する方法はなく、回避手段もありません。本機のロックデータを削除して設定し直すしかありません。', + 'settings.lock.enabled': 'ロックを有効にする', + 'settings.lock.enabledDesc': + 'アイドル時と起動時にメインウィンドウをロックします。解除には上のパスワードが必要です', + 'settings.lock.needPassword': '先にロックパスワードを設定してください', + 'settings.lock.autoLock': 'アイドル時に自動ロック', + 'settings.lock.autoLockDesc': 'システムがこの時間アイドル状態になったらロックします(0 は無効)', + 'settings.lock.autoLockOff': '無効', + 'settings.lock.autoLockMinutes': '{n} 分', + 'settings.lock.lockAtStartup': '起動時にロック', + 'settings.lock.lockAtStartupDesc': '起動直後にパスワードの入力を求めます', + 'settings.lock.lockNow': '今すぐロック', + 'settings.lock.lockNowDesc': 'メインウィンドウをすぐにロックします(セッションは動作を続けます)', + 'settings.lock.title': 'ロック中', + 'settings.lock.screenDesc': 'ロックパスワードを入力して解除します', + 'settings.lock.passwordPlaceholder': 'パスワード', + 'settings.lock.unlock': '解除', + 'settings.lock.unlocking': '解除中…', + 'settings.lock.screenForgot': + 'パスワードを忘れた場合、復元する方法はありません。本機のロックデータを削除するしかありません。', + 'settings.lock.error.invalidPassword': 'パスワードが無効です(空または短すぎます)', + 'settings.lock.error.wrongPassword': 'パスワードが違います', + 'settings.lock.error.cooldown': '試行回数が多すぎます。{n} 秒後にもう一度お試しください', + 'settings.lock.error.cooldownGeneric': '試行回数が多すぎます。しばらくしてからお試しください', + 'settings.lock.error.saveFailed': '保存に失敗しました。もう一度お試しください', 'settings.resetTerminal': 'ターミナル設定をリセット', 'settings.resetTerminalTitle': 'ターミナル設定をリセットしますか?', 'settings.resetTerminalDesc': 'フォント、カーソル、レンダリング、テーマなどすべてのターミナル設定がデフォルトに戻ります。', diff --git a/src/shared/i18n/dicts/zh-CN/main.ts b/src/shared/i18n/dicts/zh-CN/main.ts index 7881784..f748dde 100644 --- a/src/shared/i18n/dicts/zh-CN/main.ts +++ b/src/shared/i18n/dicts/zh-CN/main.ts @@ -17,6 +17,7 @@ const main: Record = { 'main.ssh.connectTimeout': '连接超时 ({host}:{port})', 'main.ssh.saveFingerprintFailed': '保存主机指纹失败: {detail}', + 'main.ssh.knownHostsUnreadable': '已知主机文件 (ssh_known_hosts.json) 存在但无法读取,为避免覆盖已保存的指纹,本次连接被拒绝。请修复或删除该文件后重试。', 'main.ssh.hostKeyRejected': '用户拒绝了主机指纹', 'main.ssh.connectFailed': '连接失败 {host}:{port}: {detail}', 'main.ssh.shellOpenFailed': '无法打开 SSH shell ({host}:{port}): {detail}', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index 5298838..b2da98f 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -7,6 +7,7 @@ const settings: Record = { 'settings.tabs.highlight': '高亮', 'settings.tabs.theme': '主题', 'settings.tabs.system': '系统', + 'settings.tabs.lock': '锁屏', 'settings.tabs.about': '关于', 'settings.resizeHandle': '拖拽调整大小', 'settings.preview': '预览', @@ -94,6 +95,49 @@ const settings: Record = { 'settings.system.shortcutPlaceholder': '点击后按下快捷键,留空禁用', 'settings.system.shortcutConflict': '该组合键已被应用内快捷键占用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),请换一个。', + 'settings.lock.password.title': '锁屏密码', + 'settings.lock.password.desc': '设置密码后才能启用锁屏', + 'settings.lock.password.configured': '已配置', + 'settings.lock.password.notConfigured': '未配置', + 'settings.lock.password.current': '当前密码', + 'settings.lock.password.currentPlaceholder': '请输入当前密码', + 'settings.lock.password.new': '新密码', + 'settings.lock.password.newPlaceholder': '请输入新密码', + 'settings.lock.password.confirm': '确认新密码', + 'settings.lock.password.confirmPlaceholder': '再次输入新密码', + 'settings.lock.password.set': '设置密码', + 'settings.lock.password.change': '修改密码', + 'settings.lock.password.clear': '清除密码', + 'settings.lock.password.clearTitle': '清除锁屏密码?', + 'settings.lock.password.clearDesc': '清除后锁屏会一并关闭,需要当前密码确认。', + 'settings.lock.password.mismatch': '两次输入的新密码不一致', + 'settings.lock.password.empty': '请填写密码', + 'settings.lock.password.saved': '密码已保存', + 'settings.lock.password.cleared': '锁屏密码已清除', + 'settings.lock.password.forgot': + '忘记锁屏密码无法通过云端找回,也没有后门:只能删除本机锁屏数据后重新设置。', + 'settings.lock.enabled': '启用锁屏', + 'settings.lock.enabledDesc': '闲置或启动时锁定主窗口,解锁需要上面的密码', + 'settings.lock.needPassword': '请先设置锁屏密码', + 'settings.lock.autoLock': '闲置自动锁屏', + 'settings.lock.autoLockDesc': '系统闲置超过该时长后自动锁定(0 表示从不)', + 'settings.lock.autoLockOff': '关闭', + 'settings.lock.autoLockMinutes': '{n} 分钟', + 'settings.lock.lockAtStartup': '启动时锁屏', + 'settings.lock.lockAtStartupDesc': '每次启动后先要求输入密码', + 'settings.lock.lockNow': '立即锁屏', + 'settings.lock.lockNowDesc': '马上锁定主窗口(会话保持运行)', + 'settings.lock.title': '已锁定', + 'settings.lock.screenDesc': '输入锁屏密码解锁', + 'settings.lock.passwordPlaceholder': '密码', + 'settings.lock.unlock': '解锁', + 'settings.lock.unlocking': '解锁中…', + 'settings.lock.screenForgot': '忘记密码?锁屏密码无法找回,只能删除本机锁屏数据。', + 'settings.lock.error.invalidPassword': '密码无效(不能为空或过短)', + 'settings.lock.error.wrongPassword': '密码错误', + 'settings.lock.error.cooldown': '尝试次数过多,请等待 {n} 秒后重试', + 'settings.lock.error.cooldownGeneric': '尝试次数过多,请稍后再试', + 'settings.lock.error.saveFailed': '保存失败,请重试', 'settings.resetTerminal': '恢复默认终端设置', 'settings.resetTerminalTitle': '恢复默认终端设置?', 'settings.resetTerminalDesc': '字体、光标、渲染与主题等全部终端设置将恢复为默认值。', diff --git a/src/shared/i18n/dicts/zh-TW/main.ts b/src/shared/i18n/dicts/zh-TW/main.ts index d258eb6..21d0269 100644 --- a/src/shared/i18n/dicts/zh-TW/main.ts +++ b/src/shared/i18n/dicts/zh-TW/main.ts @@ -17,6 +17,7 @@ const main: Record = { 'main.ssh.connectTimeout': '連線逾時 ({host}:{port})', 'main.ssh.saveFingerprintFailed': '儲存主機指紋失敗: {detail}', + 'main.ssh.knownHostsUnreadable': '已知主機檔案 (ssh_known_hosts.json) 存在但無法讀取,為避免覆寫已儲存的指紋,本次連線被拒絕。請修復或刪除該檔案後重試。', 'main.ssh.hostKeyRejected': '使用者拒絕了主機指紋', 'main.ssh.connectFailed': '連線失敗 {host}:{port}: {detail}', 'main.ssh.shellOpenFailed': '無法開啟 SSH shell ({host}:{port}): {detail}', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index 3d40437..71600ef 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -7,6 +7,7 @@ const settings: Record = { 'settings.tabs.highlight': '高亮', 'settings.tabs.theme': '主題', 'settings.tabs.system': '系統', + 'settings.tabs.lock': '鎖定畫面', 'settings.tabs.about': '關於', 'settings.resizeHandle': '拖曳調整大小', 'settings.preview': '預覽', @@ -94,6 +95,49 @@ const settings: Record = { 'settings.system.shortcutPlaceholder': '點擊後按下快速鍵,留空為停用', 'settings.system.shortcutConflict': '此組合鍵已被應用內快捷鍵佔用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),請換一個。', + 'settings.lock.password.title': '鎖定密碼', + 'settings.lock.password.desc': '設定密碼後才能啟用鎖定', + 'settings.lock.password.configured': '已設定', + 'settings.lock.password.notConfigured': '未設定', + 'settings.lock.password.current': '目前密碼', + 'settings.lock.password.currentPlaceholder': '請輸入目前密碼', + 'settings.lock.password.new': '新密碼', + 'settings.lock.password.newPlaceholder': '請輸入新密碼', + 'settings.lock.password.confirm': '確認新密碼', + 'settings.lock.password.confirmPlaceholder': '再次輸入新密碼', + 'settings.lock.password.set': '設定密碼', + 'settings.lock.password.change': '變更密碼', + 'settings.lock.password.clear': '清除密碼', + 'settings.lock.password.clearTitle': '清除鎖定密碼?', + 'settings.lock.password.clearDesc': '清除後鎖定會一併關閉,需要目前密碼確認。', + 'settings.lock.password.mismatch': '兩次輸入的新密碼不一致', + 'settings.lock.password.empty': '請填寫密碼', + 'settings.lock.password.saved': '密碼已儲存', + 'settings.lock.password.cleared': '鎖定密碼已清除', + 'settings.lock.password.forgot': + '忘記鎖定密碼無法透過雲端找回,也沒有後門:只能刪除本機鎖定資料後重新設定。', + 'settings.lock.enabled': '啟用鎖定', + 'settings.lock.enabledDesc': '閒置或啟動時鎖定主視窗,解鎖需要上面的密碼', + 'settings.lock.needPassword': '請先設定鎖定密碼', + 'settings.lock.autoLock': '閒置自動鎖定', + 'settings.lock.autoLockDesc': '系統閒置超過該時間後自動鎖定(0 表示從不)', + 'settings.lock.autoLockOff': '關閉', + 'settings.lock.autoLockMinutes': '{n} 分鐘', + 'settings.lock.lockAtStartup': '啟動時鎖定', + 'settings.lock.lockAtStartupDesc': '每次啟動後先要求輸入密碼', + 'settings.lock.lockNow': '立即鎖定', + 'settings.lock.lockNowDesc': '馬上鎖定主視窗(工作階段保持運作)', + 'settings.lock.title': '已鎖定', + 'settings.lock.screenDesc': '輸入鎖定密碼以解鎖', + 'settings.lock.passwordPlaceholder': '密碼', + 'settings.lock.unlock': '解鎖', + 'settings.lock.unlocking': '解鎖中…', + 'settings.lock.screenForgot': '忘記密碼?鎖定密碼無法找回,只能刪除本機鎖定資料。', + 'settings.lock.error.invalidPassword': '密碼無效(不能為空或過短)', + 'settings.lock.error.wrongPassword': '密碼錯誤', + 'settings.lock.error.cooldown': '嘗試次數過多,請等待 {n} 秒後重試', + 'settings.lock.error.cooldownGeneric': '嘗試次數過多,請稍後再試', + 'settings.lock.error.saveFailed': '儲存失敗,請重試', 'settings.resetTerminal': '恢復預設終端設定', 'settings.resetTerminalTitle': '恢復預設終端設定?', 'settings.resetTerminalDesc': '字體、游標、渲染與主題等全部終端設定將恢復為預設值。', diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index bdc376d..60deb1a 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -110,9 +110,60 @@ export const Ipc = { /** normalize a cwd reported by the shell (OSC 7 / OSC 9;9) */ CWD_REPORT: 'session:cwdReport', /** open a local directory in the OS file manager (terminal toolbar) */ - CWD_OPEN: 'session:cwdOpen' + CWD_OPEN: 'session:cwdOpen', + + // ---- lock screen (main-window overlay; the main process owns the state) ---- + /** renderer pulls the current lock state without changing it */ + LOCK_STATE_GET: 'lock:stateGet', + /** set or replace the password; an existing one must be verified first */ + LOCK_SET_PASSWORD: 'lock:setPassword', + /** remove the password; the existing one must be verified first */ + LOCK_CLEAR_PASSWORD: 'lock:clearPassword', + LOCK_UNLOCK: 'lock:unlock', + LOCK_NOW: 'lock:now', + /** main -> renderer broadcast: LockSettingsState */ + LOCK_STATE_CHANGED: 'lock:state' } as const +/** + * The lock state the renderer sees. Deliberately free of salt, hash and + * password: the stored verifier never leaves the main process. + */ +export interface LockSettingsState { + /** a password is set, so the lock can engage at all */ + configured: boolean + enabled: boolean + autoLockMinutes: number + lockAtStartup: boolean + locked: boolean + /** remaining lockout in ms; absent while no cooldown is running */ + cooldownMs?: number +} + +/** Passwords travel one way only: in. Neither field is ever echoed back. */ +export interface LockPasswordInput { + /** required when a password is already set (replace / clear) */ + currentPassword?: string + /** required when setting or replacing */ + newPassword?: string +} + +export type LockOperationError = + /** the offered new password is unusable (empty, too short, too long) */ + | 'invalid-password' + /** the offered current password does not match */ + | 'wrong-password' + /** too many failed attempts: retry after state.cooldownMs */ + | 'cooldown' + /** the verifier could not be written to disk */ + | 'save-failed' + +export interface LockOperationResult { + ok: boolean + state: LockSettingsState + error?: LockOperationError +} + export interface PtyCreateOptions { cwd?: string /** executable; omit for platform default shell */ diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 7f1d9a5..9cdfe07 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -125,6 +125,41 @@ export function highlightModeOf(value: unknown): HighlightMode { return value === 'basic' || value === 'off' ? value : 'all' } +/** + * The delays offered for the idle auto-lock, in minutes. A closed set rather + * than a free number: `0` means "never", and the settings UI, the sanitizer and + * the idle watcher all read this one list so they cannot disagree. + */ +export type LockAutoDelay = 0 | 1 | 5 | 15 | 30 | 60 + +export const LOCK_AUTO_DELAYS: LockAutoDelay[] = [0, 1, 5, 15, 30, 60] + +/** + * Read a stored auto-lock delay, tolerating a hand-edited settings.json: only a + * whitelisted value survives, anything else falls back to 0 (never). + */ +export function lockAutoDelayOf(value: unknown): LockAutoDelay { + return LOCK_AUTO_DELAYS.includes(value as LockAutoDelay) ? (value as LockAutoDelay) : 0 +} + +export function isLockAutoDelay(value: unknown): value is LockAutoDelay { + return LOCK_AUTO_DELAYS.includes(value as LockAutoDelay) +} + +/** + * Screen-lock preferences. The password itself is never stored here — the + * verifier lives in `/lock.json` (src/main/lockStore.ts), so settings + * can be copied around, synced or logged without leaking it. + */ +export interface LockSettings { + /** master switch: without it nothing ever locks, idle watcher included */ + enabled: boolean + /** minutes of system idle before the screen locks; 0 = never */ + autoLockMinutes: LockAutoDelay + /** start each run locked (asks for the password before the app is usable) */ + lockAtStartup: boolean +} + export interface SystemSettings { /** register the app to launch at OS login */ launchAtLogin: boolean @@ -171,6 +206,8 @@ export interface AppSettings { /** named rule subsets for per-host highlighting (see terminal.highlightPerHost) */ highlightProfiles: HighlightProfile[] system: SystemSettings + /** screen lock; the password verifier lives outside settings (lock.json) */ + lock: LockSettings } const RULE = ( @@ -429,5 +466,8 @@ export const DEFAULT_SETTINGS: AppSettings = { customThemes: [], highlightRules: DEFAULT_HIGHLIGHT_RULES, highlightProfiles: [], - system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' } + system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' }, + // Off until the user sets a password and turns it on: an app that locks + // itself out of the box would be a support ticket, not a feature. + lock: { enabled: false, autoLockMinutes: 0, lockAtStartup: false } } diff --git a/tests/build-bundles.cjs b/tests/build-bundles.cjs index 459e84b..56ff2a7 100644 --- a/tests/build-bundles.cjs +++ b/tests/build-bundles.cjs @@ -19,7 +19,15 @@ const BUNDLES = [ // ESM (`.mjs`): tests/sftp-*.mjs load it with `await import()`. { entry: 'src/main/sftp.ts', out: 'tests/.sftp-svc.mjs', format: 'esm', external: ['ssh2'] }, { entry: 'src/main/commands.ts', out: 'tests/.commands-store.cjs' }, + // Known-hosts store: TOFU / changed / unreadable fail-closed behavior. + { entry: 'src/main/knownHosts.ts', out: 'tests/.known-hosts.cjs' }, { entry: 'src/main/settingsStore.ts', out: 'tests/.settings-store.cjs' }, + // Lock-password store: scrypt verifier, round trip, damaged-file handling. + { entry: 'src/main/lockStore.ts', out: 'tests/.lock-store.cjs' }, + // Lock controller: cooldown ladder, serialized attempts, persisted flags. + // Pulls in settingsStore + broadcast, which is why the electron stub needs + // powerMonitor as well. + { entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.cjs' }, // zmodem.js stays bundled (NOT external) — the test drives a second in-process // Sentry from the same library. { entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] }, diff --git a/tests/commands-store.mjs b/tests/commands-store.mjs index 18333a7..7bdfcf1 100644 --- a/tests/commands-store.mjs +++ b/tests/commands-store.mjs @@ -10,8 +10,8 @@ * --alias:@shared=./src/shared * Run: node tests/commands-store.mjs (must exit 0) */ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' -import { join } from 'path' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { basename, join, resolve, sep } from 'path' import { tmpdir } from 'os' import { createRequire } from 'module' const require_ = createRequire(import.meta.url) @@ -39,7 +39,13 @@ let commandsMod try { commandsMod = require_('./.commands-store.cjs') } catch { - fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs --alias:@shared=./src/shared') + fail('bundle not found — run: node tests/build-bundles.cjs (or the esbuild line in the header)') +} +let knownHostsMod +try { + knownHostsMod = require_('./.known-hosts.cjs') +} catch { + fail('bundle not found — run: node tests/build-bundles.cjs (builds tests/.known-hosts.cjs)') } // ---- 2. Store over the temp userData; capture openDir target ------------------- @@ -227,8 +233,105 @@ const expected = 'partial-tail' ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order') -// The store wrote into a temp userData dir; drop it so repeated runs do not -// litter %TEMP%. +// ---- 7. index.json path containment (hydrateIndex) ----------------------------- +// index.json is data, not trust: a tampered `file` value must never turn +// logWrite into an arbitrary-path append. Only entries that resolve inside +// logsDir and point at a regular file may hydrate. +const logsDir = join(userData, 'logs') +mkdirSync(join(logsDir, 'subdir'), { recursive: true }) +const escapeFile = join(userData, 'escaped.log') +const fwdSlashEntry = `${userData.split(sep).join('/')}/logs/${basename(start.file)}` +const driveCaseEntry = + process.platform === 'win32' + ? start.file.replace(/^[A-Z]:/, (m) => m.toLowerCase()) + : start.file +writeFileSync( + join(logsDir, 'index.json'), + JSON.stringify([ + { sessionId: 'escape-abs', file: escapeFile, startedAt: 1 }, // outside logsDir + { sessionId: 'escape-dotdot', file: join(logsDir, '..', 'escaped2.log'), startedAt: 2 }, + { sessionId: 'escape-dir', file: join(logsDir, 'subdir'), startedAt: 3 }, // not a regular file + { sessionId: 'ok-legit', file: start.file, startedAt: 4, endedAt: 5 }, // real hydrated log + { sessionId: 'ok-fwdslash', file: fwdSlashEntry, startedAt: 6, endedAt: 7 }, // same file, '/' separators + { sessionId: 'ok-drivecase', file: driveCaseEntry, startedAt: 8, endedAt: 9 } // same file, 'C:' recased + ]), + 'utf8' +) +const store3 = new commandsMod.CommandsStore(userData) +let hydrated = store3.listSessionLogs() +const ids = hydrated.map((m) => m.sessionId).sort() +ok(!ids.includes('escape-abs'), 'absolute path outside logsDir is dropped') +ok(!ids.includes('escape-dotdot'), '`..` escape out of logsDir is dropped') +ok(!ids.includes('escape-dir'), 'entry pointing at a directory is dropped') +ok(ids.includes('ok-legit') && ids.includes('ok-fwdslash'), 'legit entry survives, also spelled with / separators') +if (process.platform === 'win32') { + ok(ids.includes('ok-drivecase'), 'drive-letter case does not break containment') +} +// The dropped entries must not come back either: a later index persist only +// ever writes the contained subset. +store3.logStart('persist-1') +const persisted = JSON.parse(readFileSync(join(logsDir, 'index.json'), 'utf8')) +ok( + !persisted.some((m) => resolve(m.file) === resolve(escapeFile)) && + !persisted.some((m) => resolve(m.file) === resolve(join(userData, 'escaped2.log'))), + 're-persisted index keeps out-of-logsDir entries out' +) +ok( + !existsSync(escapeFile) && !existsSync(join(userData, 'escaped2.log')), + 'no log file was created outside logsDir' +) + +// ---- 8. KnownHostsStore: TOFU, change detect, unreadable fail-closed ----------- +const khDir = mkdtempSync(join(tmpdir(), 'm5-kh-')) +const khFile = join(khDir, 'ssh_known_hosts.json') +const kh = new knownHostsMod.KnownHostsStore(khFile) +const keyA = Buffer.from('host-key-a') +const keyB = Buffer.from('host-key-b') +const fpA = knownHostsMod.fingerprintOf(keyA) +const fpB = knownHostsMod.fingerprintOf(keyB) + +ok(kh.check('h1', 22, keyA).status === 'new', 'knownHosts: missing file is TOFU new') +kh.accept('h1', 22, keyA, fpA) +ok(kh.check('h1', 22, keyA).status === 'match', 'knownHosts: accepted key matches') +const changed = kh.check('h1', 22, keyB) +ok(changed.status === 'changed' && changed.stored.fingerprint === fpA, 'knownHosts: other key reports changed + stored fingerprint') + +// Truncated JSON: the file exists but cannot be trusted. +writeFileSync(khFile, '{"version":1,"entries":[{"id":"x"', 'utf8') +ok(kh.check('h1', 22, keyB).status === 'unreadable', 'knownHosts: corrupt store checks as unreadable, never new') +let threw = false +try { + kh.accept('h1', 22, keyB, fpB) +} catch { + threw = true +} +ok(threw, 'knownHosts: accept refuses to overwrite an unreadable store') + +// Valid JSON but not the store shape counts as unreadable too. +writeFileSync(khFile, '{"nope":true}', 'utf8') +ok(kh.check('h1', 22, keyA).status === 'unreadable', 'knownHosts: shapeless JSON checks as unreadable') + +// A directory at the store path (EISDIR) is unreadable, not "no pins yet". +writeFileSync( + khFile, + JSON.stringify({ version: 1, entries: [{ id: 'x', host: 'h1', port: 22, keyBase64: keyA.toString('base64'), fingerprint: fpA, addedAt: 1 }] }) +) +rmSync(khFile) +mkdirSync(khFile) +ok(kh.check('h1', 22, keyA).status === 'unreadable', 'knownHosts: a directory at the store path is unreadable, not new') +rmSync(khFile, { recursive: true, force: true }) + +// Restore the good store: a transient unreadable episode lost nothing. +writeFileSync( + khFile, + JSON.stringify({ version: 1, entries: [{ id: 'x', host: 'h1', port: 22, keyBase64: keyA.toString('base64'), fingerprint: fpA, addedAt: 1 }] }) +) +ok(kh.check('h1', 22, keyA).status === 'match', 'knownHosts: pins survive an unreadable episode') +kh.accept('h1', 22, keyB, fpB) +ok(kh.check('h1', 22, keyB).status === 'match', 'knownHosts: accept works again once the store is readable') +rmSync(khDir, { recursive: true, force: true }) + +// All stores wrote into temp dirs; drop them so repeated runs do not litter. rmSync(userData, { recursive: true, force: true }) console.log('\n[commands] ALL CHECKS PASSED') diff --git a/tests/electron-stub.cjs b/tests/electron-stub.cjs index dbbb31e..fb10e42 100644 --- a/tests/electron-stub.cjs +++ b/tests/electron-stub.cjs @@ -28,6 +28,9 @@ module.exports = { isRegistered: () => false }, webContents: {}, + powerMonitor: { + getSystemIdleTime: () => 0 + }, powerSaveBlocker: { start: () => 1, stop: () => {}, diff --git a/tests/lock-controller.mjs b/tests/lock-controller.mjs new file mode 100644 index 0000000..7c8a6fe --- /dev/null +++ b/tests/lock-controller.mjs @@ -0,0 +1,420 @@ +/** + * Lock-controller self-test (lock-controller.mjs). + * + * Offline and Electron-free: every input the controller has — the two stores, + * the settings, the clock, the idle time, the publisher — is injectable, so the + * whole state machine runs under plain Node without a window or a real 15s poll. + * Guards the contract the lock screen depends on: + * - the backoff ladder (1s / 2s / 5s / 10s / 30s, capped) and that an attempt + * inside the window is refused as `cooldown`, not answered as `wrong-password` + * - a success clears the failure count and the backoff with it + * - concurrent attempts are serialized, so firing two at once cannot step + * around the backoff (the regression this file exists for) + * - `locked` / `failures` / `cooldownUntil` survive a restart, and `start()` + * restores them silently (nothing is listening yet) + * - a stored lock without a verifier is discarded, never applied + * - a cooldown restored from the future is clamped (clock moved backwards) + * - idle auto-lock fires exactly once, and never on a broken or disabled input + * - clearing the password turns the preferences off and unlocks + * - lockNow()/unlock() are no-ops in the directions that would trap the user + * + * Build: node tests/build-bundles.cjs + * Run: node tests/lock-controller.mjs (must exit 0) + */ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const dir = mkdtempSync(join(tmpdir(), 'ot-lockctl-')) + +// The controller bundle re-exports only the controller; the stores come from the +// lock-store bundle, so the controller is tested against the real scrypt store +// rather than a hand-written double. +const { LockController } = require('./.lock-controller.cjs') +const { LockStore, LockStateStore, defaultLockStatePath } = require('./.lock-store.cjs') + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed++ +} + +const PASSWORD = 'correct horse' + +/** Controllable clock: the whole ladder is driven without ever waiting on it. */ +let clockMs = 1_700_000_000_000 +const now = () => clockMs +const advance = (ms) => { + clockMs += ms +} + +/** Idle auto-lock off, so the real 15s poll `start()` installs is a no-op in + * every test that is not about idle time (no stray publish mid-assertion). */ +const idleOff = () => ({ enabled: false, autoLockMinutes: 0, lockAtStartup: false }) + +let seq = 0 +const freshStore = async () => { + const store = new LockStore(join(dir, `lock-${seq++}.json`)) + await store.setPassword(PASSWORD) + return store +} +const freshState = () => new LockStateStore(join(dir, `state-${seq++}.json`)) + +/** A controller with every input pinned; returns the publishes it produced. */ +const makeController = (opts = {}) => { + const publishes = [] + const controller = new LockController({ + store: opts.store, + stateStore: opts.stateStore ?? freshState(), + getLockSettings: opts.getLockSettings ?? idleOff, + publish: (state) => publishes.push(state), + now, + idleSeconds: opts.idleSeconds ?? (() => 0), + clearLockPreferences: opts.clearLockPreferences ?? (() => {}) + }) + return { controller, publishes } +} + +/** A real store whose verify() takes a couple of turns of the event loop, so two + * attempts fired without awaiting genuinely overlap unless they are serialized. */ +const slowStore = (store, delayMs = 20) => ({ + isConfigured: () => store.isConfigured(), + setPassword: (password) => store.setPassword(password), + clear: () => store.clear(), + verify: async (password) => { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + return store.verify(password) + } +}) + +// ---- 1. backoff ladder ------------------------------------------------------ +console.log('[cooldown ladder]') +{ + const store = await freshStore() + const stateStore = freshState() + const { controller } = makeController({ store, stateStore }) + controller.lockNow() + ok(controller.isLocked() === true, 'a configured lock can engage') + + const steps = [1000, 2000, 5000, 10000, 30000, 30000] + for (let i = 0; i < steps.length; i++) { + const attempt = await controller.unlock({ password: 'wrong' }) + ok(attempt.ok === false && attempt.error === 'wrong-password', `failure ${i + 1} reports wrong-password`) + ok(attempt.state.cooldownMs === steps[i], `failure ${i + 1} backs off for ${steps[i]}ms`) + ok(stateStore.load().failures === i + 1, `failure ${i + 1} is on disk`) + + // A second guess inside the window must be answered `cooldown`. Answering + // `wrong-password` would mean the password was verified again, so a caller + // could keep guessing (and keep escalating the ladder) with no waiting. + const blocked = await controller.unlock({ password: 'wrong' }) + ok(blocked.ok === false && blocked.error === 'cooldown', `failure ${i + 1}: an immediate retry is refused as cooldown`) + ok(blocked.state.cooldownMs === steps[i], `failure ${i + 1}: a refused retry does not restart the backoff`) + ok(stateStore.load().failures === i + 1, `failure ${i + 1}: a refused retry is not counted as a failure`) + + // Advance by exactly the step: the remaining cooldown reaches 0, so the next + // iteration is allowed through the gate again. + advance(steps[i]) + } + + const opened = await controller.unlock({ password: PASSWORD }) + ok(opened.ok === true && opened.state.locked === false, 'the correct password still opens the screen after the full ladder') +} + +// ---- 2. success clears the backoff ------------------------------------------ +console.log('[success clears]') +{ + const store = await freshStore() + const stateStore = freshState() + const { controller } = makeController({ store, stateStore }) + controller.lockNow() + const first = await controller.unlock({ password: 'wrong' }) + advance(first.state.cooldownMs) + const second = await controller.unlock({ password: 'wrong' }) + ok(stateStore.load().failures === 2, 'two failures are recorded') + advance(second.state.cooldownMs) + + const opened = await controller.unlock({ password: PASSWORD }) + ok(opened.ok === true, 'the correct password opens the screen') + ok(opened.state.cooldownMs === undefined, 'a success reports no cooldown') + ok( + stateStore.load().failures === 0 && stateStore.load().cooldownUntil === 0, + 'a success clears the failure count and the backoff on disk' + ) + + // Indirect proof that the ladder really restarted: the next failure waits 1s, + // which it could not do if the two earlier failures were still counted. + controller.lockNow() + const after = await controller.unlock({ password: 'wrong' }) + ok(after.state.cooldownMs === 1000, 'the failure after a success starts the ladder over at 1s') +} + +// ---- 3. concurrent attempts are serialized ---------------------------------- +console.log('[serialized attempts]') +{ + const store = await freshStore() + const stateStore = freshState() + const { controller } = makeController({ store: slowStore(store), stateStore }) + controller.lockNow() + + // Fired without awaiting: both calls are already inside the controller before + // either verification resolves. + const [first, second] = await Promise.all([ + controller.unlock({ password: 'wrong' }), + controller.unlock({ password: 'wrong' }) + ]) + ok(first.error === 'wrong-password', 'the first of two concurrent attempts is verified and fails') + ok(second.error === 'cooldown', 'the second is refused by the backoff the first just raised') + ok(stateStore.load().failures === 1, 'two concurrent attempts count as one failure') + + advance(1000) + const after = await controller.unlock({ password: PASSWORD }) + ok(after.ok === true, 'the correct password still works once the cooldown has passed') +} + +// ---- 4. persistence round trip ---------------------------------------------- +console.log('[persistence]') +{ + const lockFile = join(dir, 'lock-persist.json') + const stateFile = join(dir, 'state-persist.json') + const store = new LockStore(lockFile) + await store.setPassword(PASSWORD) + + const first = makeController({ store, stateStore: new LockStateStore(stateFile) }) + first.controller.lockNow() + ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === true, 'locking writes locked:true to the state file') + + // A relaunch is not a way out of a lock that was already up. + const second = makeController({ + store: new LockStore(lockFile), + stateStore: new LockStateStore(stateFile) + }) + second.controller.start() + ok(second.controller.isLocked() === true, 'a new instance over the same files starts locked') + ok(second.publishes.length === 0, 'start() does not publish: nothing is listening yet') + ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === true, 'and the restored lock is not written back as unlocked') + + const opened = await second.controller.unlock({ password: PASSWORD }) + ok(opened.ok === true && opened.state.locked === false, 'the correct password opens the restored lock') + ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === false, 'unlocking writes locked:false to the state file') + + const third = makeController({ + store: new LockStore(lockFile), + stateStore: new LockStateStore(stateFile) + }) + third.controller.start() + ok(third.controller.isLocked() === false, 'a fresh instance after an unlock does not lock') +} + +// ---- 5. a stored lock with no verifier is discarded ------------------------- +console.log('[start without a verifier]') +{ + const stateFile = join(dir, 'state-orphan.json') + writeFileSync(stateFile, JSON.stringify({ version: 1, locked: true, failures: 2, cooldownUntil: 0 }), 'utf8') + const { controller, publishes } = makeController({ + store: new LockStore(join(dir, 'lock-missing.json')), + stateStore: new LockStateStore(stateFile) + }) + controller.start() + ok(existsSync(stateFile) === false, 'the stored flags are deleted: no password could ever open that lock again') + ok(controller.isLocked() === false, 'and the screen is not locked') + ok(publishes.length === 0, 'start() does not publish') +} + +// ---- 6. a cooldown restored from the future is clamped ---------------------- +console.log('[clock skew]') +{ + const store = await freshStore() + const stateFile = join(dir, 'state-skew.json') + writeFileSync( + stateFile, + JSON.stringify({ version: 1, locked: false, failures: 3, cooldownUntil: now() + 3_600_000 }), + 'utf8' + ) + const { controller, publishes } = makeController({ store, stateStore: new LockStateStore(stateFile) }) + controller.start() + ok(controller.getState().cooldownMs === 30000, 'an hour-long restored cooldown is clamped to the longest step') + ok(publishes.length === 0, 'start() does not publish') + + // The restored failure count still drives the ladder: the 4th failure waits 10s. + advance(30000) + controller.lockNow() + const attempt = await controller.unlock({ password: 'wrong' }) + ok(attempt.state.cooldownMs === 10000, 'the restored failure count still picks the matching step') +} +{ + const store = await freshStore() + const stateFile = join(dir, 'state-keep.json') + writeFileSync( + stateFile, + JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: now() + 5000 }), + 'utf8' + ) + const { controller } = makeController({ store, stateStore: new LockStateStore(stateFile) }) + controller.start() + ok(controller.getState().cooldownMs === 5000, 'a legitimately stored cooldown is kept exactly as it is') +} + +// ---- 7. idle auto-lock ------------------------------------------------------ +console.log('[idle auto-lock]') +{ + // checkIdle() is only `private` to TypeScript; the modifier is erased at + // runtime, so the poll can be driven directly instead of waiting 15 seconds. + const store = await freshStore() + const enabled = () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }) + + const idle = makeController({ store, getLockSettings: enabled, idleSeconds: () => 60 }) + idle.controller.checkIdle() + ok(idle.controller.isLocked() === true, 'one minute of idleness locks the screen') + ok( + idle.publishes.length === 1 && idle.publishes[0].locked === true, + 'the lock is published once, so the overlay appears without being asked' + ) + idle.controller.checkIdle() + ok(idle.publishes.length === 1, 'a poll while already locked publishes nothing') +} +{ + const store = await freshStore() + const enabled = () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }) + + const justUnder = makeController({ store, getLockSettings: enabled, idleSeconds: () => 59 }) + justUnder.controller.checkIdle() + ok(justUnder.controller.isLocked() === false, '59 seconds is not yet a minute of idleness') + ok(justUnder.publishes.length === 0, 'and nothing is published') + + const disabled = makeController({ + store, + getLockSettings: () => ({ ...enabled(), enabled: false }), + idleSeconds: () => 3600 + }) + disabled.controller.checkIdle() + ok(disabled.controller.isLocked() === false, 'the master switch off means idle never locks') + + const never = makeController({ + store, + getLockSettings: () => ({ ...enabled(), autoLockMinutes: 0 }), + idleSeconds: () => 3600 + }) + never.controller.checkIdle() + ok(never.controller.isLocked() === false, 'autoLockMinutes 0 means never') +} +{ + const store = await freshStore() + const broken = makeController({ + store, + getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }), + idleSeconds: () => { + throw new Error('powerMonitor is unavailable without a session') + } + }) + let threw = false + try { + broken.controller.checkIdle() + } catch { + threw = true + } + ok(!threw, 'a failing idle reading does not throw out of the poll') + ok(broken.controller.isLocked() === false, 'and unknown idleness reads as not idle rather than locking the app') +} +{ + const unconfigured = makeController({ + store: new LockStore(join(dir, 'lock-noverifier-idle.json')), + getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }), + idleSeconds: () => 3600 + }) + unconfigured.controller.checkIdle() + ok(unconfigured.controller.isLocked() === false, 'an unconfigured lock never engages on idle') +} + +// ---- 8. clearing the password ----------------------------------------------- +console.log('[clear password]') +{ + const store = await freshStore() + let cleared = 0 + const { controller } = makeController({ store, clearLockPreferences: () => void cleared++ }) + controller.lockNow() + ok(controller.isLocked() === true, 'the screen is locked before clearing') + + const res = await controller.clearPassword({ currentPassword: PASSWORD }) + ok(res.ok === true, 'clearing with the correct password succeeds') + ok(cleared === 1, 'the lock preferences are turned off exactly once') + ok(controller.isLocked() === false, 'clearing also unlocks: an unconfigured lock can never be answered') + ok(controller.getState().configured === false, 'the state reports unconfigured') + ok(store.isConfigured() === false, 'the verifier file is gone') + + // Wrong current password: removing the lock must not be possible from the + // keyboard alone, so nothing is cleared and the screen stays shut. + await store.setPassword(PASSWORD) + controller.lockNow() + ok(controller.isLocked() === true, 'the screen locks again once a password exists') + const bad = await controller.clearPassword({ currentPassword: 'wrong' }) + ok(bad.ok === false && bad.error === 'wrong-password', 'clearing with the wrong password is refused') + ok(cleared === 1, 'and the preferences are left alone') + ok(store.isConfigured() === true, 'and the password is still set') + ok(controller.isLocked() === true, 'and the screen stays locked') +} + +// ---- 9. the paths that must not trap the user ------------------------------- +console.log('[unconfigured paths]') +{ + const { controller, publishes } = makeController({ store: new LockStore(join(dir, 'lock-none.json')) }) + const state = controller.lockNow() + ok(state.locked === false && controller.isLocked() === false, 'lockNow() cannot lock without a verifier') + ok(publishes.length === 0, 'and it publishes nothing') +} +{ + // Verifier deleted while running: nothing could ever open the screen again, so + // it has to open rather than stay shut forever. + const store = await freshStore() + const { controller } = makeController({ store }) + controller.lockNow() + ok(controller.isLocked() === true, 'a configured lock does engage') + store.clear() + const res = await controller.unlock({ password: 'anything' }) + ok(res.ok === true && controller.isLocked() === false, 'unlock() opens a screen whose verifier disappeared') + ok(res.state.configured === false, 'and reports it as unconfigured') +} + +// ---- 10. applyLocked is idempotent ------------------------------------------ +console.log('[idempotent lock changes]') +{ + const store = await freshStore() + const { controller, publishes } = makeController({ + store, + getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }), + idleSeconds: () => 3600 + }) + controller.lockNow() + ok(publishes.length === 1, 'the first lock publishes once') + controller.lockNow() + ok(publishes.length === 1, 'locking an already locked screen publishes nothing') + controller.checkIdle() + ok(publishes.length === 1, 'the idle watcher does not republish an existing lock') + + await controller.unlock({ password: PASSWORD }) + ok(publishes.length === 2, 'unlocking publishes once') + await controller.unlock({ password: PASSWORD }) + ok(publishes.length === 2, 'unlocking an unlocked screen publishes nothing') + + store.clear() + await controller.clearPassword({}) + ok(publishes.length === 2, 'clearing an unconfigured lock publishes nothing new') + ok(controller.isLocked() === false, 'and leaves the screen unlocked') +} + +// ---- 11. path helper -------------------------------------------------------- +console.log('[paths]') +{ + const statePath = join(dir, 'lock-state.json') + ok( + resolve(defaultLockStatePath(dir)) === resolve(statePath), + 'defaultLockStatePath resolves to /lock-state.json' + ) +} + +// The stores wrote into a temp dir; drop it so repeated runs do not litter %TEMP%. +rmSync(dir, { recursive: true, force: true }) + +console.log(failed === 0 ? '\n[lock-ctl] ALL CHECKS PASSED' : `\n[lock-ctl] ${failed} CHECK(S) FAILED`) +process.exit(failed === 0 ? 0 : 1) diff --git a/tests/lock-store.mjs b/tests/lock-store.mjs new file mode 100644 index 0000000..ae9042d --- /dev/null +++ b/tests/lock-store.mjs @@ -0,0 +1,309 @@ +/** + * Lock-store self-test (lock-store.mjs). + * + * Offline and Electron-free: the store takes its file path by injection, so it + * runs under plain Node. Guards the contract the lock controller relies on: + * - a fresh install is "not configured", and looking does not create a file + * - setting a password writes a scrypt verifier and never the password + * - correct / incorrect verification, case sensitivity, salt regenerated per write + * - the verifier survives a restart (a new store over the same file) + * - clearing removes the file and returns to "not configured" + * - missing / truncated / wrongly-shaped / wrongly-sized files read as + * unconfigured instead of throwing (a corrupt lock must not break startup) + * - the lock flags (locked / failures / cooldownUntil) round-trip through the + * state store, and every unusable shape of that file reads back as "unlocked + * with no failures" instead of throwing on the startup path + * + * Build: node tests/build-bundles.cjs + * Run: node tests/lock-store.mjs (must exit 0) + */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const dir = mkdtempSync(join(tmpdir(), 'ot-lock-')) +const lockFile = join(dir, 'lock.json') + +const lock = require('./.lock-store.cjs') + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed++ +} + +const PASSWORD = 'correct horse' +const fresh = () => new lock.LockStore(lockFile) +const readRaw = () => readFileSync(lockFile, 'utf8') +/** Real base64 for the expected verifier sizes, so a damaged-file case can + * break exactly one field. */ +const SALT16 = Buffer.alloc(16).toString('base64') +const HASH64 = Buffer.alloc(64).toString('base64') + +// ---- 1. path helper --------------------------------------------------------- +console.log('[paths]') +// resolve() so the assertion holds whether the helper joins with '/' or '\'. +ok(resolve(lock.defaultLockPath(dir)) === resolve(lockFile), 'defaultLockPath resolves to /lock.json') + +// ---- 2. unconfigured -------------------------------------------------------- +console.log('[unconfigured]') +{ + const s = fresh() + ok(s.isConfigured() === false, 'a missing file is not configured') + ok((await s.verify(PASSWORD)) === false, 'verify() on an unconfigured store is false') + ok(existsSync(lockFile) === false, 'verifying does not create the file') +} + +// ---- 3. set + verify -------------------------------------------------------- +console.log('[set + verify]') +{ + const s = fresh() + await s.setPassword(PASSWORD) + ok(s.isConfigured(), 'after setPassword the store is configured') + ok(existsSync(lockFile), 'the verifier file exists') + + const raw = JSON.parse(readRaw()) + ok(raw.version === 1, 'stored version is 1') + ok(typeof raw.salt === 'string' && typeof raw.hash === 'string', 'salt + hash are strings') + ok(typeof raw.createdAt === 'number', 'createdAt is a number') + ok(!readRaw().includes(PASSWORD), 'the password itself is not on disk') + ok(!readRaw().includes('correct'), 'no fragment of the password is on disk') + + ok((await s.verify(PASSWORD)) === true, 'the correct password verifies') + ok((await s.verify('correct hors')) === false, 'a near miss does not verify') + ok((await s.verify('correct horse ')) === false, 'a trailing space does not verify') + ok((await s.verify('')) === false, 'the empty string does not verify') + ok((await s.verify('CORRECT HORSE')) === false, 'verification is case sensitive') + ok(s.isConfigured(), 'a failed attempt does not unconfigure the store') +} +{ + // Replacing regenerates the salt, so the previous hash is worthless even when + // the new password is the same one. + const s = fresh() + await s.setPassword(PASSWORD) + const first = JSON.parse(readRaw()) + await s.setPassword(PASSWORD) + const second = JSON.parse(readRaw()) + ok(first.salt !== second.salt, 'each write generates a fresh salt') + ok(first.hash !== second.hash, 'and therefore a different hash') + ok((await s.verify(PASSWORD)) === true, 'the replaced verifier still matches the same password') +} + +// ---- 4. persistence round trip ---------------------------------------------- +console.log('[persistence]') +{ + const s = fresh() + await s.setPassword(PASSWORD) + const reopened = new lock.LockStore(lockFile) // "restart" + ok(reopened.isConfigured(), 'a new store over the same file is configured') + ok((await reopened.verify(PASSWORD)) === true, 'the password survives a restart') + ok((await reopened.verify('nope')) === false, 'a wrong password still fails after a restart') +} + +// ---- 5. password length policy ---------------------------------------------- +console.log('[length policy]') +{ + ok(lock.isValidPassword('abcd') === true, '4 characters is accepted') + ok(lock.isValidPassword('a'.repeat(128)) === true, '128 characters is accepted') + ok(lock.isValidPassword('abc') === false, '3 characters is refused') + ok(lock.isValidPassword('') === false, 'the empty password is refused') + ok(lock.isValidPassword('a'.repeat(129)) === false, '129 characters is refused') + ok(lock.isValidPassword(undefined) === false, 'a missing password is refused') + ok(lock.isValidPassword(1234) === false, 'a non-string password is refused') +} +{ + const path = join(dir, 'unset.json') + const s = new lock.LockStore(path) + let threw = false + try { + await s.setPassword('abc') + } catch { + threw = true + } + ok(threw, 'setPassword refuses a too-short password') + threw = false + try { + await s.setPassword('') + } catch { + threw = true + } + ok(threw, 'setPassword refuses an empty password') + ok(s.isConfigured() === false, 'a refused password leaves the store unconfigured') + ok(existsSync(path) === false, 'nothing was written for a refused password') +} + +// ---- 6. clear --------------------------------------------------------------- +console.log('[clear]') +{ + const s = fresh() + await s.setPassword(PASSWORD) + s.clear() + ok(s.isConfigured() === false, 'clearing returns the store to unconfigured') + ok(existsSync(lockFile) === false, 'clearing removes the file') + ok((await s.verify(PASSWORD)) === false, 'a cleared store verifies nothing') +} +{ + let threw = false + try { + fresh().clear() + } catch { + threw = true + } + ok(!threw, 'clearing an unconfigured store does not throw') +} + +// ---- 7. damaged files ------------------------------------------------------- +console.log('[damaged files]') +const damaged = [ + ['truncated JSON', '{"version":1,"salt":"AAAA"'], + ['an empty file', ''], + ['JSON null', 'null'], + ['a JSON array', '[]'], + ['a bare string', '"nope"'], + ['an unknown version', JSON.stringify({ version: 2, salt: SALT16, hash: HASH64, createdAt: 1 })], + ['a missing hash', JSON.stringify({ version: 1, salt: SALT16, createdAt: 1 })], + ['a non-string salt', JSON.stringify({ version: 1, salt: 42, hash: HASH64, createdAt: 1 })], + [ + 'a salt of the wrong size', + JSON.stringify({ version: 1, salt: Buffer.alloc(8).toString('base64'), hash: HASH64, createdAt: 1 }) + ], + ['a hash of the wrong size', JSON.stringify({ version: 1, salt: SALT16, hash: 'AAAA', createdAt: 1 })], + ['a missing createdAt', JSON.stringify({ version: 1, salt: SALT16, hash: HASH64 })] +] +for (const [name, content] of damaged) { + writeFileSync(lockFile, content, 'utf8') + const s = fresh() + let threw = false + let configured = true + let verified = true + try { + configured = s.isConfigured() + verified = await s.verify(PASSWORD) + } catch { + threw = true + } + ok( + !threw && configured === false && verified === false, + `${name} reads as unconfigured without throwing` + ) +} +{ + // A directory at the store path (EISDIR) is unreadable, not "a password is set". + const asDir = join(dir, 'as-dir') + mkdirSync(asDir, { recursive: true }) + const s = new lock.LockStore(asDir) + let threw = false + let configured = true + try { + configured = s.isConfigured() + } catch { + threw = true + } + ok(!threw && configured === false, 'a directory at the store path reads as unconfigured') +} +{ + // Recovery: a corrupt file is overwritten by the next set, not left blocking. + writeFileSync(lockFile, 'not json at all', 'utf8') + const s = fresh() + await s.setPassword(PASSWORD) + ok(s.isConfigured() === true && (await s.verify(PASSWORD)) === true, 'a corrupt file can be replaced') +} + +// ---- 8. lock state store ---------------------------------------------------- +console.log('[lock state store]') +const stateFile = join(dir, 'lock-state.json') +const stateStore = () => new lock.LockStateStore(stateFile) +{ + ok( + resolve(lock.defaultLockStatePath(dir)) === resolve(stateFile), + 'defaultLockStatePath resolves to /lock-state.json' + ) + + const s = stateStore() + ok(s.load().locked === false, 'a missing state file reads as unlocked') + ok(existsSync(stateFile) === false, 'and reading it does not create the file') +} +{ + // The controller writes every flag change through; a lost round trip would hand + // back an unlocked app (or a reset backoff) after a restart. + const s = stateStore() + s.save({ locked: true, failures: 2, cooldownUntil: 1234 }) + const raw = JSON.parse(readFileSync(stateFile, 'utf8')) + ok(raw.version === 1, 'the state file records version 1') + const loaded = s.load() + ok( + loaded.locked === true && loaded.failures === 2 && loaded.cooldownUntil === 1234, + 'load() returns exactly what save() wrote' + ) + ok(new lock.LockStateStore(stateFile).load().failures === 2, 'the flags survive a restart (a new store over the same file)') +} +{ + const s = stateStore() + s.save({ locked: true, failures: 1, cooldownUntil: 5000 }) + s.clear() + ok(existsSync(stateFile) === false, 'clear() removes the state file') + ok(s.load().locked === false, 'and the flags read back as unlocked') + let threw = false + try { + s.clear() + } catch { + threw = true + } + ok(!threw, 'clearing an absent state file does not throw') +} +{ + // Every one of these must land on the same fallback: the load runs on the + // startup path, where throwing would leave the app without a window while it + // still holds the single-instance lock. + const fallback = (state) => + state.locked === false && state.failures === 0 && state.cooldownUntil === 0 + const damagedStates = [ + ['an empty file', ''], + ['truncated JSON', '{"version":1,"locked":true'], + ['JSON null', 'null'], + ['a JSON array', '[]'], + ['a bare string', '"locked"'], + ['an unknown version', JSON.stringify({ version: 2, locked: true, failures: 3, cooldownUntil: 9 })], + ['a missing version', JSON.stringify({ locked: true, failures: 3, cooldownUntil: 9 })], + ['a non-boolean locked', JSON.stringify({ version: 1, locked: 'true', failures: 0, cooldownUntil: 0 })], + ['locked: 1', JSON.stringify({ version: 1, locked: 1, failures: 0, cooldownUntil: 0 })], + ['a fractional failure count', JSON.stringify({ version: 1, locked: false, failures: 2.5, cooldownUntil: 0 })], + ['a negative failure count', JSON.stringify({ version: 1, locked: false, failures: -1, cooldownUntil: 0 })], + ['a stringified failure count', JSON.stringify({ version: 1, locked: false, failures: '2', cooldownUntil: 0 })], + ['a missing failure count', JSON.stringify({ version: 1, locked: false, cooldownUntil: 0 })], + // JSON has no NaN and no Infinity: both arrive as null, or as text that is + // not JSON at all. Either way the cooldown must not become a live deadline. + ['a nulled cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: null })], + ['a literal NaN cooldown', '{"version":1,"locked":false,"failures":0,"cooldownUntil":NaN}'], + ['a literal Infinity cooldown', '{"version":1,"locked":false,"failures":0,"cooldownUntil":Infinity}'], + ['a stringified cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: '1234' })], + ['a negative cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: -5000 })] + ] + for (const [name, content] of damagedStates) { + writeFileSync(stateFile, content, 'utf8') + let threw = false + let state = null + try { + state = stateStore().load() + } catch { + threw = true + } + ok(!threw && fallback(state), `${name} reads as unlocked with no failures, without throwing`) + } +} +{ + // A damaged file must not block the next save (the controller writes after + // every change, so it has to be able to recover on its own). + writeFileSync(stateFile, 'not json at all', 'utf8') + const s = stateStore() + s.save({ locked: true, failures: 0, cooldownUntil: 0 }) + ok(s.load().locked === true, 'a damaged state file can be replaced') +} + +// The stores wrote into a temp dir; drop it so repeated runs do not litter %TEMP%. +rmSync(dir, { recursive: true, force: true }) + +console.log(failed === 0 ? '\n[lock] ALL CHECKS PASSED' : `\n[lock] ${failed} CHECK(S) FAILED`) +process.exit(failed === 0 ? 0 : 1) diff --git a/tests/ssh-loopback.mjs b/tests/ssh-loopback.mjs index 809b463..b630774 100644 --- a/tests/ssh-loopback.mjs +++ b/tests/ssh-loopback.mjs @@ -25,7 +25,22 @@ const fail = (msg) => { } // ---- 1. Boot the loopback server ------------------------------------------- -const serverKey = utils.generateKeyPairSync('ed25519') +// ssh2's ed25519 keygen turns out a malformed key roughly once per few +// hundred runs — its own parser then rejects it ("Malformed OpenSSH private +// key"), which is enough to flake a release build's pretest. The Server +// constructor parses hostKeys eagerly, so generate until one is accepted. +function newHostKey() { + for (let attempt = 0; ; attempt++) { + const pair = utils.generateKeyPairSync('ed25519') + try { + new Server({ hostKeys: [pair.private] }, () => {}) + return pair + } catch (err) { + if (attempt >= 9) throw err + } + } +} +const serverKey = newHostKey() let serverPort = 0 let seenWindowChange = { cols: 0, rows: 0 } diff --git a/tests/ssh-session-e2e.mjs b/tests/ssh-session-e2e.mjs index 19ac478..1955090 100644 --- a/tests/ssh-session-e2e.mjs +++ b/tests/ssh-session-e2e.mjs @@ -26,11 +26,31 @@ const fail = (msg) => { } // ---- 1. Loopback ssh server -------------------------------------------------- -const serverKey = utils.generateKeyPairSync('ed25519') +// ssh2's ed25519 keygen turns out a malformed key roughly once per few +// hundred runs — its own parser then rejects it ("Malformed OpenSSH private +// key"), which is enough to flake a release build's pretest. The Server +// constructor parses hostKeys eagerly, so generate until one is accepted. +function newHostKey() { + for (let attempt = 0; ; attempt++) { + const pair = utils.generateKeyPairSync('ed25519') + try { + new Server({ hostKeys: [pair.private] }, () => {}) + return pair + } catch (err) { + if (attempt >= 9) throw err + } + } +} +const serverKey = newHostKey() let serverPort = 0 let seenWindowChange = { cols: 0, rows: 0 } +// Latest server-side connection, so a test can hang up on the transport under +// an established session (see the PTY_EXIT guard near the end). +let serverSideClient = null + const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { + serverSideClient = client client.on('authentication', (ctx) => { if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') { ctx.accept() @@ -108,7 +128,7 @@ sessionLayer.configureSessionRuntime({ }) // ---- 3. Drive the pipeline ---------------------------------------------------- -const timer = setTimeout(() => fail('e2e timed out'), 15000) +const timer = setTimeout(() => fail('e2e timed out'), 25000) const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' }) if (!openResult?.id) fail('openSession did not resolve with an id') console.log(`[e2e] session open: ${openResult.id}`) @@ -149,6 +169,34 @@ console.log('[e2e] kill -> PTY_EXIT ok') if (!events.some((e) => e.channel === 'touched' && e.payload === 'conn-1')) fail('lastConnectedAt touch not recorded') console.log('[e2e] connection touch recorded') +// ---- 4. Transport death under an established session ------------------------- +// pty.ts's teardown is now the ONLY PTY_EXIT broadcaster (ssh.ts dropped its own +// emit), so this is the guard for both failure modes: a teardown that never +// fires leaves the renderer's panel stuck on "connecting" forever, and a broken +// idempotence guard would broadcast the exit twice. +const open2 = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' }) +if (!open2?.id) fail('second openSession did not resolve with an id') +for (let i = 0; i < 50 && !(await sessionLayer.getSessionReplay(open2.id)).includes('SESSION-E2E-BANNER'); i++) { + await wait(100) +} +if (!(await sessionLayer.getSessionReplay(open2.id)).includes('SESSION-E2E-BANNER')) { + fail('second session never became established') +} +console.log('[e2e] second session established') + +const exitCount = (id) => + events.filter((e) => e.channel === 'pty:exit' && e.payload?.id === id).length +if (exitCount(open2.id) !== 0) fail('PTY_EXIT arrived before the transport died') + +// The server hangs up. The client stream must land in the teardown path, which +// owns the single broadcast; the waits below give 'close' a moment to arrive. +serverSideClient.end() +for (let i = 0; i < 50 && exitCount(open2.id) === 0; i++) await wait(100) +if (exitCount(open2.id) !== 1) { + fail(`transport death must broadcast PTY_EXIT exactly once, got ${exitCount(open2.id)}`) +} +console.log('[e2e] transport death -> PTY_EXIT exactly once') + clearTimeout(timer) srv.close() console.log('[e2e] ALL CHECKS PASSED') diff --git a/tests/sysinfo-e2e.mjs b/tests/sysinfo-e2e.mjs index 2e8f174..944679f 100644 --- a/tests/sysinfo-e2e.mjs +++ b/tests/sysinfo-e2e.mjs @@ -28,7 +28,22 @@ const fail = (msg) => { const wait = (ms) => new Promise((r) => setTimeout(r, ms)) // ---- 1. Loopback ssh server with canned /proc exec ----------------------------- -const serverKey = utils.generateKeyPairSync('ed25519') +// ssh2's ed25519 keygen turns out a malformed key roughly once per few +// hundred runs — its own parser then rejects it ("Malformed OpenSSH private +// key"), which is enough to flake a release build's pretest. The Server +// constructor parses hostKeys eagerly, so generate until one is accepted. +function newHostKey() { + for (let attempt = 0; ; attempt++) { + const pair = utils.generateKeyPairSync('ed25519') + try { + new Server({ hostKeys: [pair.private] }, () => {}) + return pair + } catch (err) { + if (attempt >= 9) throw err + } + } +} +const serverKey = newHostKey() let serverPort = 0 // Two successive outputs with rising cpu ticks and rx/tx bytes so rates compute. @@ -198,6 +213,40 @@ const after = samples(openResult.id).length if (after !== before) fail(`stopPolling did not halt: got ${after - before} new samples`) console.log('[sysinfo] stopPolling halts the sample stream') +// ---- 5. Reference counting: split panels share one sessionId ---------------------- +// Two panels start on the same session; the poll must survive one stop and +// only halt on the last release. Counts grow at ~5 samples/s (200ms interval), +// so the waits below must show growth while a reference is held. +sessionLayer.startPolling(openResult.id, 200) +sessionLayer.startPolling(openResult.id, 200) +const refCounted = samples(openResult.id).length +await wait(600) +if (samples(openResult.id).length <= refCounted) fail('shared poll (refs=2) stopped sampling') +sessionLayer.stopPolling(openResult.id) // first panel unmounts +const oneRef = samples(openResult.id).length +await wait(600) +if (samples(openResult.id).length <= oneRef) fail('poll stopped while a sibling panel still held a reference') +console.log('[sysinfo] shared poll survives one sibling stop') +sessionLayer.stopPolling(openResult.id) // last panel unmounts +const zeroRefs = samples(openResult.id).length +await wait(600) +if (samples(openResult.id).length !== zeroRefs) fail('poll must stop only once the last reference is released') +console.log('[sysinfo] last release stops the poll') +// Restart after a full release must work on fresh state (no stale refs/timer). +sessionLayer.startPolling(openResult.id, 200) +await wait(600) +if (samples(openResult.id).length <= zeroRefs) fail('poll did not restart cleanly after a full release') +sessionLayer.stopPolling(openResult.id) +console.log('[sysinfo] restart after full release works') +// A killed session must force-stop regardless of outstanding references. +sessionLayer.startPolling(openResult.id, 200) +sessionLayer.startPolling(openResult.id, 200) +sessionLayer.forceStopPolling(openResult.id) +const forced = samples(openResult.id).length +await wait(600) +if (samples(openResult.id).length !== forced) fail('forceStopPolling must halt even with outstanding references') +console.log('[sysinfo] forceStopPolling overrides outstanding references') + clearTimeout(timer) srv.close() console.log('[sysinfo] ALL CHECKS PASSED')