Skip to content

SSH Key Generator — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// SSH Key Generator - Ed25519 / RSA SSH key pair generation via the JVM's
// standard KeyPairGenerator. 100% local: keys are created in-process and
// never leave it.
//
// Language: Kotlin 1.9+ (JVM 15+ for Ed25519), standard library only.
// Ported from src/lib/ssh-keygen.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference (which
// drives the browser's Web Crypto): same output formats, byte for byte.
//
// 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).

import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.interfaces.RSAPublicKey
import java.security.spec.RSAKeyGenParameterSpec
import java.util.Base64

/** The three key algorithms the tool offers. */
enum class SSHAlgorithm { ED25519, RSA_2048, RSA_4096 }

data class SSHKeyOptions(
    val algorithm: SSHAlgorithm,
    /** Optional key comment, conventionally `user@host`. Omitted when empty. */
    val comment: String? = null,
)

data class SSHKeyPair(
    val publicKey: String,
    val privateKey: String,
    val fingerprint: String,
)

/** "openssh-key-v1\0" - the magic prefix of the private key structure. */
private val MAGIC = "openssh-key-v1\u0000".toByteArray(Charsets.US_ASCII)

private fun concat(vararg parts: ByteArray): ByteArray {
    val out = ByteArray(parts.sumOf { it.size })
    var offset = 0
    for (p in parts) {
        p.copyInto(out, offset)
        offset += p.size
    }
    return out
}

private fun u32(n: Int): ByteArray = byteArrayOf(
    (n ushr 24).toByte(), (n ushr 16).toByte(), (n ushr 8).toByte(), n.toByte(),
)

/** SSH "string": 4-byte big-endian length + raw bytes. */
private fun sshString(data: ByteArray): ByteArray = concat(u32(data.size), data)

private fun sshString(data: String): ByteArray = sshString(data.toByteArray(Charsets.UTF_8))

/** SSH "mpint": big-endian integer, minimal, with a leading zero when the high bit is set. */
fun mpint(bytes: ByteArray): ByteArray {
    var i = 0
    while (i < bytes.size - 1 && bytes[i].toInt() == 0) i++
    val v = bytes.copyOfRange(i, bytes.size)
    return if (v.isNotEmpty() && (v[0].toInt() and 0x80) != 0) {
        sshString(concat(byteArrayOf(0), v))
    } else {
        sshString(v)
    }
}

private fun toBase64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes)

private fun pemWrap(base64: String, label: String, width: Int): String =
    "-----BEGIN $label-----\n${base64.chunked(width).joinToString("\n")}\n-----END $label-----\n"

/** "SHA256:<base64 digest of the public blob, unpadded>" - the OpenSSH fingerprint format. */
private fun sshFingerprint(pubBlob: ByteArray): String {
    val digest = MessageDigest.getInstance("SHA-256").digest(pubBlob)
    return "SHA256:" + toBase64(digest).trimEnd('=')
}

/**
 * Build the unencrypted "openssh-key-v1" private key structure for Ed25519
 * (cipher "none", kdf "none"), wrapped in an OPENSSH PRIVATE KEY PEM.
 */
private fun openSshPrivatePem(pubBlob: ByteArray, privateKeyBytes: ByteArray, comment: String): String {
    val check = u32(SecureRandom().nextInt())
    var 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,...
    val padLen = (8 - inner.size % 8) % 8
    if (padLen > 0) {
        inner = concat(inner, ByteArray(padLen) { (it + 1).toByte() })
    }
    val outer = concat(
        MAGIC,
        sshString("none"), // ciphername
        sshString("none"), // kdfname
        sshString(ByteArray(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 JVM's key generators. */
fun generateSSHKeyPair(options: SSHKeyOptions): SSHKeyPair {
    val comment = options.comment?.trim() ?: ""
    val suffix = if (comment.isNotEmpty()) " $comment" else ""

    return when (options.algorithm) {
        SSHAlgorithm.ED25519 -> {
            val kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
            // The X.509 SubjectPublicKeyInfo wrapper is 44 bytes; the raw key
            // is always the trailing 32 (the TS reference's exportKey('raw')).
            val pub = kp.public.encoded.copyOfRange(kp.public.encoded.size - 32, kp.public.encoded.size)
            if (pub.size != 32) {
                throw IllegalStateException("Unexpected Ed25519 public key length: ${pub.size}.")
            }
            // The PKCS#8 wrapper is <...header...> 04 20 <32-byte seed>; the
            // seed is always the trailing 32 bytes.
            val pkcs8 = kp.private.encoded
            if (pkcs8.size < 34) {
                throw IllegalStateException("Unexpected Ed25519 private key encoding.")
            }
            val seed = pkcs8.copyOfRange(pkcs8.size - 32, pkcs8.size)
            val pubBlob = concat(sshString("ssh-ed25519"), sshString(pub))
            SSHKeyPair(
                publicKey = "ssh-ed25519 ${toBase64(pubBlob)}$suffix",
                privateKey = openSshPrivatePem(pubBlob, concat(seed, pub), comment),
                fingerprint = sshFingerprint(pubBlob),
            )
        }

        SSHAlgorithm.RSA_2048, SSHAlgorithm.RSA_4096 -> {
            val bits = if (options.algorithm == SSHAlgorithm.RSA_4096) 4096 else 2048
            val kpg = KeyPairGenerator.getInstance("RSA")
            kpg.initialize(RSAKeyGenParameterSpec(bits, RSAKeyGenParameterSpec.F4))
            val kp = kpg.generateKeyPair()
            val rsaPublic = kp.public as RSAPublicKey
            val pubBlob = concat(
                sshString("ssh-rsa"),
                mpint(rsaPublic.publicExponent.toByteArray()),
                mpint(rsaPublic.modulus.toByteArray()),
            )
            SSHKeyPair(
                publicKey = "ssh-rsa ${toBase64(pubBlob)}$suffix",
                // private.getEncoded() is already PKCS#8 - same as Web Crypto.
                privateKey = pemWrap(toBase64(kp.private.encoded), "PRIVATE KEY", 64),
                fingerprint = sshFingerprint(pubBlob),
            )
        }
    }
}

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 →