Skip to content

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 →