diff --git a/trustlab/README.md b/trustlab/README.md index ba12c55..2be67fa 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -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 diff --git a/trustlab/src/index.js b/trustlab/src/index.js index bb4f69d..7295734 100644 --- a/trustlab/src/index.js +++ b/trustlab/src/index.js @@ -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"; diff --git a/trustlab/src/investigation-bundle.js b/trustlab/src/investigation-bundle.js new file mode 100644 index 0000000..9f3c0c4 --- /dev/null +++ b/trustlab/src/investigation-bundle.js @@ -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")); +} diff --git a/trustlab/src/tls-probe.js b/trustlab/src/tls-probe.js index 9545421..0775b3b 100644 --- a/trustlab/src/tls-probe.js +++ b/trustlab/src/tls-probe.js @@ -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(), diff --git a/trustlab/test/investigation-bundle.test.js b/trustlab/test/investigation-bundle.test.js new file mode 100644 index 0000000..e651127 --- /dev/null +++ b/trustlab/test/investigation-bundle.test.js @@ -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"); } diff --git a/trustlab/test/ui.test.js b/trustlab/test/ui.test.js index a4430d7..4ef5c5d 100644 --- a/trustlab/test/ui.test.js +++ b/trustlab/test/ui.test.js @@ -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/); }); diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js index 97d734d..fff1f03 100644 --- a/trustlab/ui/app.js +++ b/trustlab/ui/app.js @@ -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) { diff --git a/trustlab/ui/index.html b/trustlab/ui/index.html index b7c6b2a..871ebd2 100644 --- a/trustlab/ui/index.html +++ b/trustlab/ui/index.html @@ -32,6 +32,14 @@
+