Expand TrustLab certificate failure scenarios
This commit is contained in:
parent
9830f444ac
commit
f1fcef9b85
@ -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",
|
||||
};
|
||||
|
||||
@ -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}.`;
|
||||
}
|
||||
|
||||
|
||||
@ -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 ?? {},
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user