diff --git a/README.md b/README.md index 5389c70..5bb90dd 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,37 @@ # HTMLTrust Browser Reference -Reference browser extension for client-side validation of HTMLTrust signed content. Verifies cryptographic signatures embedded in web pages using the `` element protocol. +Reference browser extension for validating HTMLTrust `` elements in a browser. -This is a companion to the [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec). +The extension verifies signatures locally, shows a status marker beside each signed section, and exposes details in the popup. It is a companion to the [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec). -## What It Does +## Start here -When you visit a web page containing signed content, this extension: +Readers: contributors and implementers. The normal workflow is: -- **Detects** `` elements carrying `signature`, `keyid`, `algorithm`, and `content-hash` attributes -- **Verifies** signatures by fetching the author's public key and validating the cryptographic signature -- **Displays** trust indicators (badges, outlines) showing verification status -- **Queries** optional trust directories for author reputation and endorsements -- **Enables** community trust/distrust voting on authors and content +1. Build the sibling browser-client package. +2. Install this repository. +3. Run tests and type checking. +4. Build the extension for the browser you use. -## Architecture - -The codebase is split into **shared** (reusable) and **browser-specific** layers: - -``` -src/ -├── core/ # ✅ SHARED — reusable across any browser -│ ├── api/ # REST clients for HTMLTrust trust directory server -│ ├── auth/ # Authentication service (API key management) -│ ├── common/ # Types, constants, utilities -│ ├── content/ # Content processor (DOM canonicalization, hashing, metadata extraction) -│ └── storage/ # Storage abstraction (interface + in-memory implementation) -├── platforms/ # 🔴 BROWSER-SPECIFIC — one adapter per browser -│ ├── common/ # PlatformAdapter interface (storage, messaging, tabs, scripting) -│ ├── chromium/ # Chrome / Edge implementation + Manifest V3 -│ ├── firefox/ # Future, Manifest V2 (manifest only, no adapter yet) -│ └── safari/ # Future, Manifest V3 (manifest only, no adapter yet) -├── ui/ # ✅ SHARED — React components for popup, options, and in-page UI -│ ├── components/ # Reusable widgets (Button, MetadataInput, ProfileManager, etc.) -│ ├── popup/ # Extension popup (verification status, signing controls) -│ └── options/ # Extension options page (settings, profiles, server config) -├── background/ # Service worker entry point -├── content-scripts/ # Content script entry point -└── assets/ # Icons, CSS -``` +After each page load or same-document navigation, the content script refetches the current HTTPS URL (using the browser HTTP cache when available). It parses that response with the browser's HTML parser and freezes signed-section snapshots. It verifies those snapshots, then compares each one with the current live element. If page code changes a signed element, the extension marks it as stale and re-verifies it. A refetch can differ from the original response on personalized, time-varying, or service-worker-controlled pages. Status markers are siblings of ``, so extension UI cannot become signed content. -### Adding a New Browser - -1. Create `src/platforms//adapter.ts` implementing the `PlatformAdapter` interface -2. Create `src/platforms//manifest.json` for that browser -3. Update `webpack.config.js` to add the new target -4. The shared `core/`, `ui/`, `background/`, and `content-scripts/` code works unchanged - -## Tech Stack - -- **TypeScript** with strict mode -- **React 19** for UI components -- **Webpack 5** with per-browser build targets -- **Jest** + ts-jest for testing -- **js-sha256** + **simhash-js** for content hashing - -## Quick Start +## Quick start ### Prerequisites -- Node.js 22+ and npm -- Chromium, Firefox, or Safari for loading the matching build +- Node.js 22 or newer +- npm +- Chromium, Firefox, or Safari for loading a built extension -The extension consumes the browser-client package from a sibling checkout. Use this layout when developing the two repositories together: +Use this checkout layout. The browser-reference package has a local dependency on the browser-client package during development: ``` -workspace/ +htmltrust-workspace/ ├── htmltrust-browser-client/ └── htmltrust-browser-reference/ ``` -The canonicalization package is downloaded from its pinned v0.2.2 release archive. The browser-client sibling must be built before installing this package. - -### Clean checkout +Create both checkouts and build the client first: ```sh mkdir htmltrust-workspace @@ -80,61 +39,104 @@ cd htmltrust-workspace git clone https://github.com/HTMLTrust/htmltrust-browser-client.git git clone https://github.com/HTMLTrust/htmltrust-browser-reference.git cd htmltrust-browser-client -git checkout 09e8c7552c8111a2cedd83fa45f4ffe3811bf5ca npm ci --ignore-scripts npm run build cd ../htmltrust-browser-reference npm ci --ignore-scripts ``` -The browser-client commit above is the revision pinned by the reference repository's CI. Keep the checkout at that revision when reproducing CI locally. +The reference repository CI pins the client to commit `09e8c7552c8111a2cedd83fa45f4ffe3811bf5ca`. Check out that revision when reproducing CI exactly. -### Build +### Test and type-check -Build for a specific browser: +```sh +npm test -- --runInBand +npm run typecheck +npm run lint +``` + +Tests use jsdom for DOM behavior. Run the complete check in a Node 22 +container with: ```sh -npm run build:chromium # → build/chromium/ -npm run build:firefox # → build/firefox/ -npm run build:safari # → build/safari/ +./scripts/test-in-docker.sh ``` -Or build all: +The script copies both sibling repositories into the container, builds the +browser client, runs 60 extension tests, checks types and lint, then builds the +Chromium, Firefox, and Safari packages. Generated files stay outside the +checkout. + +### Build + +Build one browser with `npm run build:chromium`, `npm run build:firefox`, or `npm run build:safari`. Build all targets and zip archives with: ```sh -npm run build # Builds all targets + creates zips +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/`. + ### Development ```sh -npm run dev:chromium # Watch mode for Chromium +npm run dev:chromium ``` -### Load in Chrome +Use the matching `dev:firefox` or `dev:safari` command for another target. Reload the unpacked extension after a rebuild. -1. Open `chrome://extensions/` -2. Enable "Developer mode" -3. Click "Load unpacked" → select the `build/chromium/` folder +## Verification lifecycle -### Test +`src/core/content/navigation-lifecycle.ts` owns navigation state: -```sh -npm test # Run all tests -npm run typecheck # TypeScript check without emitting files -npm run lint # Lint TypeScript sources -``` +- `captureNavigationSnapshot` parses refetched HTML and freezes source sections. +- `mapSnapshotToLiveSections` pairs source sections with live elements by signed attributes, so page reordering does not pair one signature with another. +- `observeSignedSection` watches only the live signed element. Mutations trigger re-verification against the immutable source section. History changes and replacement of signed sections trigger a fresh page refetch. +- The content script inserts markers beside the signed element. The marker, tooltip, and vote controls are outside signed content. -The CI validation sequence runs `npm run lint`, `npm test`, and each browser build: +The popup receives copied result records. It cannot mutate the content script's verification cache. -```sh -npm run lint -npm test -npm run build:chromium -npm run build:firefox -npm run build:safari +## Architecture + +The codebase is split into **shared** (reusable) and **browser-specific** layers: + +``` +src/ +├── core/ # Shared code used by every browser +│ ├── api/ # REST clients for HTMLTrust trust directory server +│ ├── auth/ # Authentication service (API key management) +│ ├── common/ # Types, constants, utilities +│ ├── content/ # Content processor (DOM canonicalization, hashing, metadata extraction) +│ └── storage/ # Storage abstraction (interface + in-memory implementation) +├── platforms/ # One adapter per browser +│ ├── common/ # PlatformAdapter interface (storage, messaging, tabs, scripting) +│ ├── chromium/ # Chrome / Edge implementation + Manifest V3 +│ ├── firefox/ # Future, Manifest V2 (manifest only, no adapter yet) +│ └── safari/ # Future, Manifest V3 (manifest only, no adapter yet) +├── ui/ # Shared popup, options, and in-page React UI +│ ├── components/ # Reusable widgets (Button, MetadataInput, ProfileManager, etc.) +│ ├── popup/ # Extension popup (verification status, signing controls) +│ └── options/ # Extension options page (settings, profiles, server config) +├── background/ # Service worker entry point +├── content-scripts/ # Content script entry point +└── assets/ # Icons, CSS ``` +### Adding a New Browser + +1. Create `src/platforms//adapter.ts` implementing the `PlatformAdapter` interface +2. Create `src/platforms//manifest.json` for that browser +3. Update `webpack.config.js` to add the new target +4. The shared `core/`, `ui/`, `background/`, and `content-scripts/` code works unchanged + +## Tech stack + +- **TypeScript** with strict mode +- **React 19** for UI components +- **Webpack 5** with per-browser build targets +- **Jest** + ts-jest for testing +- **js-sha256** + **simhash-js** for content hashing + ## Project Structure ``` @@ -150,11 +152,10 @@ npm run build:safari ## Current Status -- ✅ Chromium adapter fully implemented -- ✅ Core content verification pipeline -- ✅ React popup and options UI -- ⬜ Firefox adapter, manifest only, needs a `browser.*` API adapter -- ⬜ Safari adapter, manifest only, needs an adapter +- Complete: Chromium adapter, core verification, popup, and options UI +- Complete: navigation snapshots and mutation re-verification +- Pending: Firefox `browser.*` API adapter; the manifest exists +- Pending: Safari adapter; the manifest exists ## Companion Repositories @@ -170,18 +171,14 @@ npm run build:safari This project is licensed under the [PolyForm Noncommercial License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0). You may use, modify, and share the software for any noncommercial purpose with attribution. Commercial use requires a separate agreement with the licensor. -## Origin & Contributions - -HTMLTrust is an idea I (Jason Grey) have been chewing on since 2024. I'm not an academic — I'm an engineer with a day job and a family — so the spec, the reference implementations, and most of this prose have been written with significant help from AI tools acting as research assistant, technical writer, and pair programmer. I wrote the original architectural sketches and reviewed every line; the assistants filled in the gaps and saved me from re-typing the same explanation for the hundredth time. - -**Contributions are welcome — human or AI-assisted, doesn't matter to me.** What matters is whether the code, the spec text, or the conformance vectors move the project forward. Open a PR. - -What this project is **not** a forum for: +## Origin and contributions -- Debates about whether AI should be used to write code or specifications. -- Opinions on who is or isn't trustworthy on the web. -- Politics, religion, professional practice, or personal philosophy. +Jason Grey began HTMLTrust in 2024 and reviews the protocol and reference +implementations. AI tools have supported research, drafting, and pair +programming throughout the project. -HTMLTrust is a mechanism — a way for *anyone* to sign content they publish and for *anyone* to decide whom they trust, on their own terms. The project takes no position on what the right answers are; it just provides the tools. If you want to debate the answers, there are entire continents of the internet better suited to it. +Contributions are welcome. Open a pull request with the tests or conformance +vectors that demonstrate the change. Keep repository discussion focused on +the protocol and implementation behavior. If this work is useful to you and you'd like to support it, see [GitHub Sponsors](https://github.com/sponsors/jt55401) or the other channels in [`.github/FUNDING.yml`](.github/FUNDING.yml). diff --git a/scripts/test-in-docker.sh b/scripts/test-in-docker.sh new file mode 100755 index 0000000..b4fb96d --- /dev/null +++ b/scripts/test-in-docker.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CLIENT_ROOT="$(cd "$REPO_ROOT/../htmltrust-browser-client" 2>/dev/null && pwd || true)" + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required." >&2 + exit 2 +fi +if [[ -z "$CLIENT_ROOT" || ! -f "$CLIENT_ROOT/package.json" ]]; then + echo "Expected htmltrust-browser-client beside this checkout." >&2 + echo "Clone it at: $(dirname "$REPO_ROOT")/htmltrust-browser-client" >&2 + exit 2 +fi + +CHECKOUT_ID="$(printf '%s\n%s' "$REPO_ROOT" "$CLIENT_ROOT" | cksum | awk '{print $1}')" +NPM_CACHE="htmltrust-browser-${CHECKOUT_ID}-npm" + +docker run --rm \ + --volume "$REPO_ROOT:/source/browser-reference:ro" \ + --volume "$CLIENT_ROOT:/source/browser-client:ro" \ + --volume "$NPM_CACHE:/root/.npm" \ + node:22-bookworm sh -euc ' + mkdir -p /work/htmltrust-browser-reference /work/htmltrust-browser-client + (cd /source/browser-reference && tar --exclude=node_modules --exclude=build -cf - .) \ + | (cd /work/htmltrust-browser-reference && tar -xf -) + (cd /source/browser-client && tar --exclude=node_modules --exclude=build -cf - .) \ + | (cd /work/htmltrust-browser-client && tar -xf -) + + cd /work/htmltrust-browser-client + npm ci --ignore-scripts --no-audit --no-fund + npm run build + + cd /work/htmltrust-browser-reference + npm ci --ignore-scripts --no-audit --no-fund + npm test -- --runInBand + npm run typecheck + npm run lint + npm run build:all + ' diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts index d619f14..64ee03f 100644 --- a/src/content-scripts/index.ts +++ b/src/content-scripts/index.ts @@ -7,8 +7,8 @@ * this script as document_idle equivalent for content_scripts), find * every on the page, verify each via * @htmltrust/browser-client (Layer 1, SubtleCrypto-backed), evaluate the - * trust policy locally (Layer 2), and inject the corresponding badges - * inline next to each section. No popup interaction required. + * trust policy locally (Layer 2), and inject the corresponding status + * marker beside each section. No popup interaction required. * * 2. Preserve the existing popup-driven flow. The background script can * still push a richer VerificationResult via UPDATE_VERIFICATION_UI, in @@ -22,7 +22,6 @@ */ import { verifySignedSection, - extractSignedSections, evaluateTrustPolicy, defaultResolverChain, type VerifyResult, @@ -32,6 +31,13 @@ import { } from '@htmltrust/browser-client'; import { MESSAGE_TYPES, CSS_CLASSES, TRUST_STATUS, STORAGE_KEYS } from '../core/common/constants'; import { ContentProcessor } from '../core/content'; +import { + captureNavigationSnapshot, + mapSnapshotToLiveSections, + observeSignedSection, + SIGNED_SECTION_SELECTOR, + type NavigationSnapshot, +} from '../core/content/navigation-lifecycle'; import { PlatformAdapter, MessageContext } from '../platforms/common'; import { VerificationResult, @@ -91,6 +97,17 @@ type SectionVerificationRun = { /** Module-scoped cache of this page's verification results. */ const pageVerifications: PageVerification[] = []; +const pageVerificationBySection = new WeakMap(); +const sectionObserverDisposers = new WeakMap void>(); +let navigationSnapshot: NavigationSnapshot | null = null; +let observedSections = new Set(); +let rerenderObserver: MutationObserver | null = null; +let navigationRun = 0; +let rerenderTimer: ReturnType | null = null; +let navigationPollTimer: ReturnType | null = null; +let lastObservedUrl = ''; +let lifecycleInstalled = false; +const sectionReverifyGeneration = new WeakMap(); /** * Pull authorId out of a `.../authors/{id}/public-key` keyid URL. Returns @@ -135,10 +152,11 @@ async function initialize() { directories, fetch: createVerifierFetch(), }); + installNavigationLifecycle(); // 2. Auto-verify on page load. Idempotent: re-running is a no-op for // sections that already have an auto badge container next to them. - await autoVerifyPage(currentResolverChain, currentSettings); + await autoVerifyPage(currentResolverChain, currentSettings, navigationRun); // 3. Legacy popup path: notify background, optionally apply richer UI // on UPDATE_VERIFICATION_UI messages. This is best-effort and @@ -160,6 +178,10 @@ async function initialize() { const next = changes[STORAGE_KEYS.SETTINGS].newValue as Settings | undefined; if (!next) return; currentSettings = next; + currentResolverChain = defaultResolverChain({ + directories: getTrustDirectoryUrls(next), + fetch: createVerifierFetch(), + }); redecoratePage(); }); } @@ -175,22 +197,16 @@ async function initialize() { */ function redecoratePage(): void { if (!currentSettings) return; - const sections = document.querySelectorAll(`signed-section[signature]`); + const sections = document.querySelectorAll(SIGNED_SECTION_SELECTOR); // Clear our existing additions on every section we've touched. sections.forEach((section) => { - section.classList.remove( - SECTION_DECORATED_CLASS, - CSS_CLASSES.CONTENT_OUTLINE, - CSS_CLASSES.VERIFIED_CONTENT, - CSS_CLASSES.UNVERIFIED_CONTENT, - ); - (section as HTMLElement).removeAttribute('title'); - section.querySelectorAll(`.${AUTO_BADGE_MARKER}`).forEach((b) => b.remove()); + clearSectionStatusUI(section); }); // Re-apply using cached results so we don't rerun verification. const list = Array.from(sections); - for (let i = 0; i < list.length && i < pageVerifications.length; i++) { - const cached = pageVerifications[i]; + for (const section of list) { + const cached = pageVerificationBySection.get(section); + if (!cached) continue; // Reconstruct a minimal VerifyResult/TrustEvaluation shape for the UI // applier. The cache is intentionally a flat snapshot; the original // objects don't survive across the listener boundary. @@ -220,8 +236,77 @@ function redecoratePage(): void { displayValid: cached.valid, reason: cached.reason, }; - applySectionStatusUI(list[i], runShape, trustShape, cached.reason, currentSettings); + applySectionStatusUI(section, runShape, trustShape, cached.reason, currentSettings); + } +} + +/** Reset cached state before a same-document navigation or page rerender. */ +function resetNavigationState(): void { + navigationRun += 1; + if (rerenderTimer !== null) { + clearTimeout(rerenderTimer); + rerenderTimer = null; + } + observedSections.forEach((section) => { + sectionReverifyGeneration.set(section, (sectionReverifyGeneration.get(section) ?? 0) + 1); + sectionObserverDisposers.get(section)?.(); + clearSectionStatusUI(section); + }); + observedSections = new Set(); + pageVerifications.length = 0; + navigationSnapshot = null; +} + +function scheduleNavigationRefresh(): void { + if (rerenderTimer !== null) return; + resetNavigationState(); + lastObservedUrl = window.location.href; + rerenderTimer = setTimeout(() => { + rerenderTimer = null; + if (!currentSettings || !currentSettings.autoVerify) return; + void autoVerifyPage(currentResolverChain, currentSettings, navigationRun); + }, 0); +} + +/** Watch history events and replacement of signed sections by SPA renderers. */ +function installNavigationLifecycle(): void { + if (lifecycleInstalled) return; + lifecycleInstalled = true; + lastObservedUrl = window.location.href; + const notify = () => scheduleNavigationRefresh(); + window.addEventListener('popstate', notify); + window.addEventListener('hashchange', notify); + for (const method of ['pushState', 'replaceState'] as const) { + const original = window.history[method]; + window.history[method] = function (...args) { + const result = original.apply(this, args); + notify(); + return result; + }; + } + rerenderObserver = new MutationObserver(() => { + const current = new Set(document.querySelectorAll(SIGNED_SECTION_SELECTOR)); + if (current.size !== observedSections.size || [...current].some((section) => !observedSections.has(section))) { + notify(); + } + }); + if (document.documentElement) { + rerenderObserver.observe(document.documentElement, { childList: true, subtree: true }); } + // Extension content scripts run in an isolated JavaScript world in Chromium. + // A page-world pushState call may bypass the wrapper above, so compare the + // shared location on a short interval as a cross-browser fallback. + navigationPollTimer = setInterval(() => { + if (window.location.href !== lastObservedUrl) notify(); + }, 500); + window.addEventListener('pagehide', () => { + if (navigationPollTimer !== null) { + clearInterval(navigationPollTimer); + navigationPollTimer = null; + } + rerenderObserver?.disconnect(); + observedSections.forEach((section) => sectionObserverDisposers.get(section)?.()); + }, { once: true }); } /** @@ -303,13 +388,13 @@ function createVerifierFetch(): typeof fetch { } async function fetchPristineSignedSections(settings: Settings): Promise<{ - slices: string[]; + snapshot: NavigationSnapshot | null; error: string | null; }> { const pageUrl = new URL(window.location.href); if (pageUrl.protocol !== 'https:') { return { - slices: [], + snapshot: null, error: 'network-policy-blocked: source refetch requires HTTPS', }; } @@ -323,17 +408,20 @@ async function fetchPristineSignedSections(settings: Settings): Promise<{ redirect: 'error', }); if (!pageResp.ok) { - return { slices: [], error: `source-refetch-failed: HTTP ${pageResp.status}` }; + return { snapshot: null, error: `source-refetch-failed: HTTP ${pageResp.status}` }; } if (new URL(pageResp.url).origin !== currentOrigin()) { - return { slices: [], error: 'network-policy-blocked: source refetch changed origin' }; + return { snapshot: null, error: 'network-policy-blocked: source refetch changed origin' }; } const pageHTML = await pageResp.text(); - return { slices: extractSignedSections(pageHTML), error: null }; + return { + snapshot: captureNavigationSnapshot(pageHTML, pageResp.url || window.location.href), + error: null, + }; } catch (err) { const message = err instanceof Error ? err.message : String(err); debugLog(settings, 'source refetch failed', { reason: message }); - return { slices: [], error: `source-refetch-failed: ${message}` }; + return { snapshot: null, error: `source-refetch-failed: ${message}` }; } } @@ -399,26 +487,27 @@ async function verifySectionWithState( /** * Walk every on the page and verify it locally. * - * Each section is verified independently — a failure on one does not skip - * the others. Badges are inserted as the next sibling of the section - * element, matching the e2e harness's visual placement. + * Each section is verified independently. A failure on one does not skip + * the others. Markers are inserted as the next sibling of the section + * element, keeping extension-owned nodes out of signed content. * - * Idempotent: if a section already has an auto-badge sibling, it's skipped. + * Idempotent: if a section already has an auto-marker sibling, it's skipped. * This protects against re-runs (e.g. the script being injected twice on a * page that does its own DOM manipulation). */ async function autoVerifyPage( resolverChain: KeyResolver[], settings: Settings, + expectedNavigationRun = navigationRun, ): 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) { + if (!settings.autoVerify || expectedNavigationRun !== navigationRun) { return; } - const sections = document.querySelectorAll('signed-section[signature]'); + const sections = document.querySelectorAll(SIGNED_SECTION_SELECTOR); if (sections.length === 0) { // Graceful no-op: pages without signed-sections are common and not an error. return; @@ -438,47 +527,53 @@ async function autoVerifyPage( // hashed. Documented as "Known Issue: Runtime DOM Mutation" in the spec // README. // - // The DOM section is still used for UI placement (badge anchor, decoration) - // — only the bytes fed to verifySignedSection come from the pristine fetch. + // The DOM section is used for UI placement only. The bytes fed to + // verifySignedSection come from the pristine fetch. // - // Pristine slices are position-paired with live DOM sections by document - // order. A page that re-orders signed-sections at runtime would defeat - // this pairing; that case is out of scope (would also defeat any - // signature-validity semantics). + // The parser-backed mapper pairs source sections by their signed identity, + // so a page that re-orders sections cannot swap one source signature for + // another. // // Fetch is cache-friendly: 'force-cache' lets the browser HTTP cache // serve this near-instantaneously on the typical reload-after-load path. // On the first load it's a duplicate of the navigation, which the HTTP // cache catches per RFC 7234 when the origin sets reasonable cache headers. - const { slices: fetchedPristineSlices, error: pristineFetchError } = + const { snapshot: fetchedSnapshot, error: pristineFetchError } = await fetchPristineSignedSections(settings); - let pristineSlices = fetchedPristineSlices; + if (expectedNavigationRun !== navigationRun) return; + navigationSnapshot = fetchedSnapshot; + const liveSections = Array.from(sections); + observedSections = new Set(liveSections); + const mapped = fetchedSnapshot + ? mapSnapshotToLiveSections(fetchedSnapshot, liveSections) + : { matches: [], complete: false }; // If the pristine fetch failed entirely OR returned a different count // than the DOM (page re-rendered between navigation and our fetch, SPA // route change, intercepting service worker), we fall back to per-section // DOM-based verification. The runtime-mutation false-invalid risk // re-applies, but it's better than no verification at all. - if (pristineFetchError || pristineSlices.length !== sections.length) { + if (pristineFetchError || !mapped.complete) { debugLog(settings, 'source snapshot unavailable; falling back to rendered DOM verification', { reason: pristineFetchError, - sourceSections: pristineSlices.length, + sourceSections: fetchedSnapshot?.sections.length ?? 0, renderedSections: sections.length, }); - pristineSlices = []; + navigationSnapshot = null; } let i = 0; - for (const section of Array.from(sections)) { + for (const section of liveSections) { + if (expectedNavigationRun !== navigationRun) return; // Idempotency: skip sections we've already decorated. - if (section.classList.contains(SECTION_DECORATED_CLASS)) { + if (section.nextElementSibling?.classList.contains(AUTO_BADGE_MARKER)) { continue; } try { const run = await verifySectionWithState( section, - pristineSlices.length ? pristineSlices[i] : null, + mapped.complete ? (mapped.matches.find((match) => match.live === section)?.source.outerHTML ?? null) : null, resolverChain, settings, ); @@ -499,7 +594,7 @@ async function autoVerifyPage( }); applySectionStatusUI(section, run, trust, null, settings); - pageVerifications.push({ + const pageVerification: PageVerification = { index: i, valid: run.displayValid, cryptoValid: verify.valid, @@ -515,13 +610,16 @@ async function autoVerifyPage( signedAt: verify.signedAt, domain: verify.domain, claims: verify.claims ?? {}, - }); + }; + pageVerifications.push(pageVerification); + pageVerificationBySection.set(section, pageVerification); + armSectionMutationInvalidation(section, mapped.complete ? (mapped.matches.find((match) => match.live === section)?.source.outerHTML ?? null) : null, resolverChain, settings); } catch (err) { 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 }); applySectionStatusUI(section, null, null, reason, settings); - pageVerifications.push({ + const pageVerification: PageVerification = { index: i, valid: false, cryptoValid: false, @@ -537,19 +635,21 @@ async function autoVerifyPage( signedAt: '', domain: currentOrigin(), claims: {}, - }); + }; + pageVerifications.push(pageVerification); + pageVerificationBySection.set(section, pageVerification); + armSectionMutationInvalidation(section, null, resolverChain, settings); } i++; } } -/** Class added to a signed-section once we've decorated it. */ -const SECTION_DECORATED_CLASS = 'cs-decorated'; - /** - * Apply quiet, per-section visual cues directly to the signed-section: - * - dotted outline whose color reflects signature validity - * - tiny circular ✓/✗ badge in the top-right + * Apply quiet, per-section visual cues beside the signed-section. + * + * The indicator is always a sibling. It never becomes a child of the signed + * element, so extension-owned nodes cannot enter the signed verification + * input. The same rule applies to the legacy popup path below. * * Three settings gate what gets drawn: * - showBadges: master switch. Off = no decoration at all. @@ -566,23 +666,21 @@ function applySectionStatusUI( errorReason: string | null, settings: Settings, ): void { - // Mark the section as decorated regardless of settings so we don't redo - // verification on it. (The verify result is already cached for the popup.) - section.classList.add(SECTION_DECORATED_CLASS); - // Master kill switch. + clearSectionStatusUI(section); if (!settings.showBadges) return; const verify = run?.verify ?? null; const valid = run?.displayValid === true; const stale = run?.inputState === 'stale'; - if (valid && settings.highlightVerified) { - section.classList.add(CSS_CLASSES.CONTENT_OUTLINE, CSS_CLASSES.VERIFIED_CONTENT); - } else if (stale && settings.highlightVerified) { - section.classList.add(CSS_CLASSES.CONTENT_OUTLINE, CSS_CLASSES.UNKNOWN_CONTENT); - } else if (!valid && settings.highlightUnverified) { - section.classList.add(CSS_CLASSES.CONTENT_OUTLINE, CSS_CLASSES.UNVERIFIED_CONTENT); - } + const outlineClass = + valid && settings.highlightVerified + ? CSS_CLASSES.VERIFIED_CONTENT + : stale && settings.highlightVerified + ? CSS_CLASSES.UNKNOWN_CONTENT + : !valid && settings.highlightUnverified + ? CSS_CLASSES.UNVERIFIED_CONTENT + : null; // Tooltip carries a short warning only. The extension popup is the // authoritative, less-spoofable surface for details. @@ -599,10 +697,11 @@ function applySectionStatusUI( const tooltip = `HTMLTrust page marker only; open the extension popup for authoritative details. ${statePart}${trustPart}${ reason ? ` (${reason})` : '' }`; - (section as HTMLElement).title = tooltip; - const badges = document.createElement('div'); badges.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; + if (outlineClass) badges.classList.add(CSS_CLASSES.CONTENT_OUTLINE, outlineClass); + badges.setAttribute('role', 'status'); + badges.setAttribute('aria-label', tooltip); const sig = document.createElement('span'); sig.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VALIDITY_BADGE} ${ valid @@ -614,7 +713,116 @@ function applySectionStatusUI( sig.textContent = valid ? '✓' : stale || verify?.valid ? '!' : '✗'; sig.title = tooltip; badges.appendChild(sig); - section.appendChild(badges); + section.parentNode?.insertBefore(badges, section.nextSibling); +} + +/** Remove only extension-owned sibling UI, leaving signed content untouched. */ +function clearSectionStatusUI(section: Element): void { + let sibling = section.nextElementSibling; + while (sibling?.classList.contains(AUTO_BADGE_MARKER)) { + const next = sibling.nextElementSibling; + sibling.remove(); + sibling = next; + } +} + +/** Re-verify a section after live content changes, against its frozen source. */ +function armSectionMutationInvalidation( + section: Element, + sourceSlice: string | null, + resolverChain: KeyResolver[], + settings: Settings, +): void { + sectionObserverDisposers.get(section)?.(); + const observerNavigationRun = navigationRun; + const dispose = observeSignedSection(section, (changedSection) => { + const generation = (sectionReverifyGeneration.get(changedSection) ?? 0) + 1; + sectionReverifyGeneration.set(changedSection, generation); + clearSectionStatusUI(changedSection); + const activeSettings = currentSettings ?? settings; + applySectionStatusUI( + changedSection, + null, + null, + 'signed content changed; re-verifying', + activeSettings, + ); + + void (async () => { + try { + const run = await verifySectionWithState( + changedSection, + sourceSlice, + currentResolverChain.length ? currentResolverChain : resolverChain, + activeSettings, + ); + const trust = await evaluateTrustPolicy(run.verify, { + personalTrustList: activeSettings.personalTrustList ?? [], + trustedDomains: activeSettings.trustedDomains ?? [], + directorySubscriptions: [], + }); + if ( + sectionReverifyGeneration.get(changedSection) !== generation || + navigationRun !== observerNavigationRun + ) return; + applySectionStatusUI(changedSection, run, trust, null, activeSettings); + const existing = pageVerificationBySection.get(changedSection); + if (!existing) return; + const updated: PageVerification = { + ...existing, + valid: run.displayValid, + cryptoValid: run.verify.valid, + inputState: run.inputState, + sourceVerified: run.sourceVerified, + renderedVerified: run.renderedVerified, + reason: run.reason, + trustScore: trust.score, + trustIndicator: trust.indicator, + trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + keyid: run.verify.keyid, + algorithm: run.verify.algorithm, + signedAt: run.verify.signedAt, + domain: run.verify.domain, + claims: run.verify.claims ?? {}, + }; + pageVerificationBySection.set(changedSection, updated); + const index = pageVerifications.indexOf(existing); + if (index >= 0) pageVerifications[index] = updated; + } catch (error) { + if ( + sectionReverifyGeneration.get(changedSection) !== generation || + navigationRun !== observerNavigationRun + ) return; + const reason = error instanceof Error ? error.message : String(error); + applySectionStatusUI( + changedSection, + null, + null, + reason, + activeSettings, + ); + const existing = pageVerificationBySection.get(changedSection); + if (existing) { + const failed: PageVerification = { + ...existing, + valid: false, + cryptoValid: false, + inputState: 'stale', + sourceVerified: false, + renderedVerified: false, + reason, + trustScore: 0, + trustIndicator: 'red', + trustLabel: 'Untrusted', + }; + pageVerificationBySection.set(changedSection, failed); + const index = pageVerifications.indexOf(existing); + if (index >= 0) pageVerifications[index] = failed; + } + } + })(); + }); + sectionObserverDisposers.set(section, dispose); } /** @@ -791,20 +999,6 @@ function applyVerificationUIToElement( verificationResult: VerificationResult, settings: NonNullable ) { - // Add content outline class - element.classList.add(CSS_CLASSES.CONTENT_OUTLINE); - - // Determine verification status class - if (verificationResult.verified) { - if (settings.highlightVerified) { - element.classList.add(CSS_CLASSES.VERIFIED_CONTENT); - } - } else { - if (settings.highlightUnverified) { - element.classList.add(CSS_CLASSES.UNVERIFIED_CONTENT); - } - } - // Add verification badges if enabled if (settings.showBadges) { addVerificationBadges(element, verificationResult); @@ -816,9 +1010,10 @@ function applyVerificationUIToElement( */ function addVerificationBadges(element: Element, verificationResult: VerificationResult) { try { + clearSectionStatusUI(element); // Create badge container const badgeContainer = document.createElement('div'); - badgeContainer.className = CSS_CLASSES.VERIFICATION_BADGES; + badgeContainer.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; // Add validity badge const validityBadge = createValidityBadge(verificationResult); @@ -828,8 +1023,9 @@ function addVerificationBadges(element: Element, verificationResult: Verificatio const trustBadge = createTrustBadge(verificationResult); badgeContainer.appendChild(trustBadge); - // Add the badge container to the element - element.appendChild(badgeContainer); + // Keep extension UI outside the signed element. This prevents a badge or + // tooltip from becoming part of the bytes that the signature protects. + element.parentNode?.insertBefore(badgeContainer, element.nextSibling); } catch (error) { console.error('Failed to add verification badges:', error); } @@ -1040,13 +1236,20 @@ function listenForMessages() { applyVerificationUI(message.verificationResult); return { success: true }; case 'GET_PAGE_VERIFICATIONS': + { // Popup reads the per-section results from here. Snapshot to keep - // the array immutable from the caller's perspective. + // both the array and each record immutable from the caller's + // perspective. The popup cannot mutate the content script cache. + const results = pageVerifications.map((result) => Object.freeze({ ...result })); return { url: window.location.href, domain: currentOrigin(), - results: pageVerifications.slice(), + snapshot: navigationSnapshot + ? { url: navigationSnapshot.url, capturedAt: navigationSnapshot.capturedAt, sectionCount: navigationSnapshot.sections.length } + : null, + results: Object.freeze(results), }; + } case MESSAGE_TYPES.VOTE_ACKNOWLEDGED: if (message.authorId) { const upvoteButtons = document.querySelectorAll( diff --git a/src/core/content/index.ts b/src/core/content/index.ts index 1941d61..0b3bef2 100644 --- a/src/core/content/index.ts +++ b/src/core/content/index.ts @@ -2,4 +2,5 @@ * Content module exports */ -export * from './content-processor'; \ No newline at end of file +export * from './content-processor'; +export * from './navigation-lifecycle'; diff --git a/src/core/content/navigation-lifecycle.test.ts b/src/core/content/navigation-lifecycle.test.ts new file mode 100644 index 0000000..3fd3366 --- /dev/null +++ b/src/core/content/navigation-lifecycle.test.ts @@ -0,0 +1,103 @@ +import { + captureNavigationSnapshot, + mapSnapshotToLiveSections, + mutationTouchesSignedSection, + observeSignedSection, + SIGNED_SECTION_SELECTOR, +} from './navigation-lifecycle'; + +describe('navigation lifecycle snapshots', () => { + it('parses served HTML and freezes the navigation snapshot', () => { + const snapshot = captureNavigationSnapshot( + '

