Skip to content

CSR Generator — TypeScript source

Generate a Certificate Signing Request and private key pair entirely in your browser. Download the CSR and key as PEM files.

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

// CSR Generator — ASN.1 DER encoding + PKCS#10 CSR construction on Web Crypto.
//
// The whole certificate-request pipeline runs client-side: key pair generation
// (Web Crypto), DER assembly of the CertificationRequest structure, signature,
// and PEM wrapping. Nothing leaves the browser.

export type KeyAlgorithm = 'RSA-2048' | 'RSA-4096' | 'ECDSA-P256';

export interface CSROptions {
  commonName: string;
  organization?: string;
  country?: string;
  state?: string;
  locality?: string;
  email?: string;
  keyAlgorithm: KeyAlgorithm;
  subjectAltNames?: string[];
}

export interface CSRResult {
  csr: string;
  privateKey: string;
}

// --- DER primitives ---------------------------------------------------------

/** DER length: short form below 0x80, long form (0x80 | byte count) above. */
export function encodeLength(len: number): Uint8Array {
  if (!Number.isInteger(len) || len < 0) throw new Error('Length must be a non-negative integer');
  if (len < 0x80) return Uint8Array.of(len);
  const bytes: number[] = [];
  let n = len;
  while (n > 0) {
    bytes.unshift(n & 0xff);
    n = Math.floor(n / 256);
  }
  return Uint8Array.of(0x80 | bytes.length, ...bytes);
}

/** Wrap content bytes in a tag + DER length header. */
function tlv(tag: number, content: Uint8Array): Uint8Array {
  const len = encodeLength(content.length);
  const out = new Uint8Array(1 + len.length + content.length);
  out[0] = tag;
  out.set(len, 1);
  out.set(content, 1 + len.length);
  return out;
}

function concat(...parts: Uint8Array[]): Uint8Array {
  const out = new Uint8Array(parts.reduce((sum, p) => sum + p.length, 0));
  let offset = 0;
  for (const p of parts) {
    out.set(p, offset);
    offset += p.length;
  }
  return out;
}

/**
 * DER INTEGER. Accepts a small non-negative number, or raw big-endian bytes
 * (signature r/s halves): leading zeros are stripped and a 0x00 sign byte is
 * prepended when the high bit is set, per DER minimal-encoding rules.
 */
export function encodeInteger(value: number | Uint8Array): Uint8Array {
  let bytes: Uint8Array;
  if (typeof value === 'number') {
    if (!Number.isInteger(value) || value < 0) {
      throw new Error('encodeInteger supports non-negative integers only');
    }
    const digits: number[] = [];
    let n = value;
    while (n > 0) {
      digits.unshift(n & 0xff);
      n = Math.floor(n / 256);
    }
    bytes = Uint8Array.from(digits.length ? digits : [0]);
  } else {
    let start = 0;
    while (start < value.length - 1 && value[start] === 0) start++;
    bytes = value.slice(start);
  }
  if (bytes[0] > 0x7f) bytes = Uint8Array.of(0, ...bytes);
  return tlv(0x02, bytes);
}

/** DER OBJECT IDENTIFIER from a dotted string, e.g. '1.2.840.113549.1.1.11'. */
export function encodeOID(oid: string): Uint8Array {
  const parts = oid.split('.').map(Number);
  if (parts.length < 2 || parts.some((p) => !Number.isInteger(p) || p < 0)) {
    throw new Error(`Invalid OID: ${oid}`);
  }
  const content: number[] = [40 * parts[0] + parts[1]];
  for (let i = 2; i < parts.length; i++) {
    const stack: number[] = [];
    let v = parts[i];
    do {
      stack.unshift(v & 0x7f);
      v = Math.floor(v / 128);
    } while (v > 0);
    for (let j = 0; j < stack.length - 1; j++) content.push(stack[j] | 0x80);
    content.push(stack[stack.length - 1]);
  }
  return tlv(0x06, Uint8Array.from(content));
}

export function encodeUTF8String(str: string): Uint8Array {
  return tlv(0x0c, new TextEncoder().encode(str));
}

