fix(x509-certificate-decoder): match OpenSSL on adversarial certificate shapes

Differential testing against an OpenSSL 3.5 corpus surfaced four
rendering bugs, all now pinned by a regression suite built from the
same certificates:

- subjectAltName IPv6 addresses printed as 16 dot-decimal bytes;
  render RFC 5952 text (2001:db8::1) with zero-run compression, keep
  IPv4 dotted-decimal
- keyUsage read only the first named-bit byte, dropping decipherOnly
  (bit 9); walk both bytes
- subjectKeyIdentifier hex-dumped the DER wrapper; print the bare key
  id like OpenSSL
- authorityKeyIdentifier hex-dumped the whole extension for the
  context-tagged [0] keyIdentifier shape; extract the key id child
- OID decoding assumed the first sub-identifier fits one byte;
  accumulate base-128 continuation bytes so arc-2 OIDs >= 40 (e.g.
  1.2.999.1) decode correctly
This commit is contained in:
a0666660 2026-10-03 19:47:58 +08:00
parent 5a8913988c
commit d6bfef3e4a
2 changed files with 531 additions and 361 deletions

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { decodeCertificatePem } from './x509-certificate-decoder.service';
// Regression corpus generated with OpenSSL 3.5 (`openssl req -x509`), then
// every asserted value cross-checked against `openssl x509 -text`. These
// pin the decoder to OpenSSL's canonical rendering of the tricky shapes:
// IPv6 SANs, multi-byte named bits (decipherOnly), bare SKI key ids,
// context-tagged AKI key ids, and an extension OID with a multi-byte first
// sub-identifier (1.2.999.1 → 0x88 0x67).
const RSA_IPV6_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIDnDCCAoSgAwIBAgIUYQ5gUMphhz1LUtSnI4XJxAnGzTcwDQYJKoZIhvcNAQEL
BQAwSTELMAkGA1UEBhMCQ04xEDAOBgNVBAgMB0JlaWppbmcxDTALBgNVBAoMBFRl
c3QxGTAXBgNVBAMMEHRlc3QuZXhhbXBsZS5jb20wHhcNMjYxMDAzMDgzMjMzWhcN
MjYxMTAyMDgzMjMzWjBJMQswCQYDVQQGEwJDTjEQMA4GA1UECAwHQmVpamluZzEN
MAsGA1UECgwEVGVzdDEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnND40VnXTg3ZvBrfJOPh4WGmV718Cb
URSFKnK1yDITpAc4HsJjcTntl92Xsb0ov1Nc20/L7ePz2qN4QbFqmhzZbmNhdmIF
IXlldpFsFHRjSJ3kbwsApWtozDVi3iAYseJ2GfFZ4rUfOIoRbCkHWR6KNwpJrJT4
IWktnE0fK7tIMRc4O+d6ksVg54tLi+r6BSZAurdWLy9Bq3zWwYUrZUvG2TBJ1HIi
CRDC7PSyCV/o/C9eGtJr0EGDq7Exl/kfW+QYwqFMXXrfjhSWBwEZfiegUPr9/7DD
ZMdPTmdOwrV8OmkhSdXSM5tAoRwlSako/Rirf52o02Jm7U2phlHxQP0CAwEAAaN8
MHowDwYDVR0TAQH/BAUwAwEB/zA3BgNVHREEMDAuhxAgAQ24AAAAAAAAAAAAAAAB
hwTAqAEBgg1hLmV4YW1wbGUuY29tgQVhQGIuYzAPBgNVHQ8BAf8EBQMDB6CAMB0G
A1UdDgQWBBSMRZA0vuzo5oiW4uxRGmP62SZcsTANBgkqhkiG9w0BAQsFAAOCAQEA
QerYkO13fxKoqpLitIKKdm5jDCOXfEMJbH2CwKUIhJbxsslCW32p0IXRmvudwPSO
yFwD3fIy7vPrPxiLOvrvs1uEMEYhNKeE0yVhk2DjQxHAJpQRDCHYj4vZSPkrx6ze
JpjEaJ9TniIlxKS4ggubyOkVM51o47rqevsmUccFgT6Umu2AMmgSP4WvUgbncYrK
SKodoq9fZp1e2eVy7CeN7A6rpbYHAgktoYMBvtRSnZVJo+CcqcNl4xc97gtXeKQI
vt8bIDLVFsV0CHuVIi/LzhcZvZ8khTniu4YELpzqYjfLkib8XADtWdeWGTwOPz8a
Y1eZ1r6V6qU4MQpkyc1EdQ==
-----END CERTIFICATE-----`;
const ED25519_CUSTOM_OID_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIB1jCCAVygAwIBAgIUGd2IUBuv1j/C7zeNuXkPo96XpjQwCgYIKoZIzj0EAwIw
MDELMAkGA1UEBhMCQ04xDzANBgNVBAoMBlRlc3RDQTEQMA4GA1UEAwwHUm9vdCBD
QTAeFw0yNjEwMDMxMDQ0MDhaFw0yNjExMDIxMDQ0MDhaMB4xHDAaBgNVBAMME2Vk
MjU1MTkuZXhhbXBsZS5jb20wKjAFBgMrZXADIQAU+Pxvo65PrtplGQ2m7ro0w+Nl
8LT+55hSlOUnchBPTaOBlDCBkTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAZBgNV
HREEEjAQgg5lZS5leGFtcGxlLmNvbTAcBgQqh2cBBBQMEm11bHRpYnl0ZS1vaWQt
dGVzdDAdBgNVHQ4EFgQUFP9MR1+AykhnKRueeH/chyvm8DswHwYDVR0jBBgwFoAU
5mzqfASzZNXN4M6pjkcrzP8dfRgwCgYIKoZIzj0EAwIDaAAwZQIxALE9DbikX0km
UVBx2tc3QM7kM2TdIDhOCQ5a0cARDcfXmpQKoPFw31fVoHvbPaDFGAIwBH8Ixj3N
hG5ERArF3f+YnESSA8SOR0QDJxrAx/dP702H7Imct3LFSJiYB5DYkzWS
-----END CERTIFICATE-----`;
function rowsOf(pem: string, title: string): Map<string, string> {
const decoded = decodeCertificatePem(pem);
const section = decoded.sections.find(s => s.title === title);
expect(section, `section ${title}`).toBeDefined();
return new Map(section!.rows.map(row => [row.label, row.value]));
}
describe('x509 decoder vs OpenSSL corpus (regression)', () => {
it('renders IPv6 SANs in RFC 5952 text form and keeps IPv4 dotted', () => {
const san = rowsOf(RSA_IPV6_CERT_PEM, 'Extensions').get('subjectAltName') ?? '';
expect(san).toContain('IP: 2001:db8::1');
expect(san).toContain('IP: 192.168.1.1');
expect(san).toContain('DNS: a.example.com');
expect(san).toContain('Email: a@b.c');
expect(san).not.toContain('32.1.13.184');
});
it('decodes named bits beyond the first byte (decipherOnly)', () => {
const ku = rowsOf(RSA_IPV6_CERT_PEM, 'Extensions').get('keyUsage (critical)') ?? '';
expect(ku).toContain('digitalSignature');
expect(ku).toContain('keyEncipherment');
expect(ku).toContain('decipherOnly');
});
it('prints the bare SKI key id without the DER wrapper', () => {
const ski = rowsOf(RSA_IPV6_CERT_PEM, 'Extensions').get('subjectKeyIdentifier') ?? '';
expect(ski).toBe('8C:45:90:34:BE:EC:E8:E6:88:96:E2:EC:51:1A:63:FA:D9:26:5C:B1');
});
it('prints the AKI key id from the context-tagged child', () => {
const aki = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Extensions').get('authorityKeyIdentifier') ?? '';
expect(aki).toBe('E6:6C:EA:7C:04:B3:64:D5:CD:E0:CE:A9:8E:47:2B:CC:FF:1D:7D:18');
});
it('decodes extension OIDs with multi-byte first sub-identifiers', () => {
const extensions = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Extensions');
// 1.2.999.1 encodes its first sub-identifier as two base-128 bytes;
// the old first-byte shortcut decoded it as 3.39.77.1.
expect(extensions.has('1.2.999.1')).toBe(true);
expect(extensions.has('3.39.77.1')).toBe(false);
});
it('identifies the Ed25519 public key without a curve row', () => {
const key = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Public key');
expect(key.get('Algorithm')).toBe('Ed25519');
expect(key.get('Key size')).toBe('256 bits');
});
it('keeps the signature algorithm names for ECDSA issuer chains', () => {
const cert = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Certificate');
expect(cert.get('Signature algorithm')).toBe('ecdsa-with-SHA256');
});
});

