268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
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;
|
|
}
|