Add synchronized DER view and investigation bundles
This commit is contained in:
1 parent
216afb6c18
commit
f5e4ece6e7
10 files changed
+372
-31
No files matched your search
+135
-28
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+27
-2
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user