Skip to content

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 →