const UNIVERSAL_NAMES = new Map([ [1, "BOOLEAN"], [2, "INTEGER"], [3, "BIT STRING"], [4, "OCTET STRING"], [5, "NULL"], [6, "OBJECT IDENTIFIER"], [12, "UTF8String"], [16, "SEQUENCE"], [17, "SET"], [19, "PrintableString"], [22, "IA5String"], [23, "UTCTime"], [24, "GeneralizedTime"], [30, "BMPString"], ]); const EXTENSION_NAMES = new Map([ ["2.5.29.14", "Subject Key Identifier"], ["2.5.29.15", "Key Usage"], ["2.5.29.17", "Subject Alternative Name"], ["2.5.29.19", "Basic Constraints"], ["2.5.29.31", "CRL Distribution Points"], ["2.5.29.32", "Certificate Policies"], ["2.5.29.35", "Authority Key Identifier"], ["2.5.29.37", "Extended Key Usage"], ["1.3.6.1.5.5.7.1.1", "Authority Information Access"], ]); const KEY_USAGE_NAMES = [ "digitalSignature", "nonRepudiation", "keyEncipherment", "dataEncipherment", "keyAgreement", "keyCertSign", "crlSign", "encipherOnly", "decipherOnly", ]; const SUPPORTED_EXTENSIONS = new Set([ "2.5.29.14", "2.5.29.15", "2.5.29.17", "2.5.29.19", "2.5.29.35", "2.5.29.37", ]); export function exploreCertificateDer(input, limits = {}) { const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); const state = { bytes, nodes: 0, maxNodes: limits.maxNodes ?? 4096, maxDepth: limits.maxDepth ?? 32, }; const root = readNode(state, 0, bytes.length, 0); if (root.end !== bytes.length) throw new TypeError("Trailing bytes after DER certificate"); const extensions = certificateExtensions(root, bytes); const basicConstraints = extensions.find((item) => item.oid === "2.5.29.19")?.decoded; const keyUsage = extensions.find((item) => item.oid === "2.5.29.15")?.decoded; return { byteLength: bytes.length, tree: publicNode(root), extensions, derived: { isCa: basicConstraints?.ca === true, pathLength: basicConstraints?.pathLength, keyUsages: keyUsage?.usages ?? [], subjectKeyIdentifier: extensions.find((item) => item.oid === "2.5.29.14")?.decoded?.keyIdentifier, authorityKeyIdentifier: extensions.find((item) => item.oid === "2.5.29.35")?.decoded?.keyIdentifier, subjectAlternativeNames: extensions.find((item) => item.oid === "2.5.29.17")?.decoded?.names ?? [], unsupportedCriticalExtensions: extensions .filter((item) => item.critical && !item.supported) .map((item) => item.oid), }, }; } function readNode(state, offset, containerEnd, depth) { if (depth > state.maxDepth) throw new RangeError("DER nesting limit exceeded"); if (++state.nodes > state.maxNodes) throw new RangeError("DER node limit exceeded"); if (offset >= containerEnd) throw new TypeError("Truncated DER identifier"); const start = offset; const first = state.bytes[offset++]; const tagClass = first >> 6; const constructed = (first & 0x20) !== 0; let tagNumber = first & 0x1f; if (tagNumber === 0x1f) { tagNumber = 0; let octets = 0; while (true) { if (offset >= containerEnd || ++octets > 5) throw new TypeError("Invalid high-tag DER identifier"); const value = state.bytes[offset++]; tagNumber = tagNumber * 128 + (value & 0x7f); if ((value & 0x80) === 0) break; } } if (offset >= containerEnd) throw new TypeError("Truncated DER length"); const firstLength = state.bytes[offset++]; let length; if ((firstLength & 0x80) === 0) length = firstLength; else { const count = firstLength & 0x7f; if (count === 0) throw new TypeError("Indefinite length is forbidden in DER"); if (count > 4 || offset + count > containerEnd) throw new TypeError("Invalid DER length"); if (state.bytes[offset] === 0) throw new TypeError("Non-minimal DER length"); length = 0; for (let index = 0; index < count; index++) length = length * 256 + state.bytes[offset++]; if (length < 128) throw new TypeError("Non-minimal DER length encoding"); } const valueStart = offset; const end = valueStart + length; if (end > containerEnd || end < valueStart) throw new TypeError("DER value exceeds its container"); const children = []; if (constructed) { while (offset < end) { const child = readNode(state, offset, end, depth + 1); children.push(child); offset = child.end; } } return { tagClass, tagNumber, constructed, start, valueStart, end, length, children }; } function certificateExtensions(root, bytes) { requireTag(root, 0, 16, "Certificate"); const tbs = root.children[0]; requireTag(tbs, 0, 16, "TBSCertificate"); const wrapper = tbs.children.find((node) => node.tagClass === 2 && node.tagNumber === 3); if (!wrapper) return []; const sequence = wrapper.children[0]; requireTag(sequence, 0, 16, "Extensions"); return sequence.children.map((extension) => decodeExtension(extension, bytes)); } function decodeExtension(node, bytes) { requireTag(node, 0, 16, "Extension"); const oidNode = node.children[0]; requireTag(oidNode, 0, 6, "extension OID"); const oid = decodeOid(sliceValue(oidNode, bytes)); let index = 1; let critical = false; if (node.children[index]?.tagClass === 0 && node.children[index]?.tagNumber === 1) { critical = sliceValue(node.children[index], bytes)[0] !== 0; index++; } const valueNode = node.children[index]; requireTag(valueNode, 0, 4, "extension value"); const value = sliceValue(valueNode, bytes); const decoded = decodeKnownExtension(oid, value); return { oid, name: EXTENSION_NAMES.get(oid) ?? "Unknown extension", known: EXTENSION_NAMES.has(oid), supported: SUPPORTED_EXTENSIONS.has(oid), critical, offset: node.start, length: node.end - node.start, valueOffset: valueNode.valueStart, valueLength: valueNode.length, valueHex: hex(value, 96), ...(decoded === undefined ? {} : { decoded }), }; } function decodeKnownExtension(oid, value) { if (oid === "2.5.29.19") return decodeBasicConstraints(value); if (oid === "2.5.29.15") return decodeKeyUsage(value); if (oid === "2.5.29.14") return { keyIdentifier: hex(readSingle(value, 4).value) }; if (oid === "2.5.29.17") return decodeGeneralNames(value); if (oid === "2.5.29.35") return decodeAuthorityKeyIdentifier(value); if (oid === "2.5.29.37") return { purposes: readSingle(value, 16).children.map((node) => decodeOid(node.value)) }; return undefined; } function decodeGeneralNames(value) { const sequence = readSingle(value, 16); const labels = new Map([[1, "email"], [2, "dns"], [6, "uri"]]); const names = sequence.children.map((node) => { if (node.tagClass !== 2) return { type: `tag-${node.tagNumber}`, valueHex: hex(node.value) }; if (labels.has(node.tagNumber)) return { type: labels.get(node.tagNumber), value: new TextDecoder().decode(node.value) }; if (node.tagNumber === 7) return { type: "ip", value: decodeIpAddress(node.value), valueHex: hex(node.value) }; return { type: `general-name-${node.tagNumber}`, valueHex: hex(node.value) }; }); return { names }; } function decodeAuthorityKeyIdentifier(value) { const sequence = readSingle(value, 16); const keyIdentifier = sequence.children.find((node) => node.tagClass === 2 && node.tagNumber === 0); const serial = sequence.children.find((node) => node.tagClass === 2 && node.tagNumber === 2); return { ...(keyIdentifier ? { keyIdentifier: hex(keyIdentifier.value) } : {}), ...(serial ? { authorityCertificateSerial: hex(serial.value) } : {}), }; } function decodeIpAddress(bytes) { if (bytes.length === 4) return [...bytes].join("."); if (bytes.length === 16) { const groups = []; for (let offset = 0; offset < 16; offset += 2) groups.push(((bytes[offset] << 8) | bytes[offset + 1]).toString(16)); return groups.join(":"); } return "invalid IP address encoding"; } function decodeBasicConstraints(value) { const sequence = readSingle(value, 16); let ca = false; let pathLength; for (const node of sequence.children) { if (node.tagClass === 0 && node.tagNumber === 1) ca = node.value[0] !== 0; if (node.tagClass === 0 && node.tagNumber === 2) pathLength = decodeSmallInteger(node.value); } return { ca, ...(pathLength === undefined ? {} : { pathLength }) }; } function decodeKeyUsage(value) { const bits = readSingle(value, 3).value; if (bits.length === 0 || bits[0] > 7) throw new TypeError("Invalid Key Usage BIT STRING"); const usages = []; for (let bit = 0; bit < KEY_USAGE_NAMES.length; bit++) { const octet = bits[1 + Math.floor(bit / 8)] ?? 0; if ((octet & (0x80 >> (bit % 8))) !== 0) usages.push(KEY_USAGE_NAMES[bit]); } return { unusedBits: bits[0], usages }; } function readSingle(value, expectedTag) { const state = { bytes: value, nodes: 0, maxNodes: 512, maxDepth: 16 }; const node = readNode(state, 0, value.length, 0); if (node.end !== value.length) throw new TypeError("Trailing bytes in extension value"); requireTag(node, 0, expectedTag, "extension payload"); attachValues(node, value); return node; } function attachValues(node, bytes) { node.value = sliceValue(node, bytes); node.children.forEach((child) => attachValues(child, bytes)); } function decodeOid(bytes) { if (bytes.length === 0) throw new TypeError("Empty object identifier"); const first = bytes[0]; const parts = [Math.min(2, Math.floor(first / 40)), first < 80 ? first % 40 : first - 80]; let value = 0; for (const octet of bytes.slice(1)) { value = value * 128 + (octet & 0x7f); if (!Number.isSafeInteger(value)) throw new RangeError("Object identifier component is too large"); if ((octet & 0x80) === 0) { parts.push(value); value = 0; } } if ((bytes.at(-1) & 0x80) !== 0) throw new TypeError("Truncated object identifier"); return parts.join("."); } function decodeSmallInteger(bytes) { if (bytes.length === 0 || bytes.length > 4 || (bytes[0] & 0x80) !== 0) return undefined; return bytes.reduce((value, octet) => value * 256 + octet, 0); } function publicNode(node) { return { type: node.tagClass === 0 ? (UNIVERSAL_NAMES.get(node.tagNumber) ?? `UNIVERSAL ${node.tagNumber}`) : `${["UNIVERSAL", "APPLICATION", "CONTEXT", "PRIVATE"][node.tagClass]} ${node.tagNumber}`, tagClass: node.tagClass, tagNumber: node.tagNumber, constructed: node.constructed, offset: node.start, headerLength: node.valueStart - node.start, valueOffset: node.valueStart, valueLength: node.length, endOffset: node.end, children: node.children.map(publicNode), }; } function requireTag(node, tagClass, tagNumber, label) { if (!node || node.tagClass !== tagClass || node.tagNumber !== tagNumber) throw new TypeError(`${label} has an unexpected ASN.1 tag`); } function sliceValue(node, bytes) { return bytes.subarray(node.valueStart, node.end); } function hex(bytes, limit = Infinity) { const visible = bytes.subarray(0, limit); const result = [...visible].map((value) => value.toString(16).padStart(2, "0")).join(""); return bytes.length > limit ? `${result}…` : result; }