/** * 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… " */ 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(/=+$/, '')}`; }