From e4e3de0fc7647a21fa0936b1624f934b2239723e Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 13:42:22 -0500 Subject: [PATCH 1/3] feat(policy): persist directory subscriptions --- README.md | 31 +++++- docs/api_integration_plan.md | 6 +- package-lock.json | 8 +- package.json | 2 +- src/background/index.ts | 39 ++++++- src/content-scripts/auto-verify.test.ts | 57 ++++++++++ src/content-scripts/index.ts | 102 ++++++++++++----- src/core/common/constants.ts | 1 + src/core/common/trust-directory.test.ts | 36 ++++++ src/core/common/types.ts | 68 +++++++++++- src/ui/options/index.tsx | 141 +++++++++++++++++------- src/ui/popup/index.tsx | 13 +++ 12 files changed, 426 insertions(+), 78 deletions(-) create mode 100644 src/core/common/trust-directory.test.ts diff --git a/README.md b/README.md index 2252eae..69cb4b8 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,12 @@ After each page load or same-document navigation, the content script refetches t - npm - Chromium, Firefox, or Safari for loading a built extension -The published dependencies pin browser client commit `39dc873c` and canonicalization commit `5e51040d`. A sibling browser-client checkout is optional. Use one when developing both repositories together. +The published dependencies pin browser client commit +`e7cf034bc696f78f22707823001af5d9d8ba7541` and canonicalization commit +`5e51040dcaaf50935e245702bdefbc18a1d542ce`. These are the revisions recorded +in `package.json` and `package-lock.json`; update both files together when the +release revision changes. A sibling browser-client checkout is optional. Use +one when developing both repositories together. For a standalone checkout: @@ -64,8 +69,13 @@ restore the pinned commit. npm test -- --runInBand npm run typecheck npm run lint +npm run build:chromium ``` +The last command is a clean verification build for the default supported +runtime, Chromium. `npm run build:all` builds and archives all three packaging +targets. + Tests use jsdom for DOM behavior. Run the complete check in a Node 22 container with: @@ -91,7 +101,12 @@ Build one browser with `npm run build:chromium`, `npm run build:firefox`, or `np npm run build:all ``` -The unpacked extension is written to `build//`. For Chromium, open `chrome://extensions/`, enable Developer mode, choose **Load unpacked**, and select `build/chromium/`. +The unpacked extension is written to `build//`, with a zip archive in +`build/`. Chromium is the implemented runtime adapter. Firefox and Safari +currently have packaging targets and manifests, but their runtime adapters are +pending, so those builds do not claim working extension support in those +browsers. For Chromium, open `chrome://extensions/`, enable Developer mode, +choose **Load unpacked**, and select `build/chromium/`. ### Development @@ -112,6 +127,18 @@ Use the matching `dev:firefox` or `dev:safari` command for another target. Reloa The popup receives copied result records. It cannot mutate the content script's verification cache. +## Trust directory policy + +Open **Options**, add a directory, choose a weight from 0 to 1, and leave the +subscription disabled until you want it consulted. The extension stores these +rows in browser storage. Each enabled row is queried by the background or +content verifier at `GET /signers/{id}/reputation`; the page marker and popup +show cryptographic validity separately from the resulting directory trust. +Only the signer identifier is sent to a configured HTTPS directory. Invalid +URLs and weights are rejected before settings are saved. A timeout, malformed +response, unavailable directory, or conflicting result leaves the other policy +inputs visible and does not turn a valid signature into an invalid one. + ## Architecture The codebase is split into **shared** (reusable) and **browser-specific** layers: diff --git a/docs/api_integration_plan.md b/docs/api_integration_plan.md index 7cc13dc..ca2b7f4 100644 --- a/docs/api_integration_plan.md +++ b/docs/api_integration_plan.md @@ -31,7 +31,7 @@ | **Verify Content** (Placeholder) | `POST /content/verify` (Uses active server URL) | **Major Change:** Replace placeholder. Plugin needs to find the signature (e.g., from page metadata, directory lookup), extract necessary fields (`contentHash`, `domain`, `authorId`, `signature`), and call the API on the *active server*. | | **Content Extraction/Hashing** | N/A (Client-side responsibility) | **No Change:** `ContentProcessor` logic remains relevant for preparing `contentHash`. | | **Metadata Extraction** | N/A (Client-side, potentially used for claims) | **No Change:** `ContentProcessor` logic remains. Extracted metadata could pre-populate claims for signing. | -| **Trust Directory Lookup** (Unused) | `/directory/*` endpoints (Uses active server URL) | **Opportunity:** Can now implement features using `/directory/keys`, `/directory/content`, `/directory/keys/{keyId}/reputation` against the *active server* for richer verification context. | +| **Trust Directory Lookup** | User-selected HTTPS directories using `/signers/{id}/reputation` | The extension keeps weighted subscriptions in browser storage and treats reputation as a policy input after local signature verification. | | **Settings Management** | N/A (Client-side) | **Change:** Settings need to be extended to manage a list of server configurations (URL, optional ApiKey, optional AuthorId, active status). | | **Sign Out** | N/A (Client-side action) | **Change:** Signing out means deleting the stored `AuthorApiKey` and `authorId` *for the active server configuration*. | | **Key Reporting** (N/A) | `POST /directory/keys/{keyId}/report` | **New Feature:** Can add UI/functionality to report keys using this endpoint (requires `GeneralApiKey`, potentially also managed per server or globally). | @@ -97,7 +97,7 @@ graph TD end subgraph Directory [Directory Interaction (Optional)] - HH[Verification Flow] --> II(Call GET /directory/keys/{keyId}/reputation on Active Server); + HH[Verification Flow] --> II(Call GET /signers/{id}/reputation on each enabled configured directory); II --> JJ[Display Key Reputation]; KK[User Action: Report Key/Content] --> LL{GeneralApiKey Present?}; LL -- Yes --> MM[Call POST /directory/.../report on Active Server]; @@ -180,4 +180,4 @@ graph TD * Unit tests for server configuration logic. * Integration tests for signing and verification flows (potentially using mock API responses or a staging API environment). * Integration tests for switching active servers. - * End-to-end tests simulating user actions in the browser (managing servers, signing, verifying). \ No newline at end of file + * End-to-end tests simulating user actions in the browser (managing servers, signing, verifying). diff --git a/package-lock.json b/package-lock.json index c59e6f5..0ab5b5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#e7cf034bc696f78f22707823001af5d9d8ba7541", "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", @@ -789,9 +789,9 @@ } }, "node_modules/@htmltrust/browser-client": { - "version": "0.1.2", - "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", - "integrity": "sha512-uVpf48nk0vnXTUe3sosj8OZJilvdW8hwLt+vZesJlZm/JujcTJD6Upm4h/Kr0LnCx78lUx1IRESYS1XxWsipZw==", + "version": "0.2.0", + "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#e7cf034bc696f78f22707823001af5d9d8ba7541", + "integrity": "sha512-DzncftvGN297RyHmVToY/GeNmNubMEqN9PWJmETx3mHOFXEGI9NCVXtXXe2Kzwp30iPslNcSUcxvT6ItkLMrzw==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", diff --git a/package.json b/package.json index 5188834..4255bf4 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#e7cf034bc696f78f22707823001af5d9d8ba7541", "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", diff --git a/src/background/index.ts b/src/background/index.ts index 38762e3..4b1201c 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -3,6 +3,7 @@ */ import { verifySignedSection, + evaluateTrustPolicy, defaultResolverChain, isPrivateHost, } from "@htmltrust/browser-client"; @@ -14,7 +15,8 @@ import { AuthorVote, BatchedVotesPayload, BatchVoteResult, - getTrustDirectoryUrls, + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, buildKeyidUrl, requireCanonicalBase64, requireContentHash, @@ -304,6 +306,7 @@ async function verifyContent(url: string): Promise { if (!pristine) { verificationResult = { verified: false, + cryptoValid: false, reason: "No signed-section found on this page", verifiedAt: Date.now(), domain: serializedOrigin(url), @@ -314,7 +317,9 @@ async function verifyContent(url: string): Promise { // background service worker context, which has SubtleCrypto. The // resolver chain is built from the user's configured directory list; // empty list still works for did:web and direct-URL keyids. - const directories = getTrustDirectoryUrls(settings); + const directories = getTrustDirectorySubscriptions(settings) + .filter((subscription) => subscription.enabled && !validateTrustDirectorySubscription(subscription)) + .map((subscription) => subscription.url); const resolverChain = defaultResolverChain({ directories, fetch: createVerifierFetch(), @@ -353,8 +358,15 @@ async function verifyContent(url: string): Promise { } } + const trust = await evaluateTrustPolicy(verify, { + personalTrustList: settings.personalTrustList ?? [], + trustedDomains: settings.trustedDomains ?? [], + directorySubscriptions: getTrustDirectorySubscriptions(settings), + fetch: createVerifierFetch(), + }); verificationResult = { verified: true, + cryptoValid: true, verifiedAt: Date.now(), domain: serializedOrigin(url), user: { @@ -364,15 +376,21 @@ async function verifyContent(url: string): Promise { publicKey: "", verified: true, }, - trustStatus: "trusted", + trustStatus: trust.indicator === "green" ? "trusted" : trust.indicator === "red" ? "untrusted" : "unknown", + trustScore: trust.score, + trustIndicator: trust.indicator, + trustInputs: trust.inputs, }; } else { verificationResult = { verified: false, + cryptoValid: false, reason: verify.reason || "Signature verification failed", verifiedAt: Date.now(), domain: serializedOrigin(url), trustStatus: "untrusted", + trustScore: 0, + trustIndicator: "red", }; } } @@ -792,7 +810,20 @@ async function removeServer(id: string): Promise { * @returns A promise that resolves when the settings are updated */ async function updateSettings(newSettings: Settings): Promise { - settings = newSettings; + if (Array.isArray(newSettings.trustDirectorySubscriptions)) { + const invalid = newSettings.trustDirectorySubscriptions + .map(validateTrustDirectorySubscription) + .find((message): message is string => message !== null); + if (invalid) throw new Error(invalid); + } + const subscriptions = getTrustDirectorySubscriptions(newSettings); + if (Array.isArray(newSettings.trustDirectorySubscriptions) && subscriptions.length !== newSettings.trustDirectorySubscriptions.length) { + throw new Error("Invalid trust directory subscription; use an HTTPS URL and a weight between 0 and 1"); + } + settings = { + ...newSettings, + trustDirectorySubscriptions: subscriptions, + }; await storage.set(STORAGE_KEYS.SETTINGS, settings); // Update the badge diff --git a/src/content-scripts/auto-verify.test.ts b/src/content-scripts/auto-verify.test.ts index 2fb5ff5..9893e86 100644 --- a/src/content-scripts/auto-verify.test.ts +++ b/src/content-scripts/auto-verify.test.ts @@ -17,6 +17,7 @@ jest.mock('@htmltrust/browser-client', () => ({ verifySignedSection: jest.fn(), evaluateTrustPolicy: jest.fn(), defaultResolverChain: jest.fn(() => []), + isPrivateHost: jest.fn((hostname: string) => hostname === '127.0.0.1' || hostname === 'localhost'), })); // The legacy content-extraction path is outside these lifecycle tests. Mocking @@ -37,6 +38,7 @@ const { armSectionMutationInvalidation, autoVerifyPage, buildAutoBadges, + invalidateAutoVerifyGeneration, resetNavigationState, } = require('./index') as typeof import('./index'); @@ -225,6 +227,61 @@ describe('production content-script UI and lifecycle', () => { expect(calls[calls.length - 1]?.[0]).toBe(newHTML); }); + it('does not let an older policy run overwrite results after settings change', async () => { + document.body.innerHTML = 'text'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + url: window.location.href, + text: async () => document.body.innerHTML, + }); + let oldVerificationStarted!: () => void; + const oldStarted = new Promise((resolve) => { oldVerificationStarted = resolve; }); + let releaseOldVerification!: (result: VerifyResult) => void; + const oldVerification = new Promise((resolve) => { + releaseOldVerification = resolve; + }); + let secondRun = false; + (verifySignedSection as jest.Mock).mockImplementation(() => { + if (secondRun) return Promise.resolve(verifyShape()); + oldVerificationStarted(); + return oldVerification; + }); + (evaluateTrustPolicy as jest.Mock).mockResolvedValue(trustShape({ score: 91, indicator: 'green' })); + + const oldRun = autoVerifyPage([], settings); + await oldStarted; + expect(verifySignedSection).toHaveBeenCalled(); + + // This is the same invalidation used by chrome.storage.onChanged, without + // depending on the browser API in this deterministic race test. + invalidateAutoVerifyGeneration(); + document.querySelectorAll(`.${AUTO_BADGE_MARKER}`).forEach((marker) => marker.remove()); + secondRun = true; + await autoVerifyPage([], { ...settings, trustedDomains: ['https://new-policy.example'] }); + expect(verifySignedSection).toHaveBeenCalledTimes(2); + expect(evaluateTrustPolicy).toHaveBeenCalledTimes(1); + expect(document.querySelector(`.${AUTO_BADGE_MARKER}`)?.getAttribute('aria-label')).toContain('Trust: 91%'); + + releaseOldVerification(verifyShape({ keyid: 'old-result.example' })); + await oldRun; + expect(document.querySelector(`.${AUTO_BADGE_MARKER}`)?.getAttribute('aria-label')).toContain('Trust: 91%'); + }); + + it('passes the hardened verifier fetch to every trust-policy evaluation', async () => { + document.body.innerHTML = 'text'; + (verifySignedSection as jest.Mock).mockResolvedValue(verifyShape()); + (evaluateTrustPolicy as jest.Mock).mockResolvedValue(trustShape()); + + await autoVerifyPage([], settings); + + expect(evaluateTrustPolicy).toHaveBeenCalled(); + for (const [, policy] of (evaluateTrustPolicy as jest.Mock).mock.calls) { + expect(policy.fetch).toEqual(expect.any(Function)); + await expect(policy.fetch('http://private.example/')).rejects.toThrow('network-policy-blocked'); + await expect(policy.fetch('https://127.0.0.1/')).rejects.toThrow('network-policy-blocked'); + } + }); + it('keeps the production auto badge builder warning-aware', () => { const warning = buildAutoBadges(verifyShape({ inputState: 'stale' }), trustShape()); expect(warning.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_WARNING}`)).not.toBeNull(); diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts index b0a2263..3ad9af1 100644 --- a/src/content-scripts/index.ts +++ b/src/content-scripts/index.ts @@ -24,6 +24,7 @@ import { verifySignedSection, evaluateTrustPolicy, defaultResolverChain, + isPrivateHost, type VerifyResult, type TrustEvaluation, type TrustInput, @@ -50,7 +51,8 @@ import { VoteType, Settings, VerificationInputState, - getTrustDirectoryUrls, + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, } from '../core/common/types'; // Import platform-specific adapter @@ -84,6 +86,7 @@ type PageVerification = { trustScore: number; trustIndicator: 'green' | 'yellow' | 'red'; trustLabel: string; + trustInputs: Array<{ source: string; contribution: number; rationale: string }>; keyid: string; algorithm: string; signedAt: string; @@ -116,6 +119,12 @@ let lifecycleInstalled = false; let baseObserverDisposer: (() => void) | null = null; const sectionReverifyGeneration = new WeakMap(); +function directoryUrls(settings: Settings): string[] { + return getTrustDirectorySubscriptions(settings) + .filter((subscription) => subscription.enabled && !validateTrustDirectorySubscription(subscription)) + .map((subscription) => subscription.url); +} + /** * Pull authorId out of a `.../authors/{id}/public-key` keyid URL. Returns * null for keyids that aren't in this shape (e.g. did:web identifiers). @@ -147,6 +156,16 @@ function authorIdFromKeyid(keyid: string): string | null { */ let currentSettings: Settings | null = null; let currentResolverChain: KeyResolver[] = []; +// Settings changes invalidate every in-flight auto-verification. A run may +// await source fetch, key resolution, or directory policy requests, so the +// generation is checked again before it can mutate markers or cached results. +let autoVerifyGeneration = 0; + +/** Invalidate in-flight runs when policy inputs change. */ +export function invalidateAutoVerifyGeneration(): void { + autoVerifyGeneration += 1; + pageVerifications.length = 0; +} async function initialize() { try { @@ -154,7 +173,7 @@ async function initialize() { // 1. Settings → resolver chain + trust policy inputs currentSettings = await loadSettings(); - const directories = getTrustDirectoryUrls(currentSettings); + const directories = directoryUrls(currentSettings); currentResolverChain = defaultResolverChain({ directories, fetch: createVerifierFetch(), @@ -186,10 +205,17 @@ async function initialize() { if (!next) return; currentSettings = next; currentResolverChain = defaultResolverChain({ - directories: getTrustDirectoryUrls(next), + directories: directoryUrls(next), fetch: createVerifierFetch(), }); - redecoratePage(); + invalidateAutoVerifyGeneration(); + // Settings may change the trust policy or its directory set. Clear + // old markers and rerun against the frozen navigation source so a + // stale page snapshot never displays a result for the previous policy. + document.querySelectorAll(SIGNED_SECTION_SELECTOR).forEach((section) => { + clearSectionStatusUI(section); + }); + void autoVerifyPage(currentResolverChain, next, navigationRun, autoVerifyGeneration); }); } } catch (error) { @@ -233,7 +259,7 @@ function redecoratePage(): void { const trustShape: TrustEvaluation = { score: cached.trustScore, indicator: cached.trustIndicator, - inputs: [], + inputs: cached.trustInputs ?? [], }; const runShape: SectionVerificationRun = { verify: verifyShape, @@ -250,6 +276,7 @@ function redecoratePage(): void { /** Reset cached state before a same-document navigation or page rerender. */ export function resetNavigationState(): void { navigationRun += 1; + invalidateAutoVerifyGeneration(); if (rerenderTimer !== null) { clearTimeout(rerenderTimer); rerenderTimer = null; @@ -260,7 +287,6 @@ export function resetNavigationState(): void { clearSectionStatusUI(section); }); observedSections = new Set(); - pageVerifications.length = 0; navigationSnapshot = null; } @@ -346,6 +372,7 @@ async function loadSettings(): Promise { highlightVerified: true, highlightUnverified: false, trustDirectoryUrls: [], + trustDirectorySubscriptions: [], personalTrustList: [], trustedDomains: [], authMethod: 'apikey', @@ -393,10 +420,14 @@ function debugLog(settings: Settings, message: string, details?: unknown): void function createVerifierFetch(): typeof fetch { return async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = new URL(input instanceof Request ? input.url : String(input)); + const inputUrl = typeof input === 'string' || input instanceof URL ? input.toString() : input.url; + const url = new URL(inputUrl); if (url.protocol !== 'https:') { throw new Error('network-policy-blocked: verifier key and directory fetches require HTTPS'); } + if (url.username || url.password || isPrivateHost(url.hostname)) { + throw new Error('network-policy-blocked: verifier fetches may not target private or credential-bearing URLs'); + } return fetch(input, { ...init, credentials: 'omit', @@ -539,11 +570,16 @@ export async function autoVerifyPage( resolverChain: KeyResolver[], settings: Settings, expectedNavigationRun = navigationRun, + expectedAutoVerifyGeneration = autoVerifyGeneration, ): Promise { // `autoVerify` gates the entire content-script verification path. When off, // the page is left untouched and the popup's "Verifying…" state stays put // until the user explicitly triggers verification. - if (!settings.autoVerify || expectedNavigationRun !== navigationRun) { + if ( + !settings.autoVerify || + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) { return; } @@ -580,7 +616,10 @@ export async function autoVerifyPage( // cache catches per RFC 7234 when the origin sets reasonable cache headers. const { snapshot: fetchedSnapshot, error: pristineFetchError } = await fetchPristineSignedSections(settings); - if (expectedNavigationRun !== navigationRun) return; + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; navigationSnapshot = fetchedSnapshot; const liveSections = Array.from(sections); observedSections = new Set(liveSections); @@ -602,7 +641,10 @@ export async function autoVerifyPage( let i = 0; for (const section of liveSections) { - if (expectedNavigationRun !== navigationRun) return; + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; // Idempotency: skip sections we've already decorated. const knownMarker = sectionMarkers.get(section); if (knownMarker && !knownMarker.isConnected) sectionMarkers.delete(section); @@ -623,20 +665,18 @@ export async function autoVerifyPage( ); const verify = run.verify; - // Layer 2: trust policy. directorySubscriptions is intentionally empty - // here — the spec-compliant `/keys//reputation` endpoint - // shape is not yet implemented by the reference trust server. The e2e - // harness layers reports/score on top via a custom server lookup; the - // extension follows the same TODO pattern and stays out of that - // business until the server endpoint exists. - // TODO(directory-shape): wire `directorySubscriptions` once the trust - // server exposes `/keys/{keyid}/reputation` per spec. const trust = await evaluateTrustPolicy(verify, { personalTrustList, trustedDomains, - directorySubscriptions: [], + directorySubscriptions: getTrustDirectorySubscriptions(settings), + fetch: createVerifierFetch(), }); + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; + applySectionStatusUI(section, run, trust, null, settings); const pageVerification: PageVerification = { index: i, @@ -647,8 +687,9 @@ export async function autoVerifyPage( renderedVerified: run.renderedVerified, reason: run.reason, trustScore: trust.score, - trustIndicator: trust.indicator, - trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustIndicator: trust.indicator, + trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustInputs: trust.inputs, keyid: verify.keyid, algorithm: verify.algorithm, signedAt: verify.signedAt, @@ -666,6 +707,10 @@ export async function autoVerifyPage( settings, ); } catch (err) { + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; const reason = (err as Error).message ?? 'verification error'; console.error('Content Signing: verification failed for a signed-section'); debugLog(settings, 'signed-section verification exception', { reason }); @@ -681,6 +726,7 @@ export async function autoVerifyPage( trustScore: 0, trustIndicator: 'red', trustLabel: 'Untrusted', + trustInputs: [], keyid: '', algorithm: '', signedAt: '', @@ -798,6 +844,7 @@ export function armSectionMutationInvalidation( ): void { sectionObserverDisposers.get(section)?.(); const observerNavigationRun = navigationRun; + const observerAutoVerifyGeneration = autoVerifyGeneration; const dispose = observeSignedSection(section, (changedSection) => { const generation = (sectionReverifyGeneration.get(changedSection) ?? 0) + 1; sectionReverifyGeneration.set(changedSection, generation); @@ -824,11 +871,13 @@ export function armSectionMutationInvalidation( const trust = await evaluateTrustPolicy(run.verify, { personalTrustList: activeSettings.personalTrustList ?? [], trustedDomains: activeSettings.trustedDomains ?? [], - directorySubscriptions: [], + directorySubscriptions: getTrustDirectorySubscriptions(activeSettings), + fetch: createVerifierFetch(), }); if ( sectionReverifyGeneration.get(changedSection) !== generation || - navigationRun !== observerNavigationRun + navigationRun !== observerNavigationRun || + autoVerifyGeneration !== observerAutoVerifyGeneration ) return; applySectionStatusUI(changedSection, run, trust, null, activeSettings); const existing = pageVerificationBySection.get(changedSection); @@ -843,7 +892,8 @@ export function armSectionMutationInvalidation( reason: run.reason, trustScore: trust.score, trustIndicator: trust.indicator, - trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustInputs: trust.inputs, keyid: run.verify.keyid, algorithm: run.verify.algorithm, signedAt: run.verify.signedAt, @@ -856,7 +906,8 @@ export function armSectionMutationInvalidation( } catch (error) { if ( sectionReverifyGeneration.get(changedSection) !== generation || - navigationRun !== observerNavigationRun + navigationRun !== observerNavigationRun || + autoVerifyGeneration !== observerAutoVerifyGeneration ) return; const reason = error instanceof Error ? error.message : String(error); applySectionStatusUI( @@ -879,6 +930,7 @@ export function armSectionMutationInvalidation( trustScore: 0, trustIndicator: 'red', trustLabel: 'Untrusted', + trustInputs: [], }; pageVerificationBySection.set(changedSection, failed); const index = pageVerifications.indexOf(existing); diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts index 5f4c27e..a114943 100644 --- a/src/core/common/constants.ts +++ b/src/core/common/constants.ts @@ -31,6 +31,7 @@ export const DEFAULT_SETTINGS = { highlightUnverified: false, trustDirectoryUrls: [] as string[], trustDirectoryUrl: '', // legacy, unused if trustDirectoryUrls is populated + trustDirectorySubscriptions: [] as Array<{ url: string; weight: number; enabled: boolean }>, personalTrustList: [] as string[], trustedDomains: [] as string[], authMethod: 'apikey' as const, diff --git a/src/core/common/trust-directory.test.ts b/src/core/common/trust-directory.test.ts new file mode 100644 index 0000000..1678372 --- /dev/null +++ b/src/core/common/trust-directory.test.ts @@ -0,0 +1,36 @@ +import { MemoryStorage } from '../storage'; +import { + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, + type DirectorySubscription, +} from './types'; + +describe('trust directory subscriptions', () => { + it('persists weighted enabled state through extension storage', async () => { + const storage = new MemoryStorage(); + const configured: DirectorySubscription[] = [ + { url: 'https://directory.example', weight: 0.75, enabled: true }, + { url: 'https://paused.example', weight: 0.25, enabled: false }, + ]; + + await storage.set('settings', { trustDirectorySubscriptions: configured }); + const settings = await storage.get<{ trustDirectorySubscriptions: DirectorySubscription[] }>('settings'); + + expect(getTrustDirectorySubscriptions(settings!)).toEqual(configured); + }); + + it('migrates legacy URL-only settings with an enabled neutral subscription', () => { + expect(getTrustDirectorySubscriptions({ trustDirectoryUrls: [' https://legacy.example/ '] })).toEqual([ + { url: 'https://legacy.example/', weight: 1, enabled: true }, + ]); + }); + + it('rejects insecure, credential-bearing, and out-of-range subscriptions', () => { + expect(validateTrustDirectorySubscription({ url: 'http://directory.example', weight: 1 })).toMatch(/HTTPS/); + expect(validateTrustDirectorySubscription({ url: 'https://user:pass@directory.example', weight: 1 })).toMatch(/credentials/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example?tenant=one', weight: 1 })).toMatch(/query/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example#tenant', weight: 1 })).toMatch(/fragment/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example', weight: 2 })).toMatch(/between 0 and 1/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example', weight: 0.5 })).toBeNull(); + }); +}); diff --git a/src/core/common/types.ts b/src/core/common/types.ts index 52b45ce..a80c06e 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -206,6 +206,16 @@ export type TrustStatus = 'trusted' | 'untrusted' | 'unknown'; */ export type VerificationInputState = 'source-only' | 'stale' | 'rendered-match'; +/** A user-selected trust directory and its policy weight. */ +export interface DirectorySubscription { + /** HTTPS directory base URL. */ + url: string; + /** Contribution multiplier. Values outside 0..1 are rejected. */ + weight: number; + /** Keep the subscription visible while preventing network requests. */ + enabled: boolean; +} + /** * Represents the result of a content verification */ @@ -222,6 +232,12 @@ export interface VerificationResult { verifiedAt: number; /** The trust status of the verification */ trustStatus?: TrustStatus; + /** Cryptographic validity remains separate from this policy result. */ + cryptoValid?: boolean; + /** Local trust-policy result, if policy evaluation was requested. */ + trustScore?: number; + trustIndicator?: 'trusted' | 'unknown' | 'untrusted' | 'green' | 'yellow' | 'red'; + trustInputs?: Array<{ source: string; contribution: number; rationale: string }>; /** The domain of the content */ domain?: string; /** Settings for displaying verification UI */ @@ -262,6 +278,8 @@ export interface Settings { * directory that resolves a keyid wins. */ trustDirectoryUrls?: string[]; + /** Weighted, user-controlled reputation subscriptions. */ + trustDirectorySubscriptions?: DirectorySubscription[]; /** * User's personal trust list, expressed as keyid strings (typically * did:web identifiers or direct public-key URLs). Empty by default; @@ -296,7 +314,9 @@ export interface Settings { */ export function getTrustDirectoryUrls(settings: Pick): string[] { if (settings.trustDirectoryUrls && settings.trustDirectoryUrls.length > 0) { - return settings.trustDirectoryUrls.filter((u) => u && u.trim().length > 0); + return settings.trustDirectoryUrls + .filter((u) => u && u.trim().length > 0) + .map((u) => u.trim()); } if (settings.trustDirectoryUrl && settings.trustDirectoryUrl.trim().length > 0) { return [settings.trustDirectoryUrl.trim()]; @@ -304,6 +324,52 @@ export function getTrustDirectoryUrls(settings: Pick, +): DirectorySubscription[] { + if (Array.isArray(settings.trustDirectorySubscriptions)) { + return settings.trustDirectorySubscriptions + .map((subscription) => { + if (!subscription || typeof subscription.url !== 'string') return null; + const url = subscription.url.trim(); + const weight = Number(subscription.weight); + if (!url || !Number.isFinite(weight) || weight < 0 || weight > 1) return null; + return { url, weight, enabled: subscription.enabled !== false }; + }) + .filter((subscription): subscription is DirectorySubscription => subscription !== null); + } + return getTrustDirectoryUrls(settings).map((url) => ({ url, weight: 1, enabled: true })); +} + +/** Validate a subscription before it is persisted or used for network I/O. */ +export function validateTrustDirectorySubscription(subscription: Partial): string | null { + if (typeof subscription.url !== 'string' || !subscription.url.trim()) return 'Directory URL is required'; + let parsed: URL; + try { + parsed = new URL(subscription.url.trim()); + } catch { + return 'Directory URL must be an absolute HTTPS URL'; + } + if ( + parsed.protocol !== 'https:' || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + !parsed.hostname + ) { + return 'Directory URL must use HTTPS, cannot contain credentials, query, or fragment'; + } + const weight = Number(subscription.weight); + if (!Number.isFinite(weight) || weight < 0 || weight > 1) return 'Directory weight must be between 0 and 1'; + return null; +} + /** * Represents an error in the extension */ diff --git a/src/ui/options/index.tsx b/src/ui/options/index.tsx index f54be8e..6208d3b 100644 --- a/src/ui/options/index.tsx +++ b/src/ui/options/index.tsx @@ -3,7 +3,13 @@ */ import React, { useState, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; -import { Settings, Profile, getTrustDirectoryUrls } from '../../core/common'; +import { + Settings, + Profile, + DirectorySubscription, + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, +} from '../../core/common'; import { STORAGE_KEYS, DEFAULT_SETTINGS, DEFAULT_PROFILE } from '../../core/common/constants'; import { PlatformAdapter, MessageContext } from '../../platforms/common'; import { ProfileManager } from '../../ui/components'; @@ -104,19 +110,46 @@ const Options: React.FC = ({ adapter }) => { })); }; + // Keep raw rows in the form so an invalid URL stays visible until the user + // fixes it. The background validates the same rows before persistence. + const directorySubscriptions: DirectorySubscription[] = Array.isArray(state.settings.trustDirectorySubscriptions) + ? state.settings.trustDirectorySubscriptions + : getTrustDirectorySubscriptions(state.settings); + + const updateDirectorySubscriptions = (subscriptions: DirectorySubscription[]) => { + setState(prevState => ({ + ...prevState, + settings: { + ...prevState.settings, + trustDirectorySubscriptions: subscriptions, + trustDirectoryUrls: subscriptions.map(subscription => subscription.url), + trustDirectoryUrl: '', + }, + isSaved: false, + })); + }; + // Handle save settings const handleSaveSettings = async () => { try { + const invalid = directorySubscriptions + .map(validateTrustDirectorySubscription) + .find((message): message is string => message !== null); + if (invalid) { + setState(prevState => ({ ...prevState, error: invalid })); + return; + } setState(prevState => ({ ...prevState, isLoading: true })); - // Save the settings to storage - const storage = adapter.getStorage(); - await storage.set(STORAGE_KEYS.SETTINGS, state.settings); - // Notify the background script that settings have changed await adapter.sendMessage(MessageContext.OPTIONS, { type: 'UPDATE_SETTINGS', - settings: state.settings, + settings: { + ...state.settings, + trustDirectorySubscriptions: directorySubscriptions, + trustDirectoryUrls: directorySubscriptions.map(subscription => subscription.url), + trustDirectoryUrl: '', + }, }); setState(prevState => ({ @@ -468,38 +501,70 @@ const Options: React.FC = ({ adapter }) => {

Trust Directory Settings

-
- -