Skip to content

WireGuard Key Generator — Kotlin source

Generate Curve25519 key pairs for WireGuard VPN configuration. Derives the public key from a clamped private key with a pure-BigInt RFC 7748 Montgomery ladder, optionally generates a pre-shared key, and renders a ready-to-edit wg-quick config template. Everything runs 100% client-side - keys never leave your browser.

This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// WireGuard key generation — pure logic.
//
// Language: Kotlin 1.9+ (JVM), standard library only (java.math.BigInteger
// for the curve arithmetic, java.util.Base64 for the key format).
// Ported from src/lib/wireguard-keygen.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// clamping, same RFC 7748 Montgomery ladder (here over BigInteger instead of
// BigInt), same Base64 encoding, same config template.
//
// WireGuard uses Curve25519 (RFC 7748 X25519) for its key exchange:
//   - a private key is 32 random bytes, clamped per the Curve25519 rules
//     (`key[0] &= 248; key[31] &= 127; key[31] |= 64`)
//   - the public key is that scalar multiplied by the curve's base point 9
//   - an optional pre-shared key is 32 random bytes, used as-is (no clamping)
//
// Every key is serialized as standard Base64 with padding — 44 characters for
// 32 bytes — which is exactly the format WireGuard config files expect.
// Randomness comes from SecureRandom; everything runs locally.

import java.math.BigInteger
import java.security.SecureRandom
import java.util.Base64

data class WireGuardKeys(
    val privateKey: String,
    val publicKey: String,
)

/** Length of every WireGuard key, in bytes. */
const val KEY_LENGTH = 32

// Curve25519 domain parameters: y² = x³ + 486662x² + x over GF(2^255 - 19).
private val P = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19))
private val A24 = BigInteger.valueOf(121665) // (486662 - 2) / 4

private val BASE_POINT = ByteArray(KEY_LENGTH).also { it[0] = 9 } // u = 9, little-endian

private val RANDOM = SecureRandom()

// ---------------------------------------------------------------------------
// Base64 codec (standard alphabet, always padded — the WireGuard format)
// ---------------------------------------------------------------------------

/** Encode bytes as standard Base64 with `=` padding (32 bytes → 44 chars). */
fun bytesToBase64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes)

/** Decode standard Base64 (with padding). Throws on invalid input. */
fun base64ToBytes(b64: String): ByteArray {
    val s = b64.trim()
    if (s.isEmpty() || s.length % 4 != 0) {
        throw IllegalArgumentException("Invalid Base64: length must be a non-zero multiple of 4")
    }
    return try {
        Base64.getDecoder().decode(s)
    } catch (_: IllegalArgumentException) {
        throw IllegalArgumentException("Invalid Base64 character in input")
    }
}

// ---------------------------------------------------------------------------
// Curve25519 scalar multiplication (RFC 7748 Montgomery ladder)
// ---------------------------------------------------------------------------

/** `base^exponent mod P` — BigInteger.modPow over the field. */
private fun powMod(base: BigInteger, exponent: BigInteger): BigInteger =
    base.modPow(exponent, P)

/** Clamp 32 bytes into a valid Curve25519 scalar (RFC 7748 §5). Returns a copy. */
fun clampPrivateKey(key: ByteArray): ByteArray {
    if (key.size != KEY_LENGTH) {
        throw IllegalArgumentException("Private key must be $KEY_LENGTH bytes, got ${key.size}")
    }
    val out = key.copyOf()
    out[0] = (out[0].toInt() and 248).toByte() // clear the low 3 bits → multiple of the cofactor
    out[31] = (out[31].toInt() and 127).toByte() // clear the high bit
    out[31] = (out[31].toInt() or 64).toByte() // force bit 254 → 255-bit scalar
    return out
}

private fun decodeLittleEndian(bytes: ByteArray): BigInteger = BigInteger(1, bytes.reversedArray())

private fun encodeLittleEndian(n: BigInteger, length: Int = KEY_LENGTH): ByteArray {
    // toByteArray() is minimal big-endian (possibly shorter than 32 bytes) —
    // normalize to exactly `length` bytes, then flip to little-endian.
    val be = n.toByteArray()
    val padded = ByteArray(length)
    val take = minOf(be.size, length)
    System.arraycopy(be, be.size - take, padded, length - take, take)
    return padded.reversedArray()
}

/**
 * X25519 scalar multiplication `scalar · u` — the RFC 7748 Montgomery ladder
 * in plain BigInteger arithmetic. Deterministic and dependency-free. The
 * scalar is clamped internally (an unclamped input yields the same result as
 * its clamped form, exactly like every X25519 implementation).
 */