/** PrintableString — the required type for countryName in a Name. */
export function encodePrintableString(str: string): Uint8Array {
  if (!/^[A-Za-z0-9 '()+,\-./:=?]*$/.test(str)) {
    throw new Error(`Not a PrintableString: ${str}`);
  }
  return tlv(0x13, Uint8Array.from(str, (ch) => ch.charCodeAt(0)));
}

export function encodeBitString(data: Uint8Array): Uint8Array {
  return tlv(0x03, Uint8Array.of(0, ...data)); // 0 unused bits in the last octet
}

export function encodeOctetString(data: Uint8Array): Uint8Array {
  return tlv(0x04, data);
}

export function encodeNull(): Uint8Array {
  return Uint8Array.of(0x05, 0x00);
}

export function encodeSequence(...parts: Uint8Array[]): Uint8Array {
  return tlv(0x30, concat(...parts));
}

export function encodeSet(...parts: Uint8Array[]): Uint8Array {
  return tlv(0x31, concat(...parts));
}

// --- Object identifiers -----------------------------------------------------

const OID_COUNTRY = '2.5.4.6';
const OID_STATE = '2.5.4.8';
const OID_LOCALITY = '2.5.4.7';
const OID_ORGANIZATION = '2.5.4.10';
const OID_COMMON_NAME = '2.5.4.3';
const OID_EMAIL = '1.2.840.113549.1.9.1';
const OID_EXT_REQUEST = '1.2.840.113549.1.9.14'; // pkcs-9 at extensionRequest
const OID_SUBJECT_ALT_NAME = '2.5.29.17';
const OID_RSA_SHA256 = '1.2.840.113549.1.1.11'; // sha256WithRSAEncryption
const OID_ECDSA_SHA256 = '1.2.840.10045.4.3.2'; // ecdsa-with-SHA256

// --- Validation -------------------------------------------------------------

/**
 * Throws on invalid input: CN required, country (when present) a 2-letter ISO
 * 3166-1 code, key algorithm one of the supported values.
 */
export function validateCSROptions(options: CSROptions): void {
  if (!options.commonName || !options.commonName.trim()) {
    throw new Error('Common Name (CN) is required');
  }
  const country = options.country?.trim();
  if (country && !/^[A-Za-z]{2}$/.test(country)) {
    throw new Error('Country must be a 2-letter ISO 3166-1 code (e.g. US, DE)');
  }
  const allowed: KeyAlgorithm[] = ['RSA-2048', 'RSA-4096', 'ECDSA-P256'];
  if (!allowed.includes(options.keyAlgorithm)) {
    throw new Error(`Unsupported key algorithm: ${String(options.keyAlgorithm)}`);
  }
}

// --- SAN classification -----------------------------------------------------

export type SanType = 'dns' | 'ip' | 'email' | 'uri';

/** Classify a SAN entry: IPv4/IPv6 → ip, http(s):// → uri, contains @ → email, else dns. */
export function classifySanType(entry: string): SanType {
  const value = entry.trim().toLowerCase();
  if (ipToBytes(value)) return 'ip';
  if (/^https?:\/\//.test(value)) return 'uri';
  if (value.includes('@')) return 'email';
  return 'dns';
}

function ipToBytes(ip: string): Uint8Array | null {
  const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
  if (v4) {
    const parts = v4.slice(1).map(Number);
    if (parts.some((n) => n > 255)) return null;
    return Uint8Array.from(parts);
  }
  if (!ip.includes(':')) return null;
  const halves = ip.split('::');
  if (halves.length > 2) return null;
  const groups: number[] = [];
  for (const half of halves) {
    if (!half) continue;
    for (const group of half.split(':')) {
      if (group.includes('.')) {
        const tail = ipToBytes(group); // IPv4-mapped tail, e.g. ::ffff:10.0.0.1
        if (!tail || tail.length !== 4) return null;
        groups.push((tail[0] << 8) | tail[1], (tail[2] << 8) | tail[3]);
      } else if (/^[0-9a-f]{1,4}$/.test(group)) {
        groups.push(parseInt(group, 16));
      } else {
        return null;
      }
    }
  }
  if (halves.length === 2) {
    const missing = 8 - groups.length;
    if (missing < 0) return null;
    groups.push(...new Array<number>(missing).fill(0));
  }
  if (groups.length !== 8) return null;
  const out = new Uint8Array(16);
  groups.forEach((g, i) => {
    out[i * 2] = g >> 8;
    out[i * 2 + 1] = g & 0xff;
  });
  return out;
}

/** One GeneralName: context-specific implicit tags per RFC 5280. */
function encodeGeneralName(entry: string): Uint8Array {
  const value = entry.trim();
  const ascii = Uint8Array.from(value, (ch) => ch.charCodeAt(0));
  switch (classifySanType(value)) {
    case 'ip':
      return tlv(0x87, ipToBytes(value.toLowerCase())!);
    case 'uri':
      return tlv(0x86, ascii);
    case 'email':
      return tlv(0x81, ascii);
    default:
      return tlv(0x82, ascii); // dNSName
  }
}

// --- PEM ---------------------------------------------------------------------

/** PEM-wrap DER bytes: 64-character base64 lines between BEGIN/END markers. */
export function pemEncode(der: Uint8Array, label: string): string {
  let bin = '';
  for (const b of der) bin += String.fromCharCode(b);
  const lines = btoa(bin).match(/.{1,64}/g) ?? [];
  return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
}

// --- Key generation + CSR assembly ------------------------------------------

function getCrypto(): Crypto {
  const c = globalThis.crypto;
  if (!c?.subtle) throw new Error('Web Crypto (crypto.subtle) is not available in this environment');
  return c;
}

async function generateKeyPair(algorithm: KeyAlgorithm): Promise<CryptoKeyPair> {
  const { subtle } = getCrypto();
  if (algorithm === 'ECDSA-P256') {
    return subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
  }
  const modulusLength = algorithm === 'RSA-4096' ? 4096 : 2048;
  return subtle.generateKey(
    { name: 'RSASSA-PKCS1-v1_5', modulusLength, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
    true,
    ['sign', 'verify']
  );
}

/** AttributeTypeAndValue (SEQUENCE of OID + value) wrapped in its RDN SET. */
function rdn(oid: string, value: Uint8Array): Uint8Array {
  return encodeSet(encodeSequence(encodeOID(oid), value));
}

/**
 * Web Crypto ECDSA signatures are raw r||s; PKCS#10 expects a DER
 * ECDSA-Sig-Value SEQUENCE. Convert half-length integer halves to DER.
 */
function ecdsaRawToDer(raw: Uint8Array): Uint8Array {
  const half = raw.length / 2;
  return encodeSequence(encodeInteger(raw.slice(0, half)), encodeInteger(raw.slice(half)));
}

/**
 * Generate a key pair and build a signed PKCS#10 CSR (PEM) plus the matching
 * PKCS#8 private key (PEM). Runs entirely in the browser via Web Crypto.
 */
export async function generateCSR(options: CSROptions): Promise<CSRResult> {
  validateCSROptions(options);
  const { subtle } = getCrypto();
  const pair = await generateKeyPair(options.keyAlgorithm);
  const spki = new Uint8Array(await subtle.exportKey('spki', pair.publicKey));
  const pkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', pair.privateKey));

  // subject: RDNSequence in the conventional C, ST, L, O, CN, email order
  const rdns: Uint8Array[] = [];
  const country = options.country?.trim().toUpperCase();
  if (country) rdns.push(rdn(OID_COUNTRY, encodePrintableString(country)));
  if (options.state?.trim()) rdns.push(rdn(OID_STATE, encodeUTF8String(options.state.trim())));
  if (options.locality?.trim()) rdns.push(rdn(OID_LOCALITY, encodeUTF8String(options.locality.trim())));
  if (options.organization?.trim()) rdns.push(rdn(OID_ORGANIZATION, encodeUTF8String(options.organization.trim())));
  rdns.push(rdn(OID_COMMON_NAME, encodeUTF8String(options.commonName.trim())));
  if (options.email?.trim()) rdns.push(rdn(OID_EMAIL, encodeUTF8String(options.email.trim())));

  // attributes [0] IMPLICIT SET OF — extensionRequest carrying subjectAltName
  let attributes = new Uint8Array(0);
  const sans = (options.subjectAltNames ?? []).map((s) => s.trim()).filter(Boolean);
  if (sans.length > 0) {
    const extension = encodeSequence(
      encodeOID(OID_SUBJECT_ALT_NAME),
      encodeOctetString(encodeSequence(...sans.map(encodeGeneralName)))
    );
    const attribute = encodeSequence(
      encodeOID(OID_EXT_REQUEST),
      encodeSet(encodeSequence(extension))
    );
    attributes = tlv(0xa0, attribute); // [0] IMPLICIT SET OF Attribute
  }

  // CertificationRequestInfo: version 0, subject, SPKI, [0] attributes
  const cri = encodeSequence(encodeInteger(0), encodeSequence(...rdns), spki, attributes);

  // signatureAlgorithm + signature over the DER CRI
  let signatureAlgorithm: Uint8Array;
  let signature: Uint8Array;
  if (options.keyAlgorithm === 'ECDSA-P256') {
    signatureAlgorithm = encodeSequence(encodeOID(OID_ECDSA_SHA256));
    const raw = new Uint8Array(await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, cri));
    signature = encodeBitString(ecdsaRawToDer(raw));
  } else {
    signatureAlgorithm = encodeSequence(encodeOID(OID_RSA_SHA256), encodeNull());
    const raw = new Uint8Array(await subtle.sign('RSASSA-PKCS1-v1_5', pair.privateKey, cri));
    signature = encodeBitString(raw);
  }

  return {
    csr: pemEncode(encodeSequence(cri, signatureAlgorithm, signature), 'CERTIFICATE REQUEST'),
    privateKey: pemEncode(pkcs8, 'PRIVATE KEY'),
  };
}

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 →