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
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
`authorityCertificateSha256`. TrustLab verifies that it appears in the active
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 { parseTlsTarget, probeTls } from "./tls-probe.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))];
return {
observedAt: new Date().toISOString(),
facts: {
schemaVersion: 0,
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,
} from "../plugins/demo-plugins.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", () => {
const rows = chainRows(unknownLocalAuthority);
@ -46,6 +46,19 @@ test("certificate display names prefer the common name", () => {
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 () => {
const plugins = [
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, /cannot alter browser or system trust/);
assert.match(html, /Live TLS target/);
assert.match(html, /Export investigation/);
assert.match(html, /Import investigation/);
assert.match(html, /Decision target/);
assert.match(html, /Effective and consumed local rules/);
});

View File

@ -14,7 +14,8 @@ import {
} from "../plugins/demo-plugins.js";
import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.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 = {
"unknown-local": {
@ -62,6 +63,8 @@ const state = {
pendingDecision: undefined,
communityEnabled: true,
liveReport: undefined,
lastResult: undefined,
source: "synthetic",
};
const policyOverlay = new TrustPolicyOverlay();
@ -72,6 +75,8 @@ const elements = {
probeTarget: document.querySelector("#probe-target"),
probeSubmit: document.querySelector("#probe-submit"),
probeMessage: document.querySelector("#probe-message"),
exportBundle: document.querySelector("#export-bundle"),
importBundle: document.querySelector("#import-bundle"),
frameSeal: document.querySelector("#frame-seal"),
community: document.querySelector("#community-enabled"),
target: document.querySelector("#trust-target"),
@ -113,21 +118,7 @@ elements.probeForm.addEventListener("submit", async (event) => {
});
const report = await response.json();
if (!response.ok) throw new Error(report.error ?? `Probe failed (${response.status})`);
state.liveReport = report;
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);
activateReport(report, "live");
elements.probeMessage.textContent = `Received ${report.facts.presentedChain.length} certificate(s).`;
await render();
} catch (error) {
@ -137,6 +128,45 @@ elements.probeForm.addEventListener("submit", async (event) => {
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.includeSubdomains.disabled = !elements.target.value.startsWith("authority:");
if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false;
@ -207,6 +237,7 @@ async function render() {
);
}
const result = await new TrustRunner({ plugins }).evaluate(facts);
state.lastResult = result;
const decidedByOverlay = result.journal.entries.some(
(entry) =>
entry.pluginId === "org.browsec.local-policy-overlay" &&
@ -219,8 +250,28 @@ async function render() {
renderRules(policyOverlay.snapshot());
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
elements.frameSeal.textContent = state.scenario === "live"
? "TRUSTLAB · LIVE PROBE"
? `TRUSTLAB · ${state.source === "offline" ? "OFFLINE EVIDENCE" : "LIVE PROBE"}`
: "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) {
@ -384,20 +435,76 @@ function renderDerExplorer(certificate) {
}
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);
details.append(renderSynchronizedDer(certificate));
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 renderSynchronizedDer(certificate) {
const explorer = document.createElement("details");
explorer.className = "der-tree";
explorer.append(node("summary", "Synchronized ASN.1 structure ↔ hexadecimal bytes"));
const panes = document.createElement("div");
panes.className = "der-panes";
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) {

View File

@ -32,6 +32,14 @@
<p id="probe-message" class="probe-message" aria-live="polite"></p>
</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">
<input id="community-enabled" type="checkbox" checked>
<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) {
if (certificate.sha256 === facts.failure?.certificateSha256) {
const messages = {

View File

@ -114,6 +114,10 @@ select {
.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.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 {
display: flex;
@ -248,8 +252,28 @@ select {
.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; }
.der-selection { margin: 0.6rem 0; color: var(--ink-muted); font-size: 0.7rem; }
.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-entry {
@ -313,6 +337,7 @@ select {
.certificate { grid-template-columns: 1fr; }
.certificate code, .edge-label { grid-column: 1; grid-row: auto; }
.der-derived { grid-template-columns: 1fr; }
.der-panes { grid-template-columns: 1fr; }
.journal-entry { grid-template-columns: 4.5rem 1fr; }
.journal-entry code { display: none; }
.decision-bar { flex-wrap: wrap; }