Initialize Browsec architecture and TrustLab
This commit is contained in:
commit
a1e56c1d09
97
ARCHITECTURE.md
Normal file
97
ARCHITECTURE.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# Browsec Architecture Decisions
|
||||||
|
|
||||||
|
Status: active
|
||||||
|
Date: 2026-08-16
|
||||||
|
|
||||||
|
## ADR-001: Firefox is the first browser integration
|
||||||
|
|
||||||
|
Browsec will be an independently governed Firefox downstream. Upstream Firefox
|
||||||
|
remains a source of browser-engine and security updates, but Mozilla acceptance
|
||||||
|
is not a project requirement or a trust-policy dependency.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- Firefox supports privileged WebExtension Experiments for rapid API work.
|
||||||
|
- Its extension background model is suitable for continuously prepared trust
|
||||||
|
evidence.
|
||||||
|
- Existing Firefox certificate-override machinery is useful for early work.
|
||||||
|
- Firefox ESR provides a plausible downstream maintenance base.
|
||||||
|
- MPL 2.0 is compatible with an open, independently distributed browser.
|
||||||
|
|
||||||
|
The integration must use independent branding, profiles, release signing,
|
||||||
|
updates, and governance.
|
||||||
|
|
||||||
|
## ADR-002: TrustLab precedes deep browser changes
|
||||||
|
|
||||||
|
The trust protocol will first be implemented in `trustlab/`, a browserless,
|
||||||
|
host-neutral JavaScript testbed. It runs under Node.js/V8 during development but
|
||||||
|
must not expose Node-specific facilities to trust plugins.
|
||||||
|
|
||||||
|
TrustLab exists to stabilize:
|
||||||
|
|
||||||
|
- immutable TLS fact records;
|
||||||
|
- the append-only decision journal;
|
||||||
|
- independent evidence collection;
|
||||||
|
- ordered trust-decision plugins;
|
||||||
|
- scoped Boolean trust verdicts;
|
||||||
|
- the immutable built-in fallback/final handler;
|
||||||
|
- plugin timeouts, failures, provenance, and audit records;
|
||||||
|
- synthetic certificate scenarios and conformance tests.
|
||||||
|
|
||||||
|
An optional browser-hosted TrustLab UI will later exercise interactive plugin
|
||||||
|
pages without requiring a Firefox build.
|
||||||
|
|
||||||
|
## ADR-003: Trust plugins decide; the browser enforces
|
||||||
|
|
||||||
|
A trust plugin's only enforceable policy result is a scoped `trusted` or
|
||||||
|
`not-trusted` verdict. Plugins may also append evidence and warnings. Operational
|
||||||
|
states such as timeout, error, abstention, and pending are not trust verdicts.
|
||||||
|
|
||||||
|
The browser core is a reference monitor. It verifies plugin identity,
|
||||||
|
capabilities, scope, journal integrity, deadlines, and binding to the active TLS
|
||||||
|
connection, then executes the plugin-chain result. It does not introduce a
|
||||||
|
separate hidden trust policy.
|
||||||
|
|
||||||
|
The built-in final handler is permanently last. It preserves an earlier valid
|
||||||
|
terminal verdict; when no plugin decides, it provides the browser-owned
|
||||||
|
diagnostic UI and obtains or supplies the final Boolean verdict.
|
||||||
|
|
||||||
|
## ADR-004: Trust policy is an overlay
|
||||||
|
|
||||||
|
Browsec policy will primarily live in its own versioned, auditable overlay rather
|
||||||
|
than directly encoding all decisions in Firefox/NSS certificate trust bits. The
|
||||||
|
overlay must eventually express host and port scope, certificate or key binding,
|
||||||
|
CA namespace constraints, expiration, explicit distrust, provenance, and
|
||||||
|
community evidence.
|
||||||
|
|
||||||
|
Conventional global CA import may still be offered when global trust is exactly
|
||||||
|
what the user intends.
|
||||||
|
|
||||||
|
## Required Firefox additions
|
||||||
|
|
||||||
|
The exact patch boundaries will be established after TrustLab protocol v0 and a
|
||||||
|
privileged-extension integration spike. Expected additions are:
|
||||||
|
|
||||||
|
1. Produce immutable, serializable TLS validation facts for successful and
|
||||||
|
failed verification, including presented and constructed chains.
|
||||||
|
2. Invoke a bounded `onBeforeTlsAccept` trust pipeline before HTTP data is sent.
|
||||||
|
3. Route failed validation into asynchronous trust investigation.
|
||||||
|
4. Publish post-decision `onTlsAccepted` and `onTlsRejected` events.
|
||||||
|
5. Enforce the Browsec trust-policy overlay in all relevant network paths,
|
||||||
|
including HTTP/1.1, HTTP/2, HTTP/3, WebSocket, workers, and connection reuse.
|
||||||
|
6. Host capability-controlled trust plugins and their background activity.
|
||||||
|
7. Provide browser-mediated interactive plugin surfaces.
|
||||||
|
8. Provide an unforgeable built-in security frame and final diagnostic handler.
|
||||||
|
9. Broker scoped trust changes without granting plugins arbitrary NSS/database
|
||||||
|
access.
|
||||||
|
10. Isolate Browsec branding, profiles, updates, signing keys, and audit data.
|
||||||
|
|
||||||
|
## Deferred decisions
|
||||||
|
|
||||||
|
- Firefox source revision and ESR release;
|
||||||
|
- plugin package format and signing envelope;
|
||||||
|
- persistent policy database technology;
|
||||||
|
- P2P transport and community governance;
|
||||||
|
- release governance and threshold signing;
|
||||||
|
- precise UI design and accessibility treatment.
|
||||||
|
|
||||||
252
CONCEPT.md
Normal file
252
CONCEPT.md
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
# Browsec: Coarse-Grained Concept
|
||||||
|
|
||||||
|
Status: discussion draft
|
||||||
|
Date: 2026-08-16
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Browsec is a Firefox-derived browser in which trust is not limited to one
|
||||||
|
central authority. It preserves ordinary cryptographic verification while
|
||||||
|
making the final trust policy extensible. People may use personal, community,
|
||||||
|
institutional, or mixed trust models without rebuilding the browser for each
|
||||||
|
model.
|
||||||
|
|
||||||
|
Browsec is an independently governed Firefox downstream. Cooperation with
|
||||||
|
upstream is welcome where interests coincide, but upstream acceptance is neither
|
||||||
|
a project requirement nor a security dependency. The trust protocol is first
|
||||||
|
developed in the browser-neutral JavaScript testbed described in
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
The primary problem is the fragility of one global, authority-maintained Web
|
||||||
|
PKI. Browsec lets a user supplement or override that global verdict with
|
||||||
|
narrowly scoped local and community trust providers, without being forced to
|
||||||
|
trust a local provider globally for every site.
|
||||||
|
|
||||||
|
This document defines architectural possibilities and boundaries. It does not
|
||||||
|
yet select a community algorithm, peer-to-peer protocol, reputation system, or
|
||||||
|
final user interface.
|
||||||
|
|
||||||
|
## Initial plan
|
||||||
|
|
||||||
|
### 1. Trust-regulation extensions
|
||||||
|
|
||||||
|
Add a privileged, capability-controlled extension class for trust regulation.
|
||||||
|
Its first hooks are:
|
||||||
|
|
||||||
|
- `onSslFailure`: inspect a failed TLS validation and begin an asynchronous
|
||||||
|
investigation that may ask the user for a trust decision;
|
||||||
|
- `onBeforeSslSuccess`: make a fast, blocking decision before Firefox sends HTTP
|
||||||
|
data on a connection whose ordinary TLS validation succeeded;
|
||||||
|
- `onSslSuccess`: asynchronously observe the committed successful decision and
|
||||||
|
optionally update local evidence, notify permitted peers, or schedule further
|
||||||
|
investigation;
|
||||||
|
- background activity: maintain local evidence, communicate with explicitly
|
||||||
|
permitted services or peers, and implement future community models;
|
||||||
|
- trust-policy operations: propose additions, removals, distrust rules, and
|
||||||
|
temporary exceptions through a browser-owned broker.
|
||||||
|
|
||||||
|
Extensions do not receive unrestricted access to Firefox's certificate
|
||||||
|
database. The browser validates, scopes, records, and applies requested changes.
|
||||||
|
Hook ordering, time limits, conflict resolution, and failure behavior belong to
|
||||||
|
the browser core and will be specified later.
|
||||||
|
|
||||||
|
`onBeforeSslSuccess` may read prepared local state but must not wait for the
|
||||||
|
network, peer-to-peer replies, user interaction, or long computation. It returns
|
||||||
|
within a strict browser-enforced deadline. Its only substantive verdict is
|
||||||
|
whether the connection is trusted. `Pending` and `abstain` may exist as protocol
|
||||||
|
states, but are not additional kinds of trust judgment. Timeout and plugin
|
||||||
|
failure cannot turn rejection into acceptance.
|
||||||
|
|
||||||
|
The blocking hook does not directly launch background work. After the browser
|
||||||
|
commits the connection decision, it publishes `onSslSuccess` with the decision
|
||||||
|
and relevant immutable TLS facts to authorized background handlers. This event
|
||||||
|
may update plugin-owned data or enqueue communication without delaying the
|
||||||
|
connection. Delivery is deduplicated and rate-limited so page subresources and
|
||||||
|
connection reuse do not create notification storms.
|
||||||
|
|
||||||
|
Background disclosure remains capability-controlled. A plugin must not reveal
|
||||||
|
the user's browsing targets to peers merely because a certificate validated.
|
||||||
|
Hostnames, addresses, fingerprints, and timing are separate declared disclosure
|
||||||
|
capabilities, and local policy controls which may leave the browser.
|
||||||
|
|
||||||
|
Trust plugins control trust only. They do not gain general authority over
|
||||||
|
navigation, page contents, cookies, credentials, downloads, browser settings,
|
||||||
|
or arbitrary Firefox internals. Their enforceable output is a scoped **trusted**
|
||||||
|
or **not trusted** verdict, optionally persisted through the trust-policy broker.
|
||||||
|
Explanations, evidence, and warnings support that verdict but do not create
|
||||||
|
additional browser-control powers.
|
||||||
|
|
||||||
|
Asynchronous hooks may open a browser-mediated interactive plugin page so the
|
||||||
|
plugin can explain evidence or ask the user to decide. Such a page runs in a
|
||||||
|
privileged trust-plugin surface, is visibly attributed to the plugin, and
|
||||||
|
remains distinct from Browsec's immutable built-in handler. A synchronous hook
|
||||||
|
cannot wait for this UI. It may instead request that navigation be suspended or
|
||||||
|
redirected into an asynchronous investigation; after the user decides, the
|
||||||
|
browser starts a new validation attempt using the resulting scoped trust rule.
|
||||||
|
|
||||||
|
#### Ordered plugin chain and decision journal
|
||||||
|
|
||||||
|
Every TLS decision passes through an ordered chain containing one or more trust
|
||||||
|
plugins. The final member is Browsec's built-in diagnostic and decision handler;
|
||||||
|
it cannot be removed, replaced, reordered, or impersonated by an installed
|
||||||
|
plugin.
|
||||||
|
|
||||||
|
The browser owns an append-only **decision journal** for the connection. It
|
||||||
|
starts with immutable TLS facts and ordinary Firefox validation results. Each
|
||||||
|
plugin receives a capability-filtered, read-only view of those facts and earlier
|
||||||
|
journal entries, and may append an attributed structured entry. A plugin cannot
|
||||||
|
edit, delete, or obscure another plugin's entry.
|
||||||
|
|
||||||
|
Initial entry kinds are deliberately simple:
|
||||||
|
|
||||||
|
- **evidence**: a factual observation with provenance;
|
||||||
|
- **vote**: a recommendation of trusted or not trusted;
|
||||||
|
- **warning**: a risk that must remain visible even if the connection is
|
||||||
|
allowed;
|
||||||
|
- **resolution**: a trusted or not-trusted verdict and its exact scope;
|
||||||
|
- **error/abstention**: the plugin could not or chose not to decide.
|
||||||
|
|
||||||
|
The user configures each plugin's role rather than treating all entries as equal:
|
||||||
|
|
||||||
|
- **observer**: may add evidence and warnings only;
|
||||||
|
- **advisor**: may also add non-binding votes;
|
||||||
|
- **decision authority**: may propose a binding resolution within explicitly
|
||||||
|
granted scope;
|
||||||
|
- **veto authority**: may return not trusted within explicitly granted scope.
|
||||||
|
|
||||||
|
These roles are browser-enforced capabilities. A plugin cannot promote its own
|
||||||
|
vote or warning into a decision. A decision authority's result remains subject
|
||||||
|
to non-overridable browser safety rules and to any higher-precedence local deny
|
||||||
|
policy.
|
||||||
|
|
||||||
|
The built-in final handler consumes the complete journal. When policy yields one
|
||||||
|
valid result, it applies or confirms that result and records why. When results
|
||||||
|
are absent, conflicting, timed out, or require consent, it displays the journal
|
||||||
|
to the user in the privileged diagnostic UI. It presents a readable synthesis
|
||||||
|
while preserving the attributed original entries for inspection.
|
||||||
|
|
||||||
|
"Vote" does not initially imply majority rule. Plugins may represent communities
|
||||||
|
of very different size and independence, and several plugins may rely on the
|
||||||
|
same underlying source. The later policy model decides how votes, warnings,
|
||||||
|
vetoes, and authorities compose; the journal merely preserves their provenance
|
||||||
|
and order.
|
||||||
|
|
||||||
|
The synchronous journal used by `onBeforeSslSuccess` is sealed when its deadline
|
||||||
|
expires. Later background findings create entries in a related investigation
|
||||||
|
record and may affect future connections, but cannot rewrite the decision made
|
||||||
|
for an existing connection.
|
||||||
|
|
||||||
|
### 2. The same trust framework for browser extensions
|
||||||
|
|
||||||
|
Extend the model from website certificates to browser-extension packages.
|
||||||
|
Extension identity is based on signed content and publisher-key continuity, not
|
||||||
|
only approval by one central directory. Communities and participants may issue
|
||||||
|
signed endorsements, warnings, and revocations.
|
||||||
|
|
||||||
|
Community evidence does not by itself grant runtime permissions. Package
|
||||||
|
authenticity, trust recommendation, installation approval, and capability
|
||||||
|
authorization remain separate decisions.
|
||||||
|
|
||||||
|
### 3. Community security-announcement channel
|
||||||
|
|
||||||
|
Provide a channel for signed, critical security announcements to propagate
|
||||||
|
through communities. Announcements may concern certificates, CAs, domains,
|
||||||
|
participants, extension packages, publishers, or trust plugins.
|
||||||
|
|
||||||
|
An announcement is evidence, not executable control. Its origin, signatures,
|
||||||
|
time, scope, forwarding path, and expiration must be visible. Local policy
|
||||||
|
decides whether it informs the user, quarantines an item, or blocks it. No
|
||||||
|
community message may silently install code or permanently expand trust.
|
||||||
|
|
||||||
|
### 4. Signed trust entities
|
||||||
|
|
||||||
|
Introduce two initial entities:
|
||||||
|
|
||||||
|
- **Participant**: a person, service, or device represented by a cryptographic
|
||||||
|
identity and a signed profile;
|
||||||
|
- **Community**: a signed definition of membership, governance keys, applicable
|
||||||
|
scope, and rules for accepting statements or decisions.
|
||||||
|
|
||||||
|
Signatures prove which key made a statement; they do not prove that the
|
||||||
|
statement is true or that the key represents the claimed human. Key rotation,
|
||||||
|
recovery, delegation, compromise, and community governance are necessary parts
|
||||||
|
of the later identity design.
|
||||||
|
|
||||||
|
### 5. Built-in final diagnostic handler
|
||||||
|
|
||||||
|
Ship a basic browser-owned trust handler at the end of the hook chain. If no
|
||||||
|
earlier policy safely resolves a TLS failure, it shows the certificate
|
||||||
|
investigation page, explains the broken trust path, displays plugin findings,
|
||||||
|
and presents the available browser-enforced choices.
|
||||||
|
|
||||||
|
This handler provides a dependable fallback even when trust plugins are absent,
|
||||||
|
disabled, conflicting, timed out, or broken. It cannot be replaced by an
|
||||||
|
ordinary web page.
|
||||||
|
|
||||||
|
### 6. Unmistakable trusted UI
|
||||||
|
|
||||||
|
Security decisions appear only in browser-owned privileged UI with a stable,
|
||||||
|
recognizable treatment that ordinary pages and ordinary extensions cannot draw
|
||||||
|
or overlay. The browser chrome identifies:
|
||||||
|
|
||||||
|
- that the surface belongs to Browsec;
|
||||||
|
- which trust plugin supplied each finding or recommendation;
|
||||||
|
- which action will be enforced by the browser;
|
||||||
|
- the scope and duration of the proposed decision.
|
||||||
|
|
||||||
|
Visual distinction is defense in depth, not the sole security boundary. Process
|
||||||
|
isolation, an unforgeable privileged origin, restricted APIs, trusted event
|
||||||
|
handling, and protection from page-controlled fullscreen or overlays are also
|
||||||
|
required.
|
||||||
|
|
||||||
|
## Core principles
|
||||||
|
|
||||||
|
1. **Facts before policy.** Cryptographic facts are recorded independently from
|
||||||
|
community or institutional judgments.
|
||||||
|
2. **Plural trust.** No single trust model is built in as universally correct.
|
||||||
|
3. **Local sovereignty.** The final decision follows the user's local policy.
|
||||||
|
4. **Least authority.** Plugins receive declared capabilities and brokered
|
||||||
|
operations, not direct control of the verifier or trust database.
|
||||||
|
5. **Visible provenance.** Every recommendation, announcement, and decision
|
||||||
|
identifies its source and supporting evidence.
|
||||||
|
6. **No silent widening.** Trust cannot become broader in target, namespace,
|
||||||
|
duration, or permission without an explicit applicable policy.
|
||||||
|
7. **Safe fallback.** Plugin absence or failure leads to the browser-owned
|
||||||
|
diagnostic handler, not automatic acceptance.
|
||||||
|
8. **Revocability and audit.** Decisions can expire or be revoked and leave a
|
||||||
|
local, inspectable record.
|
||||||
|
9. **Code trust is not code permission.** Endorsement may justify considering an
|
||||||
|
extension, but cannot grant its capabilities.
|
||||||
|
10. **Announcements are not commands.** Community communication supplies signed
|
||||||
|
evidence; browser policy controls its effects.
|
||||||
|
|
||||||
|
## First architectural milestone
|
||||||
|
|
||||||
|
Before forking Firefox, define and prototype only the seams:
|
||||||
|
|
||||||
|
1. immutable TLS success/failure event records;
|
||||||
|
2. a versioned trust-plugin hook interface separating fast blocking decisions
|
||||||
|
from asynchronous observation and background work;
|
||||||
|
3. a browser-owned append-only decision journal with attributed plugin entries;
|
||||||
|
4. a capability and trust-change broker;
|
||||||
|
5. signed Participant, Community, and Announcement envelopes;
|
||||||
|
6. a browser-owned hook coordinator and final diagnostic handler;
|
||||||
|
7. an unforgeable security-decision UI surface.
|
||||||
|
|
||||||
|
The prototype may use Firefox Developer Edition or Nightly privileged extension
|
||||||
|
experiments. A Firefox fork is considered only after these interfaces are small,
|
||||||
|
testable, and sufficiently stable.
|
||||||
|
|
||||||
|
## Deferred decisions
|
||||||
|
|
||||||
|
- community membership and governance models;
|
||||||
|
- peer-to-peer transport and discovery;
|
||||||
|
- ranking, quorum, reputation, and resistance to fake identities;
|
||||||
|
- global versus community-specific naming;
|
||||||
|
- detailed hook ordering and conflict precedence;
|
||||||
|
- exact temporary-trust semantics;
|
||||||
|
- extension distribution, reproducible builds, and update consensus;
|
||||||
|
- the final visual language of the investigation page.
|
||||||
|
|
||||||
|
These are intentionally deferred so the first architecture enables experiments
|
||||||
|
without prematurely declaring one social trust system correct.
|
||||||
517
DESIGN.md
Normal file
517
DESIGN.md
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
# Browsec Certificate Investigator
|
||||||
|
|
||||||
|
Status: initial design proposal
|
||||||
|
Date: 2026-08-16
|
||||||
|
|
||||||
|
> This document explores detailed certificate-investigation behavior. The
|
||||||
|
> shorter [coarse-grained concept](CONCEPT.md) defines the current project goals
|
||||||
|
> and architectural boundaries and takes precedence where the documents differ.
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
Browsec is a Firefox-based research browser for investigating HTTPS sites whose
|
||||||
|
certificates cannot be validated under the browser's current trust policy. It
|
||||||
|
must explain the failure, show the available certificate chains, and let the
|
||||||
|
user make narrow, auditable trust decisions without weakening their normal
|
||||||
|
browser profile.
|
||||||
|
|
||||||
|
The first implementation should be a privileged WebExtension Experiment for
|
||||||
|
Firefox Developer Edition or Nightly. It can use Firefox's internal certificate
|
||||||
|
interfaces while the product and policy model are being developed. Features
|
||||||
|
that must affect certificate verification before an HTTP request is sent,
|
||||||
|
especially domain-constrained CA trust, should later move into a small,
|
||||||
|
maintained Firefox patch.
|
||||||
|
|
||||||
|
This is not intended to make a broken connection appear safe. It is intended to
|
||||||
|
make the failure intelligible and let an informed user define a precise local
|
||||||
|
trust policy.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. Replace an opaque certificate failure with a useful investigation page.
|
||||||
|
2. Explain where and why certificate-path validation failed.
|
||||||
|
3. Serve both ordinary users and PKI/security engineers without presenting two
|
||||||
|
inconsistent versions of the truth.
|
||||||
|
4. Allow narrowly scoped exceptions for a leaf certificate, public key, or CA.
|
||||||
|
5. Support temporary and persistent decisions with unambiguous lifetimes.
|
||||||
|
6. Preserve Firefox protections by default and clearly identify decisions that
|
||||||
|
weaken them.
|
||||||
|
7. Keep all decisions inspectable, revocable, exportable, and attributable.
|
||||||
|
8. Isolate research browsing from the user's everyday browser data.
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- Silently accepting every invalid certificate.
|
||||||
|
- Treating encryption as proof that the remote party is trustworthy.
|
||||||
|
- Replacing TLS or implementing a new browser engine.
|
||||||
|
- Teaching users that all certificate errors are harmless.
|
||||||
|
- Globally trusting a CA when the user intended to trust it only for one site.
|
||||||
|
- Allowing a WebExtension to simulate pre-request enforcement when Firefox did
|
||||||
|
not actually enforce the decision during the TLS handshake.
|
||||||
|
|
||||||
|
## 4. Users
|
||||||
|
|
||||||
|
### 4.1 General user
|
||||||
|
|
||||||
|
The general user needs to know:
|
||||||
|
|
||||||
|
- whether communication is encrypted;
|
||||||
|
- whether the site's identity could be verified;
|
||||||
|
- the most likely reason verification failed;
|
||||||
|
- what can go wrong if they continue;
|
||||||
|
- the narrowest sensible way to continue, if one exists.
|
||||||
|
|
||||||
|
### 4.2 Engineer or investigator
|
||||||
|
|
||||||
|
The engineer additionally needs:
|
||||||
|
|
||||||
|
- raw Firefox/NSS error codes;
|
||||||
|
- the server-presented chain and Firefox-constructed chain;
|
||||||
|
- certificate and SPKI fingerprints;
|
||||||
|
- certificate fields and parsed extensions;
|
||||||
|
- TLS, revocation, Certificate Transparency, DNS, and connection metadata;
|
||||||
|
- alternate paths Firefox considered;
|
||||||
|
- a machine-readable report;
|
||||||
|
- exact scope, lifetime, and error classes of every override.
|
||||||
|
|
||||||
|
Both views must be generated from the same underlying investigation record.
|
||||||
|
The technical view expands the summary rather than contradicting it.
|
||||||
|
|
||||||
|
## 5. Threat model
|
||||||
|
|
||||||
|
Browsec assumes that any of the following may be true:
|
||||||
|
|
||||||
|
- the site is merely misconfigured;
|
||||||
|
- a private or regional CA is legitimate locally but unknown to Mozilla;
|
||||||
|
- a network intermediary is replacing certificates;
|
||||||
|
- a trusted public or operating-system CA is malicious or compromised;
|
||||||
|
- DNS or routing has been redirected;
|
||||||
|
- the server is actively hostile;
|
||||||
|
- an old certificate that was once accepted has been replaced;
|
||||||
|
- the local machine or browser policy has been modified.
|
||||||
|
|
||||||
|
Consequently, continuing past a certificate error must not grant the page
|
||||||
|
access to the user's normal cookies, saved passwords, client certificates, or
|
||||||
|
ambient authenticated sessions. The research browser uses a separate Firefox
|
||||||
|
profile. An optional isolated container is useful but is not a substitute for a
|
||||||
|
separate profile.
|
||||||
|
|
||||||
|
## 6. Design principles
|
||||||
|
|
||||||
|
### 6.1 Separate encryption, identity, and local trust
|
||||||
|
|
||||||
|
The UI must report these as separate properties:
|
||||||
|
|
||||||
|
- **Transport:** Was a TLS connection negotiated, and with what parameters?
|
||||||
|
- **Identity:** Does the certificate identify the requested host?
|
||||||
|
- **Path validation:** Can signatures and constraints be validated to an anchor?
|
||||||
|
- **Local policy:** Does this Browsec profile permit that anchor and use?
|
||||||
|
|
||||||
|
Avoid a single red/green "secure" verdict that obscures these distinctions.
|
||||||
|
|
||||||
|
### 6.2 Use the narrowest scope by default
|
||||||
|
|
||||||
|
The first suggested exception should normally bind:
|
||||||
|
|
||||||
|
- the exact hostname;
|
||||||
|
- the effective port;
|
||||||
|
- the exact leaf certificate or public key;
|
||||||
|
- only the error classes the user deliberately overrides;
|
||||||
|
- the selected lifetime;
|
||||||
|
- the current research profile or container.
|
||||||
|
|
||||||
|
Broadening from a leaf to a CA, from a host to subdomains, or from a namespace to
|
||||||
|
global trust requires a separate explicit action.
|
||||||
|
|
||||||
|
### 6.3 Never hide residual failures
|
||||||
|
|
||||||
|
If a user permits an unknown issuer but the certificate also has a hostname
|
||||||
|
mismatch, the hostname failure remains blocked. An exception is a set of
|
||||||
|
specific permitted validation failures, not a blanket "ignore TLS errors" flag.
|
||||||
|
|
||||||
|
### 6.4 Prefer decisions that can be reversed
|
||||||
|
|
||||||
|
Every decision has a visible expiration, can be revoked immediately, and is
|
||||||
|
written to a local audit log. The investigation report stores fingerprints and
|
||||||
|
metadata, never private keys or page contents.
|
||||||
|
|
||||||
|
## 7. Investigation page
|
||||||
|
|
||||||
|
### 7.1 Page structure
|
||||||
|
|
||||||
|
The initial screen uses progressive disclosure:
|
||||||
|
|
||||||
|
1. **Outcome:** one sentence explaining what Firefox could and could not prove.
|
||||||
|
2. **Broken link:** a compact chain diagram focused on the failure location.
|
||||||
|
3. **Risk:** a specific consequence, not a generic warning.
|
||||||
|
4. **Actions:** block, inspect, or create a narrowly scoped exception.
|
||||||
|
5. **Technical details:** expandable evidence and raw data.
|
||||||
|
|
||||||
|
Example summary:
|
||||||
|
|
||||||
|
> The connection is encrypted, but Firefox cannot verify that the server is
|
||||||
|
> `library.village`. The server certificate leads to "Village Network CA",
|
||||||
|
> which this profile does not currently trust.
|
||||||
|
|
||||||
|
Example risk:
|
||||||
|
|
||||||
|
> Someone controlling this network could present another certificate from the
|
||||||
|
> same untrusted authority. Continuing with this exact certificate is narrower
|
||||||
|
> than trusting the authority.
|
||||||
|
|
||||||
|
### 7.2 Chain representation
|
||||||
|
|
||||||
|
The primary diagram is vertical because it works at narrow window widths and
|
||||||
|
maps naturally from the requested identity to a trust anchor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
library.village Requested identity
|
||||||
|
| name matches
|
||||||
|
v
|
||||||
|
library.village certificate Leaf
|
||||||
|
| signature valid
|
||||||
|
v
|
||||||
|
Village Services CA Intermediate
|
||||||
|
| signature valid
|
||||||
|
v
|
||||||
|
Village Network CA Root candidate
|
||||||
|
x not trusted by this profile
|
||||||
|
Firefox trust policy Validation stopped here
|
||||||
|
```
|
||||||
|
|
||||||
|
Each node displays a short name, role, validity state, and shortened SHA-256
|
||||||
|
fingerprint. Selecting it opens the full certificate panel.
|
||||||
|
|
||||||
|
Edges represent checks, not merely containment. Each edge should say, for
|
||||||
|
example, "signature valid", "issuer not supplied", "name constraints reject
|
||||||
|
this host", or "no trusted path found". The failed edge is emphasized by icon,
|
||||||
|
label, and shape as well as color.
|
||||||
|
|
||||||
|
When the server-presented and Firefox-constructed chains differ, the page first
|
||||||
|
shows the chain Firefox used and labels it **Validation path**. A switch exposes
|
||||||
|
**Presented by server** and **Other paths considered**. Roots are often not sent
|
||||||
|
by servers, so the UI must not imply that every displayed root came from the
|
||||||
|
network.
|
||||||
|
|
||||||
|
### 7.3 Certificate detail panel
|
||||||
|
|
||||||
|
The general section contains:
|
||||||
|
|
||||||
|
- subject and issuer display names;
|
||||||
|
- DNS names covered;
|
||||||
|
- valid-from and valid-until dates in local time and UTC;
|
||||||
|
- certificate SHA-256 fingerprint;
|
||||||
|
- whether it was supplied by the server, cached, fetched, or found locally.
|
||||||
|
|
||||||
|
The engineering section additionally contains:
|
||||||
|
|
||||||
|
- serial number;
|
||||||
|
- subject and issuer distinguished names;
|
||||||
|
- SPKI SHA-256 fingerprint;
|
||||||
|
- signature and public-key algorithms and sizes;
|
||||||
|
- basic constraints, key usage and extended key usage;
|
||||||
|
- name constraints and policy constraints;
|
||||||
|
- Authority/Subject Key Identifiers;
|
||||||
|
- AIA, CRL, OCSP and SCT information;
|
||||||
|
- PEM and DER export.
|
||||||
|
|
||||||
|
### 7.4 Failure explanation
|
||||||
|
|
||||||
|
The page maps the internal error to:
|
||||||
|
|
||||||
|
- a stable Browsec failure category;
|
||||||
|
- the original Firefox/NSS error code;
|
||||||
|
- the affected certificate or chain edge;
|
||||||
|
- a plain-language explanation;
|
||||||
|
- evidence supporting the explanation;
|
||||||
|
- whether Firefox considers the failure overridable;
|
||||||
|
- what an override would and would not permit.
|
||||||
|
|
||||||
|
Initial failure categories:
|
||||||
|
|
||||||
|
- unknown issuer or no trusted path;
|
||||||
|
- explicitly distrusted certificate or CA;
|
||||||
|
- missing or incorrect intermediate;
|
||||||
|
- expired or not-yet-valid certificate;
|
||||||
|
- hostname mismatch;
|
||||||
|
- invalid signature or malformed certificate;
|
||||||
|
- invalid CA constraints, key usage, or name constraints;
|
||||||
|
- revoked certificate or revocation-status failure;
|
||||||
|
- Certificate Transparency failure;
|
||||||
|
- weak or prohibited cryptography;
|
||||||
|
- HSTS, pinning, or browser policy prohibits an override;
|
||||||
|
- internal or network failure preventing a conclusion.
|
||||||
|
|
||||||
|
The page must distinguish "not checked", "check failed", and "check found a
|
||||||
|
negative result". For example, an OCSP timeout is not the same as revocation.
|
||||||
|
|
||||||
|
## 8. Trust and continuation actions
|
||||||
|
|
||||||
|
### 8.1 Terminology
|
||||||
|
|
||||||
|
Do not use **Trust once** in the UI. "Once" is ambiguous: it could mean one TLS
|
||||||
|
connection, one top-level load, one tab, one origin visit, or one browser
|
||||||
|
session. The precise single-use behavior will be defined after prototyping
|
||||||
|
Firefox's connection reuse, redirects, subresources, workers, and HTTP/2 or
|
||||||
|
HTTP/3 connection coalescing.
|
||||||
|
|
||||||
|
Until then, use explicit labels:
|
||||||
|
|
||||||
|
- **Continue for this browser session**
|
||||||
|
- **Allow until…**
|
||||||
|
- **Always allow under this rule**
|
||||||
|
|
||||||
|
A possible future one-operation action should be named after its actual scope,
|
||||||
|
such as **Continue for this tab visit**, and not be shipped until that scope can
|
||||||
|
be enforced reliably.
|
||||||
|
|
||||||
|
### 8.2 Decision target
|
||||||
|
|
||||||
|
The user chooses what is being accepted:
|
||||||
|
|
||||||
|
1. **Exact certificate for this host** — binds the leaf DER fingerprint.
|
||||||
|
2. **Public key for this host** — permits certificate renewal with the same key;
|
||||||
|
this has different operational and compromise risks and is an advanced
|
||||||
|
option.
|
||||||
|
3. **CA for this host or DNS namespace** — permits chains anchored at that CA
|
||||||
|
only for the declared host scope.
|
||||||
|
4. **CA globally in this research profile** — advanced and high impact.
|
||||||
|
|
||||||
|
The UI recommends the first applicable, narrowest option. Trusting an
|
||||||
|
intermediate or root never appears as an incidental checkbox on the leaf action.
|
||||||
|
|
||||||
|
### 8.3 Host scope
|
||||||
|
|
||||||
|
Available scopes are:
|
||||||
|
|
||||||
|
- exact host and port;
|
||||||
|
- exact host on any port;
|
||||||
|
- explicit wildcard/subdomain namespace;
|
||||||
|
- global, for a CA only.
|
||||||
|
|
||||||
|
Internationalized domain names are displayed in both Unicode and ASCII/Punycode
|
||||||
|
when confusable characters are possible. Wildcard expansion is previewed in
|
||||||
|
plain language before confirmation.
|
||||||
|
|
||||||
|
### 8.4 Lifetime
|
||||||
|
|
||||||
|
Available lifetimes are:
|
||||||
|
|
||||||
|
- current browser session;
|
||||||
|
- a fixed duration, expressed with its resulting expiration timestamp;
|
||||||
|
- until a chosen date and time;
|
||||||
|
- persistent until revoked.
|
||||||
|
|
||||||
|
Session permission ends when the research browser profile shuts down, not when
|
||||||
|
the last window happens to close if Firefox remains running. Timed permission
|
||||||
|
must expire even across restarts. Existing connections should be closed or
|
||||||
|
revalidated when a permission expires or is revoked.
|
||||||
|
|
||||||
|
### 8.5 Error scope
|
||||||
|
|
||||||
|
The confirmation dialog lists each observed failure separately. Only eligible,
|
||||||
|
selected failures are overridden. Invalid signatures, known revocation, and
|
||||||
|
browser-enforced non-overridable policy remain blocked unless a future forensic
|
||||||
|
mode is designed with stronger isolation.
|
||||||
|
|
||||||
|
### 8.6 Confirmation
|
||||||
|
|
||||||
|
Before committing a rule, show a sentence generated from the complete policy:
|
||||||
|
|
||||||
|
> Until 2026-08-17 18:00 +04, allow certificate `A1:B2:…` for
|
||||||
|
> `library.village:443` when the only failure is an unknown issuer. Continue in
|
||||||
|
> the research profile without normal cookies or saved credentials.
|
||||||
|
|
||||||
|
Broad CA decisions require the user to inspect the selected CA and confirm the
|
||||||
|
host scope. No countdown, repeated warning, or expert quiz is required; clarity
|
||||||
|
and precision are preferred over friction that users learn to dismiss.
|
||||||
|
|
||||||
|
## 9. Policy model
|
||||||
|
|
||||||
|
An illustrative stored rule:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "0191-example",
|
||||||
|
"target": {
|
||||||
|
"kind": "leaf_certificate",
|
||||||
|
"sha256": "base64-or-canonical-hex-fingerprint"
|
||||||
|
},
|
||||||
|
"network_scope": {
|
||||||
|
"host": "library.village",
|
||||||
|
"include_subdomains": false,
|
||||||
|
"port": 443
|
||||||
|
},
|
||||||
|
"allowed_failures": ["unknown_issuer"],
|
||||||
|
"profile_scope": "research",
|
||||||
|
"created_at": "2026-08-16T12:00:00+04:00",
|
||||||
|
"expires_at": "2026-08-17T18:00:00+04:00",
|
||||||
|
"reason": "Local library network",
|
||||||
|
"source_investigation_id": "0191-investigation"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules are evaluated with deny taking precedence over allow. More-specific rules
|
||||||
|
take precedence over broader rules only after deny precedence is applied. An
|
||||||
|
unexpected certificate change never inherits a leaf-fingerprint exception.
|
||||||
|
|
||||||
|
The policy format must be versioned and support export/import. Import previews
|
||||||
|
the effective grants and never silently enables a global CA.
|
||||||
|
|
||||||
|
## 10. Explicit distrust
|
||||||
|
|
||||||
|
Browsec also needs negative policy because the user may reject a CA that Firefox
|
||||||
|
or the operating system normally trusts.
|
||||||
|
|
||||||
|
Negative rules may target:
|
||||||
|
|
||||||
|
- exact leaf certificate;
|
||||||
|
- public key;
|
||||||
|
- intermediate CA;
|
||||||
|
- root CA;
|
||||||
|
- a CA only within or outside a DNS namespace.
|
||||||
|
|
||||||
|
The investigation page should say whether the chain was rejected by Mozilla's
|
||||||
|
root program, the operating-system store, a Browsec rule, revocation data, or
|
||||||
|
another policy source. Trust sources must be visible; "trusted by computer" is
|
||||||
|
not sufficient.
|
||||||
|
|
||||||
|
## 11. Investigation record and audit log
|
||||||
|
|
||||||
|
Each failed navigation creates an investigation record containing:
|
||||||
|
|
||||||
|
- timestamp, requested URL origin, SNI, resolved address and proxy state;
|
||||||
|
- Firefox/NSS errors and Browsec categories;
|
||||||
|
- presented and constructed certificate chains;
|
||||||
|
- fingerprints and parsed certificate metadata;
|
||||||
|
- TLS version, cipher, key exchange, ALPN and ECH state when available;
|
||||||
|
- OCSP, CRL and Certificate Transparency observations;
|
||||||
|
- relevant trust and distrust rules;
|
||||||
|
- the user's decision and resulting rule ID.
|
||||||
|
|
||||||
|
Query parameters and fragments should be redacted by default because they may
|
||||||
|
contain secrets. Reports should not include cookies, authorization headers,
|
||||||
|
form data, response bodies, private keys, or session secrets.
|
||||||
|
|
||||||
|
Export formats:
|
||||||
|
|
||||||
|
- human-readable HTML or PDF report;
|
||||||
|
- canonical JSON for tooling and comparison;
|
||||||
|
- PEM/DER for individual public certificates.
|
||||||
|
|
||||||
|
## 12. Architecture
|
||||||
|
|
||||||
|
### 12.1 Prototype
|
||||||
|
|
||||||
|
The prototype consists of:
|
||||||
|
|
||||||
|
- a privileged WebExtension Experiment;
|
||||||
|
- an internal API that obtains failed-handshake details and chains;
|
||||||
|
- a privileged investigation page, isolated from remote content;
|
||||||
|
- a policy database and audit database in the research profile;
|
||||||
|
- an adapter to Firefox's certificate override service for supported leaf
|
||||||
|
exceptions.
|
||||||
|
|
||||||
|
An ordinary WebExtension is inadequate. Firefox's public `webRequest` API can
|
||||||
|
inspect successful TLS connections but cannot override trust decisions, and the
|
||||||
|
headers event is not delivered when the TLS handshake fails.
|
||||||
|
|
||||||
|
### 12.2 Firefox integration
|
||||||
|
|
||||||
|
Firefox currently has an internal certificate override service with host, port,
|
||||||
|
origin attributes, certificate, and temporary/persistent state. The prototype
|
||||||
|
can use this for supported exact-certificate exceptions.
|
||||||
|
|
||||||
|
The verifier-level implementation is required for:
|
||||||
|
|
||||||
|
- domain-constrained CA anchors;
|
||||||
|
- complete enforcement before HTTP data is transmitted;
|
||||||
|
- timed rule expiry and revalidation integrated with connections;
|
||||||
|
- consistent handling in the socket/network process;
|
||||||
|
- detailed alternate-path diagnostics;
|
||||||
|
- precise deny rules against otherwise trusted chains.
|
||||||
|
|
||||||
|
### 12.3 Security boundaries
|
||||||
|
|
||||||
|
- The investigation UI is browser-owned privileged content, never supplied by
|
||||||
|
the failed site.
|
||||||
|
- Remote certificate text is escaped and treated as untrusted input.
|
||||||
|
- The page cannot be framed or navigated by web content.
|
||||||
|
- Trust operations require a user gesture in the top-level privileged page.
|
||||||
|
- Rules are committed atomically and validated against the certificate that was
|
||||||
|
actually investigated.
|
||||||
|
- On retry, the verifier confirms that the current certificate still matches the
|
||||||
|
chosen rule.
|
||||||
|
- Private CA keys are never generated or stored by Browsec.
|
||||||
|
|
||||||
|
## 13. Research-profile defaults
|
||||||
|
|
||||||
|
- Use a dedicated Firefox profile and visible separate branding.
|
||||||
|
- Disable automatic import of operating-system/enterprise roots by default.
|
||||||
|
- Disable saved passwords and payment information.
|
||||||
|
- Do not import client certificates from the normal profile.
|
||||||
|
- Use separate cookies, storage, history and downloads.
|
||||||
|
- Warn before opening downloaded executables, but do not imply that certificate
|
||||||
|
acceptance makes a download safe.
|
||||||
|
- Keep Firefox sandboxing, site isolation, Safe Browsing, HSTS, revocation and
|
||||||
|
Certificate Transparency enabled unless a specific investigation explains
|
||||||
|
and records a change.
|
||||||
|
|
||||||
|
## 14. Delivery plan
|
||||||
|
|
||||||
|
### Milestone 1: static UX and data model
|
||||||
|
|
||||||
|
- Create the investigation record schema and rule schema.
|
||||||
|
- Build representative fixtures for major failure categories.
|
||||||
|
- Prototype the summary, chain diagram and certificate detail panel.
|
||||||
|
- Test terminology with both non-specialists and PKI engineers.
|
||||||
|
- Resolve the exact semantics, if any, of a single-visit continuation.
|
||||||
|
|
||||||
|
### Milestone 2: privileged Firefox prototype
|
||||||
|
|
||||||
|
- Capture real failed-handshake diagnostics.
|
||||||
|
- Render the browser-owned investigation page.
|
||||||
|
- Export JSON and certificates.
|
||||||
|
- Add exact leaf-certificate session and persistent overrides.
|
||||||
|
- Add rule listing, expiry, revocation and audit history.
|
||||||
|
|
||||||
|
### Milestone 3: policy enforcement
|
||||||
|
|
||||||
|
- Add timed permissions.
|
||||||
|
- Add explicit distrust of otherwise trusted certificates and CAs.
|
||||||
|
- Add domain-constrained CA trust.
|
||||||
|
- Ensure enforcement occurs before HTTP request data is sent.
|
||||||
|
- Test redirects, subresources, service workers, WebSockets, HTTP/2, HTTP/3,
|
||||||
|
connection coalescing, proxies, private browsing and containers.
|
||||||
|
|
||||||
|
### Milestone 4: research-browser distribution
|
||||||
|
|
||||||
|
- Maintain a minimal Firefox patch set and reproducible build.
|
||||||
|
- Apply distinct branding and profile paths.
|
||||||
|
- Define update and migration behavior without silently widening policy.
|
||||||
|
- Conduct a security review and build adversarial integration tests.
|
||||||
|
|
||||||
|
## 15. Open questions
|
||||||
|
|
||||||
|
1. Can a useful "continue for this tab visit" scope be defined and enforced
|
||||||
|
across redirects, subresources, workers, and multiplexed connections?
|
||||||
|
2. Should public-key trust survive certificate renewal by default, or remain an
|
||||||
|
expert-only option?
|
||||||
|
3. How should CA namespace constraints interact with certificate name
|
||||||
|
constraints and public-suffix boundaries?
|
||||||
|
4. Which failures, if any, belong in an isolated forensic mode rather than being
|
||||||
|
absolutely non-overridable?
|
||||||
|
5. Should timed-rule expiry terminate existing connections immediately?
|
||||||
|
6. How should independently observed chains (for example, from another network
|
||||||
|
vantage point) be displayed without implying that they prove correctness?
|
||||||
|
7. How should policy synchronization work without exposing browsing targets or
|
||||||
|
allowing a compromised sync source to widen trust?
|
||||||
|
|
||||||
|
## 16. Relevant Firefox interfaces and documentation
|
||||||
|
|
||||||
|
- [Firefox WebExtension API implementation and Experiments](https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/basics.html)
|
||||||
|
- [Firefox `webRequest.getSecurityInfo`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/getSecurityInfo)
|
||||||
|
- [Firefox certificate verification implementation](https://searchfox.org/mozilla-central/source/security/manager/ssl/SSLServerCertVerification.cpp)
|
||||||
|
- [Firefox certificate override service](https://searchfox.org/firefox-main/source/security/manager/ssl/nsCertOverrideService.cpp)
|
||||||
|
- [Firefox enterprise certificate configuration](https://support.mozilla.org/en-US/kb/setting-certificate-authorities-firefox)
|
||||||
|
- [NSS `certutil` reference](https://nss-crypto.org/reference/security/nss/legacy/tools/certutil/index.html)
|
||||||
30
trustlab/README.md
Normal file
30
trustlab/README.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# TrustLab
|
||||||
|
|
||||||
|
TrustLab is the browser-neutral executable model of Browsec's trust-plugin
|
||||||
|
protocol. It currently runs under Node.js without external dependencies, but
|
||||||
|
plugins receive only portable JavaScript values and TrustLab capabilities.
|
||||||
|
|
||||||
|
The initial runner separates two phases:
|
||||||
|
|
||||||
|
1. Evidence plugins independently inspect the same immutable TLS facts. Their
|
||||||
|
attributed results are appended deterministically to the journal.
|
||||||
|
2. Decision plugins inspect the sealed evidence view in configured order. The
|
||||||
|
first authorized Boolean verdict is terminal. Browsec's immutable built-in
|
||||||
|
handler finalizes that result or supplies the safe fallback.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
npm run demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current protocol boundary
|
||||||
|
|
||||||
|
Plugins may return evidence, warnings, and a scoped Boolean trust verdict. They
|
||||||
|
cannot access Node.js facilities through the protocol, mutate TLS facts or
|
||||||
|
journal entries, or perform browser actions.
|
||||||
|
|
||||||
|
This first slice intentionally omits persistence, package signatures,
|
||||||
|
interactive UI, community identities, networking, and real X.509 parsing.
|
||||||
|
|
||||||
42
trustlab/examples/demo.js
Normal file
42
trustlab/examples/demo.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { unknownLocalAuthority } from "../fixtures/tls.js";
|
||||||
|
import { TrustRunner } from "../src/index.js";
|
||||||
|
|
||||||
|
const villagePlugin = {
|
||||||
|
manifest: {
|
||||||
|
id: "community.village.trust",
|
||||||
|
name: "Village community trust",
|
||||||
|
role: "decision-authority",
|
||||||
|
},
|
||||||
|
collectEvidence({ facts }) {
|
||||||
|
if (facts.hostname !== "library.village") return;
|
||||||
|
return {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
kind: "evidence",
|
||||||
|
code: "known-community-key",
|
||||||
|
message: "The community has previously observed this certificate.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
decide({ facts, journal }) {
|
||||||
|
const known = journal.entries.some(
|
||||||
|
(entry) => entry.code === "known-community-key",
|
||||||
|
);
|
||||||
|
if (!known) return;
|
||||||
|
return {
|
||||||
|
trusted: true,
|
||||||
|
scope: { hostname: facts.hostname, port: facts.port },
|
||||||
|
reasonEntryIds: journal.entries
|
||||||
|
.filter((entry) => entry.code === "known-community-key")
|
||||||
|
.map((entry) => entry.id),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await new TrustRunner({ plugins: [villagePlugin] }).evaluate(
|
||||||
|
unknownLocalAuthority,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
35
trustlab/fixtures/tls.js
Normal file
35
trustlab/fixtures/tls.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export const validPublicCertificate = {
|
||||||
|
connectionId: "connection-valid-public",
|
||||||
|
hostname: "example.test",
|
||||||
|
port: 443,
|
||||||
|
validation: "success",
|
||||||
|
errors: [],
|
||||||
|
presentedChain: [
|
||||||
|
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
|
||||||
|
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
|
||||||
|
],
|
||||||
|
constructedChain: [
|
||||||
|
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
|
||||||
|
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
|
||||||
|
{ subject: "CN=Example Root", sha256: "root-public" },
|
||||||
|
],
|
||||||
|
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unknownLocalAuthority = {
|
||||||
|
connectionId: "connection-unknown-local",
|
||||||
|
hostname: "library.village",
|
||||||
|
port: 443,
|
||||||
|
validation: "failure",
|
||||||
|
errors: ["unknown-issuer"],
|
||||||
|
presentedChain: [
|
||||||
|
{ subject: "CN=library.village", sha256: "leaf-village-library" },
|
||||||
|
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
|
||||||
|
],
|
||||||
|
constructedChain: [
|
||||||
|
{ subject: "CN=library.village", sha256: "leaf-village-library" },
|
||||||
|
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
|
||||||
|
],
|
||||||
|
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||||
|
};
|
||||||
|
|
||||||
14
trustlab/package.json
Normal file
14
trustlab/package.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@browsec/trustlab",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Browser-neutral reference runner for Browsec trust plugins",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test --test-isolation=none",
|
||||||
|
"demo": "node examples/demo.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
trustlab/src/builtin-final-handler.js
Normal file
24
trustlab/src/builtin-final-handler.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export const BUILTIN_HANDLER_ID = "org.browsec.builtin-final-handler";
|
||||||
|
|
||||||
|
export function createBuiltinFinalHandler() {
|
||||||
|
return Object.freeze({
|
||||||
|
manifest: {
|
||||||
|
id: BUILTIN_HANDLER_ID,
|
||||||
|
name: "Browsec built-in final handler",
|
||||||
|
role: "decision-authority",
|
||||||
|
immutable: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
async finalize({ facts, journal, terminalVerdict }) {
|
||||||
|
if (terminalVerdict) return terminalVerdict;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trusted: facts.validation === "success",
|
||||||
|
scope: { hostname: facts.hostname, port: facts.port },
|
||||||
|
reasonEntryIds: journal.entries
|
||||||
|
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
|
||||||
|
.map((entry) => entry.id),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
17
trustlab/src/immutable.js
Normal file
17
trustlab/src/immutable.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/** Return a deeply frozen clone suitable for crossing a plugin boundary. */
|
||||||
|
export function immutableClone(value) {
|
||||||
|
return deepFreeze(structuredClone(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepFreeze(value) {
|
||||||
|
if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
deepFreeze(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze(value);
|
||||||
|
}
|
||||||
|
|
||||||
5
trustlab/src/index.js
Normal file
5
trustlab/src/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { BUILTIN_HANDLER_ID, createBuiltinFinalHandler } from "./builtin-final-handler.js";
|
||||||
|
export { DecisionJournal } from "./journal.js";
|
||||||
|
export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
|
||||||
|
export { TrustRunner } from "./runner.js";
|
||||||
|
|
||||||
36
trustlab/src/journal.js
Normal file
36
trustlab/src/journal.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { immutableClone } from "./immutable.js";
|
||||||
|
|
||||||
|
export class DecisionJournal {
|
||||||
|
#entries = [];
|
||||||
|
#sealed = false;
|
||||||
|
|
||||||
|
append(plugin, entries) {
|
||||||
|
if (this.#sealed) throw new Error("Decision journal is sealed");
|
||||||
|
|
||||||
|
const appended = entries.map((entry) => {
|
||||||
|
const record = immutableClone({
|
||||||
|
id: `entry-${this.#entries.length + 1}`,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
pluginName: plugin.name,
|
||||||
|
kind: entry.kind,
|
||||||
|
code: entry.code,
|
||||||
|
message: entry.message,
|
||||||
|
data: entry.data,
|
||||||
|
});
|
||||||
|
this.#entries.push(record);
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
return immutableClone(appended);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
return immutableClone({ entries: this.#entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
seal() {
|
||||||
|
this.#sealed = true;
|
||||||
|
return this.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
113
trustlab/src/protocol.js
Normal file
113
trustlab/src/protocol.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { immutableClone } from "./immutable.js";
|
||||||
|
|
||||||
|
export const ENTRY_KINDS = Object.freeze([
|
||||||
|
"evidence",
|
||||||
|
"warning",
|
||||||
|
"vote",
|
||||||
|
"resolution",
|
||||||
|
"error",
|
||||||
|
"abstention",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const PLUGIN_ROLES = Object.freeze([
|
||||||
|
"observer",
|
||||||
|
"advisor",
|
||||||
|
"decision-authority",
|
||||||
|
"veto-authority",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createTlsFacts(input) {
|
||||||
|
requireString(input?.connectionId, "connectionId");
|
||||||
|
requireString(input?.hostname, "hostname");
|
||||||
|
requireInteger(input?.port, "port");
|
||||||
|
requireString(input?.validation, "validation");
|
||||||
|
|
||||||
|
return immutableClone({
|
||||||
|
schemaVersion: 0,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
hostname: input.hostname,
|
||||||
|
port: input.port,
|
||||||
|
validation: input.validation,
|
||||||
|
errors: input.errors ?? [],
|
||||||
|
presentedChain: input.presentedChain ?? [],
|
||||||
|
constructedChain: input.constructedChain ?? [],
|
||||||
|
tls: input.tls ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateManifest(manifest) {
|
||||||
|
requireString(manifest?.id, "plugin manifest id");
|
||||||
|
requireString(manifest?.name, "plugin manifest name");
|
||||||
|
if (!PLUGIN_ROLES.includes(manifest.role)) {
|
||||||
|
throw new TypeError(`Unsupported plugin role: ${manifest.role}`);
|
||||||
|
}
|
||||||
|
return immutableClone(manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateContribution(contribution, role) {
|
||||||
|
if (contribution === undefined) return immutableClone({ entries: [] });
|
||||||
|
if (contribution === null || typeof contribution !== "object") {
|
||||||
|
throw new TypeError("Plugin contribution must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = contribution.entries ?? [];
|
||||||
|
if (!Array.isArray(entries)) throw new TypeError("entries must be an array");
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!ENTRY_KINDS.includes(entry?.kind)) {
|
||||||
|
throw new TypeError(`Unsupported journal entry kind: ${entry?.kind}`);
|
||||||
|
}
|
||||||
|
if (entry.kind === "resolution") {
|
||||||
|
throw new TypeError("Plugins cannot append resolution entries directly");
|
||||||
|
}
|
||||||
|
if (role === "observer" && !["evidence", "warning"].includes(entry.kind)) {
|
||||||
|
throw new TypeError("Observer plugins may append evidence and warnings only");
|
||||||
|
}
|
||||||
|
if (role === "advisor" && entry.kind !== "vote" && !["evidence", "warning"].includes(entry.kind)) {
|
||||||
|
throw new TypeError("Advisor plugins may append evidence, warnings, and votes only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return immutableClone({ entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateVerdict(verdict, facts) {
|
||||||
|
if (verdict === undefined) return undefined;
|
||||||
|
if (verdict === null || typeof verdict !== "object") {
|
||||||
|
throw new TypeError("Trust verdict must be an object");
|
||||||
|
}
|
||||||
|
if (typeof verdict.trusted !== "boolean") {
|
||||||
|
throw new TypeError("Trust verdict must contain Boolean trusted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = verdict.scope ?? {};
|
||||||
|
if (scope.hostname !== facts.hostname || scope.port !== facts.port) {
|
||||||
|
throw new TypeError("Trust verdict scope does not match the connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
return immutableClone({
|
||||||
|
trusted: verdict.trusted,
|
||||||
|
scope: { hostname: scope.hostname, port: scope.port },
|
||||||
|
reasonEntryIds: verdict.reasonEntryIds ?? [],
|
||||||
|
expiresAt: verdict.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateVerdictAuthority(verdict, role) {
|
||||||
|
if (verdict === undefined) return;
|
||||||
|
if (role === "observer" || role === "advisor") {
|
||||||
|
throw new TypeError(`${role} plugin cannot issue a terminal verdict`);
|
||||||
|
}
|
||||||
|
if (role === "veto-authority" && verdict.trusted) {
|
||||||
|
throw new TypeError("Veto authority cannot issue a trusted verdict");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(value, name) {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new TypeError(`${name} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireInteger(value, name) {
|
||||||
|
if (!Number.isInteger(value)) throw new TypeError(`${name} must be an integer`);
|
||||||
|
}
|
||||||
137
trustlab/src/runner.js
Normal file
137
trustlab/src/runner.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { createBuiltinFinalHandler } from "./builtin-final-handler.js";
|
||||||
|
import { DecisionJournal } from "./journal.js";
|
||||||
|
import {
|
||||||
|
createTlsFacts,
|
||||||
|
validateContribution,
|
||||||
|
validateManifest,
|
||||||
|
validateVerdict,
|
||||||
|
validateVerdictAuthority,
|
||||||
|
} from "./protocol.js";
|
||||||
|
|
||||||
|
export class TrustRunner {
|
||||||
|
constructor({ plugins = [], finalHandler, timeoutMs = 25 } = {}) {
|
||||||
|
this.plugins = plugins.map(normalizePlugin);
|
||||||
|
this.finalHandler = normalizeFinalHandler(
|
||||||
|
finalHandler ?? createBuiltinFinalHandler(),
|
||||||
|
);
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(inputFacts) {
|
||||||
|
const facts = createTlsFacts(inputFacts);
|
||||||
|
const journal = new DecisionJournal();
|
||||||
|
|
||||||
|
await this.#collectEvidence(facts, journal);
|
||||||
|
let terminalVerdict;
|
||||||
|
|
||||||
|
for (const plugin of this.plugins) {
|
||||||
|
if (!plugin.decide) continue;
|
||||||
|
try {
|
||||||
|
const candidate = await withTimeout(
|
||||||
|
Promise.resolve(plugin.decide({ facts, journal: journal.snapshot() })),
|
||||||
|
this.timeoutMs,
|
||||||
|
);
|
||||||
|
const verdict = validateVerdict(candidate, facts);
|
||||||
|
if (!verdict) {
|
||||||
|
journal.append(plugin.manifest, [{ kind: "abstention" }]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validateVerdictAuthority(verdict, plugin.manifest.role);
|
||||||
|
|
||||||
|
terminalVerdict = verdict;
|
||||||
|
journal.append(plugin.manifest, [
|
||||||
|
{
|
||||||
|
kind: "resolution",
|
||||||
|
code: verdict.trusted ? "trusted" : "not-trusted",
|
||||||
|
data: verdict,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
journal.append(plugin.manifest, [pluginError(error)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sealedJournal = journal.seal();
|
||||||
|
const finalVerdict = validateVerdict(
|
||||||
|
await this.finalHandler.finalize({
|
||||||
|
facts,
|
||||||
|
journal: sealedJournal,
|
||||||
|
terminalVerdict,
|
||||||
|
}),
|
||||||
|
facts,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!finalVerdict) {
|
||||||
|
throw new Error("Built-in final handler must return a trust verdict");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ facts, journal: sealedJournal, verdict: finalVerdict });
|
||||||
|
}
|
||||||
|
|
||||||
|
async #collectEvidence(facts, journal) {
|
||||||
|
const collectors = this.plugins.map(async (plugin) => {
|
||||||
|
if (!plugin.collectEvidence) return { plugin, contribution: { entries: [] } };
|
||||||
|
try {
|
||||||
|
const result = await withTimeout(
|
||||||
|
Promise.resolve(plugin.collectEvidence({ facts })),
|
||||||
|
this.timeoutMs,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
plugin,
|
||||||
|
contribution: validateContribution(result, plugin.manifest.role),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { plugin, contribution: { entries: [pluginError(error)] } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect independently, then append in configured order for reproducibility.
|
||||||
|
for (const { plugin, contribution } of await Promise.all(collectors)) {
|
||||||
|
journal.append(plugin.manifest, contribution.entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlugin(plugin) {
|
||||||
|
return Object.freeze({
|
||||||
|
manifest: validateManifest(plugin.manifest),
|
||||||
|
collectEvidence: plugin.collectEvidence?.bind(plugin),
|
||||||
|
decide: plugin.decide?.bind(plugin),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFinalHandler(handler) {
|
||||||
|
const normalized = normalizePlugin(handler);
|
||||||
|
if (handler.manifest.id !== "org.browsec.builtin-final-handler") {
|
||||||
|
throw new TypeError("Final handler must be Browsec's built-in handler");
|
||||||
|
}
|
||||||
|
if (typeof handler.finalize !== "function") {
|
||||||
|
throw new TypeError("Built-in final handler must implement finalize");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...normalized,
|
||||||
|
finalize: handler.finalize.bind(handler),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginError(error) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
code: error?.name === "TimeoutError" ? "plugin-timeout" : "plugin-error",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(promise, timeoutMs) {
|
||||||
|
let timeoutId;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
const error = new Error(`Plugin exceeded ${timeoutMs} ms deadline`);
|
||||||
|
error.name = "TimeoutError";
|
||||||
|
reject(error);
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
|
||||||
|
}
|
||||||
181
trustlab/test/runner.test.js
Normal file
181
trustlab/test/runner.test.js
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
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.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 decision plugin can trust a narrowly scoped connection", async () => {
|
||||||
|
const plugin = pluginWith({
|
||||||
|
decide({ facts }) {
|
||||||
|
return {
|
||||||
|
trusted: true,
|
||||||
|
scope: { hostname: facts.hostname, port: facts.port },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||||
|
unknownLocalAuthority,
|
||||||
|
);
|
||||||
|
assert.equal(result.verdict.trusted, true);
|
||||||
|
assert.equal(result.journal.entries.at(-1).kind, "resolution");
|
||||||
|
assert.equal(result.journal.entries.at(-1).pluginId, plugin.manifest.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evidence collection is independent but appended in plugin order", async () => {
|
||||||
|
const slowFirst = pluginWith({
|
||||||
|
id: "test.first",
|
||||||
|
async collectEvidence() {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
return { entries: [{ kind: "evidence", code: "first" }] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fastSecond = pluginWith({
|
||||||
|
id: "test.second",
|
||||||
|
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("plugin timeout is recorded and falls through safely", async () => {
|
||||||
|
const plugin = pluginWith({
|
||||||
|
async decide() {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await new TrustRunner({ plugins: [plugin], 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() {
|
||||||
|
return {
|
||||||
|
trusted: true,
|
||||||
|
scope: { hostname: "different.test", port: 443 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await new TrustRunner({ plugins: [plugin] }).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 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await new TrustRunner({ plugins: [plugin] }).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 plugin = pluginWith({
|
||||||
|
role: "veto-authority",
|
||||||
|
decide({ facts }) {
|
||||||
|
return {
|
||||||
|
trusted: true,
|
||||||
|
scope: { hostname: facts.hostname, port: facts.port },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||||
|
unknownLocalAuthority,
|
||||||
|
);
|
||||||
|
assert.equal(result.verdict.trusted, false);
|
||||||
|
assert.match(result.journal.entries.at(-1).message, /cannot issue a trusted/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {
|
||||||
|
id: "test.impostor",
|
||||||
|
name: "Impostor",
|
||||||
|
role: "decision-authority",
|
||||||
|
},
|
||||||
|
finalize() {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/must be Browsec's built-in handler/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
new TrustRunner({ finalHandler: createBuiltinFinalHandler() }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function pluginWith({
|
||||||
|
id = "test.plugin",
|
||||||
|
role = "decision-authority",
|
||||||
|
collectEvidence,
|
||||||
|
decide,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
manifest: { id, name: id, role },
|
||||||
|
collectEvidence,
|
||||||
|
decide,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user