WireGuard Key Generator — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
/**
* WireGuard key generation — pure logic (no React, no DOM).
*
* 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,
* computed with a pure-BigInt Montgomery ladder over GF(2^255 - 19)
* - 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 the Web Crypto CSPRNG (`crypto.getRandomValues`) and
* the curve arithmetic is deterministic BigInt math, so the whole module runs
* locally in any JS environment (browser, Node, Bun) with zero dependencies.
*/
export interface WireGuardKeys {
privateKey: string;
publicKey: string;
}
/** Length of every WireGuard key, in bytes. */
export const KEY_LENGTH = 32;
// Curve25519 domain parameters: y^2 = x^3 + 486662x^2 + x over GF(2^255 - 19).
const P = (1n << 255n) - 19n;
const A24 = 121665n; // (486662 - 2) / 4
const BASE_POINT = new Uint8Array(KEY_LENGTH); // u = 9, little-endian
BASE_POINT[0] = 9;
const B64_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// ---------------------------------------------------------------------------
// Base64 codec (standard alphabet, always padded — the WireGuard format)
// ---------------------------------------------------------------------------
/** Encode bytes as standard Base64 with `=` padding (32 bytes → 44 chars). */
export function bytesToBase64(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const b0 = bytes[i]!;
const b1 = i + 1 < bytes.length ? bytes[i + 1]! : 0;
const b2 = i + 2 < bytes.length ? bytes[i + 2]! : 0;
out += B64_ALPHABET[b0 >> 2];
out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)];
out += i + 1 < bytes.length ? B64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] : '=';
out += i + 2 < bytes.length ? B64_ALPHABET[b2 & 0x3f] : '=';
}
return out;
}
/** Decode standard Base64 (with padding). Throws on invalid input. */
export function base64ToBytes(b64: string): Uint8Array {
const s = b64.trim();
if (s.length === 0 || s.length % 4 !== 0) {
throw new Error('Invalid Base64: length must be a non-zero multiple of 4');
}
const pad = s.endsWith('==') ? 2 : s.endsWith('=') ? 1 : 0;
const data = s.slice(0, s.length - pad);
const out = new Uint8Array((s.length / 4) * 3 - pad);
let buffer = 0;
let bits = 0;
let o = 0;
for (const ch of data) {
const v = B64_ALPHABET.indexOf(ch);
if (v === -1) throw new Error(`Invalid Base64 character: "${ch}"`);
buffer = (buffer << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (buffer >> bits) & 0xff;
}
}
return out;
}
// ---------------------------------------------------------------------------
// Curve25519 scalar multiplication (RFC 7748 Montgomery ladder)
// ---------------------------------------------------------------------------
/** Reduce `a` into the canonical range [0, P). */
function mod(a: bigint): bigint {
const r = a % P;
return r >= 0n ? r : r + P;
}
/** `base^exponent mod P` via square-and-multiply (used for field inversion). */
function powMod(base: bigint, exponent: bigint): bigint {
let result = 1n;
let b = mod(base);
let e = exponent;
while (e > 0n) {
if (e & 1n) result = mod(result * b);
b = mod(b * b);
e >>= 1n;
}
return result;
}
/** Clamp 32 bytes into a valid Curve25519 scalar (RFC 7748 §5). Returns a copy. */
export function clampPrivateKey(key: Uint8Array): Uint8Array {
if (key.length !== KEY_LENGTH) {
throw new Error(`Private key must be ${KEY_LENGTH} bytes, got ${key.length}`);
}
const out = key.slice();
out[0]! &= 248; // clear the low 3 bits → multiple of the cofactor
out[31]! &= 127; // clear the high bit
out[31]! |= 64; // force bit 254 → the ladder always sees a 255-bit scalar
return out;
}
function decodeLittleEndian(bytes: Uint8Array): bigint {
let n = 0n;
for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]!);
return n;
}
function encodeLittleEndian(n: bigint, length = KEY_LENGTH): Uint8Array {
const out = new Uint8Array(length);
for (let i = 0; i < length; i++) {
out[i] = Number(n & 0xffn);
n >>= 8n;
}
return out;
}
/**
* X25519 scalar multiplication `scalar · u` — the RFC 7748 Montgomery ladder
* in plain BigInt 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).
*/
export function curve25519(scalar: Uint8Array, u: Uint8Array): Uint8Array {
if (scalar.length !== KEY_LENGTH) {
throw new Error(`Scalar must be ${KEY_LENGTH} bytes, got ${scalar.length}`);
}
if (u.length !== KEY_LENGTH) {
throw new Error(`u-coordinate must be ${KEY_LENGTH} bytes, got ${u.length}`);
}
const k = decodeLittleEndian(clampPrivateKey(scalar));
// Mask the most significant bit of the u-coordinate per RFC 7748 §5.
const x1 = decodeLittleEndian(u) & ((1n << 255n) - 1n);
let x2 = 1n;
let z2 = 0n;
let x3 = x1;
let z3 = 1n;
let swap = 0n;
for (let t = 254; t >= 0; t--) {
const bit = (k >> BigInt(t)) & 1n;
swap ^= bit;
if (swap === 1n) {
[x2, x3] = [x3, x2];
[z2, z3] = [z3, z2];
}
swap = bit;
const a = mod(x2 + z2);
const aa = mod(a * a);
const b = mod(x2 - z2);
const bb = mod(b * b);
const e = mod(aa - bb);
const c = mod(x3 + z3);
const d = mod(x3 - z3);
const da = mod(d * a);
const cb = mod(c * b);
const sum = mod(da + cb);
const diff = mod(da - cb);
x3 = mod(sum * sum);
z3 = mod(x1 * mod(diff * diff));
x2 = mod(aa * bb);
z2 = mod(e * mod(aa + A24 * e));
}
// 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(mod(x2 * powMod(z2, P - 2n)));
}
// ---------------------------------------------------------------------------
// Key generation
// ---------------------------------------------------------------------------
function randomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length);
globalThis.crypto.getRandomValues(bytes);
return bytes;
}
/** A fresh private key: 32 CSPRNG bytes, clamped, Base64. */
export function generatePrivateKey(): string {
return bytesToBase64(clampPrivateKey(randomBytes(KEY_LENGTH)));
}
/** A fresh pre-shared key: 32 CSPRNG bytes, Base64 — used as-is, never clamped. */
export function generatePresharedKey(): string {
return bytesToBase64(randomBytes(KEY_LENGTH));
}
/**
* Derive the WireGuard public key that pairs with a Base64 private key
* (Curve25519 scalar multiplication of the base point). Async so a WebCrypto
* X25519 backend could drop in without touching callers.
*/
export async function privateKeyToPublic(privateKeyBase64: string): Promise<string> {
const priv = base64ToBytes(privateKeyBase64);
if (priv.length !== KEY_LENGTH) {
throw new Error(`Invalid private key: expected ${KEY_LENGTH} bytes, got ${priv.length}`);
}
return bytesToBase64(curve25519(priv, BASE_POINT));
}
/** A fresh WireGuard key pair (private + matching public key, both Base64). */
export async function generateWireGuardKeys(): Promise<WireGuardKeys> {
const privateKey = generatePrivateKey();
const publicKey = await privateKeyToPublic(privateKey);
return { privateKey, publicKey };
}
// ---------------------------------------------------------------------------
// 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).
*/
export function formatConfig(keys: WireGuardKeys, psk?: string): string {
const lines: string[] = [
'[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 !== undefined && psk !== '') {
lines.push(
'# Optional pre-shared key — the same value must be set on BOTH sides',
`PresharedKey = ${psk}`,
);
}
lines.push(
'# Route everything through the tunnel (or scope it, e.g. 10.0.0.0/24)',
'AllowedIPs = 0.0.0.0/0, ::/0',
"# The peer's public address and port",
'Endpoint = vpn.example.com:51820',
'PersistentKeepalive = 25',
);
return lines.join('\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 →