feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+473
View File
@@ -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());
},
};
}