Initialize Browsec architecture and TrustLab

This commit is contained in:
Sergey Chernov 2026-08-16 15:22:51 +04:00
commit a1e56c1d09
14 changed files with 1500 additions and 0 deletions

97
ARCHITECTURE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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
View 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
View 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
View 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
View 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
View 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));
}

View 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,
};
}