import crypto from 'node:crypto' /** * Generate an ed25519 SSH keypair. Returns the OpenSSH-formatted public key * (suitable for `sh.tangled.publicKey` records / GitHub deploy keys / authorized_keys) * and the PKCS#8-PEM-encoded private key (suitable for storage). * * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`. * The OpenSSH-private-key format used by `git`/`ssh` for authentication is * produced on demand by `pkcs8ToOpenSshPrivate` below. */ export interface GeneratedKeypair { publicKeyOpenSsh: string privateKeyPem: string } export function generateKeypair(comment: string): GeneratedKeypair { const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }) // SPKI-DER for ed25519 is a fixed 44-byte ASN.1 wrapper; the last 32 bytes // are the raw public key. (See RFC 8410 §4.) Skip the wrapper. const rawPublic = (publicKey as Buffer).subarray(-32) return { publicKeyOpenSsh: encodeOpenSshEd25519(rawPublic, comment), privateKeyPem: privateKey, } } /** * Encode a 32-byte ed25519 public key in OpenSSH `authorized_keys` format: * ssh-ed25519 * * The base64 payload uses SSH's length-prefixed string format (uint32 big-endian * length + bytes), per RFC 4253 §6.6 and the ed25519 draft. */ function encodeOpenSshEd25519(rawPublicKey: Buffer, comment: string): string { if (rawPublicKey.length !== 32) { throw new Error(`expected 32 raw bytes for ed25519 public key, got ${rawPublicKey.length}`) } const algo = Buffer.from('ssh-ed25519', 'utf8') const payload = Buffer.concat([ sshString(algo), sshString(rawPublicKey), ]) return `ssh-ed25519 ${payload.toString('base64')} ${comment}` } function sshString(buf: Buffer): Buffer { const len = Buffer.alloc(4) len.writeUInt32BE(buf.length, 0) return Buffer.concat([len, buf]) } /** * Convert an ed25519 PKCS#8 PEM private key (what we store) to the OpenSSH * private key format (what `ssh`/`git` consume). Format spec: OpenSSH's * PROTOCOL.key. No passphrase — the file we hand to ssh is plaintext and * lives only for the duration of one push, in a 0600 temp file. * * Structure for an unencrypted ed25519 key: * "openssh-key-v1\0" * string ciphername = "none" * string kdfname = "none" * string kdfoptions = "" * uint32 nkeys = 1 * string public-key-blob (ssh-ed25519 wire format: algo + raw32) * string private-section (padded to a multiple of 8): * uint32 checkint * uint32 checkint (same value, sanity check for decryption) * string "ssh-ed25519" * string public (raw 32) * string private (64 bytes: seed(32) || public(32)) * string comment * padding bytes 1,2,3,...,n * * The whole binary blob is then base64-wrapped in * `-----BEGIN OPENSSH PRIVATE KEY-----` / `-----END OPENSSH PRIVATE KEY-----` * with 70-char line breaks. */ export function pkcs8ToOpenSshPrivate(privateKeyPem: string, comment: string): string { const keyObj = crypto.createPrivateKey(privateKeyPem) if (keyObj.asymmetricKeyType !== 'ed25519') { throw new Error(`expected ed25519 private key, got ${String(keyObj.asymmetricKeyType)}`) } // PKCS#8 DER for ed25519 is a fixed 48-byte ASN.1 structure with the // 32-byte seed as the trailing bytes. (RFC 8410 §7.) const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' }) const seed = pkcs8Der.subarray(-32) // Derive the matching public key by re-extracting from the same key object. const publicKeyDer = crypto.createPublicKey(keyObj).export({ type: 'spki', format: 'der' }) const rawPublic = publicKeyDer.subarray(-32) const algo = Buffer.from('ssh-ed25519', 'utf8') const publicKeyBlob = Buffer.concat([sshString(algo), sshString(rawPublic)]) // checkint: a random uint32 written twice. ssh verifies the two are equal // after decryption — cheap integrity check. For an unencrypted key it's // still required but doesn't really verify anything; use random bytes. const checkint = crypto.randomBytes(4) // OpenSSH's private key format stores the seed concatenated with the public // key as one 64-byte "private" string. Looks redundant but is what ssh // parses. const privateMaterial = Buffer.concat([seed, rawPublic]) let privateSection = Buffer.concat([ checkint, checkint, sshString(algo), sshString(rawPublic), sshString(privateMaterial), sshString(Buffer.from(comment, 'utf8')), ]) // Pad to a multiple of 8 (the "none" cipher's block size). Padding bytes // are 1, 2, 3, … not zeros. const padLen = (8 - (privateSection.length % 8)) % 8 if (padLen > 0) { const pad = Buffer.alloc(padLen) for (let i = 0; i < padLen; i++) pad[i] = i + 1 privateSection = Buffer.concat([privateSection, pad]) } const blob = Buffer.concat([ Buffer.from('openssh-key-v1\0', 'utf8'), sshString(Buffer.from('none', 'utf8')), sshString(Buffer.from('none', 'utf8')), sshString(Buffer.alloc(0)), uint32BE(1), sshString(publicKeyBlob), sshString(privateSection), ]) const base64 = blob.toString('base64') const lines: string[] = [] for (let i = 0; i < base64.length; i += 70) { lines.push(base64.slice(i, i + 70)) } return [ '-----BEGIN OPENSSH PRIVATE KEY-----', ...lines, '-----END OPENSSH PRIVATE KEY-----', '', ].join('\n') } function uint32BE(n: number): Buffer { const buf = Buffer.alloc(4) buf.writeUInt32BE(n, 0) return buf }