|
| 1 | +// SSH Key Generation via Web Crypto API |
| 2 | +// Supports Ed25519 and RSA (2048/4096) |
| 3 | + |
| 4 | +export type KeyType = 'ed25519' | 'rsa-2048' | 'rsa-4096'; |
| 5 | + |
| 6 | +export interface GeneratedKey { |
| 7 | + privateKey: string; |
| 8 | + publicKey: string; |
| 9 | + fingerprint: string; |
| 10 | + command: string; |
| 11 | + keyType: KeyType; |
| 12 | +} |
| 13 | + |
| 14 | +// --- Utility helpers --- |
| 15 | + |
| 16 | +function encodeUint32BE(n: number): Uint8Array { |
| 17 | + const buf = new Uint8Array(4); |
| 18 | + buf[0] = (n >>> 24) & 0xff; |
| 19 | + buf[1] = (n >>> 16) & 0xff; |
| 20 | + buf[2] = (n >>> 8) & 0xff; |
| 21 | + buf[3] = n & 0xff; |
| 22 | + return buf; |
| 23 | +} |
| 24 | + |
| 25 | +function sshString(data: Uint8Array | string): Uint8Array { |
| 26 | + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; |
| 27 | + const result = new Uint8Array(4 + bytes.length); |
| 28 | + result.set(encodeUint32BE(bytes.length), 0); |
| 29 | + result.set(bytes, 4); |
| 30 | + return result; |
| 31 | +} |
| 32 | + |
| 33 | +function concat(...arrays: Uint8Array[]): Uint8Array { |
| 34 | + const total = arrays.reduce((sum, a) => sum + a.length, 0); |
| 35 | + const result = new Uint8Array(total); |
| 36 | + let offset = 0; |
| 37 | + for (const a of arrays) { |
| 38 | + result.set(a, offset); |
| 39 | + offset += a.length; |
| 40 | + } |
| 41 | + return result; |
| 42 | +} |
| 43 | + |
| 44 | +function toBase64(data: Uint8Array): string { |
| 45 | + let binary = ''; |
| 46 | + for (let i = 0; i < data.length; i++) { |
| 47 | + binary += String.fromCharCode(data[i]); |
| 48 | + } |
| 49 | + return btoa(binary); |
| 50 | +} |
| 51 | + |
| 52 | +function wrapPem(tag: string, base64: string): string { |
| 53 | + const lines: string[] = []; |
| 54 | + for (let i = 0; i < base64.length; i += 70) { |
| 55 | + lines.push(base64.slice(i, i + 70)); |
| 56 | + } |
| 57 | + return `-----BEGIN ${tag}-----\n${lines.join('\n')}\n-----END ${tag}-----\n`; |
| 58 | +} |
| 59 | + |
| 60 | +// --- Ed25519 --- |
| 61 | + |
| 62 | +async function generateEd25519(comment: string): Promise<GeneratedKey> { |
| 63 | + const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); |
| 64 | + |
| 65 | + // Extract seed from PKCS8 (bytes 16..48) |
| 66 | + const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); |
| 67 | + const seed = pkcs8.slice(16, 48); |
| 68 | + |
| 69 | + // Extract public key from SPKI (last 32 bytes) |
| 70 | + const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey)); |
| 71 | + const pubRaw = spki.slice(spki.length - 32); |
| 72 | + |
| 73 | + // Public key blob (for one-liner and fingerprint) |
| 74 | + const pubBlob = concat(sshString('ssh-ed25519'), sshString(pubRaw)); |
| 75 | + |
| 76 | + // Public key one-liner |
| 77 | + const publicKeyStr = `ssh-ed25519 ${toBase64(pubBlob)}${comment ? ' ' + comment : ''}`; |
| 78 | + |
| 79 | + // Fingerprint: SHA256 of public key blob, base64 no padding |
| 80 | + const hashBuf = await crypto.subtle.digest('SHA-256', pubBlob); |
| 81 | + const fingerprint = `SHA256:${toBase64(new Uint8Array(hashBuf)).replace(/=+$/, '')}`; |
| 82 | + |
| 83 | + // OpenSSH private key format |
| 84 | + const privateKeyStr = buildOpenSshEd25519(seed, pubRaw, comment); |
| 85 | + |
| 86 | + const cmd = `ssh-keygen -t ed25519${comment ? ` -C "${comment}"` : ''}`; |
| 87 | + |
| 88 | + return { privateKey: privateKeyStr, publicKey: publicKeyStr, fingerprint, command: cmd, keyType: 'ed25519' }; |
| 89 | +} |
| 90 | + |
| 91 | +function buildOpenSshEd25519(seed: Uint8Array, pubKey: Uint8Array, comment: string): string { |
| 92 | + const AUTH_MAGIC = new TextEncoder().encode('openssh-key-v1\0'); |
| 93 | + |
| 94 | + // Cipher/KDF = none (unencrypted) |
| 95 | + const cipherName = sshString('none'); |
| 96 | + const kdfName = sshString('none'); |
| 97 | + const kdfOptions = sshString(new Uint8Array(0)); |
| 98 | + const numKeys = encodeUint32BE(1); |
| 99 | + |
| 100 | + // Public key section |
| 101 | + const pubBlob = concat(sshString('ssh-ed25519'), sshString(pubKey)); |
| 102 | + const pubSection = sshString(pubBlob); |
| 103 | + |
| 104 | + // Private key section |
| 105 | + const checkInt = crypto.getRandomValues(new Uint8Array(4)); |
| 106 | + const privKey64 = concat(seed, pubKey); // 64 bytes: seed || pubkey |
| 107 | + const commentBytes = new TextEncoder().encode(comment); |
| 108 | + |
| 109 | + const privPayload = concat( |
| 110 | + checkInt, |
| 111 | + checkInt, // same value twice |
| 112 | + sshString('ssh-ed25519'), |
| 113 | + sshString(pubKey), |
| 114 | + sshString(privKey64), |
| 115 | + sshString(commentBytes), |
| 116 | + ); |
| 117 | + |
| 118 | + // Pad to multiple of 8 (cipher block size for "none") |
| 119 | + const padLen = (8 - (privPayload.length % 8)) % 8; |
| 120 | + const padding = new Uint8Array(padLen); |
| 121 | + for (let i = 0; i < padLen; i++) { |
| 122 | + padding[i] = (i + 1) & 0xff; |
| 123 | + } |
| 124 | + |
| 125 | + const privSection = sshString(concat(privPayload, padding)); |
| 126 | + |
| 127 | + // Assemble |
| 128 | + const binary = concat(AUTH_MAGIC, cipherName, kdfName, kdfOptions, numKeys, pubSection, privSection); |
| 129 | + |
| 130 | + return wrapPem('OPENSSH PRIVATE KEY', toBase64(binary)); |
| 131 | +} |
| 132 | + |
| 133 | +// --- RSA --- |
| 134 | + |
| 135 | +async function generateRsa(bits: 2048 | 4096, comment: string): Promise<GeneratedKey> { |
| 136 | + const keyPair = await crypto.subtle.generateKey( |
| 137 | + { |
| 138 | + name: 'RSASSA-PKCS1-v1_5', |
| 139 | + modulusLength: bits, |
| 140 | + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), |
| 141 | + hash: 'SHA-256', |
| 142 | + }, |
| 143 | + true, |
| 144 | + ['sign', 'verify'], |
| 145 | + ); |
| 146 | + |
| 147 | + // PKCS8 private key (OpenSSH reads this natively since 7.8) |
| 148 | + const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); |
| 149 | + const privateKeyStr = wrapPem('PRIVATE KEY', toBase64(pkcs8)); |
| 150 | + |
| 151 | + // Public key in SSH format |
| 152 | + const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey)); |
| 153 | + const { n, e } = extractRsaComponents(spki); |
| 154 | + |
| 155 | + const pubBlob = concat(sshString('ssh-rsa'), sshString(e), sshString(n)); |
| 156 | + const publicKeyStr = `ssh-rsa ${toBase64(pubBlob)}${comment ? ' ' + comment : ''}`; |
| 157 | + |
| 158 | + // Fingerprint |
| 159 | + const hashBuf = await crypto.subtle.digest('SHA-256', pubBlob); |
| 160 | + const fingerprint = `SHA256:${toBase64(new Uint8Array(hashBuf)).replace(/=+$/, '')}`; |
| 161 | + |
| 162 | + const cmd = `ssh-keygen -t rsa -b ${bits}${comment ? ` -C "${comment}"` : ''}`; |
| 163 | + |
| 164 | + return { privateKey: privateKeyStr, publicKey: publicKeyStr, fingerprint, command: cmd, keyType: `rsa-${bits}` as KeyType }; |
| 165 | +} |
| 166 | + |
| 167 | +// Minimal ASN.1 DER parser for RSA SPKI to extract n and e |
| 168 | +function extractRsaComponents(spki: Uint8Array): { n: Uint8Array; e: Uint8Array } { |
| 169 | + let offset = 0; |
| 170 | + |
| 171 | + function readLength(): number { |
| 172 | + let length = spki[offset++]; |
| 173 | + if (length & 0x80) { |
| 174 | + const numBytes = length & 0x7f; |
| 175 | + length = 0; |
| 176 | + for (let i = 0; i < numBytes; i++) { |
| 177 | + length = (length << 8) | spki[offset++]; |
| 178 | + } |
| 179 | + } |
| 180 | + return length; |
| 181 | + } |
| 182 | + |
| 183 | + function readTag(): { tag: number; length: number } { |
| 184 | + const tag = spki[offset++]; |
| 185 | + const length = readLength(); |
| 186 | + return { tag, length }; |
| 187 | + } |
| 188 | + |
| 189 | + function skipTlv(): void { |
| 190 | + offset++; // tag |
| 191 | + const length = readLength(); |
| 192 | + offset += length; |
| 193 | + } |
| 194 | + |
| 195 | + function readInteger(): Uint8Array { |
| 196 | + const { tag, length } = readTag(); |
| 197 | + if (tag !== 0x02) throw new Error('Expected INTEGER'); |
| 198 | + const data = spki.slice(offset, offset + length); |
| 199 | + offset += length; |
| 200 | + return data; |
| 201 | + } |
| 202 | + |
| 203 | + // Outer SEQUENCE |
| 204 | + readTag(); // SEQUENCE (outer) |
| 205 | + |
| 206 | + // AlgorithmIdentifier SEQUENCE — skip entirely |
| 207 | + const algSeq = readTag(); // SEQUENCE (algorithm) |
| 208 | + offset += algSeq.length; // skip OID + optional NULL inside |
| 209 | + |
| 210 | + // BIT STRING containing RSAPublicKey |
| 211 | + const bitString = readTag(); |
| 212 | + if (bitString.tag !== 0x03) throw new Error('Expected BIT STRING'); |
| 213 | + offset++; // skip unused bits byte (0x00) |
| 214 | + |
| 215 | + // Inner SEQUENCE (RSAPublicKey) |
| 216 | + readTag(); // SEQUENCE |
| 217 | + |
| 218 | + const n = readInteger(); // modulus |
| 219 | + const e = readInteger(); // exponent |
| 220 | + |
| 221 | + return { n, e }; |
| 222 | +} |
| 223 | + |
| 224 | +// --- Public API --- |
| 225 | + |
| 226 | +export async function generateSshKey(type: KeyType, comment: string): Promise<GeneratedKey> { |
| 227 | + switch (type) { |
| 228 | + case 'ed25519': |
| 229 | + return generateEd25519(comment); |
| 230 | + case 'rsa-2048': |
| 231 | + return generateRsa(2048, comment); |
| 232 | + case 'rsa-4096': |
| 233 | + return generateRsa(4096, comment); |
| 234 | + } |
| 235 | +} |
| 236 | + |
| 237 | +export function isEd25519Supported(): boolean { |
| 238 | + try { |
| 239 | + return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'; |
| 240 | + } catch { |
| 241 | + return false; |
| 242 | + } |
| 243 | +} |
0 commit comments