Add synchronized DER view and investigation bundles

This commit is contained in:
Sergey Chernov 2026-08-16 23:42:04 +04:00
parent 216afb6c18
commit f5e4ece6e7
10 changed files with 372 additions and 31 deletions

View File

@ -80,6 +80,19 @@ It preserves the original certificate for offline export, lists every extension
status and Key Usage directly from the signed certificate encoding. Parsing is status and Key Usage directly from the signed certificate encoding. Parsing is
dependency-free and guarded by input-size, node-count, and nesting limits. dependency-free and guarded by input-size, node-count, and nesting limits.
The deeper explorer synchronizes the ASN.1 structure with a hexadecimal view:
selecting a field highlights its complete encoded byte range, while selecting a
byte resolves to the narrowest enclosing ASN.1 node.
Live investigations can be exported as versioned
`.browsec-investigation.json` bundles and reopened offline. A bundle contains
the original public DER certificates, normalized facts, findings, journal,
verdict, and policy snapshot, together with an explicit no-private-keys and
no-session-secrets declaration. Import is limited to 10 MiB; it reparses every
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.
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

@ -5,3 +5,4 @@ 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"; export { exploreCertificateDer } from "./der-explorer.js";
export { createInvestigationBundle, parseInvestigationBundle } from "./investigation-bundle.js";

View File

