Expand TrustLab certificate failure scenarios

This commit is contained in:
Sergey Chernov 2026-08-16 16:27:20 +04:00
parent 9830f444ac
commit f1fcef9b85
7 changed files with 240 additions and 16 deletions

View File

@ -22,6 +22,12 @@ export const unknownLocalAuthority = {
port: 443, port: 443,
validation: "failure", validation: "failure",
errors: ["unknown-issuer"], 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: [ presentedChain: [
{ subject: "CN=library.village", sha256: "leaf-village-library" }, { subject: "CN=library.village", sha256: "leaf-village-library" },
{ subject: "CN=Village Services CA", sha256: "ca-village-services" }, { subject: "CN=Village Services CA", sha256: "ca-village-services" },
@ -33,3 +39,97 @@ export const unknownLocalAuthority = {
tls: { version: "TLSv1.3", alpn: "h2" }, 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",
};

View File

@ -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) { export function createUserDecisionPlugin(decision) {
if (decision !== true && decision !== false) return undefined; if (decision !== true && decision !== false) return undefined;
return { return {
@ -84,7 +107,8 @@ function explainValidationError(error) {
expired: "At least one certificate in the validation path is outside its validity period.", expired: "At least one certificate in the validation path is outside its validity period.",
"hostname-mismatch": "hostname-mismatch":
"The leaf certificate does not identify the requested hostname.", "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}.`; return explanations[error] ?? `Firefox reported certificate error: ${error}.`;
} }

View File

@ -29,6 +29,7 @@ export function createTlsFacts(input) {
port: input.port, port: input.port,
validation: input.validation, validation: input.validation,
errors: input.errors ?? [], errors: input.errors ?? [],
failure: input.failure,
presentedChain: input.presentedChain ?? [], presentedChain: input.presentedChain ?? [],
constructedChain: input.constructedChain ?? [], constructedChain: input.constructedChain ?? [],
tls: input.tls ?? {}, tls: input.tls ?? {},

View File

@ -2,8 +2,14 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import { unknownLocalAuthority } from "../fixtures/tls.js";
import { import {
explicitlyDistrustedAuthority,
expiredLeafCertificate,
hostnameMismatch,
unknownLocalAuthority,
} from "../fixtures/tls.js";
import {
createCommunityAdvicePlugin,
createFirefoxValidationPlugin, createFirefoxValidationPlugin,
createUserDecisionPlugin, createUserDecisionPlugin,
createVillageCommunityPlugin, 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/); 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", () => { test("certificate display names prefer the common name", () => {
assert.equal(subjectName("O=Village,CN=Library CA,C=GE"), "Library CA"); assert.equal(subjectName("O=Village,CN=Library CA,C=GE"), "Library CA");
assert.equal(subjectName("O=Nameless"), "O=Nameless"); 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"); 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 () => { test("security surface contains immutable-frame and simulation labels", async () => {
const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8"); const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8");
assert.match(html, /Browsec security decision/); 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, /TRUSTLAB · SYNTHETIC/);
assert.match(html, /It cannot alter browser trust/); assert.match(html, /It cannot alter browser trust/);
}); });

View File

@ -1,5 +1,13 @@
import { unknownLocalAuthority, validPublicCertificate } from "../fixtures/tls.js";
import { import {
conflictingCommunityAdvice,
explicitlyDistrustedAuthority,
expiredLeafCertificate,
hostnameMismatch,
unknownLocalAuthority,
validPublicCertificate,
} from "../fixtures/tls.js";
import {
createCommunityAdvicePlugin,
createFirefoxValidationPlugin, createFirefoxValidationPlugin,
createUserDecisionPlugin, createUserDecisionPlugin,
createVillageCommunityPlugin, createVillageCommunityPlugin,
@ -16,6 +24,36 @@ const scenarios = {
label: "Valid conventional path", label: "Valid conventional path",
facts: validPublicCertificate, 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 = { const state = {
@ -68,7 +106,11 @@ elements.clear.addEventListener("click", () => {
async function render() { async function render() {
const facts = scenarios[state.scenario].facts; const facts = scenarios[state.scenario].facts;
const plugins = [createFirefoxValidationPlugin()]; const plugins = [createFirefoxValidationPlugin()];
if (state.communityEnabled) plugins.push(createVillageCommunityPlugin()); if (state.communityEnabled) {
plugins.push(
...(scenarios[state.scenario].plugins ?? [createVillageCommunityPlugin()]),
);
}
const userPlugin = createUserDecisionPlugin(state.userDecision); const userPlugin = createUserDecisionPlugin(state.userDecision);
if (userPlugin) plugins.push(userPlugin); if (userPlugin) plugins.push(userPlugin);
@ -156,4 +198,3 @@ function formatCode(value = "") {
} }
render(); render();

View File

@ -29,7 +29,9 @@ export function verdictCopy(result, hasUserDecision) {
return { return {
eyebrow: "Decision required", eyebrow: "Decision required",
title: "Firefox could not verify this identity", 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 const chain = facts.constructedChain.length
? facts.constructedChain ? facts.constructedChain
: facts.presentedChain; : facts.presentedChain;
const failureAtEnd = facts.validation === "failure"; const failedFingerprint = facts.failure?.certificateSha256;
return chain.map((certificate, index) => ({ return chain.map((certificate, index) => ({
...certificate, ...certificate,
name: subjectName(certificate.subject), name: subjectName(certificate.subject),
role: role:
index === 0 ? "Leaf certificate" : index === chain.length - 1 ? "Root candidate" : "Intermediate CA", index === 0 ? "Leaf certificate" : index === chain.length - 1 ? "Root candidate" : "Intermediate CA",
edge: edge: edgeDescription(facts, certificate, index, chain.length),
index === chain.length - 1 failed: certificate.sha256 === failedFingerprint,
? failureAtEnd
? "Not anchored in current Firefox trust"
: "Trusted by current Firefox policy"
: "Signature links to next issuer",
failed: index === chain.length - 1 && failureAtEnd,
})); }));
} }
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";
}

View File

@ -229,6 +229,7 @@ select {
} }
.journal-entry.kind-warning { border-color: var(--amber); } .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.kind-resolution { border-color: var(--cyan); }
.journal-entry h3, .journal-entry p { margin: 0; } .journal-entry h3, .journal-entry p { margin: 0; }
.journal-entry h3 { font-size: 0.86rem; } .journal-entry h3 { font-size: 0.86rem; }
@ -283,4 +284,3 @@ select {
.decision-bar div { flex-basis: 100%; } .decision-bar div { flex-basis: 100%; }
.button { flex: 1; } .button { flex: 1; }
} }