Files
OpenTerminal/src/main/ssh.ts
T
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

352 lines
12 KiB
TypeScript

/**
* SSH session service (M2).
*
* Deliberately decoupled from Electron: broadcast and host-key store are
* injected (`SshServiceDeps`), so the connect / verify / shell flow can be
* exercised by the pure-node loopback harness (tests/ssh-loopback.mjs) with a
* plain ssh2 client and no Chromium runtime.
*
* Host-key verification pauses the handshake inside the `hostVerifier`
* callback: ssh2's kex suspends until `verify()` is called, which is exactly
* what we need for store-check + renderer prompt + accept-record. The 15s
* connect timeout is paused while a host-key prompt is outstanding so the
* prompt's own 30s budget governs that wait.
*/
import type { SshConnection, SshSecretOverride } from '../shared/connections'
import { t } from '../shared/i18n'
import { randomUUID } from 'crypto'
import { readFileSync } from 'fs'
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
import type { ConnectionsStore } from './connectionsStore'
import type { Client, ClientChannel, ConnectConfig } from 'ssh2'
export interface SshSessionHandle {
id: string
/** established ssh connection; powers the session routing in pty.ts */
client: Client
/** open interactive shell channel (ClientChannel, a Duplex) */
stream: ClientChannel
/**
* Exit code reported for the session: the channel's 'exit' event when the
* remote sends one, 1 when the client errors out mid-session, else 0.
*/
exitCode: number
}
export interface SshServiceDeps {
/** resolves saved secrets (service never writes them) */
connections: Pick<ConnectionsStore, 'getSecret' | 'touch'>
/** host key pinning store access */
knownHosts: {
check(host: string, port: number, key: Buffer): HostKeyCheckResult
accept(host: string, port: number, key: Buffer, fingerprint: string): void
}
/** electron-free broadcast; matches main/broadcast.ts */
broadcast(channel: string, ...args: unknown[]): void
/**
* Ask the renderer to decide on an unknown / changed host key. The prompt
* resolves via the module-level `resolveHostKey(promptId, action)`.
*/
promptHostKey(prompt: {
promptId: string
host: string
port: number
fingerprint: string
reason: 'new' | 'changed'
}): void
timeoutMs?: { prompt: number; connect: number }
}
// Host-key confirmation is a security decision — users may take time to
// compare fingerprints. 30s timed out on people who merely screenshot it.
const DEFAULT_TIMEOUTS = { prompt: 120_000, connect: 15_000 }
/**
* Open an ssh session (connect + auth + open shell) for `conn`.
*
* Resolves with `{ id, client, stream }` once the shell stream is ready to
* stream data. Rejects with a human-readable Chinese Error when anything goes
* wrong before the shell is ready.
*/
export async function connectSsh(
conn: SshConnection,
secretOverride: SshSecretOverride | undefined,
deps: SshServiceDeps
): Promise<SshSessionHandle> {
const mod = await import('ssh2')
const Client = mod.Client
const timeouts = { ...DEFAULT_TIMEOUTS, ...deps.timeoutMs }
const sessionId = randomUUID()
const handshake = new Client()
let settled = false
let connectTimer: NodeJS.Timeout | undefined
let verifierErr: string | undefined
let sessionHandle: SshSessionHandle | undefined
let resolvePromise!: (handle: SshSessionHandle) => void
let rejectPromise!: (err: Error) => void
const armConnectTimer = (): void => {
if (connectTimer) clearTimeout(connectTimer)
connectTimer = setTimeout(() => {
fail(new Error(t('main.ssh.connectTimeout', { host: conn.host, port: conn.port })))
}, timeouts.connect)
}
const fail = (err: Error): void => {
if (settled) return
settled = true
if (connectTimer) clearTimeout(connectTimer)
rejectPromise(err)
try {
handshake.destroy()
} catch {
// best effort
}
}
// --- host key verification ------------------------------------------------
// 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: 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)
// 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') {
verify(true)
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)
promptUser(conn.host, conn.port, fingerprint, status, timeouts.prompt, deps)
.then((accepted) => {
if (accepted) {
try {
deps.knownHosts.accept(conn.host, conn.port, hostKey, fingerprint)
} catch (err) {
verifierErr = t('main.ssh.saveFingerprintFailed', { detail: (err as Error).message })
verify(false)
return
}
verify(true)
} else {
verifierErr = t('main.ssh.hostKeyRejected')
verify(false)
}
})
.finally(() => {
// Resume the overall connect timer once the decision lands.
if (!settled) armConnectTimer()
})
}
return new Promise<SshSessionHandle>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
// failure paths before resolution: `fail` and the connect timer
armConnectTimer()
handshake.on('error', (err: Error) => {
console.error(`[ssh] client error: ${err.message}`)
const message = verifierErr ?? err.message
if (!settled) {
fail(new Error(t('main.ssh.connectFailed', { host: conn.host, port: conn.port, detail: message })))
return
}
// 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 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 {
handshake.destroy()
} catch {
// best effort
}
})
handshake.on('ready', () => {
handshake.shell(
{ term: 'xterm-256color', cols: 80, rows: 24 },
(err: Error | undefined, shell: ClientChannel) => {
if (err) {
fail(
new Error(
t('main.ssh.shellOpenFailed', { host: conn.host, port: conn.port, detail: err.message })
)
)
return
}
if (settled) {
try {
shell.end()
} catch {
// best effort
}
return
}
settled = true
if (connectTimer) clearTimeout(connectTimer)
// Successful connect: record lastConnectedAt on the bookmark.
try {
deps.connections.touch(conn.id)
} catch {
// store write failure must not break the session
}
sessionHandle = { id: sessionId, client: handshake, stream: shell, exitCode: 0 }
resolvePromise(sessionHandle)
}
)
})
const cfg: ConnectConfig = {
host: conn.host,
port: conn.port,
username: conn.username,
// SshConnection keeps it in seconds; ssh2 expects milliseconds.
keepaliveInterval: Math.round(conn.keepaliveIntervalSec * 1000),
hostVerifier
}
// 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. 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 =
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)
if (conn.auth === 'privateKey') {
const keyContent = deps.connections.getSecret(conn, 'keyContent')
const privateKey: string | undefined =
conn.keyPath !== undefined && conn.keyPath.length > 0
? readKeyFile(conn.keyPath)
: keyContent !== undefined && keyContent.length > 0
? keyContent
: 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
}
}
// Agent auth (defaults supplied only when an agent socket is reachable)
if (conn.auth === 'agent') {
const agent = process.env.SSH_AUTH_SOCK ?? (process.platform === 'win32' ? 'pageant' : undefined)
if (agent !== undefined) cfg.agent = agent
}
try {
handshake.connect(cfg)
} catch (err) {
// e.g. unparseable privateKey is thrown synchronously by ssh2
fail(
new Error(
t('main.ssh.initFailed', { host: conn.host, port: conn.port, detail: (err as Error).message })
)
)
}
})
}
// --- host-key prompt routing -------------------------------------------------
interface PendingPrompt {
resolve: (accepted: boolean) => void
timer: NodeJS.Timeout
}
const pendingPrompts = new Map<string, PendingPrompt>()
/**
* Ask the renderer to approve / reject a host key. Resolves `true`/`false`.
* Times out (default 30s) -> treated as reject.
*/
function promptUser(
host: string,
port: number,
fingerprint: string,
reason: 'new' | 'changed',
timeoutMs: number,
deps: SshServiceDeps
): Promise<boolean> {
const promptId = randomUUID()
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
pendingPrompts.delete(promptId)
resolve(false)
}, timeoutMs)
pendingPrompts.set(promptId, { resolve, timer })
try {
deps.promptHostKey({ promptId, host, port, fingerprint, reason })
} catch {
// broadcast failure must not hang the attempt forever
pendingPrompts.delete(promptId)
clearTimeout(timer)
resolve(false)
}
})
}
/**
* Route a renderer host-key decision to the matching pending prompt.
* Wired by the main process: ipcMain.on(Ipc.HOSTKEY_RESPOND, (…, promptId, action)).
*/
export function resolveHostKey(promptId: string, action: 'accept' | 'reject'): void {
const pending = pendingPrompts.get(promptId)
if (!pending) {
console.error(`[ssh] resolveHostKey: no pending prompt ${promptId}`)
return
}
clearTimeout(pending.timer)
pendingPrompts.delete(promptId)
pending.resolve(action === 'accept')
}
/** Read a private key file; surfaces a descriptive error on failure. */
function readKeyFile(keyPath: string): string {
try {
return readFileSync(keyPath, 'utf8')
} catch (err) {
throw new Error(t('main.ssh.readKeyFailed', { path: keyPath, detail: (err as Error).message }))
}
}