From 216afb6c18836939b9557588ad6a6b5fe87dd9fd Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 16 Aug 2026 20:42:11 +0400 Subject: [PATCH] Add bounded DER certificate explorer --- trustlab/README.md | 6 + trustlab/examples/probe.js | 1 + trustlab/sdk/plugin-api.ts | 3 + trustlab/src/der-explorer.js | 228 +++++++++++++++++++++++++++++ trustlab/src/index.js | 1 + trustlab/src/protocol.js | 6 + trustlab/src/tls-probe.js | 19 +-- trustlab/test/der-explorer.test.js | 51 +++++++ trustlab/ui/app.js | 61 ++++++++ trustlab/ui/styles.css | 20 +++ 10 files changed, 384 insertions(+), 12 deletions(-) create mode 100644 trustlab/src/der-explorer.js create mode 100644 trustlab/test/der-explorer.test.js diff --git a/trustlab/README.md b/trustlab/README.md index 65c8cb6..ba12c55 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -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 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 `authorityCertificateSha256`. TrustLab verifies that it appears in the active chain, has CA Basic Constraints in the supplied facts, and permits diff --git a/trustlab/examples/probe.js b/trustlab/examples/probe.js index 0710810..5e8bdae 100644 --- a/trustlab/examples/probe.js +++ b/trustlab/examples/probe.js @@ -28,6 +28,7 @@ function printReport({ facts, findings, caveats }) { console.log(`${index + 1}. ${certificate.subject}`); console.log(` SHA-256 ${certificate.sha256}`); 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:"); if (findings.length === 0) console.log("- No failure found by the current checks."); diff --git a/trustlab/sdk/plugin-api.ts b/trustlab/sdk/plugin-api.ts index 853a466..ed63015 100644 --- a/trustlab/sdk/plugin-api.ts +++ b/trustlab/sdk/plugin-api.ts @@ -21,7 +21,10 @@ export interface CertificateFacts { readonly validUntil?: string; readonly isCa: boolean; readonly keyUsages: readonly CertificateKeyUsage[]; + readonly unsupportedCriticalExtensions?: readonly string[]; readonly selfSigned: boolean; + readonly derBase64?: string; + readonly der?: Readonly>; } export type CertificateKeyUsage = diff --git a/trustlab/src/der-explorer.js b/trustlab/src/der-explorer.js new file mode 100644 index 0000000..0e357a3 --- /dev/null +++ b/trustlab/src/der-explorer.js @@ -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; +} diff --git a/trustlab/src/index.js b/trustlab/src/index.js index 699ca23..bb4f69d 100644 --- a/trustlab/src/index.js +++ b/trustlab/src/index.js @@ -4,3 +4,4 @@ export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js"; export { TrustPolicyOverlay } from "./policy-overlay.js"; export { TrustRunner } from "./runner.js"; export { parseTlsTarget, probeTls } from "./tls-probe.js"; +export { exploreCertificateDer } from "./der-explorer.js"; diff --git a/trustlab/src/protocol.js b/trustlab/src/protocol.js index 5bd9f38..79119f9 100644 --- a/trustlab/src/protocol.js +++ b/trustlab/src/protocol.js @@ -192,6 +192,9 @@ function validateScope(scope, facts) { if (!authority.keyUsages.includes("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; } @@ -207,6 +210,9 @@ function validateCertificateFacts(certificate) { if (!Array.isArray(certificate.keyUsages)) { 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") { throw new TypeError("certificate selfSigned must be Boolean"); } diff --git a/trustlab/src/tls-probe.js b/trustlab/src/tls-probe.js index 6f0db1e..9545421 100644 --- a/trustlab/src/tls-probe.js +++ b/trustlab/src/tls-probe.js @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { isIP } from "node:net"; import { connect } from "node:tls"; import { X509Certificate } from "node:crypto"; +import { exploreCertificateDer } from "./der-explorer.js"; const DEFAULT_TIMEOUT_MS = 10_000; @@ -149,6 +150,7 @@ function normalizeProbe(input) { } function certificateFacts(certificate) { + const der = exploreCertificateDer(certificate.raw); return { subject: certificate.subject, issuer: certificate.issuer, @@ -158,22 +160,15 @@ function certificateFacts(certificate) { dnsNames: certificate.subjectAltName ? certificate.subjectAltName.split(", ") : [], validFrom: new Date(certificate.validFrom).toISOString(), validUntil: new Date(certificate.validTo).toISOString(), - isCa: certificate.ca, - keyUsages: normalizeKeyUsages(certificate.keyUsage), + isCa: der.derived.isCa, + keyUsages: der.derived.keyUsages, + unsupportedCriticalExtensions: der.derived.unsupportedCriticalExtensions, 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) { const code = String(error ?? "UNTRUSTED_ISSUER"); const table = { diff --git a/trustlab/test/der-explorer.test.js b/trustlab/test/der-explorer.test.js new file mode 100644 index 0000000..e51b0fe --- /dev/null +++ b/trustlab/test/der-explorer.test.js @@ -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))); } diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js index 371d730..97d734d 100644 --- a/trustlab/ui/app.js +++ b/trustlab/ui/app.js @@ -333,6 +333,7 @@ function renderChain(result) { node("code", certificate.sha256 ?? "No fingerprint"), node("span", certificate.edge, "edge-label"), ); + if (certificate.der) item.append(renderDerExplorer(certificate)); item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`); if (index < all.length - 1) item.dataset.linked = "true"; 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) { if (result.journal.entries.length === 0) { elements.journal.replaceChildren(node("p", "No plugin findings.", "empty")); @@ -390,6 +450,7 @@ function renderTrustTargets(facts) { if ( !certificate.isCa || !certificate.keyUsages.includes("keyCertSign") || + certificate.unsupportedCriticalExtensions?.length > 0 || seen.has(certificate.sha256) ) { continue; diff --git a/trustlab/ui/styles.css b/trustlab/ui/styles.css index d222c16..bcc7b7b 100644 --- a/trustlab/ui/styles.css +++ b/trustlab/ui/styles.css @@ -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; } .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-entry { display: grid; @@ -293,6 +312,7 @@ select { .identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .certificate { grid-template-columns: 1fr; } .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 code { display: none; } .decision-bar { flex-wrap: wrap; }