474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
/**
|
|
* 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><algorithm name string><...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 (!/^[[email protected]]+$/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());
|
|
},
|
|
};
|
|
}
|