From a1e56c1d09d1c85810f58e3ff5ec935eb6587471 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 16 Aug 2026 15:22:51 +0400 Subject: [PATCH] Initialize Browsec architecture and TrustLab --- ARCHITECTURE.md | 97 +++++ CONCEPT.md | 252 +++++++++++++ DESIGN.md | 517 ++++++++++++++++++++++++++ trustlab/README.md | 30 ++ trustlab/examples/demo.js | 42 +++ trustlab/fixtures/tls.js | 35 ++ trustlab/package.json | 14 + trustlab/src/builtin-final-handler.js | 24 ++ trustlab/src/immutable.js | 17 + trustlab/src/index.js | 5 + trustlab/src/journal.js | 36 ++ trustlab/src/protocol.js | 113 ++++++ trustlab/src/runner.js | 137 +++++++ trustlab/test/runner.test.js | 181 +++++++++ 14 files changed, 1500 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 CONCEPT.md create mode 100644 DESIGN.md create mode 100644 trustlab/README.md create mode 100644 trustlab/examples/demo.js create mode 100644 trustlab/fixtures/tls.js create mode 100644 trustlab/package.json create mode 100644 trustlab/src/builtin-final-handler.js create mode 100644 trustlab/src/immutable.js create mode 100644 trustlab/src/index.js create mode 100644 trustlab/src/journal.js create mode 100644 trustlab/src/protocol.js create mode 100644 trustlab/src/runner.js create mode 100644 trustlab/test/runner.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ec45e4e --- /dev/null +++ b/ARCHITECTURE.md @@ -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. + diff --git a/CONCEPT.md b/CONCEPT.md new file mode 100644 index 0000000..4951ff9 --- /dev/null +++ b/CONCEPT.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..072368d --- /dev/null +++ b/DESIGN.md @@ -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) diff --git a/trustlab/README.md b/trustlab/README.md new file mode 100644 index 0000000..d48329c --- /dev/null +++ b/trustlab/README.md @@ -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. + diff --git a/trustlab/examples/demo.js b/trustlab/examples/demo.js new file mode 100644 index 0000000..98480d0 --- /dev/null +++ b/trustlab/examples/demo.js @@ -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)); + diff --git a/trustlab/fixtures/tls.js b/trustlab/fixtures/tls.js new file mode 100644 index 0000000..d497e0d --- /dev/null +++ b/trustlab/fixtures/tls.js @@ -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" }, +}; + diff --git a/trustlab/package.json b/trustlab/package.json new file mode 100644 index 0000000..73f2b27 --- /dev/null +++ b/trustlab/package.json @@ -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" + } +} diff --git a/trustlab/src/builtin-final-handler.js b/trustlab/src/builtin-final-handler.js new file mode 100644 index 0000000..bdb94ee --- /dev/null +++ b/trustlab/src/builtin-final-handler.js @@ -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), + }; + }, + }); +} diff --git a/trustlab/src/immutable.js b/trustlab/src/immutable.js new file mode 100644 index 0000000..60eeeb5 --- /dev/null +++ b/trustlab/src/immutable.js @@ -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); +} + diff --git a/trustlab/src/index.js b/trustlab/src/index.js new file mode 100644 index 0000000..f00e1f4 --- /dev/null +++ b/trustlab/src/index.js @@ -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"; + diff --git a/trustlab/src/journal.js b/trustlab/src/journal.js new file mode 100644 index 0000000..f64e6a6 --- /dev/null +++ b/trustlab/src/journal.js @@ -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(); + } +} + diff --git a/trustlab/src/protocol.js b/trustlab/src/protocol.js new file mode 100644 index 0000000..636ed59 --- /dev/null +++ b/trustlab/src/protocol.js @@ -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`); +} diff --git a/trustlab/src/runner.js b/trustlab/src/runner.js new file mode 100644 index 0000000..4353b03 --- /dev/null +++ b/trustlab/src/runner.js @@ -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)); +} diff --git a/trustlab/test/runner.test.js b/trustlab/test/runner.test.js new file mode 100644 index 0000000..1b96662 --- /dev/null +++ b/trustlab/test/runner.test.js @@ -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, + }; +}