Add bounded DER certificate explorer

This commit is contained in:
Sergey Chernov 2026-08-16 20:42:11 +04:00
parent be751f4b51
commit 216afb6c18
10 changed files with 384 additions and 12 deletions

View File

@ -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

View File

@ -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.");

View File

@ -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 =

View 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;
}

View File

@ -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";

View File

@ -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");
} }

View File

@ -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 = {

View 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))); }

View File

@ -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;

View File

@ -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; }