Define strict TLS trust plugin API
This commit is contained in:
1 parent
f1fcef9b85
commit
1eeb8bac05
17 files changed
+834
-194
No files matched your search
+123
-55
@@ -14,6 +14,7 @@ import {
|
||||
test("built-in handler rejects an unresolved certificate failure", async () => {
|
||||
const result = await new TrustRunner().evaluate(unknownLocalAuthority);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.equal(result.verdict.lifetime.kind, "connection");
|
||||
assert.equal(result.journal.entries.length, 0);
|
||||
});
|
||||
|
||||
@@ -22,92 +23,109 @@ test("built-in handler preserves an ordinary successful validation", async () =>
|
||||
assert.equal(result.verdict.trusted, true);
|
||||
});
|
||||
|
||||
test("a decision plugin can trust a narrowly scoped connection", async () => {
|
||||
const plugin = pluginWith({
|
||||
decide({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
};
|
||||
test("a failure hook can trust an exact certificate for a host", async () => {
|
||||
const configured = configuredPlugin({
|
||||
async onTlsFailure({ facts }) {
|
||||
return verdictFor(facts, true);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, true);
|
||||
assert.equal(result.verdict.scope.kind, "certificate-for-host");
|
||||
assert.equal(result.journal.entries.at(-1).kind, "resolution");
|
||||
assert.equal(result.journal.entries.at(-1).pluginId, plugin.manifest.id);
|
||||
assert.equal(
|
||||
result.journal.entries.at(-1).pluginId,
|
||||
configured.plugin.manifest.id,
|
||||
);
|
||||
});
|
||||
|
||||
test("evidence collection is independent but appended in plugin order", async () => {
|
||||
const slowFirst = pluginWith({
|
||||
const slowFirst = configuredPlugin({
|
||||
id: "test.first",
|
||||
mode: "observer",
|
||||
async collectEvidence() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return { entries: [{ kind: "evidence", code: "first" }] };
|
||||
},
|
||||
});
|
||||
const fastSecond = pluginWith({
|
||||
const fastSecond = configuredPlugin({
|
||||
id: "test.second",
|
||||
mode: "observer",
|
||||
collectEvidence() {
|
||||
return { entries: [{ kind: "warning", code: "second" }] };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [slowFirst, fastSecond] }).evaluate(
|
||||
validPublicCertificate,
|
||||
);
|
||||
const result = await new TrustRunner({
|
||||
plugins: [slowFirst, fastSecond],
|
||||
}).evaluate(validPublicCertificate);
|
||||
assert.deepEqual(
|
||||
result.journal.entries.map((entry) => entry.code),
|
||||
["first", "second"],
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin timeout is recorded and falls through safely", async () => {
|
||||
const plugin = pluginWith({
|
||||
async decide() {
|
||||
test("asynchronous failure hook timeout is recorded and falls through safely", async () => {
|
||||
const configured = configuredPlugin({
|
||||
async onTlsFailure() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [plugin], timeoutMs: 5 }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
const result = await new TrustRunner({
|
||||
plugins: [configured],
|
||||
timeoutMs: 5,
|
||||
}).evaluate(unknownLocalAuthority);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.equal(result.journal.entries.at(-1).code, "plugin-timeout");
|
||||
});
|
||||
|
||||
test("a verdict cannot escape the active hostname and port", async () => {
|
||||
const plugin = pluginWith({
|
||||
decide() {
|
||||
test("successful-path hook must return synchronously", async () => {
|
||||
const configured = configuredPlugin({
|
||||
async onBeforeTlsAccept() {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
|
||||
validPublicCertificate,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, true);
|
||||
assert.match(result.journal.entries.at(-1).message, /must return synchronously/);
|
||||
});
|
||||
|
||||
test("a verdict cannot escape the active hostname, port, or certificate", async () => {
|
||||
const configured = configuredPlugin({
|
||||
async onTlsFailure({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: "different.test", port: 443 },
|
||||
...verdictFor(facts, true),
|
||||
scope: {
|
||||
...exactScope(facts),
|
||||
hostname: "different.test",
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.match(result.journal.entries.at(-1).message, /scope does not match/);
|
||||
});
|
||||
|
||||
test("observer and advisor plugins cannot issue terminal verdicts", async () => {
|
||||
for (const role of ["observer", "advisor"]) {
|
||||
const plugin = pluginWith({
|
||||
role,
|
||||
decide({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
};
|
||||
test("observer and advisor modes cannot issue terminal verdicts", async () => {
|
||||
for (const mode of ["observer", "advisor"]) {
|
||||
const configured = configuredPlugin({
|
||||
mode,
|
||||
async onTlsFailure({ facts }) {
|
||||
return verdictFor(facts, true);
|
||||
},
|
||||
});
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
@@ -116,22 +134,43 @@ test("observer and advisor plugins cannot issue terminal verdicts", async () =>
|
||||
});
|
||||
|
||||
test("veto authority can reject but cannot trust", async () => {
|
||||
const plugin = pluginWith({
|
||||
role: "veto-authority",
|
||||
decide({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
};
|
||||
const configured = configuredPlugin({
|
||||
mode: "veto-authority",
|
||||
async onTlsFailure({ facts }) {
|
||||
return verdictFor(facts, true);
|
||||
},
|
||||
});
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.match(result.journal.entries.at(-1).message, /cannot issue a trusted/);
|
||||
});
|
||||
|
||||
test("authority scope must identify a certificate in the active chain", async () => {
|
||||
const configured = configuredPlugin({
|
||||
async onTlsFailure({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: {
|
||||
kind: "authority-for-host",
|
||||
hostname: facts.hostname,
|
||||
port: facts.port,
|
||||
authoritySha256: "not-in-this-chain",
|
||||
includeSubdomains: false,
|
||||
},
|
||||
lifetime: { kind: "session" },
|
||||
reasonEntryIds: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.match(result.journal.entries.at(-1).message, /does not match the certificate chain/);
|
||||
});
|
||||
|
||||
test("journal snapshots and entries are immutable", () => {
|
||||
const journal = new DecisionJournal();
|
||||
journal.append(
|
||||
@@ -151,11 +190,7 @@ test("only Browsec's immutable handler can occupy the final position", () => {
|
||||
() =>
|
||||
new TrustRunner({
|
||||
finalHandler: {
|
||||
manifest: {
|
||||
id: "test.impostor",
|
||||
name: "Impostor",
|
||||
role: "decision-authority",
|
||||
},
|
||||
manifest: pluginManifest("test.impostor", ["decision-authority"]),
|
||||
finalize() {},
|
||||
},
|
||||
}),
|
||||
@@ -167,15 +202,48 @@ test("only Browsec's immutable handler can occupy the final position", () => {
|
||||
);
|
||||
});
|
||||
|
||||
function pluginWith({
|
||||
function configuredPlugin({
|
||||
id = "test.plugin",
|
||||
role = "decision-authority",
|
||||
mode = "decision-authority",
|
||||
collectEvidence,
|
||||
decide,
|
||||
onBeforeTlsAccept,
|
||||
onTlsFailure,
|
||||
}) {
|
||||
return {
|
||||
manifest: { id, name: id, role },
|
||||
collectEvidence,
|
||||
decide,
|
||||
mode,
|
||||
plugin: {
|
||||
manifest: pluginManifest(id, [mode]),
|
||||
hooks: { collectEvidence, onBeforeTlsAccept, onTlsFailure },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pluginManifest(id, supportedModes) {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
trustApiVersion: "0.1",
|
||||
id,
|
||||
name: id,
|
||||
version: "0.0.0",
|
||||
supportedModes,
|
||||
capabilities: {},
|
||||
};
|
||||
}
|
||||
|
||||
function verdictFor(facts, trusted) {
|
||||
return {
|
||||
trusted,
|
||||
scope: exactScope(facts),
|
||||
lifetime: { kind: "connection" },
|
||||
reasonEntryIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function exactScope(facts) {
|
||||
return {
|
||||
kind: "certificate-for-host",
|
||||
hostname: facts.hostname,
|
||||
port: facts.port,
|
||||
certificateSha256: facts.presentedChain[0].sha256,
|
||||
};
|
||||
}
|
||||
@@ -47,9 +47,9 @@ test("certificate display names prefer the common name", () => {
|
||||
|
||||
test("UI plugins produce attributed evidence and a local Boolean verdict", async () => {
|
||||
const plugins = [
|
||||
createFirefoxValidationPlugin(),
|
||||
createVillageCommunityPlugin(),
|
||||
createUserDecisionPlugin(true),
|
||||
configure(createFirefoxValidationPlugin(), "advisor"),
|
||||
configure(createVillageCommunityPlugin(), "advisor"),
|
||||
configure(createUserDecisionPlugin(true), "decision-authority"),
|
||||
];
|
||||
const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority);
|
||||
|
||||
@@ -63,18 +63,18 @@ test("UI plugins produce attributed evidence and a local Boolean verdict", async
|
||||
|
||||
test("conflicting community advice remains visible without becoming a verdict", async () => {
|
||||
const plugins = [
|
||||
createCommunityAdvicePlugin({
|
||||
configure(createCommunityAdvicePlugin({
|
||||
id: "community.yes",
|
||||
name: "Community Yes",
|
||||
trusted: true,
|
||||
message: "Known key",
|
||||
}),
|
||||
createCommunityAdvicePlugin({
|
||||
}), "advisor"),
|
||||
configure(createCommunityAdvicePlugin({
|
||||
id: "community.no",
|
||||
name: "Community No",
|
||||
trusted: false,
|
||||
message: "Unexpected change",
|
||||
}),
|
||||
}), "advisor"),
|
||||
];
|
||||
const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority);
|
||||
|
||||
@@ -86,6 +86,10 @@ test("conflicting community advice remains visible without becoming a verdict",
|
||||
assert.ok(result.journal.entries.every((entry) => entry.kind === "vote"));
|
||||
});
|
||||
|
||||
function configure(plugin, mode) {
|
||||
return { plugin, mode };
|
||||
}
|
||||
|
||||
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/);
|
||||
|
||||
Reference in new issue
Block a user