mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

1import crypto from 'node:crypto' 2 3/** 4 * Generate an ed25519 SSH keypair. Returns the OpenSSH-formatted public key 5 * (suitable for `sh.tangled.publicKey` records / GitHub deploy keys / authorized_keys) 6 * and the PKCS#8-PEM-encoded private key (suitable for storage). 7 * 8 * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`. 9 * The OpenSSH-private-key format used by `git`/`ssh` for authentication is 10 * produced on demand by `pkcs8ToOpenSshPrivate` below. 11 */ 12export interface GeneratedKeypair { 13 publicKeyOpenSsh: string 14 privateKeyPem: string 15} 16 17export function generateKeypair(comment: string): GeneratedKeypair { 18 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { 19 publicKeyEncoding: { type: 'spki', format: 'der' }, 20 privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, 21 }) 22 23 // SPKI-DER for ed25519 is a fixed 44-byte ASN.1 wrapper; the last 32 bytes 24 // are the raw public key. (See RFC 8410 §4.) Skip the wrapper. 25 const rawPublic = (publicKey as Buffer).subarray(-32) 26 27 return { 28 publicKeyOpenSsh: encodeOpenSshEd25519(rawPublic, comment), 29 privateKeyPem: privateKey, 30 } 31} 32 33/** 34 * Encode a 32-byte ed25519 public key in OpenSSH `authorized_keys` format: 35 * ssh-ed25519 <base64(string("ssh-ed25519") + string(rawKey))> <comment> 36 * 37 * The base64 payload uses SSH's length-prefixed string format (uint32 big-endian 38 * length + bytes), per RFC 4253 §6.6 and the ed25519 draft. 39 */ 40function encodeOpenSshEd25519(rawPublicKey: Buffer, comment: string): string { 41 if (rawPublicKey.length !== 32) { 42 throw new Error(`expected 32 raw bytes for ed25519 public key, got ${rawPublicKey.length}`) 43 } 44 45 const algo = Buffer.from('ssh-ed25519', 'utf8') 46 const payload = Buffer.concat([ 47 sshString(algo), 48 sshString(rawPublicKey), 49 ]) 50 return `ssh-ed25519 ${payload.toString('base64')} ${comment}` 51} 52 53function sshString(buf: Buffer): Buffer { 54 const len = Buffer.alloc(4) 55 len.writeUInt32BE(buf.length, 0) 56 return Buffer.concat([len, buf]) 57} 58 59/** 60 * Convert an ed25519 PKCS#8 PEM private key (what we store) to the OpenSSH 61 * private key format (what `ssh`/`git` consume). Format spec: OpenSSH's 62 * PROTOCOL.key. No passphrase — the file we hand to ssh is plaintext and 63 * lives only for the duration of one push, in a 0600 temp file. 64 * 65 * Structure for an unencrypted ed25519 key: 66 * "openssh-key-v1\0" 67 * string ciphername = "none" 68 * string kdfname = "none" 69 * string kdfoptions = "" 70 * uint32 nkeys = 1 71 * string public-key-blob (ssh-ed25519 wire format: algo + raw32) 72 * string private-section (padded to a multiple of 8): 73 * uint32 checkint 74 * uint32 checkint (same value, sanity check for decryption) 75 * string "ssh-ed25519" 76 * string public (raw 32) 77 * string private (64 bytes: seed(32) || public(32)) 78 * string comment 79 * padding bytes 1,2,3,...,n 80 * 81 * The whole binary blob is then base64-wrapped in 82 * `-----BEGIN OPENSSH PRIVATE KEY-----` / `-----END OPENSSH PRIVATE KEY-----` 83 * with 70-char line breaks. 84 */ 85export function pkcs8ToOpenSshPrivate(privateKeyPem: string, comment: string): string { 86 const keyObj = crypto.createPrivateKey(privateKeyPem) 87 if (keyObj.asymmetricKeyType !== 'ed25519') { 88 throw new Error(`expected ed25519 private key, got ${String(keyObj.asymmetricKeyType)}`) 89 } 90 91 // PKCS#8 DER for ed25519 is a fixed 48-byte ASN.1 structure with the 92 // 32-byte seed as the trailing bytes. (RFC 8410 §7.) 93 const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' }) 94 const seed = pkcs8Der.subarray(-32) 95 96 // Derive the matching public key by re-extracting from the same key object. 97 const publicKeyDer = crypto.createPublicKey(keyObj).export({ type: 'spki', format: 'der' }) 98 const rawPublic = publicKeyDer.subarray(-32) 99 100 const algo = Buffer.from('ssh-ed25519', 'utf8') 101 const publicKeyBlob = Buffer.concat([sshString(algo), sshString(rawPublic)]) 102 103 // checkint: a random uint32 written twice. ssh verifies the two are equal 104 // after decryption — cheap integrity check. For an unencrypted key it's 105 // still required but doesn't really verify anything; use random bytes. 106 const checkint = crypto.randomBytes(4) 107 108 // OpenSSH's private key format stores the seed concatenated with the public 109 // key as one 64-byte "private" string. Looks redundant but is what ssh 110 // parses. 111 const privateMaterial = Buffer.concat([seed, rawPublic]) 112 113 let privateSection = Buffer.concat([ 114 checkint, 115 checkint, 116 sshString(algo), 117 sshString(rawPublic), 118 sshString(privateMaterial), 119 sshString(Buffer.from(comment, 'utf8')), 120 ]) 121 122 // Pad to a multiple of 8 (the "none" cipher's block size). Padding bytes 123 // are 1, 2, 3, … not zeros. 124 const padLen = (8 - (privateSection.length % 8)) % 8 125 if (padLen > 0) { 126 const pad = Buffer.alloc(padLen) 127 for (let i = 0; i < padLen; i++) pad[i] = i + 1 128 privateSection = Buffer.concat([privateSection, pad]) 129 } 130 131 const blob = Buffer.concat([ 132 Buffer.from('openssh-key-v1\0', 'utf8'), 133 sshString(Buffer.from('none', 'utf8')), 134 sshString(Buffer.from('none', 'utf8')), 135 sshString(Buffer.alloc(0)), 136 uint32BE(1), 137 sshString(publicKeyBlob), 138 sshString(privateSection), 139 ]) 140 141 const base64 = blob.toString('base64') 142 const lines: string[] = [] 143 for (let i = 0; i < base64.length; i += 70) { 144 lines.push(base64.slice(i, i + 70)) 145 } 146 147 return [ 148 '-----BEGIN OPENSSH PRIVATE KEY-----', 149 ...lines, 150 '-----END OPENSSH PRIVATE KEY-----', 151 '', 152 ].join('\n') 153} 154 155function uint32BE(n: number): Buffer { 156 const buf = Buffer.alloc(4) 157 buf.writeUInt32BE(n, 0) 158 return buf 159}