Skip to content

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 →