SSH Key Generator — TypeScript source
Generate Ed25519 or RSA SSH key pairs in your browser. Copy or download the public and private keys. No server involved.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// SSH Key Generator - Ed25519 / RSA SSH key pair generation via the Web Crypto
// API. 100% client-side: keys are created in the browser and never leave it.
//
// Formats produced:
// - Ed25519 public key : `ssh-ed25519 <base64(blob)> <comment>` where blob is
// the OpenSSH wire format (string "ssh-ed25519" + string 32-byte key).
// - Ed25519 private key: `-----BEGIN OPENSSH PRIVATE KEY-----` PEM containing
// the unencrypted "openssh-key-v1" structure (cipher "none", kdf "none").
// - RSA public key : `ssh-rsa <base64(blob)> <comment>` where blob is
// string "ssh-rsa" + mpint e + mpint n.
// - RSA private key : PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`).
// - Fingerprint : SHA-256 of the raw public key blob, base64-encoded
// without padding, prefixed "SHA256:" (matches OpenSSH).
export type SSHAlgorithm = 'ed25519' | 'rsa-2048' | 'rsa-4096';
export interface SSHKeyOptions {
algorithm: SSHAlgorithm;
/** Optional key comment, conventionally `user@host`. Omitted when empty. */
comment?: string;
}
export interface SSHKeyPair {
publicKey: string;
privateKey: string;
fingerprint: string;
}
const MAGIC = new Uint8Array([
0x6f, 0x70, 0x65, 0x6e, 0x73, 0x73, 0x68, 0x2d, 0x6b, 0x65, 0x79, 0x2d, 0x76, 0x31, 0x00,
]); // "openssh-key-v1\0"
function subtle(): SubtleCrypto {
const c = globalThis.crypto;
if (!c?.subtle) {
throw new Error('Web Crypto is not available in this browser context.');
}
return c.subtle;
}
function concat(...parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
function u32(n: number): Uint8Array {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n);
return b;
}
/** SSH "string": 4-byte big-endian length + raw bytes. */
function sshString(data: Uint8Array | string): Uint8Array {
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
return concat(u32(bytes.length), bytes);
}
/** SSH "mpint": big-endian integer, minimal, with a leading zero when the high bit is set. */
function mpint(bytes: Uint8Array): Uint8Array {
let i = 0;
while (i < bytes.length - 1 && bytes[i] === 0) i++;
const v = bytes.slice(i);
return v.length > 0 && v[0] & 0x80 ? sshString(concat(new Uint8Array([0]), v)) : sshString(v);
}
function toBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
function base64UrlToBytes(s: string): Uint8Array {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=');
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function pemWrap(base64: string, label: string, width: number): string {
const lines = base64.match(new RegExp(`.{1,${width}}`, 'g')) ?? [];
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
}
/** "SHA256:<base64 digest of the public blob, unpadded>" - the OpenSSH fingerprint format. */
async function sshFingerprint(pubBlob: Uint8Array): Promise<string> {
const digest = new Uint8Array(await subtle().digest('SHA-256', pubBlob));
return `SHA256:${toBase64(digest).replace(/=+$/, '')}`;
}
/**
* Build the unencrypted "openssh-key-v1" private key structure for Ed25519
* (cipher "none", kdf "none"), wrapped in an OPENSSH PRIVATE KEY PEM.
*/
function openSshPrivatePem(pubBlob: Uint8Array, privateKeyBytes: Uint8Array, comment: string): string {
const check = u32(globalThis.crypto.getRandomValues(new Uint32Array(1))[0]);
let inner = concat(check, check, pubBlob, sshString(privateKeyBytes), sshString(comment));
// Pad to a multiple of the block size (8 for cipher "none") with 1,2,3,...
const padLen = (8 - (inner.length % 8)) % 8;
inner = concat(inner, new Uint8Array(padLen).map((_, i) => i + 1));
const outer = concat(
MAGIC,
sshString('none'), // ciphername
sshString('none'), // kdfname
sshString(new Uint8Array(0)), // kdfoptions
u32(1), // number of keys
sshString(pubBlob),
sshString(inner)
);
return pemWrap(toBase64(outer), 'OPENSSH PRIVATE KEY', 70);
}
/** Generate an SSH key pair in OpenSSH format using the browser's Web Crypto API. */
export async function generateSSHKeyPair(options?: SSHKeyOptions): Promise<SSHKeyPair> {
const algorithm = options?.algorithm;
const comment = options?.comment?.trim() ?? '';
const suffix = comment ? ` ${comment}` : '';
const s = subtle();
if (algorithm === 'ed25519') {
const kp = await s.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const pub = new Uint8Array(await s.exportKey('raw', kp.publicKey));
if (pub.length !== 32) {
throw new Error(`Unexpected Ed25519 public key length: ${pub.length}.`);
}
// The PKCS#8 wrapper is <...header...> 04 20 <32-byte seed>; the seed is
// always the trailing 32 bytes.
const pkcs8 = new Uint8Array(await s.exportKey('pkcs8', kp.privateKey));
if (pkcs8.length < 34) {
throw new Error('Unexpected Ed25519 private key encoding.');
}
const seed = pkcs8.slice(pkcs8.length - 32);
const pubBlob = concat(sshString('ssh-ed25519'), sshString(pub));
return {
publicKey: `ssh-ed25519 ${toBase64(pubBlob)}${suffix}`,
privateKey: openSshPrivatePem(pubBlob, concat(seed, pub), comment),
fingerprint: await sshFingerprint(pubBlob),
};
}
if (algorithm === 'rsa-2048' || algorithm === 'rsa-4096') {
const kp = await s.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: algorithm === 'rsa-4096' ? 4096 : 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
);
const jwk = await s.exportKey('jwk', kp.publicKey);
if (!jwk.n || !jwk.e) {
throw new Error('Failed to export the RSA public key.');
}
const pubBlob = concat(
sshString('ssh-rsa'),
mpint(base64UrlToBytes(jwk.e)),
mpint(base64UrlToBytes(jwk.n))
);
const pkcs8 = new Uint8Array(await s.exportKey('pkcs8', kp.privateKey));
return {
publicKey: `ssh-rsa ${toBase64(pubBlob)}${suffix}`,
privateKey: pemWrap(toBase64(pkcs8), 'PRIVATE KEY', 64),
fingerprint: await sshFingerprint(pubBlob),
};
}
throw new Error(`Unsupported algorithm: ${String(algorithm)}`);
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →