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 →