mirror of
https://github.com/CorentinTh/it-tools.git
synced 2026-10-24 06:43:40 +00:00
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:
parent
5a8913988c
commit
d6bfef3e4a
2 changed files with 531 additions and 361 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue