feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 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());
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user