@ -0,0 +1,88 @@
import { exploreCertificateDer } from "./der-explorer.js";
import { createTlsFacts } from "./protocol.js";
export const INVESTIGATION_FORMAT = "org.browsec.investigation";
export const INVESTIGATION_VERSION = 1;
export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
export function createInvestigationBundle(report, result, policySnapshot) {
if (!report?.facts) throw new TypeError("Investigation report must contain TLS facts");
return Object.freeze({
format: INVESTIGATION_FORMAT,
version: INVESTIGATION_VERSION,
createdAt: new Date().toISOString(),
tool: { name: "Browsec TrustLab", engine: "Velvet Hammer", version: "0.1.0" },
evidence: {
observedAt: report.observedAt,
facts: report.facts,
findings: report.findings ?? [],
caveats: report.caveats ?? [],
},
decision: result ? { verdict: result.verdict, journal: result.journal } : undefined,
policySnapshot: policySnapshot ?? undefined,
privacy: {
containsPrivateKeys: false,
containsTlsSessionSecrets: false,
statement: "This bundle contains public certificates and diagnostic metadata only.",
},
});
}
export async function parseInvestigationBundle(text) {
if (typeof text !== "string") throw new TypeError("Investigation bundle must be JSON text");
if (new TextEncoder().encode(text).length > MAX_BUNDLE_BYTES) throw new RangeError("Investigation bundle exceeds 10 MiB");
const bundle = JSON.parse(text);
if (bundle?.format !== INVESTIGATION_FORMAT || bundle?.version !== INVESTIGATION_VERSION) {
throw new TypeError("Unsupported Browsec investigation bundle");
}
if (bundle.privacy?.containsPrivateKeys !== false || bundle.privacy?.containsTlsSessionSecrets !== false) {
throw new TypeError("Investigation bundle does not carry the required no-secrets declaration");
}
const suppliedFacts = bundle.evidence?.facts;
const facts = createTlsFacts({
...suppliedFacts,
presentedChain: await Promise.all((suppliedFacts?.presentedChain ?? []).map(normalizeEmbeddedDer)),
constructedChain: await Promise.all((suppliedFacts?.constructedChain ?? []).map(normalizeEmbeddedDer)),
});
if (!Array.isArray(bundle.evidence?.findings) || !Array.isArray(bundle.evidence?.caveats)) {
throw new TypeError("Investigation evidence lists are malformed");
}
return {
...bundle,
evidence: { ...bundle.evidence, facts },
};
}
async function normalizeEmbeddedDer(certificate) {
if (typeof certificate.derBase64 !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(certificate.derBase64)) {
throw new TypeError("Every bundled certificate must contain original DER bytes");
}
const bytes = decodeBase64(certificate.derBase64);
const explored = exploreCertificateDer(bytes);
const digest = await crypto.subtle.digest("SHA-256", bytes);
const sha256 = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
if (sha256 !== certificate.sha256) throw new TypeError("Bundled certificate fingerprint does not match its DER bytes");
if (explored.byteLength !== certificate.der?.byteLength) throw new TypeError("Bundled DER metadata does not match its bytes");
if (explored.derived.isCa !== certificate.isCa) throw new TypeError("Bundled CA assertion does not match its DER bytes");
if (JSON.stringify(explored.derived.keyUsages) !== JSON.stringify(certificate.keyUsages)) {
throw new TypeError("Bundled Key Usage does not match its DER bytes");
}
if (JSON.stringify(explored.derived.unsupportedCriticalExtensions) !== JSON.stringify(certificate.unsupportedCriticalExtensions ?? [])) {
throw new TypeError("Bundled critical-extension assertion does not match its DER bytes");
}
return {
...certificate,
isCa: explored.derived.isCa,
keyUsages: explored.derived.keyUsages,
unsupportedCriticalExtensions: explored.derived.unsupportedCriticalExtensions,
der: explored,
};
}
function decodeBase64(value) {
if (typeof atob === "function") {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
return Uint8Array.from(Buffer.from(value, "base64"));
}

View File

@ -123,6 +123,7 @@ function normalizeProbe(input) {
const errors = [...new Set(findings.map((item) => item.code))]; const errors = [...new Set(findings.map((item) => item.code))];
return { return {
observedAt: new Date().toISOString(),
facts: { facts: {
schemaVersion: 0, schemaVersion: 0,
connectionId: randomUUID(), connectionId: randomUUID(),

View File

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { createInvestigationBundle, parseInvestigationBundle } from "../src/investigation-bundle.js";
import { exploreCertificateDer } from "../src/der-explorer.js";
test("investigation bundles round-trip original DER and normalized evidence", async () => {
const derBytes = bytes("30023000");
const der = exploreCertificateDer(derBytes);
const certificate = {
subject: "CN=Offline Test",
sha256: sha256(derBytes),
isCa: false,
keyUsages: [],
selfSigned: false,
derBase64: Buffer.from(derBytes).toString("base64"),
der,
};
const report = {
observedAt: "2026-08-16T00:00:00.000Z",
facts: {
connectionId: "offline-test",
hostname: "offline.test",
port: 443,
validation: "failure",
errors: ["unknown-issuer"],
failure: { code: "unknown-issuer", check: "trust-anchor", summary: "Unknown", certificateSha256: certificate.sha256 },
presentedChain: [certificate],
constructedChain: [certificate],
tls: {},
},
findings: [],
caveats: [],
};
const bundle = createInvestigationBundle(report);
const parsed = await parseInvestigationBundle(JSON.stringify(bundle));
assert.equal(parsed.evidence.facts.hostname, "offline.test");
assert.equal(parsed.evidence.facts.presentedChain[0].derBase64, certificate.derBase64);
assert.equal(parsed.privacy.containsPrivateKeys, false);
});
test("bundle import rejects altered DER-derived authority facts and secret-bearing declarations", async () => {
const derBytes = bytes("30023000");
const der = exploreCertificateDer(derBytes);
const base = {
format: "org.browsec.investigation",
version: 1,
privacy: { containsPrivateKeys: false, containsTlsSessionSecrets: false },
evidence: {
facts: {
connectionId: "tampered", hostname: "offline.test", port: 443, validation: "failure", errors: [],
presentedChain: [{ subject: "x", sha256: sha256(derBytes), isCa: true, keyUsages: [], selfSigned: false, derBase64: Buffer.from(derBytes).toString("base64"), der }],
constructedChain: [], tls: {},
},
findings: [], caveats: [],
},
};
await assert.rejects(parseInvestigationBundle(JSON.stringify(base)), /CA assertion/);
base.privacy.containsPrivateKeys = true;
await assert.rejects(parseInvestigationBundle(JSON.stringify(base)), /no-secrets declaration/);
});
function bytes(hex) { return Uint8Array.from(hex.match(/../g).map((pair) => Number.parseInt(pair, 16))); }
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }

View File

@ -15,7 +15,7 @@ import {
createVillageCommunityPlugin, createVillageCommunityPlugin,
} from "../plugins/demo-plugins.js"; } from "../plugins/demo-plugins.js";
import { TrustRunner } from "../src/index.js"; import { TrustRunner } from "../src/index.js";
import { chainRows, subjectName, verdictCopy } from "../ui/model.js"; import { chainRows, flattenDerTree, smallestDerNodeAt, subjectName, verdictCopy } from "../ui/model.js";
test("UI model identifies the failed end of an unknown-authority chain", () => { test("UI model identifies the failed end of an unknown-authority chain", () => {
const rows = chainRows(unknownLocalAuthority); const rows = chainRows(unknownLocalAuthority);
@ -46,6 +46,19 @@ test("certificate display names prefer the common name", () => {
assert.equal(subjectName("O=Nameless"), "O=Nameless"); assert.equal(subjectName("O=Nameless"), "O=Nameless");
}); });
test("DER byte selection resolves to the narrowest ASN.1 node", () => {
const tree = {
type: "SEQUENCE", offset: 0, endOffset: 12, children: [
{ type: "INTEGER", offset: 2, endOffset: 5, children: [] },
{ type: "OCTET STRING", offset: 5, endOffset: 12, children: [] },
],
};
assert.deepEqual(flattenDerTree(tree).map((item) => item.depth), [0, 1, 1]);
assert.equal(smallestDerNodeAt(tree, 3).type, "INTEGER");
assert.equal(smallestDerNodeAt(tree, 8).type, "OCTET STRING");
assert.equal(smallestDerNodeAt(tree, 12), undefined);
});
test("UI plugins produce attributed evidence and a local Boolean verdict", async () => { test("UI plugins produce attributed evidence and a local Boolean verdict", async () => {
const plugins = [ const plugins = [
configure(createFirefoxValidationPlugin(), "advisor"), configure(createFirefoxValidationPlugin(), "advisor"),
@ -98,6 +111,8 @@ test("security surface contains immutable-frame and simulation labels", async ()
assert.match(html, /TRUSTLAB · SYNTHETIC/); assert.match(html, /TRUSTLAB · SYNTHETIC/);
assert.match(html, /cannot alter browser or system trust/); assert.match(html, /cannot alter browser or system trust/);
assert.match(html, /Live TLS target/); assert.match(html, /Live TLS target/);
assert.match(html, /Export investigation/);
assert.match(html, /Import investigation/);
assert.match(html, /Decision target/); assert.match(html, /Decision target/);
assert.match(html, /Effective and consumed local rules/); assert.match(html, /Effective and consumed local rules/);
}); });

View File

@ -14,7 +14,8 @@ import {
} from "../plugins/demo-plugins.js"; } from "../plugins/demo-plugins.js";
import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.js"; import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.js";
import { TrustPolicyOverlay, TrustRunner } from "../src/index.js"; import { TrustPolicyOverlay, TrustRunner } from "../src/index.js";
import { chainRows, subjectName, verdictCopy } from "./model.js"; import { createInvestigationBundle, MAX_BUNDLE_BYTES, parseInvestigationBundle } from "../src/investigation-bundle.js";
import { chainRows, flattenDerTree, smallestDerNodeAt, subjectName, verdictCopy } from "./model.js";
const scenarios = { const scenarios = {
"unknown-local": { "unknown-local": {
@ -62,6 +63,8 @@ const state = {
pendingDecision: undefined, pendingDecision: undefined,
communityEnabled: true, communityEnabled: true,
liveReport: undefined, liveReport: undefined,
lastResult: undefined,
source: "synthetic",
}; };
const policyOverlay = new TrustPolicyOverlay(); const policyOverlay = new TrustPolicyOverlay();
@ -72,6 +75,8 @@ const elements = {
probeTarget: document.querySelector("#probe-target"), probeTarget: document.querySelector("#probe-target"),
probeSubmit: document.querySelector("#probe-submit"), probeSubmit: document.querySelector("#probe-submit"),
probeMessage: document.querySelector("#probe-message"), probeMessage: document.querySelector("#probe-message"),
exportBundle: document.querySelector("#export-bundle"),
importBundle: document.querySelector("#import-bundle"),
frameSeal: document.querySelector("#frame-seal"), frameSeal: document.querySelector("#frame-seal"),
community: document.querySelector("#community-enabled"), community: document.querySelector("#community-enabled"),
target: document.querySelector("#trust-target"), target: document.querySelector("#trust-target"),
@ -113,21 +118,7 @@ elements.probeForm.addEventListener("submit", async (event) => {
}); });
const report = await response.json(); const report = await response.json();
if (!response.ok) throw new Error(report.error ?? `Probe failed (${response.status})`); if (!response.ok) throw new Error(report.error ?? `Probe failed (${response.status})`);
state.liveReport = report; activateReport(report, "live");
scenarios.live = {
label: `Live: ${report.facts.hostname}:${report.facts.port}`,
facts: report.facts,
probePlugins: [configure(createProbeEvidencePlugin(report), "advisor")],
};
let option = [...elements.scenario.options].find((item) => item.value === "live");
if (!option) {
option = new Option(scenarios.live.label, "live");
elements.scenario.prepend(option);
} else option.textContent = scenarios.live.label;
state.scenario = "live";
elements.scenario.value = "live";
state.pendingDecision = undefined;
renderTrustTargets(report.facts);
elements.probeMessage.textContent = `Received ${report.facts.presentedChain.length} certificate(s).`; elements.probeMessage.textContent = `Received ${report.facts.presentedChain.length} certificate(s).`;
await render(); await render();
} catch (error) { } catch (error) {
@ -137,6 +128,45 @@ elements.probeForm.addEventListener("submit", async (event) => {
elements.probeSubmit.disabled = false; elements.probeSubmit.disabled = false;
} }
}); });
elements.exportBundle.addEventListener("click", () => {
if (!state.liveReport) return;
const bundle = createInvestigationBundle(
state.liveReport,
state.lastResult,
policyOverlay.snapshot(),
);
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${state.liveReport.facts.hostname}-${Date.now()}.browsec-investigation.json`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
});
elements.importBundle.addEventListener("change", async () => {
const file = elements.importBundle.files?.[0];
if (!file) return;
try {
if (file.size > MAX_BUNDLE_BYTES) throw new RangeError("Investigation bundle exceeds 10 MiB");
const bundle = await parseInvestigationBundle(await file.text());
const report = {
observedAt: bundle.evidence.observedAt,
facts: bundle.evidence.facts,
findings: bundle.evidence.findings,
caveats: bundle.evidence.caveats,
};
policyOverlay.clear();
activateReport(report, "offline");
elements.probeMessage.className = "probe-message";
elements.probeMessage.textContent = `Opened offline investigation created ${bundle.createdAt}.`;
await render();
} catch (error) {
elements.probeMessage.className = "probe-message error";
elements.probeMessage.textContent = error.message;
} finally {
elements.importBundle.value = "";
}
});
elements.target.addEventListener("change", () => { elements.target.addEventListener("change", () => {
elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:"); elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:");
if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false; if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false;
@ -207,6 +237,7 @@ async function render() {
); );
} }
const result = await new TrustRunner({ plugins }).evaluate(facts); const result = await new TrustRunner({ plugins }).evaluate(facts);
state.lastResult = result;
const decidedByOverlay = result.journal.entries.some( const decidedByOverlay = result.journal.entries.some(
(entry) => (entry) =>
entry.pluginId === "org.browsec.local-policy-overlay" && entry.pluginId === "org.browsec.local-policy-overlay" &&
@ -219,8 +250,28 @@ async function render() {
renderRules(policyOverlay.snapshot()); renderRules(policyOverlay.snapshot());
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0; elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
elements.frameSeal.textContent = state.scenario === "live" elements.frameSeal.textContent = state.scenario === "live"
? "TRUSTLAB · LIVE PROBE" ? `TRUSTLAB · ${state.source === "offline" ? "OFFLINE EVIDENCE" : "LIVE PROBE"}`
: "TRUSTLAB · SYNTHETIC"; : "TRUSTLAB · SYNTHETIC";
elements.exportBundle.hidden = state.scenario !== "live";
}
function activateReport(report, source) {
state.liveReport = report;
state.source = source;
scenarios.live = {
label: `${source === "offline" ? "Offline" : "Live"}: ${report.facts.hostname}:${report.facts.port}`,
facts: report.facts,
probePlugins: [configure(createProbeEvidencePlugin(report), "advisor")],
};
let option = [...elements.scenario.options].find((item) => item.value === "live");
if (!option) {
option = new Option(scenarios.live.label, "live");
elements.scenario.prepend(option);
} else option.textContent = scenarios.live.label;
state.scenario = "live";
elements.scenario.value = "live";
state.pendingDecision = undefined;
renderTrustTargets(report.facts);
} }
function renderStatus(result, hasLocalDecision) { function renderStatus(result, hasLocalDecision) {
@ -384,20 +435,76 @@ function renderDerExplorer(certificate) {
} }
details.append(extensions); details.append(extensions);
const tree = document.createElement("details"); details.append(renderSynchronizedDer(certificate));
tree.className = "der-tree";
tree.append(node("summary", "ASN.1 structure and byte ranges"), renderDerNode(certificate.der.tree));
details.append(tree);
return details; return details;
} }
function renderDerNode(item) { function renderSynchronizedDer(certificate) {
const list = document.createElement("ul"); const explorer = document.createElement("details");
const entry = document.createElement("li"); explorer.className = "der-tree";
entry.append(node("code", `${item.type} · ${item.offset}${item.endOffset - 1} · value ${item.valueLength} B`)); explorer.append(node("summary", "Synchronized ASN.1 structure ↔ hexadecimal bytes"));
if (item.children.length) entry.append(...item.children.map(renderDerNode)); const panes = document.createElement("div");
list.append(entry); panes.className = "der-panes";
return list; const structure = document.createElement("div");
structure.className = "der-structure";
const hexView = document.createElement("div");
hexView.className = "der-hex";
const selection = node("p", "Select a field or byte.", "der-selection");
const bytes = decodeBase64(certificate.derBase64);
const flatNodes = flattenDerTree(certificate.der.tree);
for (const item of flatNodes) {
const button = document.createElement("button");
button.type = "button";
button.className = "der-node";
button.style.setProperty("--der-depth", item.depth);
button.dataset.offset = item.offset;
button.dataset.end = item.endOffset;
button.textContent = `${item.type} · ${item.offset}${item.endOffset - 1} · value ${item.valueLength} B`;
button.addEventListener("click", () => selectDerRange(explorer, item, selection));
structure.append(button);
}
bytes.forEach((value, offset) => {
const byte = document.createElement("button");
byte.type = "button";
byte.className = "der-byte";
byte.dataset.byteOffset = offset;
byte.textContent = value.toString(16).padStart(2, "0");
byte.title = `Byte ${offset}`;
byte.addEventListener("click", () => {
const item = smallestDerNodeAt(certificate.der.tree, offset);
if (item) selectDerRange(explorer, item, selection, offset);
});
hexView.append(byte);
});
panes.append(structure, hexView);
explorer.append(selection, panes);
return explorer;
}
function selectDerRange(explorer, item, selection, exactByte) {
explorer.querySelectorAll(".der-node.selected, .der-byte.selected, .der-byte.focused")
.forEach((element) => element.classList.remove("selected", "focused"));
const selectedNode = [...explorer.querySelectorAll(".der-node")].find(
(element) => Number(element.dataset.offset) === item.offset && Number(element.dataset.end) === item.endOffset,
);
selectedNode?.classList.add("selected");
explorer.querySelectorAll(".der-byte").forEach((element) => {
const offset = Number(element.dataset.byteOffset);
if (offset >= item.offset && offset < item.endOffset) element.classList.add("selected");
if (offset === exactByte) element.classList.add("focused");
});
selection.textContent = `${item.type}: bytes ${item.offset}${item.endOffset - 1}; header ${item.headerLength} B, value ${item.valueLength} B${exactByte === undefined ? "" : `; selected byte ${exactByte}`}.`;
selectedNode?.scrollIntoView({ block: "nearest" });
const focusedByte = exactByte === undefined
? explorer.querySelector(`[data-byte-offset="${item.offset}"]`)
: explorer.querySelector(`[data-byte-offset="${exactByte}"]`);
focusedByte?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
function decodeBase64(value) {
const binary = atob(value);
return [...binary].map((character) => character.charCodeAt(0));
} }
function renderJournal(result) { function renderJournal(result) {

View File

@ -32,6 +32,14 @@
<p id="probe-message" class="probe-message" aria-live="polite"></p> <p id="probe-message" class="probe-message" aria-live="polite"></p>
</form> </form>
<div class="bundle-actions">
<button id="export-bundle" class="button quiet" type="button" hidden>Export investigation</button>
<label class="button quiet import-button">
Import investigation
<input id="import-bundle" type="file" accept="application/json,.json,.browsec-investigation.json">
</label>
</div>
<label class="toggle"> <label class="toggle">
<input id="community-enabled" type="checkbox" checked> <input id="community-enabled" type="checkbox" checked>
<span>Village community evidence</span> <span>Village community evidence</span>

View File

@ -51,6 +51,24 @@ export function chainRows(facts) {
})); }));
} }
export function flattenDerTree(root) {
const nodes = [];
const visit = (node, depth) => {
nodes.push({ ...node, depth });
node.children.forEach((child) => visit(child, depth + 1));
};
visit(root, 0);
return nodes;
}
export function smallestDerNodeAt(root, byteOffset) {
return flattenDerTree(root)
.filter((node) => byteOffset >= node.offset && byteOffset < node.endOffset)
.sort((left, right) =>
(left.endOffset - left.offset) - (right.endOffset - right.offset),
)[0];
}
function edgeDescription(facts, certificate, index, chainLength) { function edgeDescription(facts, certificate, index, chainLength) {
if (certificate.sha256 === facts.failure?.certificateSha256) { if (certificate.sha256 === facts.failure?.certificateSha256) {
const messages = { const messages = {

View File

@ -114,6 +114,10 @@ select {
.probe-row .button { padding-inline: 0.8rem; } .probe-row .button { padding-inline: 0.8rem; }
.probe-message { min-height: 1.2em; margin: 0.45rem 0 0; color: var(--ink-muted); font-size: 0.72rem; } .probe-message { min-height: 1.2em; margin: 0.45rem 0 0; color: var(--ink-muted); font-size: 0.72rem; }
.probe-message.error { color: var(--red); } .probe-message.error { color: var(--red); }
.bundle-actions { display: grid; gap: 0.45rem; margin-top: 0.65rem; }
.bundle-actions .button { width: 100%; font-size: 0.72rem; text-align: center; }
.import-button { display: block; cursor: pointer; }
.import-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.toggle { .toggle {
display: flex; display: flex;
@ -248,8 +252,28 @@ select {
.der-extensions span { color: var(--ink-muted); font-size: 0.68rem; } .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-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 { 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-selection { margin: 0.6rem 0; color: var(--ink-muted); font-size: 0.7rem; }
.der-tree code { color: #9db1ba; font-size: 0.66rem; } .der-panes { display: grid; grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.15fr); gap: 0.7rem; }
.der-structure, .der-hex { height: 24rem; overflow: auto; border: 1px solid var(--line); background: #071116; }
.der-structure { padding: 0.35rem; }
.der-node {
display: block;
width: calc(100% - min(calc(var(--der-depth) * 0.7rem), 8rem));
margin-left: min(calc(var(--der-depth) * 0.7rem), 8rem);
padding: 0.25rem 0.35rem;
border: 0;
border-left: 1px solid #38505b;
color: #aebfc7;
background: transparent;
font: 0.65rem/1.25 ui-monospace, monospace;
text-align: left;
cursor: pointer;
}
.der-node:hover, .der-node.selected { color: #071319; background: var(--cyan); }
.der-hex { display: grid; grid-template-columns: repeat(16, 2.15rem); align-content: start; padding: 0.5rem; }
.der-byte { padding: 0.2rem 0; border: 0; color: #aebfc7; background: transparent; font: 0.68rem ui-monospace, monospace; cursor: pointer; }
.der-byte:hover, .der-byte.focused { color: #071319; background: var(--amber); }
.der-byte.selected:not(.focused) { color: #071319; background: var(--cyan); }
.journal { display: grid; gap: 0.65rem; } .journal { display: grid; gap: 0.65rem; }
.journal-entry { .journal-entry {
@ -313,6 +337,7 @@ select {
.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; } .der-derived { grid-template-columns: 1fr; }
.der-panes { 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; }