Files
vibn-frontend/lib/ssh-keys.ts
Mark Henderson 0797717bc1 Phase 4: AI-driven app/database/auth lifecycle
Workspace-owned deploy infra so AI agents can create and destroy
Coolify resources without ever touching the root admin token.

  vibn_workspaces
    + coolify_server_uuid, coolify_destination_uuid
    + coolify_environment_name (default "production")
    + coolify_private_key_uuid, gitea_bot_ssh_key_id

  ensureWorkspaceProvisioned
    + generates an ed25519 keypair per workspace
    + pushes pubkey to the Gitea bot user (read/write scoped by team)
    + registers privkey in Coolify as a reusable deploy key

  New endpoints under /api/workspaces/[slug]/
    apps/                POST (private-deploy-key from Gitea repo)
    apps/[uuid]          PATCH, DELETE?confirm=<name>
    apps/[uuid]/domains  GET, PATCH (policy: *.{ws}.vibnai.com only)
    databases/           GET, POST (8 types incl. postgres, clickhouse, dragonfly)
    databases/[uuid]     GET, PATCH, DELETE?confirm=<name>
    auth/                GET, POST (Pocketbase, Authentik, Keycloak, Pocket-ID, Logto, Supertokens)
    auth/[uuid]          DELETE?confirm=<name>

  MCP (/api/mcp) gains 15 new tools that mirror the REST surface and
  enforce the same workspace tenancy + delete-confirm guard.

  Safety: destructive ops require ?confirm=<exact-resource-name>; volumes
  are kept by default (pass delete_volumes=true to drop).

Made-with: Cursor
2026-04-21 12:04:59 -07:00

81 lines
2.8 KiB
TypeScript

/**
* ed25519 SSH keypair generation for per-workspace Coolify deploy keys.
*
* We generate once at provisioning time:
* - public key in OpenSSH format (for Gitea's SSH keys API)
* - private key in PKCS8 PEM (Coolify accepts this via /security/keys)
*
* Keys live in memory just long enough to be pushed to Gitea and Coolify;
* neither is ever persisted by Vibn directly (Coolify holds the private
* key, Gitea holds the public key). We keep the Coolify key uuid and the
* Gitea key id on vibn_workspaces so we can rotate them later.
*/
import { generateKeyPairSync, createPublicKey, type KeyObject } from 'crypto';
export interface Ed25519Keypair {
/** PKCS8 PEM — what Coolify's POST /security/keys wants. */
privateKeyPem: string;
/** OpenSSH public-key line: "ssh-ed25519 AAAA… <comment>" */
publicKeyOpenSsh: string;
/** SHA256 fingerprint string (without the trailing "=") */
fingerprint: string;
}
export function generateEd25519Keypair(comment = ''): Ed25519Keypair {
const { privateKey, publicKey } = generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const pubKeyObj = createPublicKey(publicKey);
const publicKeyOpenSsh = ed25519PublicKeyToOpenSsh(pubKeyObj, comment);
return {
privateKeyPem: privateKey,
publicKeyOpenSsh,
fingerprint: opensshFingerprint(publicKeyOpenSsh),
};
}
/**
* Convert a Node ed25519 public KeyObject into an OpenSSH-format line.
* Wire format for the base64 blob:
* uint32 len=11 | "ssh-ed25519" | uint32 len=32 | raw-pubkey-bytes
*/
function ed25519PublicKeyToOpenSsh(publicKey: KeyObject, comment: string): string {
const jwk = publicKey.export({ format: 'jwk' }) as { x?: string };
if (!jwk.x) {
throw new Error('public key has no jwk.x component — not ed25519?');
}
const rawKey = Buffer.from(jwk.x, 'base64url');
if (rawKey.length !== 32) {
throw new Error(`expected 32-byte ed25519 pubkey, got ${rawKey.length}`);
}
const keyTypeBytes = Buffer.from('ssh-ed25519');
const blob = Buffer.concat([
uint32BE(keyTypeBytes.length),
keyTypeBytes,
uint32BE(rawKey.length),
rawKey,
]).toString('base64');
const tail = comment ? ` ${comment}` : '';
return `ssh-ed25519 ${blob}${tail}`;
}
function uint32BE(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt32BE(n);
return b;
}
/**
* OpenSSH `ssh-keygen -lf`-style SHA256 fingerprint of a public key line.
*/
function opensshFingerprint(publicKeyLine: string): string {
const b64 = publicKeyLine.trim().split(/\s+/)[1];
if (!b64) return '';
const raw = Buffer.from(b64, 'base64');
const { createHash } = require('crypto') as typeof import('crypto');
const fp = createHash('sha256').update(raw).digest('base64');
return `SHA256:${fp.replace(/=+$/, '')}`;
}