Add synchronized DER view and investigation bundles
This commit is contained in:
parent
216afb6c18
commit
f5e4ece6e7
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
88
trustlab/src/investigation-bundle.js
Normal file
88
trustlab/src/investigation-bundle.js
Normal 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"));
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
|||||||
65
trustlab/test/investigation-bundle.test.js
Normal file
65
trustlab/test/investigation-bundle.test.js
Normal 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"); }
|
||||||
@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user