From f1fcef9b85386ebfd1e8939ee1590c5aa1191cd1 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 16 Aug 2026 16:27:20 +0400 Subject: [PATCH] Expand TrustLab certificate failure scenarios --- trustlab/fixtures/tls.js | 100 +++++++++++++++++++++++++++++++ trustlab/plugins/demo-plugins.js | 26 +++++++- trustlab/src/protocol.js | 1 + trustlab/test/ui.test.js | 49 ++++++++++++++- trustlab/ui/app.js | 47 ++++++++++++++- trustlab/ui/model.js | 31 +++++++--- trustlab/ui/styles.css | 2 +- 7 files changed, 240 insertions(+), 16 deletions(-) diff --git a/trustlab/fixtures/tls.js b/trustlab/fixtures/tls.js index d497e0d..27868f4 100644 --- a/trustlab/fixtures/tls.js +++ b/trustlab/fixtures/tls.js @@ -22,6 +22,12 @@ export const unknownLocalAuthority = { port: 443, validation: "failure", errors: ["unknown-issuer"], + failure: { + code: "unknown-issuer", + certificateSha256: "ca-village-services", + check: "trust-anchor", + summary: "The candidate authority is not trusted by the current Firefox policy.", + }, presentedChain: [ { subject: "CN=library.village", sha256: "leaf-village-library" }, { subject: "CN=Village Services CA", sha256: "ca-village-services" }, @@ -33,3 +39,97 @@ export const unknownLocalAuthority = { tls: { version: "TLSv1.3", alpn: "h2" }, }; +export const expiredLeafCertificate = { + connectionId: "connection-expired-leaf", + hostname: "archive.village", + port: 443, + validation: "failure", + errors: ["expired"], + failure: { + code: "expired", + certificateSha256: "leaf-expired-archive", + check: "validity", + summary: "The server certificate expired 46 days ago.", + }, + presentedChain: [ + { + subject: "CN=archive.village", + sha256: "leaf-expired-archive", + validFrom: "2025-06-01T00:00:00Z", + validUntil: "2026-07-01T00:00:00Z", + }, + { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, + ], + constructedChain: [ + { + subject: "CN=archive.village", + sha256: "leaf-expired-archive", + validFrom: "2025-06-01T00:00:00Z", + validUntil: "2026-07-01T00:00:00Z", + }, + { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, + { subject: "CN=Regional Root CA", sha256: "root-regional" }, + ], + tls: { version: "TLSv1.3", alpn: "h2" }, +}; + +export const hostnameMismatch = { + connectionId: "connection-hostname-mismatch", + hostname: "records.village", + port: 443, + validation: "failure", + errors: ["hostname-mismatch"], + failure: { + code: "hostname-mismatch", + certificateSha256: "leaf-wrong-host", + check: "identity", + summary: "The certificate identifies files.village, not records.village.", + }, + presentedChain: [ + { + subject: "CN=files.village", + sha256: "leaf-wrong-host", + dnsNames: ["files.village"], + }, + { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, + ], + constructedChain: [ + { + subject: "CN=files.village", + sha256: "leaf-wrong-host", + dnsNames: ["files.village"], + }, + { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, + { subject: "CN=Regional Root CA", sha256: "root-regional" }, + ], + tls: { version: "TLSv1.3", alpn: "h2" }, +}; + +export const explicitlyDistrustedAuthority = { + connectionId: "connection-distrusted-authority", + hostname: "registry.example", + port: 443, + validation: "failure", + errors: ["explicitly-distrusted-authority"], + failure: { + code: "explicitly-distrusted-authority", + certificateSha256: "root-distrusted", + check: "local-policy", + summary: "A local Browsec rule explicitly distrusts this root authority.", + }, + presentedChain: [ + { subject: "CN=registry.example", sha256: "leaf-registry" }, + { subject: "CN=Commercial Issuing CA", sha256: "ca-commercial-issuing" }, + ], + constructedChain: [ + { subject: "CN=registry.example", sha256: "leaf-registry" }, + { subject: "CN=Commercial Issuing CA", sha256: "ca-commercial-issuing" }, + { subject: "CN=Globally Trusted but Locally Rejected Root", sha256: "root-distrusted" }, + ], + tls: { version: "TLSv1.3", alpn: "h2" }, +}; + +export const conflictingCommunityAdvice = { + ...unknownLocalAuthority, + connectionId: "connection-conflicting-community-advice", +}; diff --git a/trustlab/plugins/demo-plugins.js b/trustlab/plugins/demo-plugins.js index e1ee104..541e77f 100644 --- a/trustlab/plugins/demo-plugins.js +++ b/trustlab/plugins/demo-plugins.js @@ -57,6 +57,29 @@ export function createVillageCommunityPlugin() { }; } +export function createCommunityAdvicePlugin({ + id, + name, + trusted, + message, +}) { + return { + manifest: { id, name, role: "advisor" }, + collectEvidence() { + return { + entries: [ + { + kind: "vote", + code: trusted ? "community-votes-trusted" : "community-votes-not-trusted", + message, + data: { trusted }, + }, + ], + }; + }, + }; +} + export function createUserDecisionPlugin(decision) { if (decision !== true && decision !== false) return undefined; return { @@ -84,7 +107,8 @@ function explainValidationError(error) { expired: "At least one certificate in the validation path is outside its validity period.", "hostname-mismatch": "The leaf certificate does not identify the requested hostname.", + "explicitly-distrusted-authority": + "The chain reaches an authority rejected by an explicit local Browsec rule.", }; return explanations[error] ?? `Firefox reported certificate error: ${error}.`; } - diff --git a/trustlab/src/protocol.js b/trustlab/src/protocol.js index 636ed59..a8f0c13 100644 --- a/trustlab/src/protocol.js +++ b/trustlab/src/protocol.js @@ -29,6 +29,7 @@ export function createTlsFacts(input) { port: input.port, validation: input.validation, errors: input.errors ?? [], + failure: input.failure, presentedChain: input.presentedChain ?? [], constructedChain: input.constructedChain ?? [], tls: input.tls ?? {}, diff --git a/trustlab/test/ui.test.js b/trustlab/test/ui.test.js index 0f97477..a0b8e26 100644 --- a/trustlab/test/ui.test.js +++ b/trustlab/test/ui.test.js @@ -2,8 +2,14 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { unknownLocalAuthority } from "../fixtures/tls.js"; import { + explicitlyDistrustedAuthority, + expiredLeafCertificate, + hostnameMismatch, + unknownLocalAuthority, +} from "../fixtures/tls.js"; +import { + createCommunityAdvicePlugin, createFirefoxValidationPlugin, createUserDecisionPlugin, createVillageCommunityPlugin, @@ -19,6 +25,21 @@ test("UI model identifies the failed end of an unknown-authority chain", () => { assert.match(rows.at(-1).edge, /Not anchored/); }); +test("UI model locates leaf and root policy failures precisely", () => { + const expired = chainRows(expiredLeafCertificate); + assert.equal(expired[0].failed, true); + assert.match(expired[0].edge, /validity period/); + assert.equal(expired.at(-1).failed, false); + + const mismatch = chainRows(hostnameMismatch); + assert.equal(mismatch[0].failed, true); + assert.match(mismatch[0].edge, /records\.village/); + + const distrusted = chainRows(explicitlyDistrustedAuthority); + assert.equal(distrusted.at(-1).failed, true); + assert.match(distrusted.at(-1).edge, /Explicitly distrusted/); +}); + test("certificate display names prefer the common name", () => { assert.equal(subjectName("O=Village,CN=Library CA,C=GE"), "Library CA"); assert.equal(subjectName("O=Nameless"), "O=Nameless"); @@ -40,6 +61,31 @@ test("UI plugins produce attributed evidence and a local Boolean verdict", async assert.equal(verdictCopy(result, true).title, "You trust this connection"); }); +test("conflicting community advice remains visible without becoming a verdict", async () => { + const plugins = [ + createCommunityAdvicePlugin({ + id: "community.yes", + name: "Community Yes", + trusted: true, + message: "Known key", + }), + createCommunityAdvicePlugin({ + id: "community.no", + name: "Community No", + trusted: false, + message: "Unexpected change", + }), + ]; + const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority); + + assert.equal(result.verdict.trusted, false); + assert.deepEqual( + result.journal.entries.map((entry) => entry.data?.trusted), + [true, false], + ); + assert.ok(result.journal.entries.every((entry) => entry.kind === "vote")); +}); + test("security surface contains immutable-frame and simulation labels", async () => { const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8"); assert.match(html, /Browsec security decision/); @@ -47,4 +93,3 @@ test("security surface contains immutable-frame and simulation labels", async () assert.match(html, /TRUSTLAB ยท SYNTHETIC/); assert.match(html, /It cannot alter browser trust/); }); - diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js index 802902c..6e3c0f8 100644 --- a/trustlab/ui/app.js +++ b/trustlab/ui/app.js @@ -1,5 +1,13 @@ -import { unknownLocalAuthority, validPublicCertificate } from "../fixtures/tls.js"; import { + conflictingCommunityAdvice, + explicitlyDistrustedAuthority, + expiredLeafCertificate, + hostnameMismatch, + unknownLocalAuthority, + validPublicCertificate, +} from "../fixtures/tls.js"; +import { + createCommunityAdvicePlugin, createFirefoxValidationPlugin, createUserDecisionPlugin, createVillageCommunityPlugin, @@ -16,6 +24,36 @@ const scenarios = { label: "Valid conventional path", facts: validPublicCertificate, }, + "expired-leaf": { + label: "Expired server certificate", + facts: expiredLeafCertificate, + }, + "hostname-mismatch": { + label: "Hostname mismatch", + facts: hostnameMismatch, + }, + "distrusted-authority": { + label: "Explicitly distrusted authority", + facts: explicitlyDistrustedAuthority, + }, + "conflicting-advice": { + label: "Conflicting community advice", + facts: conflictingCommunityAdvice, + plugins: [ + createCommunityAdvicePlugin({ + id: "community.archivists", + name: "Regional archivists", + trusted: true, + message: "The archivists recognize this exact certificate and recommend trust.", + }), + createCommunityAdvicePlugin({ + id: "community.network-watch", + name: "Independent network watch", + trusted: false, + message: "The network observers report an unexpected certificate change.", + }), + ], + }, }; const state = { @@ -68,7 +106,11 @@ elements.clear.addEventListener("click", () => { async function render() { const facts = scenarios[state.scenario].facts; const plugins = [createFirefoxValidationPlugin()]; - if (state.communityEnabled) plugins.push(createVillageCommunityPlugin()); + if (state.communityEnabled) { + plugins.push( + ...(scenarios[state.scenario].plugins ?? [createVillageCommunityPlugin()]), + ); + } const userPlugin = createUserDecisionPlugin(state.userDecision); if (userPlugin) plugins.push(userPlugin); @@ -156,4 +198,3 @@ function formatCode(value = "") { } render(); - diff --git a/trustlab/ui/model.js b/trustlab/ui/model.js index 7296a08..948fd87 100644 --- a/trustlab/ui/model.js +++ b/trustlab/ui/model.js @@ -29,7 +29,9 @@ export function verdictCopy(result, hasUserDecision) { return { eyebrow: "Decision required", title: "Firefox could not verify this identity", - detail: "Review the broken path and attributed plugin findings before deciding.", + detail: + result.facts.failure?.summary ?? + "Review the broken path and attributed plugin findings before deciding.", }; } @@ -37,19 +39,30 @@ export function chainRows(facts) { const chain = facts.constructedChain.length ? facts.constructedChain : facts.presentedChain; - const failureAtEnd = facts.validation === "failure"; + const failedFingerprint = facts.failure?.certificateSha256; return chain.map((certificate, index) => ({ ...certificate, name: subjectName(certificate.subject), role: index === 0 ? "Leaf certificate" : index === chain.length - 1 ? "Root candidate" : "Intermediate CA", - edge: - index === chain.length - 1 - ? failureAtEnd - ? "Not anchored in current Firefox trust" - : "Trusted by current Firefox policy" - : "Signature links to next issuer", - failed: index === chain.length - 1 && failureAtEnd, + edge: edgeDescription(facts, certificate, index, chain.length), + failed: certificate.sha256 === failedFingerprint, })); } + +function edgeDescription(facts, certificate, index, chainLength) { + if (certificate.sha256 === facts.failure?.certificateSha256) { + const messages = { + "unknown-issuer": "Not anchored in current Firefox trust", + expired: "Certificate validity period has ended", + "hostname-mismatch": `Does not identify ${facts.hostname}`, + "explicitly-distrusted-authority": "Explicitly distrusted by local Browsec policy", + }; + return messages[facts.failure.code] ?? facts.failure.summary; + } + + return index === chainLength - 1 + ? "Trusted by current Firefox policy" + : "Signature links to next issuer"; +} diff --git a/trustlab/ui/styles.css b/trustlab/ui/styles.css index da9e344..fddc913 100644 --- a/trustlab/ui/styles.css +++ b/trustlab/ui/styles.css @@ -229,6 +229,7 @@ select { } .journal-entry.kind-warning { border-color: var(--amber); } +.journal-entry.kind-vote { border-color: #9b8bea; } .journal-entry.kind-resolution { border-color: var(--cyan); } .journal-entry h3, .journal-entry p { margin: 0; } .journal-entry h3 { font-size: 0.86rem; } @@ -283,4 +284,3 @@ select { .decision-bar div { flex-basis: 100%; } .button { flex: 1; } } -