fun curve25519(scalar: ByteArray, u: ByteArray): ByteArray {
    if (scalar.size != KEY_LENGTH) {
        throw IllegalArgumentException("Scalar must be $KEY_LENGTH bytes, got ${scalar.size}")
    }
    if (u.size != KEY_LENGTH) {
        throw IllegalArgumentException("u-coordinate must be $KEY_LENGTH bytes, got ${u.size}")
    }
    val k = decodeLittleEndian(clampPrivateKey(scalar))
    // Mask the most significant bit of the u-coordinate per RFC 7748 §5.
    val x1 = decodeLittleEndian(u).and(BigInteger.ONE.shiftLeft(255).subtract(BigInteger.ONE))

    var x2 = BigInteger.ONE
    var z2 = BigInteger.ZERO
    var x3 = x1
    var z3 = BigInteger.ONE
    var swap = BigInteger.ZERO
    for (t in 254 downTo 0) {
        val bit = k.shiftRight(t).and(BigInteger.ONE)
        swap = swap.xor(bit)
        if (swap.signum() == 1) {
            var tmp = x2; x2 = x3; x3 = tmp
            tmp = z2; z2 = z3; z3 = tmp
        }
        swap = bit

        val a = x2.add(z2).mod(P)
        val aa = a.multiply(a).mod(P)
        val b = x2.subtract(z2).mod(P)
        val bb = b.multiply(b).mod(P)
        val e = aa.subtract(bb).mod(P)
        val c = x3.add(z3).mod(P)
        val d = x3.subtract(z3).mod(P)
        val da = d.multiply(a).mod(P)
        val cb = c.multiply(b).mod(P)
        val sum = da.add(cb).mod(P)
        val diff = da.subtract(cb).mod(P)
        x3 = sum.multiply(sum).mod(P)
        z3 = x1.multiply(diff.multiply(diff).mod(P)).mod(P)
        x2 = aa.multiply(bb).mod(P)
        z2 = e.multiply(aa.add(A24.multiply(e)).mod(P)).mod(P)
    }
    // No final cswap: the loop leaves swap = k_0, and clamping clears bit 0,
    // so swap is provably 0 here for every input this function accepts.
    // x2 / z2 via z2^(P-2) (Fermat): the affine u-coordinate result.
    return encodeLittleEndian(x2.multiply(powMod(z2, P.subtract(BigInteger.TWO))).mod(P))
}

// ---------------------------------------------------------------------------
// Key generation
// ---------------------------------------------------------------------------

private fun randomBytes(length: Int): ByteArray = ByteArray(length).also(RANDOM::nextBytes)

/** A fresh private key: 32 CSPRNG bytes, clamped, Base64. */
fun generatePrivateKey(): String = bytesToBase64(clampPrivateKey(randomBytes(KEY_LENGTH)))

/** A fresh pre-shared key: 32 CSPRNG bytes, Base64 — used as-is, never clamped. */
fun generatePresharedKey(): String = bytesToBase64(randomBytes(KEY_LENGTH))

/** Derive the WireGuard public key that pairs with a Base64 private key. */
fun privateKeyToPublic(privateKeyBase64: String): String {
    val priv = base64ToBytes(privateKeyBase64)
    if (priv.size != KEY_LENGTH) {
        throw IllegalArgumentException("Invalid private key: expected $KEY_LENGTH bytes, got ${priv.size}")
    }
    return bytesToBase64(curve25519(priv, BASE_POINT))
}

/** A fresh WireGuard key pair (private + matching public key, both Base64). */
fun generateWireGuardKeys(): WireGuardKeys {
    val privateKey = generatePrivateKey()
    return WireGuardKeys(privateKey, privateKeyToPublic(privateKey))
}

// ---------------------------------------------------------------------------
// Config template
// ---------------------------------------------------------------------------

/**
 * Render a `wg-quick` config template around a key pair. The peer's public
 * key, endpoint, and your tunnel address depend on the other side, so they
 * stay as placeholders. A `PresharedKey` line is included only when `psk` is
 * given (it must be present on BOTH sides of the tunnel).
 */
fun formatConfig(keys: WireGuardKeys, psk: String? = null): String {
    val lines = mutableListOf(
        "[Interface]",
        "# Your side — keep PrivateKey secret, share PublicKey with the peer",
        "PrivateKey = ${keys.privateKey}",
        "PublicKey = ${keys.publicKey}",
        "# Tunnel address assigned by your server (plus optional tunnel DNS)",
        "Address = 10.0.0.2/32",
        "# DNS = 1.1.1.1",
        "",
        "[Peer]",
        "# The other side's public key",
        "PublicKey = <PEER_PUBLIC_KEY>",
    )
    if (!psk.isNullOrEmpty()) {
        lines.add("# Optional pre-shared key — the same value must be set on BOTH sides")
        lines.add("PresharedKey = $psk")
    }
    lines.add("# Route everything through the tunnel (or scope it, e.g. 10.0.0.0/24)")
    lines.add("AllowedIPs = 0.0.0.0/0, ::/0")
    lines.add("# The peer's public address and port")
    lines.add("Endpoint = vpn.example.com:51820")
    lines.add("PersistentKeepalive = 25")
    return lines.joinToString("\n")
}

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 →