Certificate Decoder — TypeScript source
Paste a PEM X.509 certificate and see its subject, issuer, SAN, validity, key usage, fingerprints, and chain details in a human-readable format.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure ASN.1 DER parser + X.509 certificate decoder.
// No external dependencies — DER is deterministic and parsed sequentially.
// ── OID name map ───────────────────────────────────────────────────────────
export const OID_NAMES: Record<string, string> = {
'1.2.840.113549.1.1.1': 'RSA',
'1.2.840.113549.1.1.5': 'SHA-1 with RSA',
'1.2.840.113549.1.1.11': 'SHA-256 with RSA',
'1.2.840.113549.1.1.12': 'SHA-384 with RSA',
'1.2.840.113549.1.1.13': 'SHA-512 with RSA',
'1.2.840.113549.1.1.14': 'SHA-224 with RSA',
'1.2.840.10045.2.1': 'ECDSA',
'1.2.840.10045.4.3.2': 'ECDSA with SHA-256',
'1.2.840.10045.4.3.3': 'ECDSA with SHA-384',
'1.2.840.10045.4.3.4': 'ECDSA with SHA-512',
'1.3.14.3.2.29': 'SHA-1 with RSA (OIW)',
'2.5.4.3': 'CN',
'2.5.4.6': 'C',
'2.5.4.7': 'L',
'2.5.4.8': 'ST',
'2.5.4.10': 'O',
'2.5.4.11': 'OU',
'2.5.29.14': 'Subject Key Identifier',
'2.5.29.15': 'Key Usage',
'2.5.29.17': 'Subject Alternative Name',
'2.5.29.19': 'Basic Constraints',
'2.5.29.35': 'Authority Key Identifier',
'2.5.29.37': 'Extended Key Usage',
'1.3.6.1.5.5.7.1.1': 'Authority Information Access',
'1.3.6.1.5.5.7.3.1': 'serverAuth',
'1.3.6.1.5.5.7.3.2': 'clientAuth',
'1.3.6.1.5.5.7.3.3': 'codeSigning',
'1.3.6.1.5.5.7.3.4': 'emailProtection',
'1.3.6.1.5.5.7.3.8': 'timeStamping',
'1.2.840.113549.1.9.14': 'Extension Request',
'1.2.840.113549.1.9.1': 'emailAddress',
'1.3.6.1.4.1.11129.2.1.17': 'CT Precertificate SCTs',
'1.3.6.1.5.5.7.1.3': 'CRL Distribution Points',
'1.3.6.1.4.1.311.21.7': 'Microsoft Certificate Template',
};
// EC named-curve OIDs
export const EC_CURVE_NAMES: Record<string, string> = {
'1.2.840.10045.3.1.7': 'P-256',
'1.3.132.0.34': 'P-384',
'1.3.132.0.35': 'P-512',
'1.3.132.0.10': 'secp256k1',
};
// Key Usage bit names
const KEY_USAGE_BITS: string[] = [
'digitalSignature',
'nonRepudiation',
'keyEncipherment',
'dataEncipherment',
'keyAgreement',
'keyCertSign',
'cRLSign',
'encipherOnly',
'decipherOnly',
];
// ── ASN.1 DER types ────────────────────────────────────────────────────────
type TagClass = 'universal' | 'context' | 'application' | 'private';
interface ASN1Node {
tagClass: TagClass;
constructed: boolean;
tagNumber: number;
rawValue: Uint8Array; // value bytes (decoded content)
offset: number; // offset into original buffer
children?: ASN1Node[];
}
// ── DER parsing helpers ────────────────────────────────────────────────────
class DERParser {
private pos = 0;
constructor(private buf: Uint8Array, private start = 0) {}
get exhausted(): boolean {
return this.pos >= this.buf.length;
}
/** Read the next TLV node. */
readNode(): ASN1Node {
const offset = this.pos;
const byte0 = this.readByte();
const tagClass: TagClass =
(byte0 & 0xc0) === 0x00 ? 'universal' :
(byte0 & 0xc0) === 0x40 ? 'application' :
(byte0 & 0xc0) === 0x80 ? 'context' : 'private';
const constructed = (byte0 & 0x20) !== 0;
let tagNumber = byte0 & 0x1f;
// Long-form tag (tag number >= 31)
if (tagNumber === 0x1f) {
tagNumber = 0;
let b: number;
do {
b = this.readByte();
tagNumber = (tagNumber << 7) | (b & 0x7f);
} while (b & 0x80);
}
// Length
const length = this.readLength();
if (this.pos + length > this.buf.length) {
throw new Error(`Truncated DER: need ${length} bytes at offset ${this.pos}, but only ${this.buf.length - this.pos} remain`);
}
const rawValue = this.buf.slice(this.pos, this.pos + length);
this.pos += length;
const node: ASN1Node = { tagClass, constructed, tagNumber, rawValue, offset };
// Parse children for constructed types
if (constructed && rawValue.length > 0) {
const childParser = new DERParser(rawValue);
const children: ASN1Node[] = [];
while (!childParser.exhausted) {
children.push(childParser.readNode());
}
node.children = children;
}
return node;
}
private readByte(): number {
if (this.pos >= this.buf.length) throw new Error('Unexpected end of DER data');
return this.buf[this.pos++]!;
}
private readLength(): number {
const first = this.readByte();
if (first < 0x80) return first;
const numBytes = first & 0x7f;
if (numBytes === 0) throw new Error('Indefinite length is not supported in DER');
if (numBytes > 4) throw new Error(`Length too large: ${numBytes} bytes`);
let len = 0;
for (let i = 0; i < numBytes; i++) {
len = (len << 8) | this.readByte();
}
return len;
}
}
// ── OID decoding ──────────────────────────────────────────────────────────
function decodeOID(bytes: Uint8Array): string {
if (bytes.length === 0) throw new Error('Empty OID');
const parts: number[] = [];
parts.push(Math.floor(bytes[0]! / 40));
parts.push(bytes[0]! % 40);
let value = 0;
for (let i = 1; i < bytes.length; i++) {
const b = bytes[i]!;
value = (value << 7) | (b & 0x7f);
if ((b & 0x80) === 0) {
parts.push(value);
value = 0;
}
}
return parts.join('.');
}
function oidName(oid: string): string {
return OID_NAMES[oid] ?? oid;
}
// ── RDN (Relative Distinguished Name) helpers ──────────────────────────────
interface RDNAttribute {
type: string; // short name like 'CN', 'O', etc.
value: string;
}
function parseRDN(node: ASN1Node): RDNAttribute[] {
const attrs: RDNAttribute[] = [];
if (!node.children) return attrs;
for (const rdnSet of node.children) {
if (!rdnSet.children) continue;
for (const attrSeq of rdnSet.children) {
if (!attrSeq.children || attrSeq.children.length < 2) continue;
const oid = decodeOID(attrSeq.children[0]!.rawValue);
const val = attrSeq.children[1]!;
const valueStr = readString(val);
attrs.push({ type: oidName(oid), value: valueStr });
}
}
return attrs;
}
function formatDN(attrs: RDNAttribute[]): string {
return attrs.map((a) => `${a.type}=${a.value}`).join(', ');
}
function readString(node: ASN1Node): string {
// Try to decode as text string
const tag = node.tagNumber;
if (tag === 12 || tag === 19 || tag === 22 || tag === 30 || tag === 36) {
// UTF8String(12), PrintableString(19), IA5String(22), UTF8String variant,
// BMPString(30), etc. — all decode as text
return new TextDecoder().decode(node.rawValue);
}
// Fallback: try UTF-8
try {
return new TextDecoder('utf-8', { fatal: true }).decode(node.rawValue);
} catch {
// Last resort: hex
return Array.from(node.rawValue)
.map((b) => b.toString(16).padStart(2, '0'))
.join(':');
}
}
// ── Time helpers ───────────────────────────────────────────────────────────
function parseTime(node: ASN1Node): Date {
const str = readString(node);
// UTCTime (tag 23): YYMMDDHHMMSSZ
if (node.tagNumber === 23) {
const m = str.match(/^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/);
if (!m) throw new Error(`Invalid UTCTime: ${str}`);
let year = parseInt(m[1]!, 10);
year += year >= 50 ? 1900 : 2000;
return new Date(Date.UTC(year, parseInt(m[2]!, 10) - 1, parseInt(m[3]!, 10),
parseInt(m[4]!, 10), parseInt(m[5]!, 10), parseInt(m[6]!, 10)));
}
// GeneralizedTime (tag 24): YYYYMMDDHHMMSSZ
if (node.tagNumber === 24) {
const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/);
if (!m) throw new Error(`Invalid GeneralizedTime: ${str}`);
return new Date(Date.UTC(parseInt(m[1]!, 10), parseInt(m[2]!, 10) - 1, parseInt(m[3]!, 10),
parseInt(m[4]!, 10), parseInt(m[5]!, 10), parseInt(m[6]!, 10)));
}
throw new Error(`Unknown time tag: ${node.tagNumber}`);
}
// ── Extension parsing ──────────────────────────────────────────────────────
interface Extensions {
basicConstraintsCA: boolean | null;
keyUsage: string[] | null;
extKeyUsage: string[] | null;
sanDNS: string[];
sanIP: string[];
sanEmail: string[];
}
function parseExtensions(nodes: ASN1Node[]): Extensions {
const ext: Extensions = {
basicConstraintsCA: null,
keyUsage: null,
extKeyUsage: null,
sanDNS: [],
sanIP: [],
sanEmail: [],
};
for (const extNode of nodes) {
if (!extNode.children || extNode.children.length < 2) continue;
const oid = decodeOID(extNode.children[0]!.rawValue);
// Skip critical boolean if present
let valueIdx = 1;
if (extNode.children.length >= 3 && extNode.children[1]!.tagNumber === 1 && extNode.children[1]!.tagClass === 'universal') {
valueIdx = 2;
}
const valueNode = extNode.children[valueIdx]!;
// The value is wrapped in an OCTET STRING containing the actual DER
const octetContent = valueNode.rawValue;
if (octetContent.length === 0) continue;
const inner = new DERParser(octetContent);
if (inner.exhausted) continue;
const content = inner.readNode();
if (oid === '2.5.29.19') {
// Basic Constraints
ext.basicConstraintsCA = false;
if (content.children && content.children.length > 0) {
const boolVal = content.children[0]!;
if (boolVal.tagNumber === 1 && boolVal.rawValue.length === 1 && boolVal.rawValue[0] === 0xff) {
ext.basicConstraintsCA = true;
}
}
} else if (oid === '2.5.29.15') {
// Key Usage — BIT STRING
ext.keyUsage = parseBitString(content);
} else if (oid === '2.5.29.37') {
// Extended Key Usage
ext.extKeyUsage = [];
if (content.children) {
for (const child of content.children) {
ext.extKeyUsage.push(oidName(decodeOID(child.rawValue)));
}
}
} else if (oid === '2.5.29.17') {
// Subject Alternative Name
if (content.children) {
for (const child of content.children) {
if (child.tagClass === 'context') {
const tag = child.tagNumber;
if (tag === 2) {
// DNS
ext.sanDNS.push(readString(child));
} else if (tag === 7) {
// IP
const ipBytes = child.rawValue;
if (ipBytes.length === 4) {
ext.sanIP.push(Array.from(ipBytes).join('.'));
} else if (ipBytes.length === 16) {
// IPv6 — produce standard hex representation
const parts: string[] = [];
for (let i = 0; i < 16; i += 2) {
parts.push(((ipBytes[i]! << 8) | ipBytes[i + 1]!).toString(16));
}
ext.sanIP.push(parts.join(':'));
}
} else if (tag === 1) {
// Email
ext.sanEmail.push(readString(child));
}
}
}
}
}
}
return ext;
}
function parseBitString(node: ASN1Node): string[] {
if (node.rawValue.length < 2) return [];
const unusedBits = node.rawValue[0]!;
const octets = node.rawValue.slice(1);
const flags: string[] = [];
for (let i = 0; i < KEY_USAGE_BITS.length; i++) {
const octetIdx = Math.floor(i / 8);
const bitIdx = 7 - (i % 8);
if (octetIdx < octets.length) {
if ((octets[octetIdx]! & (1 << bitIdx)) !== 0) {
flags.push(KEY_USAGE_BITS[i]!);
}
}
}
// Mask out unused bits
if (unusedBits > 0 && flags.length > 0) {
const totalBits = (octets.length * 8) - unusedBits;
while (flags.length > totalBits) {
flags.pop();
}
}
return flags;
}
// ── Public types ────────────────────────────────────────────────────────────
export interface CertificateInfo {
/** Parsed subject RDN attributes */
subject: RDNAttribute[];
/** Formatted subject DN string */
subjectDN: string;
/** Parsed issuer RDN attributes */
issuer: RDNAttribute[];
/** Formatted issuer DN string */
issuerDN: string;
/** Not-before date */
notBefore: Date;
/** Not-after date */
notAfter: Date;
/** Serial number as hex string */
serialNumber: string;
/** Signature algorithm (human-readable name) */
signatureAlgorithm: string;
/** Public key algorithm name */
keyAlgorithm: string;
/** Public key size in bits */
keySize: number;
/** Basic Constraints CA flag */
basicConstraintsCA: boolean | null;
/** Key Usage flags */
keyUsage: string[];
/** Extended Key Usage purposes */
extKeyUsage: string[];
/** Subject Alternative Names — DNS entries */
sanDNS: string[];
/** Subject Alternative Names — IP entries */
sanIP: string[];
/** Subject Alternative Names — email entries */
sanEmail: string[];
/** Version number (0=v1, 1=v2, 2=v3) */
version: number;
/** Raw DER bytes (for fingerprinting) */
rawDER: Uint8Array;
}
// ── PEM handling ───────────────────────────────────────────────────────────
const PEM_REGEX = /-----BEGIN\s+CERTIFICATE-----\s*\r?\n([\s\S]*?)\r?\n-----END\s+CERTIFICATE-----/g;
function pemToDER(pem: string): Uint8Array {
// Strip whitespace and decode base64
const b64 = pem.replace(/\s/g, '');
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// ── Main decoder ───────────────────────────────────────────────────────────
export function decodeCertificate(pem: string): CertificateInfo {
if (!pem || !pem.trim()) {
throw new Error('Empty input — paste a PEM certificate');
}
// Extract PEM blocks
const blocks: string[] = [];
let match: RegExpExecArray | null;
PEM_REGEX.lastIndex = 0;
while ((match = PEM_REGEX.exec(pem)) !== null) {
blocks.push(match[1]!);
}
if (blocks.length === 0) {
throw new Error('No PEM certificate block found — expected -----BEGIN CERTIFICATE-----');
}
// Decode the first certificate
const der = pemToDER(blocks[0]!);
// Parse outer SEQUENCE
const parser = new DERParser(der);
const certSeq = parser.readNode();
if (!certSeq.children || certSeq.children.length < 3) {
throw new Error('Invalid certificate structure: expected TBSCertificate, signatureAlgorithm, signatureValue');
}
const tbs = certSeq.children[0]!;
const sigAlgNode = certSeq.children[1]!;
const sigAlgOID = sigAlgNode.children ? decodeOID(sigAlgNode.children[0]!.rawValue) : '';
// Parse TBSCertificate
if (!tbs.children || tbs.children.length < 7) {
throw new Error('Invalid TBSCertificate structure');
}
let idx = 0;
// Version (explicit context [0])
let version = 0; // default v1
if (tbs.children[idx]!.tagClass === 'context' && tbs.children[idx]!.tagNumber === 0) {
const versionNode = tbs.children[idx]!.children?.[0];
if (versionNode && versionNode.rawValue.length === 1) {
version = versionNode.rawValue[0]!;
}
idx++;
}
// Serial Number
const serialNode = tbs.children[idx++]!;
// Signature Algorithm (inside TBS)
idx++; // skip inner sig algorithm
// Issuer
const issuerNode = tbs.children[idx++]!;
const issuer = parseRDN(issuerNode);
// Validity
const validityNode = tbs.children[idx++]!;
let notBefore = new Date(0);
let notAfter = new Date(0);
if (validityNode.children && validityNode.children.length >= 2) {
notBefore = parseTime(validityNode.children[0]!);
notAfter = parseTime(validityNode.children[1]!);
}
// Subject
const subjectNode = tbs.children[idx++]!;
const subject = parseRDN(subjectNode);
// SubjectPublicKeyInfo
const spkiNode = tbs.children[idx++]!;
let keyAlgorithm = 'unknown';
let keySize = 0;
if (spkiNode.children && spkiNode.children.length >= 2) {
const algSeq = spkiNode.children[0]!;
const keyBits = spkiNode.children[1]!.rawValue;
if (algSeq.children && algSeq.children.length >= 1) {
const algOID = decodeOID(algSeq.children[0]!.rawValue);
keyAlgorithm = oidName(algOID);
// Key size estimation
if (algOID === '1.2.840.113549.1.1.1') {
// RSA: the BIT STRING value starts with an unused-bits byte (0x00),
// then a DER-encoded SEQUENCE of { modulus INTEGER, exponent INTEGER }.
if (keyBits.length > 1) {
const rsaInner = new DERParser(keyBits.slice(1)); // skip unused-bits byte
if (!rsaInner.exhausted) {
const rsaSeq = rsaInner.readNode();
// The modulus INTEGER is the first child
if (rsaSeq.children && rsaSeq.children.length > 0) {
const modBytes = rsaSeq.children[0]!.rawValue;
// First byte may be 0x00 padding for positive sign
const effectiveLen = (modBytes[0] === 0x00 ? modBytes.length - 1 : modBytes.length) * 8;
keySize = effectiveLen;
}
}
}
} else if (algOID === '1.2.840.10045.2.1') {
// ECDSA: look at the curve OID parameter
if (algSeq.children.length >= 2) {
const curveOID = decodeOID(algSeq.children[1]!.rawValue);
const curveName = EC_CURVE_NAMES[curveOID];
if (curveName) keyAlgorithm = `${keyAlgorithm} (${curveName})`;
}
// EC public key: BIT STRING value = 0x00 (unused) + uncompressed point.
// For P-256, the point is 65 bytes (0x04 + 32 + 32). Key "size" = curve order bits.
if (keyBits.length > 1) {
const pointBytes = keyBits.slice(1); // skip unused-bits byte
keySize = (pointBytes.length - 1) * 8; // subtract 0x04 prefix, then byte count → bits
}
} else {
keySize = keyBits.length > 1 ? (keyBits.length - 1) * 8 : 0;
}
}
}
// Extensions — look for context [3] after subjectPublicKeyInfo
let extensions: Extensions = {
basicConstraintsCA: null,
keyUsage: null,
extKeyUsage: null,
sanDNS: [],
sanIP: [],
sanEmail: [],
};
while (idx < tbs.children.length) {
const child = tbs.children[idx]!;
if (child.tagClass === 'context' && child.tagNumber === 3 && child.children) {
// context [3] wraps a single outer SEQUENCE of Extension entries
const outerSeq = child.children[0];
if (outerSeq && outerSeq.children) {
extensions = parseExtensions(outerSeq.children);
}
}
idx++;
}
// Serial number as hex
let serialHex = '';
if (serialNode.rawValue.length > 0) {
// Remove leading zero if it's just padding
let serialBytes = serialNode.rawValue;
if (serialBytes[0] === 0x00 && serialBytes.length > 1) {
serialBytes = serialBytes.slice(1);
}
serialHex = Array.from(serialBytes).map((b) => b.toString(16).padStart(2, '0')).join(':').toUpperCase();
}
return {
subject,
subjectDN: formatDN(subject),
issuer,
issuerDN: formatDN(issuer),
notBefore,
notAfter,
serialNumber: serialHex,
signatureAlgorithm: oidName(sigAlgOID),
keyAlgorithm,
keySize,
basicConstraintsCA: extensions.basicConstraintsCA,
keyUsage: extensions.keyUsage ?? [],
extKeyUsage: extensions.extKeyUsage ?? [],
sanDNS: extensions.sanDNS,
sanIP: extensions.sanIP,
sanEmail: extensions.sanEmail,
version,
rawDER: der,
};
}
export function isExpired(cert: CertificateInfo): boolean {
return cert.notAfter < new Date();
}
export function daysUntilExpiry(cert: CertificateInfo): number {
const now = new Date();
const diffMs = cert.notAfter.getTime() - now.getTime();
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
}
/** Extract all PEM certificate blocks and return info for each. */
export function decodeCertificateChain(pem: string): CertificateInfo[] {
if (!pem || !pem.trim()) {
throw new Error('Empty input — paste PEM certificate(s)');
}
const blocks: string[] = [];
let match: RegExpExecArray | null;
PEM_REGEX.lastIndex = 0;
while ((match = PEM_REGEX.exec(pem)) !== null) {
blocks.push(match[1]!);
}
if (blocks.length === 0) {
throw new Error('No PEM certificate block found — expected -----BEGIN CERTIFICATE-----');
}
return blocks.map((block) => decodeCertificate(`-----BEGIN CERTIFICATE-----\n${block}\n-----END CERTIFICATE-----`));
}
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 →