feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown

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

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

Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e;
GitHub Actions CI (typecheck + 10 offline tests + build)
This commit is contained in:
Bill committed 2026-09-24 22:16:43 +08:00
1 parent 471f8c3e73
commit 35583b2c15
47 files changed
+3058 -105

No files matched your search

+8
View File
@@ -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'] },
+108 -5
View File
@@ -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')
+3
View File
@@ -28,6 +28,9 @@ module.exports = {
isRegistered: () => false
},
webContents: {},
powerMonitor: {
getSystemIdleTime: () => 0
},
powerSaveBlocker: {
start: () => 1,
stop: () => {},
+420
View File
@@ -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 <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)
+309
View File
@@ -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 <userData>/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 <userData>/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)
+16 -1
View File
@@ -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 }
+50 -2
View File
@@ -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')
+50 -1
View File
@@ -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')