diff --git a/src/tools/x509-certificate-decoder/x509-certificate-decoder.openssl-regression.test.ts b/src/tools/x509-certificate-decoder/x509-certificate-decoder.openssl-regression.test.ts new file mode 100644 index 00000000..0e303ec9 --- /dev/null +++ b/src/tools/x509-certificate-decoder/x509-certificate-decoder.openssl-regression.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.ts b/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.ts index 567e38e0..3f4d4168 100644 --- a/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.ts +++ b/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.ts @@ -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 = { - '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 = { - '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 = { - '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 = { - '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 = { - '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 = { - '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 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): { 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 = { + '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 = { + '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 = { + '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 = { + '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 = { + '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 = { + '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 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): { 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 }; +}