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