View file

@ -1,361 +1,432 @@
import { asn1, md as forgeMd, pem as forgePem } from 'node-forge';
export interface CertificateRow {
label: string
value: string
hint?: string
}
export interface CertificateSection {
title: string
rows: CertificateRow[]
}
export interface DecodedCertificate {
sections: CertificateSection[]
}
/** Distinguished-name attribute OIDs (RFC 5280 / legacy PKCS#9). */
const DN_OIDS: Record<string, string> = {
'2.5.4.3': 'CommonName (CN)',
'2.5.4.6': 'Country (C)',
'2.5.4.7': 'Locality (L)',
'2.5.4.8': 'StateOrProvince (ST)',
'2.5.4.9': 'Street',
'2.5.4.10': 'Organization (O)',
'2.5.4.11': 'OrganizationalUnit (OU)',
'2.5.4.5': 'SerialNumber',
'2.5.4.4': 'Surname (SN)',
'2.5.4.42': 'GivenName (GN)',
'2.5.4.12': 'Title',
'2.5.4.13': 'Description',
'2.5.4.17': 'PostalCode',
'1.2.840.113549.1.9.1': 'Email',
'0.9.2342.19200300.100.1.25': 'DomainComponent (DC)',
'0.9.2342.19200300.100.1.1': 'UserID (UID)',
};
// Dotted strings below are ASN.1 object identifiers in dotted-arc notation,
// not network addresses (S1313 pattern-matches some of them as IPv4).
const SIGNATURE_ALGORITHM_OIDS: Record<string, string> = {
'1.2.840.113549.1.1.4': 'md5WithRSAEncryption',
'1.2.840.113549.1.1.5': 'sha1WithRSAEncryption',
'1.2.840.113549.1.1.10': 'RSASSA-PSS',
'1.2.840.113549.1.1.11': 'sha256WithRSAEncryption',
'1.2.840.113549.1.1.12': 'sha384WithRSAEncryption',
'1.2.840.113549.1.1.13': 'sha512WithRSAEncryption',
'1.2.840.10045.4.1': 'ecdsa-with-SHA1',
'1.2.840.10045.4.3.2': 'ecdsa-with-SHA256',
'1.2.840.10045.4.3.3': 'ecdsa-with-SHA384',
'1.2.840.10045.4.3.4': 'ecdsa-with-SHA512',
'1.3.101.112': 'Ed25519',
'1.3.101.113': 'Ed448',
};
const PUBLIC_KEY_ALGORITHM_OIDS: Record<string, string> = {
'1.2.840.113549.1.1.1': 'RSA',
'1.2.840.10045.2.1': 'EC (id-ecPublicKey)',
'1.3.101.110': 'X25519',
'1.3.101.111': 'X448',
'1.3.101.112': 'Ed25519',
'1.3.101.113': 'Ed448',
};
const CURVE_OIDS: Record<string, string> = {
'1.2.840.10045.3.1.1': 'secp192r1 (P-192)',
'1.2.840.10045.3.1.7': 'prime256v1 (P-256)',
'1.3.132.0.10': 'secp256k1',
'1.3.132.0.33': 'secp224r1 (P-224)',
'1.3.132.0.34': 'secp384r1 (P-384)',
'1.3.132.0.35': 'secp521r1 (P-521)',
};
const EXTENSION_OIDS: Record<string, string> = {
'2.5.29.19': 'basicConstraints',
'2.5.29.17': 'subjectAltName',
'2.5.29.18': 'issuerAltName',
'2.5.29.15': 'keyUsage',
'2.5.29.14': 'subjectKeyIdentifier',
'2.5.29.35': 'authorityKeyIdentifier',
'2.5.29.31': 'cRLDistributionPoints',
'2.5.29.37': 'extKeyUsage',
'1.3.6.1.5.5.7.1.1': 'authorityInfoAccess',
'1.3.6.1.4.1.11129.2.4.2': 'signedCertificateTimestampList',
};
const EXTENDED_KEY_USAGE_OIDS: Record<string, string> = {
'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.3.6.1.5.5.7.3.9': 'OCSPSigning',
};
const GENERAL_NAME_TYPES: Record<number, { label: string; decode: (value: string) => string }> = {
1: { label: 'Email', decode: value => value },
2: { label: 'DNS', decode: value => value },
6: { label: 'URI', decode: value => value },
7: { label: 'IP', decode: value => Array.from(value, ch => ch.charCodeAt(0)).join('.') },
4: { label: 'DirName', decode: () => '(directory name)' },
8: { label: 'RID', decode: () => '(registered id)' },
};
/** forge types .value as string | Asn1[]; narrow to the constructed shape. */
function asChildren(value: unknown): asn1.Asn1[] {
return (value ?? []) as unknown as asn1.Asn1[];
}
function isUniversal(node: asn1.Asn1, tag: number): boolean {
return node.tagClass === asn1.Class.UNIVERSAL && node.type === tag;
}
function primitiveValue(node: asn1.Asn1 | undefined): string {
return typeof node?.value === 'string' ? node.value : '';
}
/** forge may swap a BIT STRING's .value for a composed [asn1] array when its
* content parses as ASN.1; the raw bytes always live in bitStringContents. */
function bitStringValue(node: asn1.Asn1 | undefined): string {
// bitStringContents exists at runtime but is missing from forge's type defs
const raw = (node as unknown as { bitStringContents?: string } | undefined)?.bitStringContents
?? primitiveValue(node);
return raw.slice(1); // drop the leading unused-bits octet
}
/** forge's fromDer keeps OBJECT IDENTIFIER values as raw DER bytes; expand
* them to dotted notation (first byte = 40·x+y, rest base-128). */
function decodeOid(node: asn1.Asn1 | undefined): string {
const bytes = primitiveValue(node);
if (!bytes) {
return '';
}
const parts: number[] = [];
const first = bytes.charCodeAt(0);
parts.push(Math.floor(first / 40), first % 40);
let pending = 0;
for (const ch of bytes.slice(1)) {
pending = (pending << 7) | (ch.charCodeAt(0) & 0x7F);
if ((ch.charCodeAt(0) & 0x80) === 0) {
parts.push(pending);
pending = 0;
}
}
return parts.join('.');
}
function hex(bytes: string, separator = ':'): string {
return Array.from(bytes, ch => ch.charCodeAt(0).toString(16).padStart(2, '0')).join(separator).toUpperCase();
}
function toIso(date: Date): string {
return `${date.toISOString().replace('T', ' ').replace(/\..+/, '')} UTC`;
}
function parseTime(node: asn1.Asn1): Date {
const raw = primitiveValue(node);
// UTCTime YYMMDDHHMMSSZ or GeneralizedTime YYYYMMDDHHMMSSZ
const utcTime = raw.length === 13;
const year = utcTime
? (Number(raw.slice(0, 2)) < 50 ? `20${raw.slice(0, 2)}` : `19${raw.slice(0, 2)}`)
: raw.slice(0, 4);
const rest = utcTime ? raw.slice(2) : raw.slice(4);
return new Date(`${year}-${rest.slice(0, 2)}-${rest.slice(2, 4)}T${rest.slice(4, 6)}:${rest.slice(6, 8)}:${rest.slice(8, 10) || '00'}Z`);
}
function walkDn(nameNode: asn1.Asn1 | undefined): CertificateRow[] {
const rows: CertificateRow[] = [];
const rdns = (nameNode?.value ?? []) as unknown as asn1.Asn1[];
for (const rdnSet of rdns) {
for (const attr of (rdnSet.value ?? []) as unknown as asn1.Asn1[]) {
const [oidNode, valueNode] = (attr.value ?? []) as asn1.Asn1[];
const oid = decodeOid(oidNode);
rows.push({ label: DN_OIDS[oid] ?? oid, value: primitiveValue(valueNode) });
}
}
return rows;
}
function describeAlgorithmIdentifier(node: asn1.Asn1 | undefined, table: Record<string, string>): { oid: string; name: string } {
const oid = decodeOid(node?.value?.[0] as asn1.Asn1 | undefined);
return { oid, name: table[oid] ?? oid };
}
function describePublicKey(spkiNode: asn1.Asn1 | undefined): CertificateRow[] {
const rows: CertificateRow[] = [];
const [algorithmNode, keyNode] = (spkiNode?.value ?? []) as asn1.Asn1[];
const { oid: algOid, name: algName } = describeAlgorithmIdentifier(algorithmNode, PUBLIC_KEY_ALGORITHM_OIDS);
rows.push({ label: 'Algorithm', value: algName, hint: algOid });
const keyBytes = bitStringValue(keyNode);
const algParams = (algorithmNode?.value ?? [])[1] as asn1.Asn1 | undefined;
if (algOid === '1.2.840.113549.1.1.1') {
try {
const rsa = asn1.fromDer(keyBytes);
const [modulus, exponent] = asChildren(rsa.value);
const modBytes = primitiveValue(modulus);
const leadingZero = modBytes.charCodeAt(0) === 0 ? 1 : 0;
rows.push({ label: 'Key size', value: `${(modBytes.length - leadingZero) * 8} bits` });
const expBytes = primitiveValue(exponent);
const exponentValue = Array.from(expBytes, ch => ch.charCodeAt(0)).reduce((acc, byte) => acc * 256 + byte, 0);
rows.push({ label: 'Exponent', value: exponentValue.toString() });
}
catch {
rows.push({ label: 'Key size', value: 'unparseable modulus' });
}
return rows;
}
if (algOid === '1.2.840.10045.2.1') {
const curveOid = decodeOid(algParams);
rows.push({ label: 'Curve', value: CURVE_OIDS[curveOid] ?? curveOid, hint: curveOid });
rows.push({ label: 'Key size', value: `${keyBytes.charCodeAt(0) === 4 ? ((keyBytes.length - 1) / 2) * 8 : keyBytes.length * 8} bits` });
rows.push({ label: 'Public key', value: hex(keyBytes) });
return rows;
}
rows.push({ label: 'Key size', value: `${keyBytes.length * 8} bits` });
rows.push({ label: 'Public key', value: hex(keyBytes) });
return rows;
}
function describeExtension(extension: asn1.Asn1): CertificateRow {
const children = (extension.value ?? []) as asn1.Asn1[];
const [oidNode] = children;
// critical BOOLEAN is optional and only present between OID and value
const criticalNode = isUniversal(children[1], asn1.Type.BOOLEAN) ? children[1] : undefined;
const valueNode = criticalNode ? children[2] : children[1];
const oid = decodeOid(oidNode);
const name = EXTENSION_OIDS[oid] ?? oid;
const critical = criticalNode !== undefined && primitiveValue(criticalNode).charCodeAt(0) !== 0;
const raw = primitiveValue(valueNode);
const label = critical ? `${name} (critical)` : name;
try {
if (oid === '2.5.29.19') {
const bc = asn1.fromDer(raw);
const bcChildren = asChildren(bc.value);
const isCa = bcChildren[0] ? primitiveValue(bcChildren[0]).charCodeAt(0) !== 0 : false;
const pathLenNode = bcChildren[1];
const pathLen = pathLenNode ? Array.from(primitiveValue(pathLenNode), ch => ch.charCodeAt(0)).reduce((acc, b) => acc * 256 + b, 0) : undefined;
return { label, value: `CA: ${isCa}${pathLen !== undefined ? `, pathLen: ${pathLen}` : ''}` };
}
if (oid === '2.5.29.17' || oid === '2.5.29.18') {
const names = asn1.fromDer(raw);
const parts = asChildren(names.value).map((generalName) => {
const meta = GENERAL_NAME_TYPES[generalName.type];
return meta ? `${meta.label}: ${meta.decode(primitiveValue(generalName))}` : `type${generalName.type}`;
});
return { label, value: parts.join(', ') || '(empty)' };
}
if (oid === '2.5.29.15') {
const bytes = bitStringValue(asn1.fromDer(raw));
const byte0 = bytes.charCodeAt(0) ?? 0;
const flags = ['digitalSignature', 'nonRepudiation', 'keyEncipherment', 'dataEncipherment', 'keyAgreement', 'keyCertSign', 'cRLSign', 'encipherOnly'];
const active = flags.filter((_, index) => byte0 & (0b1000_0000 >> index));
return { label, value: active.join(', ') || '(none)' };
}
if (oid === '2.5.29.37') {
const usages = asn1.fromDer(raw);
const parts = asChildren(usages.value).map(node => EXTENDED_KEY_USAGE_OIDS[decodeOid(node)] ?? decodeOid(node));
return { label, value: parts.join(', ') };
}
if (oid === '2.5.29.14' || oid === '2.5.29.35') {
const first = asChildren(asn1.fromDer(raw).value)[0];
const value = isUniversal(first, asn1.Type.OCTETSTRING) ? primitiveValue(first) : raw;
return { label, value: hex(value) };
}
}
catch {
// fall through to hex display
}
return { label, value: hex(raw) };
}
function fingerprintHex(der: string, algorithm: 'sha1' | 'sha256'): string {
const digest = forgeMd.algorithms[algorithm].create();
digest.update(der);
const hexDigest = digest.digest().toHex();
return hexDigest.match(/.{2}/g)!.join(':').toUpperCase();
}
export function decodeCertificatePem(pem: string): DecodedCertificate {
const trimmed = pem.trim();
if (!trimmed) {
throw new Error('Paste a PEM certificate to decode');
}
const messages = forgePem.decode(trimmed);
const certificateMessage = messages.find(message => message.type === 'CERTIFICATE');
if (!certificateMessage) {
throw new Error('No CERTIFICATE block found (this tool decodes certificates, not keys)');
}
const root = asn1.fromDer(certificateMessage.body);
if (root.type !== asn1.Type.SEQUENCE || !(Array.isArray(root.value)) || root.value.length < 3) {
throw new Error('Input is not a valid X.509 certificate');
}
const [tbsNode, outerSignatureNode] = asChildren(root.value);
if (tbsNode.type !== asn1.Type.SEQUENCE) {
throw new Error('Input is not a valid X.509 certificate');
}
let cursor = 0;
let versionRow: CertificateRow = { label: 'Version', value: '1 (implicit)' };
const versionNode = asChildren(tbsNode.value)[0];
if (versionNode && versionNode.tagClass === asn1.Class.CONTEXT_SPECIFIC && versionNode.type === 0) {
const versionValue = versionNode.value?.[0] ? primitiveValue(asChildren(versionNode.value)[0]).charCodeAt(0) : 0;
versionRow = { label: 'Version', value: `v${(versionValue ?? 0) + 1}` };
cursor = 1;
}
const tbsChildren = asChildren(tbsNode.value);
const serialNode = tbsChildren[cursor];
const tbsSignatureNode = tbsChildren[cursor + 1];
const issuerNode = tbsChildren[cursor + 2];
const validityNode = tbsChildren[cursor + 3];
const subjectNode = tbsChildren[cursor + 4];
const spkiNode = tbsChildren[cursor + 5];
const extensionsWrapper = asChildren(tbsNode.value).find(
node => node.tagClass === asn1.Class.CONTEXT_SPECIFIC && node.type === 3,
);
const tbsSignature = describeAlgorithmIdentifier(tbsSignatureNode, SIGNATURE_ALGORITHM_OIDS);
const outerSignature = describeAlgorithmIdentifier(outerSignatureNode, SIGNATURE_ALGORITHM_OIDS);
const serialBytes = primitiveValue(serialNode as asn1.Asn1 | undefined);
const [notBeforeNode, notAfterNode] = asChildren(validityNode?.value);
const notBefore = parseTime(notBeforeNode);
const notAfter = parseTime(notAfterNode);
const extensionRows: CertificateRow[] = asChildren(asChildren(extensionsWrapper?.value)[0]?.value)
.map(extension => describeExtension(extension));
const sections: CertificateSection[] = [
{
title: 'Certificate',
rows: [
versionRow,
{ label: 'Serial number', value: hex(serialBytes) },
{ label: 'Signature algorithm', value: tbsSignature.name, hint: tbsSignature.oid },
{ label: 'Not before', value: toIso(notBefore) },
{ label: 'Not after', value: toIso(notAfter) },
{ label: 'Signature consistency', value: outerSignature.name === tbsSignature.name ? 'outer matches TBS' : `outer: ${outerSignature.name}` },
],
},
{ title: 'Subject', rows: walkDn(subjectNode as asn1.Asn1 | undefined) },
{ title: 'Issuer', rows: walkDn(issuerNode as asn1.Asn1 | undefined) },
{ title: 'Public key', rows: describePublicKey(spkiNode as asn1.Asn1 | undefined) },
{ title: 'Extensions', rows: extensionRows.length > 0 ? extensionRows : [{ label: '(none)', value: '' }] },
{
title: 'Fingerprints',
rows: [
{ label: 'SHA-256', value: fingerprintHex(certificateMessage.body, 'sha256') },
{ label: 'SHA-1', value: fingerprintHex(certificateMessage.body, 'sha1') },
],
},
];
return { sections };
}
import { asn1, md as forgeMd, pem as forgePem } from 'node-forge';
export interface CertificateRow {
label: string
value: string
hint?: string
}
export interface CertificateSection {
title: string
rows: CertificateRow[]
}
export interface DecodedCertificate {
sections: CertificateSection[]
}
/** Distinguished-name attribute OIDs (RFC 5280 / legacy PKCS#9). */
const DN_OIDS: Record<string, string> = {
'2.5.4.3': 'CommonName (CN)',
'2.5.4.6': 'Country (C)',
'2.5.4.7': 'Locality (L)',
'2.5.4.8': 'StateOrProvince (ST)',
'2.5.4.9': 'Street',
'2.5.4.10': 'Organization (O)',
'2.5.4.11': 'OrganizationalUnit (OU)',
'2.5.4.5': 'SerialNumber',
'2.5.4.4': 'Surname (SN)',
'2.5.4.42': 'GivenName (GN)',
'2.5.4.12': 'Title',
'2.5.4.13': 'Description',
'2.5.4.17': 'PostalCode',
'1.2.840.113549.1.9.1': 'Email',
'0.9.2342.19200300.100.1.25': 'DomainComponent (DC)',
'0.9.2342.19200300.100.1.1': 'UserID (UID)',
};
// Dotted strings below are ASN.1 object identifiers in dotted-arc notation,
// not network addresses (S1313 pattern-matches some of them as IPv4).
const SIGNATURE_ALGORITHM_OIDS: Record<string, string> = {
'1.2.840.113549.1.1.4': 'md5WithRSAEncryption',
'1.2.840.113549.1.1.5': 'sha1WithRSAEncryption',
'1.2.840.113549.1.1.10': 'RSASSA-PSS',
'1.2.840.113549.1.1.11': 'sha256WithRSAEncryption',
'1.2.840.113549.1.1.12': 'sha384WithRSAEncryption',
'1.2.840.113549.1.1.13': 'sha512WithRSAEncryption',
'1.2.840.10045.4.1': 'ecdsa-with-SHA1',
'1.2.840.10045.4.3.2': 'ecdsa-with-SHA256',
'1.2.840.10045.4.3.3': 'ecdsa-with-SHA384',
'1.2.840.10045.4.3.4': 'ecdsa-with-SHA512',
'1.3.101.112': 'Ed25519',
'1.3.101.113': 'Ed448',
};
const PUBLIC_KEY_ALGORITHM_OIDS: Record<string, string> = {
'1.2.840.113549.1.1.1': 'RSA',
'1.2.840.10045.2.1': 'EC (id-ecPublicKey)',
'1.3.101.110': 'X25519',
'1.3.101.111': 'X448',
'1.3.101.112': 'Ed25519',
'1.3.101.113': 'Ed448',
};
const CURVE_OIDS: Record<string, string> = {
'1.2.840.10045.3.1.1': 'secp192r1 (P-192)',
'1.2.840.10045.3.1.7': 'prime256v1 (P-256)',
'1.3.132.0.10': 'secp256k1',
'1.3.132.0.33': 'secp224r1 (P-224)',
'1.3.132.0.34': 'secp384r1 (P-384)',
'1.3.132.0.35': 'secp521r1 (P-521)',
};
const EXTENSION_OIDS: Record<string, string> = {
'2.5.29.19': 'basicConstraints',
'2.5.29.17': 'subjectAltName',
'2.5.29.18': 'issuerAltName',
'2.5.29.15': 'keyUsage',
'2.5.29.14': 'subjectKeyIdentifier',
'2.5.29.35': 'authorityKeyIdentifier',
'2.5.29.31': 'cRLDistributionPoints',
'2.5.29.37': 'extKeyUsage',
'1.3.6.1.5.5.7.1.1': 'authorityInfoAccess',
'1.3.6.1.4.1.11129.2.4.2': 'signedCertificateTimestampList',
};
const EXTENDED_KEY_USAGE_OIDS: Record<string, string> = {
'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.3.6.1.5.5.7.3.9': 'OCSPSigning',
};
/** IPv4 = dotted decimal; IPv6 = RFC 5952 text (lowercase, longest zero run
* compressed). Anything else falls back to dotted decimal of the bytes. */
function decodeIpAddress(bytes: string): string {
const octets = Array.from(bytes, ch => ch.charCodeAt(0));
if (octets.length === 16) {
const groups: string[] = [];
for (let i = 0; i < 16; i += 2) {
groups.push(((octets[i]! << 8) | octets[i + 1]!).toString(16));
}
// Compress the longest run of zero groups (length ≥ 2) into '::'.
let bestStart = -1;
let bestLength = 0;
let currentStart = -1;
let currentLength = 0;
for (let i = 0; i < groups.length; i++) {
if (groups[i] !== '0') {
currentStart = -1;
currentLength = 0;
continue;
}
if (currentStart < 0) {
currentStart = i;
}
currentLength++;
if (currentLength > bestLength) {
bestStart = currentStart;
bestLength = currentLength;
}
}
if (bestLength >= 2) {
const head = groups.slice(0, bestStart).join(':');
const tail = groups.slice(bestStart + bestLength).join(':');
return `${head}::${tail}`;
}
return groups.join(':');
}
return octets.join('.');
}
const GENERAL_NAME_TYPES: Record<number, { label: string; decode: (value: string) => string }> = {
1: { label: 'Email', decode: value => value },
2: { label: 'DNS', decode: value => value },
6: { label: 'URI', decode: value => value },
7: { label: 'IP', decode: decodeIpAddress },
4: { label: 'DirName', decode: () => '(directory name)' },
8: { label: 'RID', decode: () => '(registered id)' },
};
/** forge types .value as string | Asn1[]; narrow to the constructed shape. */
function asChildren(value: unknown): asn1.Asn1[] {
return (value ?? []) as unknown as asn1.Asn1[];
}
function isUniversal(node: asn1.Asn1, tag: number): boolean {
return node.tagClass === asn1.Class.UNIVERSAL && node.type === tag;
}
function primitiveValue(node: asn1.Asn1 | undefined): string {
return typeof node?.value === 'string' ? node.value : '';
}
/** forge may swap a BIT STRING's .value for a composed [asn1] array when its
* content parses as ASN.1; the raw bytes always live in bitStringContents. */
function bitStringValue(node: asn1.Asn1 | undefined): string {
// bitStringContents exists at runtime but is missing from forge's type defs
const raw = (node as unknown as { bitStringContents?: string } | undefined)?.bitStringContents
?? primitiveValue(node);
return raw.slice(1); // drop the leading unused-bits octet
}
/** forge's fromDer keeps OBJECT IDENTIFIER values as raw DER bytes; expand
* them to dotted notation. The first sub-identifier packs 40·arc1+arc2 and
* may itself span multiple base-128 bytes (arc2 ≥ 40 under arc 2, e.g.
* 2.999 → 0x88 0x67); the rest decode as plain base-128. */
function decodeOid(node: asn1.Asn1 | undefined): string {
const bytes = primitiveValue(node);
if (!bytes) {
return '';
}
let pending = 0;
let first = 0;
let index = 0;
// Accumulate the packed first sub-identifier (continuation while high bit set).
for (; index < bytes.length; index++) {
const byte = bytes.charCodeAt(index);
pending = (pending << 7) | (byte & 0x7F);
if ((byte & 0x80) === 0) {
first = pending;
index++;
break;
}
}
const parts: number[] = first < 80
? [Math.floor(first / 40), first % 40]
: [2, first - 80];
pending = 0;
for (; index < bytes.length; index++) {
const byte = bytes.charCodeAt(index);
pending = (pending << 7) | (byte & 0x7F);
if ((byte & 0x80) === 0) {
parts.push(pending);
pending = 0;
}
}
return parts.join('.');
}
function hex(bytes: string, separator = ':'): string {
return Array.from(bytes, ch => ch.charCodeAt(0).toString(16).padStart(2, '0')).join(separator).toUpperCase();
}
function toIso(date: Date): string {
return `${date.toISOString().replace('T', ' ').replace(/\..+/, '')} UTC`;
}
function parseTime(node: asn1.Asn1): Date {
const raw = primitiveValue(node);
// UTCTime YYMMDDHHMMSSZ or GeneralizedTime YYYYMMDDHHMMSSZ
const utcTime = raw.length === 13;
const year = utcTime
? (Number(raw.slice(0, 2)) < 50 ? `20${raw.slice(0, 2)}` : `19${raw.slice(0, 2)}`)
: raw.slice(0, 4);
const rest = utcTime ? raw.slice(2) : raw.slice(4);
return new Date(`${year}-${rest.slice(0, 2)}-${rest.slice(2, 4)}T${rest.slice(4, 6)}:${rest.slice(6, 8)}:${rest.slice(8, 10) || '00'}Z`);
}
function walkDn(nameNode: asn1.Asn1 | undefined): CertificateRow[] {
const rows: CertificateRow[] = [];
const rdns = (nameNode?.value ?? []) as unknown as asn1.Asn1[];
for (const rdnSet of rdns) {
for (const attr of (rdnSet.value ?? []) as unknown as asn1.Asn1[]) {
const [oidNode, valueNode] = (attr.value ?? []) as asn1.Asn1[];
const oid = decodeOid(oidNode);
rows.push({ label: DN_OIDS[oid] ?? oid, value: primitiveValue(valueNode) });
}
}
return rows;
}
function describeAlgorithmIdentifier(node: asn1.Asn1 | undefined, table: Record<string, string>): { oid: string; name: string } {
const oid = decodeOid(node?.value?.[0] as asn1.Asn1 | undefined);
return { oid, name: table[oid] ?? oid };
}
function describePublicKey(spkiNode: asn1.Asn1 | undefined): CertificateRow[] {
const rows: CertificateRow[] = [];
const [algorithmNode, keyNode] = (spkiNode?.value ?? []) as asn1.Asn1[];
const { oid: algOid, name: algName } = describeAlgorithmIdentifier(algorithmNode, PUBLIC_KEY_ALGORITHM_OIDS);
rows.push({ label: 'Algorithm', value: algName, hint: algOid });
const keyBytes = bitStringValue(keyNode);
const algParams = (algorithmNode?.value ?? [])[1] as asn1.Asn1 | undefined;
if (algOid === '1.2.840.113549.1.1.1') {
try {
const rsa = asn1.fromDer(keyBytes);
const [modulus, exponent] = asChildren(rsa.value);
const modBytes = primitiveValue(modulus);
const leadingZero = modBytes.charCodeAt(0) === 0 ? 1 : 0;
rows.push({ label: 'Key size', value: `${(modBytes.length - leadingZero) * 8} bits` });
const expBytes = primitiveValue(exponent);
const exponentValue = Array.from(expBytes, ch => ch.charCodeAt(0)).reduce((acc, byte) => acc * 256 + byte, 0);
rows.push({ label: 'Exponent', value: exponentValue.toString() });
}
catch {
rows.push({ label: 'Key size', value: 'unparseable modulus' });
}
return rows;
}
if (algOid === '1.2.840.10045.2.1') {
const curveOid = decodeOid(algParams);
rows.push({ label: 'Curve', value: CURVE_OIDS[curveOid] ?? curveOid, hint: curveOid });
rows.push({ label: 'Key size', value: `${keyBytes.charCodeAt(0) === 4 ? ((keyBytes.length - 1) / 2) * 8 : keyBytes.length * 8} bits` });
rows.push({ label: 'Public key', value: hex(keyBytes) });
return rows;
}
rows.push({ label: 'Key size', value: `${keyBytes.length * 8} bits` });
rows.push({ label: 'Public key', value: hex(keyBytes) });
return rows;
}
function describeExtension(extension: asn1.Asn1): CertificateRow {
const children = (extension.value ?? []) as asn1.Asn1[];
const [oidNode] = children;
// critical BOOLEAN is optional and only present between OID and value
const criticalNode = isUniversal(children[1], asn1.Type.BOOLEAN) ? children[1] : undefined;
const valueNode = criticalNode ? children[2] : children[1];
const oid = decodeOid(oidNode);
const name = EXTENSION_OIDS[oid] ?? oid;
const critical = criticalNode !== undefined && primitiveValue(criticalNode).charCodeAt(0) !== 0;
const raw = primitiveValue(valueNode);
const label = critical ? `${name} (critical)` : name;
try {
if (oid === '2.5.29.19') {
const bc = asn1.fromDer(raw);
const bcChildren = asChildren(bc.value);
const isCa = bcChildren[0] ? primitiveValue(bcChildren[0]).charCodeAt(0) !== 0 : false;
const pathLenNode = bcChildren[1];
const pathLen = pathLenNode ? Array.from(primitiveValue(pathLenNode), ch => ch.charCodeAt(0)).reduce((acc, b) => acc * 256 + b, 0) : undefined;
return { label, value: `CA: ${isCa}${pathLen !== undefined ? `, pathLen: ${pathLen}` : ''}` };
}
if (oid === '2.5.29.17' || oid === '2.5.29.18') {
const names = asn1.fromDer(raw);
const parts = asChildren(names.value).map((generalName) => {
const meta = GENERAL_NAME_TYPES[generalName.type];
return meta ? `${meta.label}: ${meta.decode(primitiveValue(generalName))}` : `type${generalName.type}`;
});
return { label, value: parts.join(', ') || '(empty)' };
}
if (oid === '2.5.29.15') {
const bytes = bitStringValue(asn1.fromDer(raw));
// Named bits span up to 2 bytes (bit 9 = decipherOnly); unused-bits
// count was already dropped with the BIT STRING prefix.
const flags = ['digitalSignature', 'nonRepudiation', 'keyEncipherment', 'dataEncipherment', 'keyAgreement', 'keyCertSign', 'cRLSign', 'encipherOnly', 'decipherOnly'];
const active = flags.filter((_, index) => {
const byte = bytes.charCodeAt(index >> 3) ?? 0;
return byte & (0b1000_0000 >> (index & 7));
});
return { label, value: active.join(', ') || '(none)' };
}
if (oid === '2.5.29.37') {
const usages = asn1.fromDer(raw);
const parts = asChildren(usages.value).map(node => EXTENDED_KEY_USAGE_OIDS[decodeOid(node)] ?? decodeOid(node));
return { label, value: parts.join(', ') };
}
if (oid === '2.5.29.14') {
// SKI: the extension value is a bare OCTET STRING — hex its content,
// not the DER wrapper (openssl prints the bare key id).
const node = asn1.fromDer(raw);
const value = isUniversal(node, asn1.Type.OCTETSTRING) ? primitiveValue(node) : raw;
return { label, value: hex(value) };
}
if (oid === '2.5.29.35') {
// AKI: SEQUENCE; the key identifier lives in the context-tagged [0]
// child. Fall back to the whole-der hex when the shape is unexpected.
const first = asChildren(asn1.fromDer(raw).value)[0];
const isKeyId = first
&& first.tagClass === asn1.Class.CONTEXT_SPECIFIC
&& first.type === 0;
const value = isKeyId ? primitiveValue(first) : raw;
return { label, value: hex(value) };
}
}
catch {
// fall through to hex display
}
return { label, value: hex(raw) };
}
function fingerprintHex(der: string, algorithm: 'sha1' | 'sha256'): string {
const digest = forgeMd.algorithms[algorithm].create();
digest.update(der);
const hexDigest = digest.digest().toHex();
return hexDigest.match(/.{2}/g)!.join(':').toUpperCase();
}
export function decodeCertificatePem(pem: string): DecodedCertificate {
const trimmed = pem.trim();
if (!trimmed) {
throw new Error('Paste a PEM certificate to decode');
}
const messages = forgePem.decode(trimmed);
const certificateMessage = messages.find(message => message.type === 'CERTIFICATE');
if (!certificateMessage) {
throw new Error('No CERTIFICATE block found (this tool decodes certificates, not keys)');
}
const root = asn1.fromDer(certificateMessage.body);
if (root.type !== asn1.Type.SEQUENCE || !(Array.isArray(root.value)) || root.value.length < 3) {
throw new Error('Input is not a valid X.509 certificate');
}
const [tbsNode, outerSignatureNode] = asChildren(root.value);
if (tbsNode.type !== asn1.Type.SEQUENCE) {
throw new Error('Input is not a valid X.509 certificate');
}
let cursor = 0;
let versionRow: CertificateRow = { label: 'Version', value: '1 (implicit)' };
const versionNode = asChildren(tbsNode.value)[0];
if (versionNode && versionNode.tagClass === asn1.Class.CONTEXT_SPECIFIC && versionNode.type === 0) {
const versionValue = versionNode.value?.[0] ? primitiveValue(asChildren(versionNode.value)[0]).charCodeAt(0) : 0;
versionRow = { label: 'Version', value: `v${(versionValue ?? 0) + 1}` };
cursor = 1;
}
const tbsChildren = asChildren(tbsNode.value);
const serialNode = tbsChildren[cursor];
const tbsSignatureNode = tbsChildren[cursor + 1];
const issuerNode = tbsChildren[cursor + 2];
const validityNode = tbsChildren[cursor + 3];
const subjectNode = tbsChildren[cursor + 4];
const spkiNode = tbsChildren[cursor + 5];
const extensionsWrapper = asChildren(tbsNode.value).find(
node => node.tagClass === asn1.Class.CONTEXT_SPECIFIC && node.type === 3,
);
const tbsSignature = describeAlgorithmIdentifier(tbsSignatureNode, SIGNATURE_ALGORITHM_OIDS);
const outerSignature = describeAlgorithmIdentifier(outerSignatureNode, SIGNATURE_ALGORITHM_OIDS);
const serialBytes = primitiveValue(serialNode as asn1.Asn1 | undefined);
const [notBeforeNode, notAfterNode] = asChildren(validityNode?.value);
const notBefore = parseTime(notBeforeNode);
const notAfter = parseTime(notAfterNode);
const extensionRows: CertificateRow[] = asChildren(asChildren(extensionsWrapper?.value)[0]?.value)
.map(extension => describeExtension(extension));
const sections: CertificateSection[] = [
{
title: 'Certificate',
rows: [
versionRow,
{ label: 'Serial number', value: hex(serialBytes) },
{ label: 'Signature algorithm', value: tbsSignature.name, hint: tbsSignature.oid },
{ label: 'Not before', value: toIso(notBefore) },
{ label: 'Not after', value: toIso(notAfter) },
{ label: 'Signature consistency', value: outerSignature.name === tbsSignature.name ? 'outer matches TBS' : `outer: ${outerSignature.name}` },
],
},
{ title: 'Subject', rows: walkDn(subjectNode as asn1.Asn1 | undefined) },
{ title: 'Issuer', rows: walkDn(issuerNode as asn1.Asn1 | undefined) },
{ title: 'Public key', rows: describePublicKey(spkiNode as asn1.Asn1 | undefined) },
{ title: 'Extensions', rows: extensionRows.length > 0 ? extensionRows : [{ label: '(none)', value: '' }] },
{
title: 'Fingerprints',
rows: [
{ label: 'SHA-256', value: fingerprintHex(certificateMessage.body, 'sha256') },
{ label: 'SHA-1', value: fingerprintHex(certificateMessage.body, 'sha1') },
],
},
];
return { sections };
}