Something went wrong. Try again.
mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
Something went wrong. Try again.
5.7 kB · 159 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160import 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 <base64(string("ssh-ed25519") + string(rawKey))> <comment> * * 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}