Add bounded DER certificate explorer
This commit is contained in:
parent
be751f4b51
commit
216afb6c18
@ -74,6 +74,12 @@ to inspect a host in the diagnostic interface. The local endpoint accepts only
|
|||||||
small JSON `POST` requests from its own browser origin and applies a ten-second
|
small JSON `POST` requests from its own browser origin and applies a ten-second
|
||||||
probe timeout.
|
probe timeout.
|
||||||
|
|
||||||
|
Each live certificate includes an expandable, byte-offset-aware DER explorer.
|
||||||
|
It preserves the original certificate for offline export, lists every extension
|
||||||
|
(including undecoded bytes), flags unknown critical extensions, and derives CA
|
||||||
|
status and Key Usage directly from the signed certificate encoding. Parsing is
|
||||||
|
dependency-free and guarded by input-size, node-count, and nesting limits.
|
||||||
|
|
||||||
Authority scopes identify the exact DER-encoded CA certificate with
|
Authority scopes identify the exact DER-encoded CA certificate with
|
||||||
`authorityCertificateSha256`. TrustLab verifies that it appears in the active
|
`authorityCertificateSha256`. TrustLab verifies that it appears in the active
|
||||||
chain, has CA Basic Constraints in the supplied facts, and permits
|
chain, has CA Basic Constraints in the supplied facts, and permits
|
||||||
|
|||||||
@ -28,6 +28,7 @@ function printReport({ facts, findings, caveats }) {
|
|||||||
console.log(`${index + 1}. ${certificate.subject}`);
|
console.log(`${index + 1}. ${certificate.subject}`);
|
||||||
console.log(` SHA-256 ${certificate.sha256}`);
|
console.log(` SHA-256 ${certificate.sha256}`);
|
||||||
console.log(` ${certificate.validFrom} — ${certificate.validUntil}${certificate.isCa ? " · CA" : ""}${certificate.selfSigned ? " · self-signed" : ""}`);
|
console.log(` ${certificate.validFrom} — ${certificate.validUntil}${certificate.isCa ? " · CA" : ""}${certificate.selfSigned ? " · self-signed" : ""}`);
|
||||||
|
if (certificate.keyUsages.length) console.log(` Key Usage: ${certificate.keyUsages.join(", ")}`);
|
||||||
});
|
});
|
||||||
console.log("\nFindings:");
|
console.log("\nFindings:");
|
||||||
if (findings.length === 0) console.log("- No failure found by the current checks.");
|
if (findings.length === 0) console.log("- No failure found by the current checks.");
|
||||||
|
|||||||
@ -21,7 +21,10 @@ export interface CertificateFacts {
|
|||||||
readonly validUntil?: string;
|
readonly validUntil?: string;
|
||||||
readonly isCa: boolean;
|
readonly isCa: boolean;
|
||||||
readonly keyUsages: readonly CertificateKeyUsage[];
|
readonly keyUsages: readonly CertificateKeyUsage[];
|
||||||
|
readonly unsupportedCriticalExtensions?: readonly string[];
|
||||||
readonly selfSigned: boolean;
|
readonly selfSigned: boolean;
|
||||||
|
readonly derBase64?: string;
|
||||||
|
readonly der?: Readonly<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CertificateKeyUsage =
|
export type CertificateKeyUsage =
|
||||||
|
|||||||
228
trustlab/src/der-explorer.js
Normal file
228
trustlab/src/der-explorer.js
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
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.19", "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 ?? [],
|
||||||
|
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.37") return { purposes: readSingle(value, 16).children.map((node) => decodeOid(node.value)) };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -4,3 +4,4 @@ export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
|
|||||||
export { TrustPolicyOverlay } from "./policy-overlay.js";
|
export { TrustPolicyOverlay } from "./policy-overlay.js";
|
||||||
export { TrustRunner } from "./runner.js";
|
export { TrustRunner } from "./runner.js";
|
||||||
export { parseTlsTarget, probeTls } from "./tls-probe.js";
|
export { parseTlsTarget, probeTls } from "./tls-probe.js";
|
||||||
|
export { exploreCertificateDer } from "./der-explorer.js";
|
||||||
|
|||||||
@ -192,6 +192,9 @@ function validateScope(scope, facts) {
|
|||||||
if (!authority.keyUsages.includes("keyCertSign")) {
|
if (!authority.keyUsages.includes("keyCertSign")) {
|
||||||
throw new TypeError("Authority trust scope targets a certificate without keyCertSign");
|
throw new TypeError("Authority trust scope targets a certificate without keyCertSign");
|
||||||
}
|
}
|
||||||
|
if (authority.unsupportedCriticalExtensions?.length > 0) {
|
||||||
|
throw new TypeError("Authority trust scope targets a certificate with unsupported critical extensions");
|
||||||
|
}
|
||||||
return scope;
|
return scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,6 +210,9 @@ function validateCertificateFacts(certificate) {
|
|||||||
if (!Array.isArray(certificate.keyUsages)) {
|
if (!Array.isArray(certificate.keyUsages)) {
|
||||||
throw new TypeError("certificate keyUsages must be an array");
|
throw new TypeError("certificate keyUsages must be an array");
|
||||||
}
|
}
|
||||||
|
if (certificate.unsupportedCriticalExtensions !== undefined && !Array.isArray(certificate.unsupportedCriticalExtensions)) {
|
||||||
|
throw new TypeError("certificate unsupportedCriticalExtensions must be an array");
|
||||||
|
}
|
||||||
if (typeof certificate.selfSigned !== "boolean") {
|
if (typeof certificate.selfSigned !== "boolean") {
|
||||||
throw new TypeError("certificate selfSigned must be Boolean");
|
throw new TypeError("certificate selfSigned must be Boolean");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|||||||
import { isIP } from "node:net";
|
import { isIP } from "node:net";
|
||||||
import { connect } from "node:tls";
|
import { connect } from "node:tls";
|
||||||
import { X509Certificate } from "node:crypto";
|
import { X509Certificate } from "node:crypto";
|
||||||
|
import { exploreCertificateDer } from "./der-explorer.js";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@ -149,6 +150,7 @@ function normalizeProbe(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function certificateFacts(certificate) {
|
function certificateFacts(certificate) {
|
||||||
|
const der = exploreCertificateDer(certificate.raw);
|
||||||
return {
|
return {
|
||||||
subject: certificate.subject,
|
subject: certificate.subject,
|
||||||
issuer: certificate.issuer,
|
issuer: certificate.issuer,
|
||||||
@ -158,22 +160,15 @@ function certificateFacts(certificate) {
|
|||||||
dnsNames: certificate.subjectAltName ? certificate.subjectAltName.split(", ") : [],
|
dnsNames: certificate.subjectAltName ? certificate.subjectAltName.split(", ") : [],
|
||||||
validFrom: new Date(certificate.validFrom).toISOString(),
|
validFrom: new Date(certificate.validFrom).toISOString(),
|
||||||
validUntil: new Date(certificate.validTo).toISOString(),
|
validUntil: new Date(certificate.validTo).toISOString(),
|
||||||
isCa: certificate.ca,
|
isCa: der.derived.isCa,
|
||||||
keyUsages: normalizeKeyUsages(certificate.keyUsage),
|
keyUsages: der.derived.keyUsages,
|
||||||
|
unsupportedCriticalExtensions: der.derived.unsupportedCriticalExtensions,
|
||||||
selfSigned: certificate.checkIssued(certificate) && certificate.verify(certificate.publicKey),
|
selfSigned: certificate.checkIssued(certificate) && certificate.verify(certificate.publicKey),
|
||||||
|
derBase64: Buffer.from(certificate.raw).toString("base64"),
|
||||||
|
der,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKeyUsages(usages = []) {
|
|
||||||
const mapping = new Map([
|
|
||||||
["Digital Signature", "digitalSignature"],
|
|
||||||
["Key Encipherment", "keyEncipherment"],
|
|
||||||
["Certificate Sign", "keyCertSign"],
|
|
||||||
["CRL Sign", "crlSign"],
|
|
||||||
]);
|
|
||||||
return usages.map((usage) => mapping.get(usage)).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapAuthorizationError(error) {
|
function mapAuthorizationError(error) {
|
||||||
const code = String(error ?? "UNTRUSTED_ISSUER");
|
const code = String(error ?? "UNTRUSTED_ISSUER");
|
||||||
const table = {
|
const table = {
|
||||||
|
|||||||
51
trustlab/test/der-explorer.test.js
Normal file
51
trustlab/test/der-explorer.test.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { exploreCertificateDer } from "../src/der-explorer.js";
|
||||||
|
|
||||||
|
test("DER explorer extracts Basic Constraints and Key Usage with offsets", () => {
|
||||||
|
const basicConstraints = extension("551d13", "30030101ff");
|
||||||
|
const keyUsage = extension("551d0f", "03020106", true);
|
||||||
|
const extensions = tlv("30", basicConstraints + keyUsage);
|
||||||
|
const tbs = tlv("30", tlv("a3", extensions));
|
||||||
|
const certificate = bytes(tlv("30", tbs));
|
||||||
|
|
||||||
|
const result = exploreCertificateDer(certificate);
|
||||||
|
assert.equal(result.byteLength, certificate.length);
|
||||||
|
assert.equal(result.derived.isCa, true);
|
||||||
|
assert.deepEqual(result.derived.keyUsages, ["keyCertSign", "crlSign"]);
|
||||||
|
assert.equal(result.extensions[1].critical, true);
|
||||||
|
assert.deepEqual(result.derived.unsupportedCriticalExtensions, []);
|
||||||
|
assert.ok(result.extensions.every((item) => item.offset < item.valueOffset));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DER explorer rejects indefinite, truncated, and excessively nested input", () => {
|
||||||
|
assert.throws(() => exploreCertificateDer(bytes("30800000")), /Indefinite length/);
|
||||||
|
assert.throws(() => exploreCertificateDer(bytes("300301")), /exceeds its container/);
|
||||||
|
let nested = "0500";
|
||||||
|
for (let index = 0; index < 34; index++) nested = tlv("30", nested);
|
||||||
|
assert.throws(() => exploreCertificateDer(bytes(nested)), /nesting limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an understood name does not make an unsupported critical extension safe", () => {
|
||||||
|
const policies = extension("551d20", "3000", true);
|
||||||
|
const certificate = bytes(tlv("30", tlv("30", tlv("a3", tlv("30", policies)))));
|
||||||
|
const result = exploreCertificateDer(certificate);
|
||||||
|
|
||||||
|
assert.equal(result.extensions[0].name, "Certificate Policies");
|
||||||
|
assert.equal(result.extensions[0].known, true);
|
||||||
|
assert.equal(result.extensions[0].supported, false);
|
||||||
|
assert.deepEqual(result.derived.unsupportedCriticalExtensions, ["2.5.29.32"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function extension(oidHex, valueHex, critical = false) {
|
||||||
|
return tlv("30", tlv("06", oidHex) + (critical ? "0101ff" : "") + tlv("04", valueHex));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tlv(tag, value) {
|
||||||
|
const length = value.length / 2;
|
||||||
|
if (length >= 128) throw new Error("test helper supports short lengths only");
|
||||||
|
return `${tag}${length.toString(16).padStart(2, "0")}${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes(hex) { return Uint8Array.from(hex.match(/../g).map((pair) => Number.parseInt(pair, 16))); }
|
||||||
@ -333,6 +333,7 @@ function renderChain(result) {
|
|||||||
node("code", certificate.sha256 ?? "No fingerprint"),
|
node("code", certificate.sha256 ?? "No fingerprint"),
|
||||||
node("span", certificate.edge, "edge-label"),
|
node("span", certificate.edge, "edge-label"),
|
||||||
);
|
);
|
||||||
|
if (certificate.der) item.append(renderDerExplorer(certificate));
|
||||||
item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`);
|
item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`);
|
||||||
if (index < all.length - 1) item.dataset.linked = "true";
|
if (index < all.length - 1) item.dataset.linked = "true";
|
||||||
return item;
|
return item;
|
||||||
@ -340,6 +341,65 @@ function renderChain(result) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDerExplorer(certificate) {
|
||||||
|
const details = document.createElement("details");
|
||||||
|
details.className = "der-explorer";
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
summary.textContent = `Certificate internals · ${certificate.der.byteLength} DER bytes · ${certificate.der.extensions.length} extensions`;
|
||||||
|
details.append(summary);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "der-actions";
|
||||||
|
const download = document.createElement("a");
|
||||||
|
download.className = "button quiet";
|
||||||
|
download.textContent = "Save original .der";
|
||||||
|
download.download = `${certificate.sha256}.der`;
|
||||||
|
download.href = `data:application/pkix-cert;base64,${certificate.derBase64}`;
|
||||||
|
actions.append(download);
|
||||||
|
details.append(actions);
|
||||||
|
|
||||||
|
const derived = document.createElement("dl");
|
||||||
|
derived.className = "der-derived";
|
||||||
|
derived.append(
|
||||||
|
definition("Basic Constraints", certificate.der.derived.isCa
|
||||||
|
? `CA: true${certificate.der.derived.pathLength === undefined ? "" : `, path length: ${certificate.der.derived.pathLength}`}`
|
||||||
|
: "CA: false"),
|
||||||
|
definition("Key Usage", certificate.der.derived.keyUsages.join(", ") || "Not asserted"),
|
||||||
|
definition("Unsupported critical extensions", certificate.der.derived.unsupportedCriticalExtensions.join(", ") || "None"),
|
||||||
|
);
|
||||||
|
details.append(derived);
|
||||||
|
|
||||||
|
const extensions = document.createElement("div");
|
||||||
|
extensions.className = "der-extensions";
|
||||||
|
for (const extension of certificate.der.extensions) {
|
||||||
|
const row = document.createElement("article");
|
||||||
|
if (extension.critical) row.className = extension.supported ? "critical" : "critical unknown";
|
||||||
|
row.append(
|
||||||
|
node("strong", extension.name),
|
||||||
|
node("code", extension.oid),
|
||||||
|
node("span", `${extension.critical ? "critical" : "non-critical"} · bytes ${extension.offset}–${extension.offset + extension.length - 1}`),
|
||||||
|
node("pre", extension.decoded ? JSON.stringify(extension.decoded, null, 2) : extension.valueHex),
|
||||||
|
);
|
||||||
|
extensions.append(row);
|
||||||
|
}
|
||||||
|
details.append(extensions);
|
||||||
|
|
||||||
|
const tree = document.createElement("details");
|
||||||
|
tree.className = "der-tree";
|
||||||
|
tree.append(node("summary", "ASN.1 structure and byte ranges"), renderDerNode(certificate.der.tree));
|
||||||
|
details.append(tree);
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDerNode(item) {
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
const entry = document.createElement("li");
|
||||||
|
entry.append(node("code", `${item.type} · ${item.offset}–${item.endOffset - 1} · value ${item.valueLength} B`));
|
||||||
|
if (item.children.length) entry.append(...item.children.map(renderDerNode));
|
||||||
|
list.append(entry);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
function renderJournal(result) {
|
function renderJournal(result) {
|
||||||
if (result.journal.entries.length === 0) {
|
if (result.journal.entries.length === 0) {
|
||||||
elements.journal.replaceChildren(node("p", "No plugin findings.", "empty"));
|
elements.journal.replaceChildren(node("p", "No plugin findings.", "empty"));
|
||||||
@ -390,6 +450,7 @@ function renderTrustTargets(facts) {
|
|||||||
if (
|
if (
|
||||||
!certificate.isCa ||
|
!certificate.isCa ||
|
||||||
!certificate.keyUsages.includes("keyCertSign") ||
|
!certificate.keyUsages.includes("keyCertSign") ||
|
||||||
|
certificate.unsupportedCriticalExtensions?.length > 0 ||
|
||||||
seen.has(certificate.sha256)
|
seen.has(certificate.sha256)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -232,6 +232,25 @@ select {
|
|||||||
.edge-label { grid-column: 3; grid-row: 1 / span 2; align-self: center; color: var(--green); font-size: 0.78rem; }
|
.edge-label { grid-column: 3; grid-row: 1 / span 2; align-self: center; color: var(--green); font-size: 0.78rem; }
|
||||||
.failed .edge-label { color: var(--red); }
|
.failed .edge-label { color: var(--red); }
|
||||||
|
|
||||||
|
.der-explorer { grid-column: 1 / -1; margin-top: 0.8rem; border-top: 1px solid var(--line); padding-top: 0.8rem; }
|
||||||
|
.der-explorer > summary, .der-tree > summary { color: var(--cyan); cursor: pointer; font-size: 0.78rem; }
|
||||||
|
.der-actions { margin: 0.8rem 0; }
|
||||||
|
.der-actions a { display: inline-block; color: inherit; text-decoration: none; font-size: 0.72rem; }
|
||||||
|
.der-derived { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.7rem; margin: 0.8rem 0; }
|
||||||
|
.der-derived div { padding: 0.65rem; background: #0b151a; }
|
||||||
|
.der-derived dt { color: var(--ink-muted); font-size: 0.68rem; }
|
||||||
|
.der-derived dd { margin: 0.25rem 0 0; font-size: 0.76rem; overflow-wrap: anywhere; }
|
||||||
|
.der-extensions { display: grid; gap: 0.45rem; }
|
||||||
|
.der-extensions article { display: grid; grid-template-columns: minmax(10rem, 1fr) auto; gap: 0.25rem 0.8rem; padding: 0.65rem; border-left: 2px solid #46616c; background: #0b151a; }
|
||||||
|
.der-extensions article.critical { border-color: var(--amber); }
|
||||||
|
.der-extensions article.unknown { border-color: var(--red); }
|
||||||
|
.der-extensions span, .der-extensions pre { grid-column: 1 / -1; }
|
||||||
|
.der-extensions span { color: var(--ink-muted); font-size: 0.68rem; }
|
||||||
|
.der-extensions pre { max-height: 12rem; margin: 0.25rem 0 0; overflow: auto; color: #b8cad2; font-size: 0.68rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.der-tree { margin-top: 0.8rem; }
|
||||||
|
.der-tree ul { margin: 0.3rem 0 0; padding-left: 1.15rem; list-style: none; border-left: 1px solid #304751; }
|
||||||
|
.der-tree code { color: #9db1ba; font-size: 0.66rem; }
|
||||||
|
|
||||||
.journal { display: grid; gap: 0.65rem; }
|
.journal { display: grid; gap: 0.65rem; }
|
||||||
.journal-entry {
|
.journal-entry {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -293,6 +312,7 @@ select {
|
|||||||
.identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.certificate { grid-template-columns: 1fr; }
|
.certificate { grid-template-columns: 1fr; }
|
||||||
.certificate code, .edge-label { grid-column: 1; grid-row: auto; }
|
.certificate code, .edge-label { grid-column: 1; grid-row: auto; }
|
||||||
|
.der-derived { grid-template-columns: 1fr; }
|
||||||
.journal-entry { grid-template-columns: 4.5rem 1fr; }
|
.journal-entry { grid-template-columns: 4.5rem 1fr; }
|
||||||
.journal-entry code { display: none; }
|
.journal-entry code { display: none; }
|
||||||
.decision-bar { flex-wrap: wrap; }
|
.decision-bar { flex-wrap: wrap; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user