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