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

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

Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e;
GitHub Actions CI (typecheck + 10 offline tests + build)
2026-09-24 22:16:43 +08:00

421 lines
18 KiB
JavaScript

/**
* 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 <userData>/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)