diff --git a/trustlab/README.md b/trustlab/README.md index 2be67fa..ac34996 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -93,6 +93,20 @@ certificate, recomputes every DER SHA-256 fingerprint, and rejects inconsistent CA, Key Usage, critical-extension, or fingerprint claims. Recorded decisions remain evidence in the bundle and are not silently installed into local policy. +## Explainable path construction + +Live probes build a candidate issuer graph independently of the order in which +OpenSSL returned certificates. Every possible certificate pair records issuer +and subject matching, signature verification, and AKI/SKI continuity. An edge +is accepted only when the issuer is a DER-confirmed CA, Key Usage permits +certificate signing, and no unsupported critical extension blocks its use. + +TrustLab enumerates every acyclic candidate path and labels it `trusted`, +`untrusted`, `incomplete`, or `invalid`. Terminal explanations retain both the +structural outcome and validation failures, including validity periods and CA +path-length constraints. Trust anchors name their provider; a path is never +presented as simply “trusted” without attribution. + 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/sdk/plugin-api.ts b/trustlab/sdk/plugin-api.ts index ed63015..d70c1ef 100644 --- a/trustlab/sdk/plugin-api.ts +++ b/trustlab/sdk/plugin-api.ts @@ -22,6 +22,9 @@ export interface CertificateFacts { readonly isCa: boolean; readonly keyUsages: readonly CertificateKeyUsage[]; readonly unsupportedCriticalExtensions?: readonly string[]; + readonly pathLengthConstraint?: number; + readonly subjectKeyIdentifier?: string; + readonly authorityKeyIdentifier?: string; readonly selfSigned: boolean; readonly derBase64?: string; readonly der?: Readonly>; diff --git a/trustlab/src/der-explorer.js b/trustlab/src/der-explorer.js index 0e357a3..30afeaa 100644 --- a/trustlab/src/der-explorer.js +++ b/trustlab/src/der-explorer.js @@ -21,7 +21,9 @@ 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"]); +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); @@ -44,6 +46,9 @@ export function exploreCertificateDer(input, limits = {}) { 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), @@ -142,10 +147,44 @@ 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; diff --git a/trustlab/src/index.js b/trustlab/src/index.js index 7295734..859c219 100644 --- a/trustlab/src/index.js +++ b/trustlab/src/index.js @@ -6,3 +6,4 @@ export { TrustRunner } from "./runner.js"; export { parseTlsTarget, probeTls } from "./tls-probe.js"; export { exploreCertificateDer } from "./der-explorer.js"; export { createInvestigationBundle, parseInvestigationBundle } from "./investigation-bundle.js"; +export { analyzeCertificatePaths } from "./path-analysis.js"; diff --git a/trustlab/src/investigation-bundle.js b/trustlab/src/investigation-bundle.js index 9f3c0c4..4094077 100644 --- a/trustlab/src/investigation-bundle.js +++ b/trustlab/src/investigation-bundle.js @@ -17,6 +17,7 @@ export function createInvestigationBundle(report, result, policySnapshot) { facts: report.facts, findings: report.findings ?? [], caveats: report.caveats ?? [], + pathAnalysis: report.pathAnalysis, }, decision: result ? { verdict: result.verdict, journal: result.journal } : undefined, policySnapshot: policySnapshot ?? undefined, @@ -75,6 +76,12 @@ async function normalizeEmbeddedDer(certificate) { isCa: explored.derived.isCa, keyUsages: explored.derived.keyUsages, unsupportedCriticalExtensions: explored.derived.unsupportedCriticalExtensions, + pathLengthConstraint: explored.derived.pathLength, + subjectKeyIdentifier: explored.derived.subjectKeyIdentifier, + authorityKeyIdentifier: explored.derived.authorityKeyIdentifier, + dnsNames: explored.derived.subjectAlternativeNames + .filter((name) => name.type === "dns") + .map((name) => name.value), der: explored, }; } diff --git a/trustlab/src/path-analysis.js b/trustlab/src/path-analysis.js new file mode 100644 index 0000000..1b08872 --- /dev/null +++ b/trustlab/src/path-analysis.js @@ -0,0 +1,84 @@ +export function analyzeCertificatePaths(certificates, relationships, options = {}) { + if (!Array.isArray(certificates) || certificates.length === 0) throw new TypeError("Path analysis requires a leaf certificate"); + const byFingerprint = new Map(certificates.map((certificate) => [certificate.sha256, certificate])); + const trustAnchors = new Map([ + ...(options.trustedCertificateSha256 ?? []).map((sha256) => [sha256, "configured trust anchor"]), + ...(options.trustAnchors ?? []).map((anchor) => [anchor.sha256, anchor.source]), + ]); + const edges = relationships.map((relationship, index) => explainEdge({ ...relationship, index }, byFingerprint)); + const acceptedByChild = new Map(); + for (const edge of edges.filter((item) => item.accepted)) { + const list = acceptedByChild.get(edge.childSha256) ?? []; + list.push(edge); + acceptedByChild.set(edge.childSha256, list); + } + const paths = []; + walk([certificates[0].sha256], [], paths, acceptedByChild, byFingerprint, trustAnchors, Date.parse(options.validationTime ?? new Date().toISOString())); + return { + leafCertificateSha256: certificates[0].sha256, + trustAnchors: [...trustAnchors].map(([sha256, source]) => ({ sha256, source })), + edges, + paths, + }; +} + +function explainEdge(relationship, certificates) { + const issuer = certificates.get(relationship.issuerSha256); + const reasons = []; + if (!relationship.issuerNameMatches) reasons.push("issuer-subject-name-mismatch"); + if (!relationship.signatureValid) reasons.push("signature-invalid"); + if (relationship.authorityKeyMatches === false) reasons.push("authority-key-identifier-mismatch"); + if (!issuer?.isCa) reasons.push("issuer-basic-constraints-not-ca"); + if (!issuer?.keyUsages.includes("keyCertSign")) reasons.push("issuer-key-usage-forbids-certificate-signing"); + if (issuer?.unsupportedCriticalExtensions?.length) reasons.push("issuer-has-unsupported-critical-extension"); + return { ...relationship, accepted: reasons.length === 0, reasons }; +} + +function walk(path, pathEdges, paths, acceptedByChild, certificates, trustAnchors, validationTime) { + const current = path.at(-1); + const certificate = certificates.get(current); + const validationFailures = validatePath(path, certificates, validationTime); + if (trustAnchors.has(current)) { + paths.push(validationFailures.length + ? { certificateSha256: path, edgeIndexes: pathEdges, status: "invalid", terminalReason: "path-validation-failed", structuralTerminalReason: "configured-trust-anchor", validationFailures, trustSource: trustAnchors.get(current) } + : { certificateSha256: path, edgeIndexes: pathEdges, status: "trusted", terminalReason: "configured-trust-anchor", validationFailures: [], trustSource: trustAnchors.get(current) }); + return; + } + if (certificate?.selfSigned) { + paths.push({ certificateSha256: path, edgeIndexes: pathEdges, status: validationFailures.length ? "invalid" : "untrusted", terminalReason: validationFailures.length ? "path-validation-failed" : "self-signed-certificate-is-not-a-configured-anchor", structuralTerminalReason: "self-signed-certificate-is-not-a-configured-anchor", validationFailures }); + return; + } + const outgoing = acceptedByChild.get(current) ?? []; + const acyclic = outgoing.filter((edge) => !path.includes(edge.issuerSha256)); + if (acyclic.length === 0) { + const structuralTerminalReason = outgoing.length ? "issuer-cycle" : "no-acceptable-issuer"; + paths.push({ certificateSha256: path, edgeIndexes: pathEdges, status: validationFailures.length ? "invalid" : "incomplete", terminalReason: validationFailures.length ? "path-validation-failed" : structuralTerminalReason, structuralTerminalReason, validationFailures }); + return; + } + for (const edge of acyclic) { + walk( + [...path, edge.issuerSha256], + [...pathEdges, edge.index], + paths, + acceptedByChild, + certificates, + trustAnchors, + validationTime, + ); + } +} + +function validatePath(path, certificates, validationTime) { + const failures = []; + path.forEach((fingerprint, index) => { + const certificate = certificates.get(fingerprint); + if (certificate.validFrom && Date.parse(certificate.validFrom) > validationTime) failures.push({ code: "not-yet-valid", certificateSha256: fingerprint }); + if (certificate.validUntil && Date.parse(certificate.validUntil) < validationTime) failures.push({ code: "expired", certificateSha256: fingerprint }); + if (certificate.unsupportedCriticalExtensions?.length) failures.push({ code: "unsupported-critical-extension", certificateSha256: fingerprint, oids: certificate.unsupportedCriticalExtensions }); + if (certificate.pathLengthConstraint !== undefined) { + const subordinateCaCount = path.slice(1, index).filter((sha256) => certificates.get(sha256)?.isCa).length; + if (subordinateCaCount > certificate.pathLengthConstraint) failures.push({ code: "path-length-constraint-exceeded", certificateSha256: fingerprint, permitted: certificate.pathLengthConstraint, observed: subordinateCaCount }); + } + }); + return failures; +} diff --git a/trustlab/src/tls-probe.js b/trustlab/src/tls-probe.js index 0775b3b..6f2b91f 100644 --- a/trustlab/src/tls-probe.js +++ b/trustlab/src/tls-probe.js @@ -3,6 +3,7 @@ import { isIP } from "node:net"; import { connect } from "node:tls"; import { X509Certificate } from "node:crypto"; import { exploreCertificateDer } from "./der-explorer.js"; +import { analyzeCertificatePaths } from "./path-analysis.js"; const DEFAULT_TIMEOUT_MS = 10_000; @@ -86,6 +87,7 @@ function certificateChain(peer) { function normalizeProbe(input) { const certificates = input.chain.map(certificateFacts); + const relationships = certificateRelationships(input.chain, certificates); const findings = []; const now = Date.now(); const leaf = input.chain[0]; @@ -124,6 +126,13 @@ function normalizeProbe(input) { const errors = [...new Set(findings.map((item) => item.code))]; return { observedAt: new Date().toISOString(), + pathAnalysis: analyzeCertificatePaths(certificates, relationships, { + trustAnchors: input.authorized ? [{ + sha256: certificates.at(-1).sha256, + source: "Node/OpenSSL conventional validation terminus", + }] : [], + validationTime: new Date().toISOString(), + }), facts: { schemaVersion: 0, connectionId: randomUUID(), @@ -158,11 +167,16 @@ function certificateFacts(certificate) { sha256: sha256(certificate.raw), spkiSha256: sha256(certificate.publicKey.export({ type: "spki", format: "der" })), serialNumber: certificate.serialNumber, - dnsNames: certificate.subjectAltName ? certificate.subjectAltName.split(", ") : [], + dnsNames: der.derived.subjectAlternativeNames + .filter((name) => name.type === "dns") + .map((name) => name.value), validFrom: new Date(certificate.validFrom).toISOString(), validUntil: new Date(certificate.validTo).toISOString(), isCa: der.derived.isCa, keyUsages: der.derived.keyUsages, + pathLengthConstraint: der.derived.pathLength, + subjectKeyIdentifier: der.derived.subjectKeyIdentifier, + authorityKeyIdentifier: der.derived.authorityKeyIdentifier, unsupportedCriticalExtensions: der.derived.unsupportedCriticalExtensions, selfSigned: certificate.checkIssued(certificate) && certificate.verify(certificate.publicKey), derBase64: Buffer.from(certificate.raw).toString("base64"), @@ -170,6 +184,29 @@ function certificateFacts(certificate) { }; } +function certificateRelationships(chain, facts) { + const relationships = []; + chain.forEach((child, childIndex) => { + chain.forEach((issuer, issuerIndex) => { + if (childIndex === issuerIndex) return; + const authorityKeyIdentifier = facts[childIndex].authorityKeyIdentifier; + const subjectKeyIdentifier = facts[issuerIndex].subjectKeyIdentifier; + let signatureValid = false; + try { signatureValid = child.verify(issuer.publicKey); } catch {} + relationships.push({ + childSha256: facts[childIndex].sha256, + issuerSha256: facts[issuerIndex].sha256, + issuerNameMatches: child.issuer === issuer.subject, + signatureValid, + authorityKeyMatches: authorityKeyIdentifier && subjectKeyIdentifier + ? authorityKeyIdentifier === subjectKeyIdentifier + : undefined, + }); + }); + }); + return relationships; +} + 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 index e51b0fe..ec8144c 100644 --- a/trustlab/test/der-explorer.test.js +++ b/trustlab/test/der-explorer.test.js @@ -38,6 +38,20 @@ test("an understood name does not make an unsupported critical extension safe", assert.deepEqual(result.derived.unsupportedCriticalExtensions, ["2.5.29.32"]); }); +test("DER explorer decodes SAN and Authority Key Identifier relationships", () => { + const sanNames = tlv("30", "820b6578616d706c652e636f6d8704c0000201"); + const authorityKey = tlv("30", "800401020304"); + const extensions = tlv("30", extension("551d11", sanNames) + extension("551d23", authorityKey)); + const certificate = bytes(tlv("30", tlv("30", tlv("a3", extensions)))); + const result = exploreCertificateDer(certificate); + + assert.deepEqual(result.derived.subjectAlternativeNames, [ + { type: "dns", value: "example.com" }, + { type: "ip", value: "192.0.2.1", valueHex: "c0000201" }, + ]); + assert.equal(result.derived.authorityKeyIdentifier, "01020304"); +}); + function extension(oidHex, valueHex, critical = false) { return tlv("30", tlv("06", oidHex) + (critical ? "0101ff" : "") + tlv("04", valueHex)); } diff --git a/trustlab/test/path-analysis.test.js b/trustlab/test/path-analysis.test.js new file mode 100644 index 0000000..7b50dd5 --- /dev/null +++ b/trustlab/test/path-analysis.test.js @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { analyzeCertificatePaths } from "../src/path-analysis.js"; + +test("path analysis enumerates two valid issuer alternatives with attributed trust", () => { + const certificates = [cert("leaf", false), cert("intermediate-a"), cert("intermediate-b"), cert("root-a", true), cert("root-b", true)]; + const relationships = [ + link("leaf", "intermediate-a"), link("leaf", "intermediate-b"), + link("intermediate-a", "root-a"), link("intermediate-b", "root-b"), + ]; + const result = analyzeCertificatePaths(certificates, relationships, { + trustedCertificateSha256: ["root-a"], + }); + + assert.equal(result.paths.length, 2); + assert.equal(result.paths.find((path) => path.certificateSha256.includes("root-a")).status, "trusted"); + assert.equal(result.paths.find((path) => path.certificateSha256.includes("root-b")).status, "untrusted"); +}); + +test("path analysis explains rejected and missing issuer paths", () => { + const certificates = [cert("leaf", false), cert("wrong-issuer")]; + const relationships = [{ ...link("leaf", "wrong-issuer"), signatureValid: false }]; + const result = analyzeCertificatePaths(certificates, relationships); + + assert.equal(result.paths[0].status, "incomplete"); + assert.equal(result.paths[0].terminalReason, "no-acceptable-issuer"); + assert.deepEqual(result.edges[0].reasons, ["signature-invalid"]); +}); + +test("path analysis rejects issuer certificates that cannot sign certificates", () => { + const issuer = { ...cert("issuer"), keyUsages: ["digitalSignature"] }; + const result = analyzeCertificatePaths([cert("leaf", false), issuer], [link("leaf", "issuer")]); + assert.equal(result.edges[0].accepted, false); + assert.match(result.edges[0].reasons.join(" "), /key-usage/); +}); + +test("path validation explains an exceeded CA path-length constraint", () => { + const intermediate = cert("intermediate"); + const root = { ...cert("root", true), pathLengthConstraint: 0 }; + const result = analyzeCertificatePaths( + [cert("leaf", false), intermediate, root], + [link("leaf", "intermediate"), link("intermediate", "root")], + { trustedCertificateSha256: ["root"] }, + ); + assert.equal(result.paths[0].status, "invalid"); + assert.deepEqual(result.paths[0].validationFailures.map((failure) => failure.code), ["path-length-constraint-exceeded"]); +}); + +function cert(sha256, selfSigned = false) { + return { sha256, subject: sha256, isCa: sha256 !== "leaf", keyUsages: ["keyCertSign"], selfSigned, unsupportedCriticalExtensions: [] }; +} +function link(childSha256, issuerSha256) { + return { childSha256, issuerSha256, issuerNameMatches: true, signatureValid: true, authorityKeyMatches: true }; +} diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js index fff1f03..c1b5460 100644 --- a/trustlab/ui/app.js +++ b/trustlab/ui/app.js @@ -85,6 +85,8 @@ const elements = { status: document.querySelector("#status"), identity: document.querySelector("#identity"), chain: document.querySelector("#chain"), + pathPanel: document.querySelector("#path-panel"), + paths: document.querySelector("#paths"), journal: document.querySelector("#journal"), rules: document.querySelector("#rules"), trust: document.querySelector("#trust"), @@ -154,6 +156,7 @@ elements.importBundle.addEventListener("change", async () => { facts: bundle.evidence.facts, findings: bundle.evidence.findings, caveats: bundle.evidence.caveats, + pathAnalysis: bundle.evidence.pathAnalysis, }; policyOverlay.clear(); activateReport(report, "offline"); @@ -246,6 +249,7 @@ async function render() { renderStatus(result, decisionApplied || decidedByOverlay); renderIdentity(result); renderChain(result); + renderPathAnalysis(state.scenario === "live" ? state.liveReport?.pathAnalysis : undefined, facts); renderJournal(result); renderRules(policyOverlay.snapshot()); elements.clear.hidden = policyOverlay.snapshot().rules.length === 0; @@ -255,6 +259,47 @@ async function render() { elements.exportBundle.hidden = state.scenario !== "live"; } +function renderPathAnalysis(analysis, facts) { + elements.pathPanel.hidden = !analysis; + if (!analysis) return; + const certificates = new Map( + [...facts.presentedChain, ...facts.constructedChain].map((certificate) => [certificate.sha256, certificate]), + ); + const pathCards = analysis.paths.map((path, index) => { + const article = document.createElement("article"); + article.className = `path-card status-${path.status}`; + article.append( + node("span", path.status, "entry-kind"), + node("h3", `Candidate path ${index + 1}`), + node("p", path.certificateSha256 + .map((fingerprint) => subjectName(certificates.get(fingerprint)?.subject)) + .join(" → ")), + node("code", [formatCode(path.terminalReason), path.structuralTerminalReason && path.structuralTerminalReason !== path.terminalReason ? `ends at: ${formatCode(path.structuralTerminalReason)}` : undefined, path.trustSource].filter(Boolean).join(" · ")), + ); + if (path.validationFailures?.length) { + article.append(node("p", `Validation: ${path.validationFailures.map((failure) => formatCode(failure.code)).join("; ")}`)); + } + return article; + }); + const rejected = analysis.edges.filter((edge) => + !edge.accepted && (edge.issuerNameMatches || edge.authorityKeyMatches === true || edge.signatureValid), + ); + if (rejected.length) { + const heading = node("h3", "Rejected issuer edges", "path-subheading"); + pathCards.push(heading, ...rejected.map((edge) => { + const article = document.createElement("article"); + article.className = "path-card status-rejected"; + article.append( + node("span", "rejected", "entry-kind"), + node("h3", `${subjectName(certificates.get(edge.childSha256)?.subject)} → ${subjectName(certificates.get(edge.issuerSha256)?.subject)}`), + node("p", edge.reasons.map(formatCode).join("; ")), + ); + return article; + })); + } + elements.paths.replaceChildren(...pathCards); +} + function activateReport(report, source) { state.liveReport = report; state.source = source; diff --git a/trustlab/ui/index.html b/trustlab/ui/index.html index 871ebd2..3b54f37 100644 --- a/trustlab/ui/index.html +++ b/trustlab/ui/index.html @@ -86,6 +86,14 @@
    + +

    Append-only journal

    diff --git a/trustlab/ui/styles.css b/trustlab/ui/styles.css index 7bf4b90..83b9a95 100644 --- a/trustlab/ui/styles.css +++ b/trustlab/ui/styles.css @@ -296,6 +296,16 @@ select { .entry-kind { color: var(--cyan); font: 700 0.62rem ui-monospace, monospace; text-transform: uppercase; } .empty { color: var(--ink-muted); } +.path-analysis { display: grid; gap: 0.65rem; } +.path-card { display: grid; grid-template-columns: 5rem 1fr auto; gap: 0.2rem 0.8rem; padding: 0.9rem 1rem; border-left: 3px solid var(--amber); background: #0c161b; } +.path-card.status-trusted { border-color: var(--green); } +.path-card.status-untrusted, .path-card.status-rejected { border-color: var(--red); } +.path-card h3, .path-card p { margin: 0; } +.path-card h3 { font-size: 0.86rem; } +.path-card p { grid-column: 2 / -1; color: var(--ink-muted); font-size: 0.78rem; overflow-wrap: anywhere; } +.path-card code { color: #78919b; font-size: 0.68rem; } +.path-subheading { margin: 1rem 0 0.2rem; font-size: 0.85rem; } + .decision-bar { position: fixed; z-index: 20;