/** * SSH connection repository. * * Design rationale (rev 4): * - `resolveConnection(id)` is a raw row lookup with NO access check. * Access decisions are centralized in src/ssh/access.ts → resolveAccess(). * Splitting them avoids the getById-blocks-granted-users pitfall. * - Host key state has a two-stage lifecycle: pending (observed but unverified) * → primary (user clicked verify). `setHostKeyPendingWithToken` generates a * fresh UUID token; `setHostKeyVerified` consumes it atomically. Concurrent * observations replace the pending key + token (last-write-wins); the previous * token becomes stale and verification returns 'stale_token'. * - Abuse/lock state is in ssh_abuse_counters (single source of truth). This * module never reads or writes failure_count / lock_until columns on * ssh_connections (they don't exist). * * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1). */ import type Database from 'better-sqlite3'; import { randomUUID } from 'node:crypto'; export type HostKeyPendingSource = 'tofu_record' | 'mismatch'; export interface SshConnection { id: string; ownerId: string | null; label: string; host: string; port: number; username: string; privateKeyEnc: Buffer; passphraseEnc: Buffer | null; keyVersion: number; keyFingerprint: string | null; hostKeyType: string | null; hostKeyB64: string | null; hostKeyFingerprint: string | null; hostKeyRecordedAt: string | null; hostKeyVerifiedAt: string | null; hostKeyPending: boolean; hostKeyPendingB64: string | null; hostKeyPendingFingerprint: string | null; hostKeyPendingToken: string | null; hostKeyPendingSource: HostKeyPendingSource | null; commandDenyPatterns: string | null; commandAllowPatterns: string | null; remotePathPrefix: string; allowRemoteUnrestricted: boolean; allowPrivateAddresses: boolean; enabled: boolean; disabledByAdmin: boolean; disabledByAdminReason: string | null; disabledByAdminAt: string | null; disabledByAdminUserId: string | null; createdAt: string; updatedAt: string; } export interface CreateConnectionInput { ownerId: string | null; label: string; host: string; port: number; username: string; privateKeyEnc: Buffer; passphraseEnc?: Buffer | null; keyVersion?: number; keyFingerprint: string; remotePathPrefix: string; allowRemoteUnrestricted?: boolean; allowPrivateAddresses?: boolean; commandDenyPatterns?: string | null; commandAllowPatterns?: string | null; /** ISO8601 timestamp; defaults to now. */ now?: string; } export interface UpdateConnectionInput { label?: string; host?: string; port?: number; username?: string; privateKeyEnc?: Buffer; passphraseEnc?: Buffer | null; keyVersion?: number; keyFingerprint?: string; remotePathPrefix?: string; commandDenyPatterns?: string | null; commandAllowPatterns?: string | null; allowRemoteUnrestricted?: boolean; allowPrivateAddresses?: boolean; enabled?: boolean; } export type HostKeyVerifyResult = | 'verified' | 'stale_token' | 'fingerprint_mismatch' | 'not_pending'; interface RawRow { id: string; owner_id: string | null; label: string; host: string; port: number; username: string; private_key_enc: Buffer; passphrase_enc: Buffer | null; key_version: number; key_fingerprint: string | null; host_key_type: string | null; host_key_b64: string | null; host_key_fingerprint: string | null; host_key_recorded_at: string | null; host_key_verified_at: string | null; host_key_pending: number; host_key_pending_b64: string | null; host_key_pending_fingerprint: string | null; host_key_pending_token: string | null; host_key_pending_source: HostKeyPendingSource | null; command_deny_patterns: string | null; command_allow_patterns: string | null; remote_path_prefix: string; allow_remote_unrestricted: number; allow_private_addresses: number; enabled: number; disabled_by_admin: number; disabled_by_admin_reason: string | null; disabled_by_admin_at: string | null; disabled_by_admin_user_id: string | null; created_at: string; updated_at: string; } function fromRow(r: RawRow): SshConnection { return { id: r.id, ownerId: r.owner_id, label: r.label, host: r.host, port: r.port, username: r.username, privateKeyEnc: r.private_key_enc, passphraseEnc: r.passphrase_enc, keyVersion: r.key_version, keyFingerprint: r.key_fingerprint, hostKeyType: r.host_key_type, hostKeyB64: r.host_key_b64, hostKeyFingerprint: r.host_key_fingerprint, hostKeyRecordedAt: r.host_key_recorded_at, hostKeyVerifiedAt: r.host_key_verified_at, hostKeyPending: r.host_key_pending === 1, hostKeyPendingB64: r.host_key_pending_b64, hostKeyPendingFingerprint: r.host_key_pending_fingerprint, hostKeyPendingToken: r.host_key_pending_token, hostKeyPendingSource: r.host_key_pending_source, commandDenyPatterns: r.command_deny_patterns, commandAllowPatterns: r.command_allow_patterns, remotePathPrefix: r.remote_path_prefix, allowRemoteUnrestricted: r.allow_remote_unrestricted === 1, allowPrivateAddresses: r.allow_private_addresses === 1, enabled: r.enabled === 1, disabledByAdmin: r.disabled_by_admin === 1, disabledByAdminReason: r.disabled_by_admin_reason, disabledByAdminAt: r.disabled_by_admin_at, disabledByAdminUserId: r.disabled_by_admin_user_id, createdAt: r.created_at, updatedAt: r.updated_at, }; } /** * Parse the SSH algorithm name out of a host key in OpenSSH wire format. * Wire format: <4-byte big-endian length><...keydata>. * Returns null on malformed input. */ export function parseHostKeyType(b64: string): string | null { try { const buf = Buffer.from(b64, 'base64'); if (buf.length < 4) return null; const len = buf.readUInt32BE(0); if (len === 0 || len > 64 || len + 4 > buf.length) return null; const name = buf.slice(4, 4 + len).toString('utf8'); if (!/^[a-z0-9@.-]+$/i.test(name)) return null; return name; } catch { return null; } } export interface SshConnectionRepo { create(input: CreateConnectionInput): SshConnection; update(id: string, patch: UpdateConnectionInput, now?: string): boolean; delete(id: string): boolean; /** Raw lookup. NO access check — caller MUST use resolveAccess() for authorization. */ resolveConnection(id: string): SshConnection | null; listOwned(ownerId: string): SshConnection[]; /** Admin-only listing. */ listAll(): SshConnection[]; disableByAdmin(id: string, reason: string, byUserId: string, now?: string): boolean; enableByAdmin(id: string, now?: string): boolean; /** * Record an observed host key as pending and issue a fresh verify token. * Replaces any existing pending key (last-write-wins). Returns the new token * — the caller surfaces this to the verifying user. */ setHostKeyPendingWithToken( id: string, b64: string, fingerprint: string, source: HostKeyPendingSource, now?: string, ): { token: string } | null; /** * Atomically promote pending → primary if the token AND fingerprint match. * Used for first-time TOFU verify. * 'not_pending' — no pending key on this connection * 'stale_token' — token mismatch (pending was replaced by a newer observation) * 'fingerprint_mismatch' — pending fingerprint differs from caller-provided one * 'verified' — promotion succeeded */ setHostKeyVerified( id: string, token: string, fingerprint: string, now?: string, ): HostKeyVerifyResult; /** * Promote a pending key over an existing verified one (host key rotation). * Atomic CHECK on token + fingerprint, same return codes as setHostKeyVerified. * Audit caller must record `ssh.connection.host_key.replace` with reason. */ replaceHostKey( id: string, token: string, fingerprint: string, now?: string, ): HostKeyVerifyResult; } export function createConnectionRepo(db: Database.Database): SshConnectionRepo { const insertStmt = db.prepare(` INSERT INTO ssh_connections ( id, owner_id, label, host, port, username, private_key_enc, passphrase_enc, key_version, key_fingerprint, remote_path_prefix, allow_remote_unrestricted, allow_private_addresses, command_deny_patterns, command_allow_patterns, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const selectByIdStmt = db.prepare(`SELECT * FROM ssh_connections WHERE id = ?`); const deleteStmt = db.prepare(`DELETE FROM ssh_connections WHERE id = ?`); const selectByOwnerStmt = db.prepare( `SELECT * FROM ssh_connections WHERE owner_id = ? ORDER BY created_at DESC`, ); const selectAllStmt = db.prepare(`SELECT * FROM ssh_connections ORDER BY created_at DESC`); const disableStmt = db.prepare(` UPDATE ssh_connections SET enabled = 0, disabled_by_admin = 1, disabled_by_admin_reason = ?, disabled_by_admin_at = ?, disabled_by_admin_user_id = ?, updated_at = ? WHERE id = ? `); const enableStmt = db.prepare(` UPDATE ssh_connections SET enabled = 1, disabled_by_admin = 0, disabled_by_admin_reason = NULL, disabled_by_admin_at = NULL, disabled_by_admin_user_id = NULL, updated_at = ? WHERE id = ? `); const setPendingStmt = db.prepare(` UPDATE ssh_connections SET host_key_pending = 1, host_key_pending_b64 = ?, host_key_pending_fingerprint = ?, host_key_pending_token = ?, host_key_pending_source = ?, updated_at = ? WHERE id = ? `); /** * Atomic verify: clear pending fields and either promote them to primary * (when source was tofu_record / replacing) or just clear (verify only). * SQL CHECK on token + fingerprint inside WHERE = atomic. */ const verifyPromoteStmt = db.prepare(` UPDATE ssh_connections SET host_key_type = ?, host_key_b64 = host_key_pending_b64, host_key_fingerprint = host_key_pending_fingerprint, host_key_recorded_at = ?, host_key_verified_at = ?, host_key_pending = 0, host_key_pending_b64 = NULL, host_key_pending_fingerprint = NULL, host_key_pending_token = NULL, host_key_pending_source = NULL, updated_at = ? WHERE id = ? AND host_key_pending = 1 AND host_key_pending_token = ? AND host_key_pending_fingerprint = ? `); function readPendingState(id: string): | { exists: true; pending: boolean; token: string | null; fingerprint: string | null } | { exists: false } { const row = selectByIdStmt.get(id) as RawRow | undefined; if (!row) return { exists: false }; return { exists: true, pending: row.host_key_pending === 1, token: row.host_key_pending_token, fingerprint: row.host_key_pending_fingerprint, }; } function runVerifyPromote( id: string, token: string, fingerprint: string, now: string, ): HostKeyVerifyResult { // We use a transaction so that the state classification (no pending / // stale_token / fingerprint_mismatch) reads from the same snapshot as // the conditional update. const tx = db.transaction((): HostKeyVerifyResult => { const state = readPendingState(id); if (!state.exists) return 'not_pending'; if (!state.pending) return 'not_pending'; if (state.token !== token) return 'stale_token'; if (state.fingerprint !== fingerprint) return 'fingerprint_mismatch'; // Derive type from the (already-validated) pending b64. const row = selectByIdStmt.get(id) as RawRow; const hostKeyType = row.host_key_pending_b64 ? parseHostKeyType(row.host_key_pending_b64) : null; const r = verifyPromoteStmt.run(hostKeyType, now, now, now, id, token, fingerprint); return r.changes === 1 ? 'verified' : 'stale_token'; }); return tx(); } return { create(input) { const id = randomUUID(); const now = input.now ?? new Date().toISOString(); if (input.remotePathPrefix.length === 0) { throw new Error('connection: remotePathPrefix must be non-empty'); } insertStmt.run( id, input.ownerId, input.label, input.host, input.port, input.username, input.privateKeyEnc, input.passphraseEnc ?? null, input.keyVersion ?? 1, input.keyFingerprint, input.remotePathPrefix, input.allowRemoteUnrestricted ? 1 : 0, input.allowPrivateAddresses ? 1 : 0, input.commandDenyPatterns ?? null, input.commandAllowPatterns ?? null, now, now, ); const row = selectByIdStmt.get(id) as RawRow; return fromRow(row); }, update(id, patch, now) { const fields: string[] = []; const values: unknown[] = []; const push = (col: string, v: unknown) => { fields.push(`${col} = ?`); values.push(v); }; if (patch.label !== undefined) push('label', patch.label); if (patch.host !== undefined) push('host', patch.host); if (patch.port !== undefined) push('port', patch.port); if (patch.username !== undefined) push('username', patch.username); if (patch.privateKeyEnc !== undefined) push('private_key_enc', patch.privateKeyEnc); if (patch.passphraseEnc !== undefined) push('passphrase_enc', patch.passphraseEnc); if (patch.keyVersion !== undefined) push('key_version', patch.keyVersion); if (patch.keyFingerprint !== undefined) push('key_fingerprint', patch.keyFingerprint); if (patch.remotePathPrefix !== undefined) { if (patch.remotePathPrefix.length === 0) { throw new Error('connection: remotePathPrefix must be non-empty'); } push('remote_path_prefix', patch.remotePathPrefix); } if (patch.commandDenyPatterns !== undefined) push('command_deny_patterns', patch.commandDenyPatterns); if (patch.commandAllowPatterns !== undefined) push('command_allow_patterns', patch.commandAllowPatterns); if (patch.allowRemoteUnrestricted !== undefined) push('allow_remote_unrestricted', patch.allowRemoteUnrestricted ? 1 : 0); if (patch.allowPrivateAddresses !== undefined) push('allow_private_addresses', patch.allowPrivateAddresses ? 1 : 0); if (patch.enabled !== undefined) push('enabled', patch.enabled ? 1 : 0); if (fields.length === 0) return false; push('updated_at', now ?? new Date().toISOString()); values.push(id); const sql = `UPDATE ssh_connections SET ${fields.join(', ')} WHERE id = ?`; const r = db.prepare(sql).run(...values); return r.changes > 0; }, delete(id) { const r = deleteStmt.run(id); return r.changes > 0; }, resolveConnection(id) { const row = selectByIdStmt.get(id) as RawRow | undefined; return row ? fromRow(row) : null; }, listOwned(ownerId) { return (selectByOwnerStmt.all(ownerId) as RawRow[]).map(fromRow); }, listAll() { return (selectAllStmt.all() as RawRow[]).map(fromRow); }, disableByAdmin(id, reason, byUserId, now) { const ts = now ?? new Date().toISOString(); const r = disableStmt.run(reason, ts, byUserId, ts, id); return r.changes > 0; }, enableByAdmin(id, now) { const ts = now ?? new Date().toISOString(); const r = enableStmt.run(ts, id); return r.changes > 0; }, setHostKeyPendingWithToken(id, b64, fingerprint, source, now) { const ts = now ?? new Date().toISOString(); const token = randomUUID(); const r = setPendingStmt.run(b64, fingerprint, token, source, ts, id); if (r.changes === 0) return null; return { token }; }, setHostKeyVerified(id, token, fingerprint, now) { return runVerifyPromote(id, token, fingerprint, now ?? new Date().toISOString()); }, replaceHostKey(id, token, fingerprint, now) { // Same atomic promotion as verify. The semantic distinction (first-time // verify vs replacing an existing key) lives in the audit log + UI flow. return runVerifyPromote(id, token, fingerprint, now ?? new Date().toISOString()); }, }; }