source

', + 'https://example.test/article', + 123, + ); + + expect(snapshot.sections).toHaveLength(1); + expect(snapshot.sections[0].outerHTML).toContain('

source

'); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.sections)).toBe(true); + expect(Object.isFrozen(snapshot.sections[0])).toBe(true); + }); + + it('maps reordered live sections by signed identity rather than array position', () => { + const source = captureNavigationSnapshot( + 'AB', + 'https://example.test/', + ); + document.body.innerHTML = + 'B changedA changed'; + const live = Array.from(document.querySelectorAll(SIGNED_SECTION_SELECTOR)); + + const result = mapSnapshotToLiveSections(source, live); + + expect(result.complete).toBe(true); + expect(result.matches.map((match) => match.source.index)).toEqual([1, 0]); + expect(result.matches.map((match) => match.live.textContent)).toEqual(['B changed', 'A changed']); + }); + + it('marks a missing or added live section as an incomplete mapping', () => { + const source = captureNavigationSnapshot( + 'A', + 'https://example.test/', + ); + document.body.innerHTML = 'A'; + + const result = mapSnapshotToLiveSections(source, Array.from(document.querySelectorAll(SIGNED_SECTION_SELECTOR))); + + expect(result.complete).toBe(false); + expect(result.matches).toHaveLength(0); + }); + + it('does not use a positional match when a signed identity is absent', () => { + const source = captureNavigationSnapshot( + 'AB', + 'https://example.test/', + ); + document.body.innerHTML = + 'BC'; + + const live = Array.from(document.querySelectorAll(SIGNED_SECTION_SELECTOR)); + const result = mapSnapshotToLiveSections(source, live); + + expect(result.complete).toBe(false); + expect(result.matches.map((match) => match.source.identity)).toEqual([ + source.sections[1].identity, + ]); + expect(result.matches[0].live).toBe(live[0]); + }); +}); + +describe('signed-section mutation invalidation', () => { + it('recognizes content and signed-attribute mutations', () => { + document.body.innerHTML = 'text
'; + const section = document.querySelector('signed-section')!; + const child = section.querySelector('span')!; + + expect(mutationTouchesSignedSection({ type: 'characterData', target: child.firstChild } as unknown as MutationRecord, section)).toBe(true); + expect(mutationTouchesSignedSection({ type: 'attributes', target: section, attributeName: 'signature' } as unknown as MutationRecord, section)).toBe(true); + expect(mutationTouchesSignedSection({ type: 'childList', target: section, addedNodes: [], removedNodes: [] } as unknown as MutationRecord, section)).toBe(true); + expect(mutationTouchesSignedSection({ type: 'attributes', target: document.querySelector('#indicator')! } as unknown as MutationRecord, section)).toBe(false); + }); + + it('notifies after a mutation and ignores sibling indicators', async () => { + document.body.innerHTML = 'text
'; + const section = document.querySelector('signed-section')!; + const indicator = document.querySelector('#indicator')!; + const callback = jest.fn(); + const disconnect = observeSignedSection(section, callback); + + section.textContent = 'changed'; + await Promise.resolve(); + await Promise.resolve(); + expect(callback).toHaveBeenCalledWith(section); + + callback.mockClear(); + indicator.textContent = 'trusted'; + await Promise.resolve(); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + disconnect(); + }); +}); diff --git a/src/core/content/navigation-lifecycle.ts b/src/core/content/navigation-lifecycle.ts new file mode 100644 index 0000000..d4503d5 --- /dev/null +++ b/src/core/content/navigation-lifecycle.ts @@ -0,0 +1,150 @@ +/** + * Navigation-scoped source snapshots for signed sections. + * + * A live page is mutable. Verification input must therefore come from the + * bytes captured for the navigation, while the live element is used only to + * compare what the reader currently sees and to anchor UI. DOMParser gives us + * the browser's HTML parsing rules before we pair source sections with live + * elements. + */ + +export const SIGNED_SECTION_SELECTOR = 'signed-section[signature]'; + +const IDENTITY_ATTRIBUTES = [ + 'signature', + 'keyid', + 'algorithm', + 'content-hash', +] as const; + +export interface SignedSectionSnapshot { + readonly index: number; + readonly identity: string; + readonly outerHTML: string; +} + +export interface NavigationSnapshot { + readonly url: string; + readonly origin: string; + readonly capturedAt: number; + readonly sections: readonly SignedSectionSnapshot[]; +} + +export interface SnapshotSectionMatch { + readonly source: SignedSectionSnapshot; + readonly live: Element; +} + +/** + * Capture signed sections from the post-load, same-URL response snapshot. + * + * The returned values are deeply immutable from the caller's perspective. + * The raw response is deliberately not retained, which limits accidental use + * of mutable strings or a later DOM serialization as verification input. + */ +export function captureNavigationSnapshot( + html: string, + url: string, + capturedAt = Date.now(), +): NavigationSnapshot { + if (typeof html !== 'string') throw new TypeError('navigation snapshot expects HTML text'); + if (typeof url !== 'string' || url.length === 0) { + throw new TypeError('navigation snapshot expects a URL'); + } + + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const sections = Array.from(parsed.querySelectorAll(SIGNED_SECTION_SELECTOR)).map( + (section, index): SignedSectionSnapshot => { + const snapshot = { + index, + identity: sectionIdentity(section), + outerHTML: section.outerHTML, + }; + return Object.freeze(snapshot); + }, + ); + + return Object.freeze({ + url, + origin: new URL(url).origin, + capturedAt, + sections: Object.freeze(sections), + }); +} + +/** + * Pair source sections with their current live counterparts. + * + * Pairing uses the signed attributes, with document order resolving duplicate + * identities. This handles SPA reordering without silently verifying the + * source for a different signature. A missing or extra section is reported by + * the `complete` flag so callers can invalidate the whole navigation view. + */ +export function mapSnapshotToLiveSections( + snapshot: NavigationSnapshot, + liveSections: readonly Element[], +): { readonly matches: readonly SnapshotSectionMatch[]; readonly complete: boolean } { + const byIdentity = new Map(); + for (const source of snapshot.sections) { + const queue = byIdentity.get(source.identity) ?? []; + queue.push(source); + byIdentity.set(source.identity, queue); + } + + const matches: SnapshotSectionMatch[] = []; + for (const live of liveSections) { + const queue = byIdentity.get(sectionIdentity(live)); + const source = queue?.shift(); + if (source) matches.push(Object.freeze({ source, live })); + } + + return Object.freeze({ + matches: Object.freeze(matches), + complete: matches.length === snapshot.sections.length && matches.length === liveSections.length, + }); +} + +/** Return a stable identity for a signed-section's signature-bearing fields. */ +export function sectionIdentity(section: Element): string { + return IDENTITY_ATTRIBUTES + .map((name) => `${name}=${section.getAttribute(name) ?? ''}`) + .join('\u001f'); +} + +/** + * Return true when a mutation can change the signed input for a section. + * Extension indicators are siblings of the section, so their mutations never + * reach this predicate. + */ +export function mutationTouchesSignedSection(mutation: MutationRecord, section: Element): boolean { + if (mutation.type === 'attributes') return mutation.target === section || section.contains(mutation.target); + if (mutation.type === 'characterData') return section.contains(mutation.target); + if (mutation.type === 'childList') { + return mutation.target === section || section.contains(mutation.target); + } + return false; +} + +/** + * Observe a live signed section and call `onInvalidated` once per microtask. + * The observer watches the section itself only. The caller owns the returned + * disconnect function and should call it when a navigation is replaced. + */ +export function observeSignedSection( + section: Element, + onInvalidated: (section: Element) => void, +): () => void { + const observer = new MutationObserver((mutations) => { + if (mutations.some((mutation) => mutationTouchesSignedSection(mutation, section))) { + onInvalidated(section); + } + }); + observer.observe(section, { + attributes: true, + attributeOldValue: false, + characterData: true, + childList: true, + subtree: true, + }); + return () => observer.disconnect(); +}