252 lines
7.0 KiB
JavaScript
252 lines
7.0 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
createBuiltinFinalHandler,
|
|
DecisionJournal,
|
|
TrustRunner,
|
|
} from "../src/index.js";
|
|
import {
|
|
unknownLocalAuthority,
|
|
validPublicCertificate,
|
|
} from "../fixtures/tls.js";
|
|
|
|
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);
|
|
});
|
|
|
|
test("built-in handler preserves an ordinary successful validation", async () => {
|
|
const result = await new TrustRunner().evaluate(validPublicCertificate);
|
|
assert.equal(result.verdict.trusted, true);
|
|
});
|
|
|
|
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: [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,
|
|
configured.plugin.manifest.id,
|
|
);
|
|
});
|
|
|
|
test("evidence collection is independent but appended in plugin order", async () => {
|
|
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 = configuredPlugin({
|
|
id: "test.second",
|
|
mode: "observer",
|
|
collectEvidence() {
|
|
return { entries: [{ kind: "warning", code: "second" }] };
|
|
},
|
|
});
|
|
|
|
const result = await new TrustRunner({
|
|
plugins: [slowFirst, fastSecond],
|
|
}).evaluate(validPublicCertificate);
|
|
assert.deepEqual(
|
|
result.journal.entries.map((entry) => entry.code),
|
|
["first", "second"],
|
|
);
|
|
});
|
|
|
|
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: [configured],
|
|
timeoutMs: 5,
|
|
}).evaluate(unknownLocalAuthority);
|
|
assert.equal(result.verdict.trusted, false);
|
|
assert.equal(result.journal.entries.at(-1).code, "plugin-timeout");
|
|
});
|
|
|
|
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 {
|
|
...verdictFor(facts, true),
|
|
scope: {
|
|
...exactScope(facts),
|
|
hostname: "different.test",
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
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 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: [configured] }).evaluate(
|
|
unknownLocalAuthority,
|
|
);
|
|
assert.equal(result.verdict.trusted, false);
|
|
assert.match(result.journal.entries.at(-1).message, /cannot issue/);
|
|
}
|
|
});
|
|
|
|
test("veto authority can reject but cannot trust", async () => {
|
|
const configured = configuredPlugin({
|
|
mode: "veto-authority",
|
|
async onTlsFailure({ facts }) {
|
|
return verdictFor(facts, true);
|
|
},
|
|
});
|
|
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,
|
|
authorityCertificateSha256: "not-in-this-chain",
|
|
includeSubdomains: false,
|
|
},
|
|
lifetime: { kind: "session" },
|
|
reasonEntryIds: [],
|
|
overriddenErrors: [...facts.errors],
|
|
};
|
|
},
|
|
});
|
|
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(
|
|
{ id: "test.plugin", name: "Test plugin" },
|
|
[{ kind: "evidence", data: { nested: true } }],
|
|
);
|
|
const snapshot = journal.snapshot();
|
|
|
|
assert.throws(() => snapshot.entries.push({}), TypeError);
|
|
assert.throws(() => {
|
|
snapshot.entries[0].data.nested = false;
|
|
}, TypeError);
|
|
});
|
|
|
|
test("only Browsec's immutable handler can occupy the final position", () => {
|
|
assert.throws(
|
|
() =>
|
|
new TrustRunner({
|
|
finalHandler: {
|
|
manifest: pluginManifest("test.impostor", ["decision-authority"]),
|
|
finalize() {},
|
|
},
|
|
}),
|
|
/must be Browsec's built-in handler/,
|
|
);
|
|
|
|
assert.doesNotThrow(() =>
|
|
new TrustRunner({ finalHandler: createBuiltinFinalHandler() }),
|
|
);
|
|
});
|
|
|
|
function configuredPlugin({
|
|
id = "test.plugin",
|
|
mode = "decision-authority",
|
|
collectEvidence,
|
|
onBeforeTlsAccept,
|
|
onTlsFailure,
|
|
}) {
|
|
return {
|
|
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: [],
|
|
overriddenErrors: trusted ? [...facts.errors] : [],
|
|
};
|
|
}
|
|
|
|
function exactScope(facts) {
|
|
return {
|
|
kind: "certificate-for-host",
|
|
hostname: facts.hostname,
|
|
port: facts.port,
|
|
certificateSha256: facts.presentedChain[0].sha256,
|
|
};
|
|
}
|