mirror your GitHub repos to tangled.org automatically
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}