diff --git a/README.md b/README.md index 71bddac..4ab14e9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # HTMLTrust Browser Reference +- Maintainer: Jason Grey +- Updated: 2026-08-28 +- Version: 0.1.0, draft v1 profile +- Status: Chromium reference implementation; Firefox and Safari adapters pending +- For: extension contributors and browser implementers +- Reading time: 8 minutes + Reference browser extension for validating HTMLTrust `` elements in a browser. 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). @@ -12,7 +19,7 @@ Readers: contributors and implementers. The normal workflow is: 2. Run tests and type checking. 3. Build the extension for the browser you use. -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. +After each page load or same-document navigation, the content script refetches the current HTTPS URL using the browser HTTP cache when available. It retains each exact signed-section source slice and a parser-owned DOM element. It verifies the source against the final response URL, then compares it with the 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. ## Quick start @@ -22,7 +29,7 @@ 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 dependency is pinned to browser-client commit `a846e3d971ab93bdcc8a9f599fb1987828344983`. A sibling browser-client checkout is optional. Use one when developing both repositories together. +The published dependencies pin browser client commit `d25c6d3c` and canonicalization commit `b0c8f305`. A sibling browser-client checkout is optional. Use one when developing both repositories together. For a standalone checkout: @@ -67,7 +74,7 @@ container with: ``` The script copies this checkout into the container, installs the pinned browser -client from Git, runs 63 extension tests, checks types and lint, then builds all +client from Git, runs 64 extension tests, checks types and lint, then builds all three browser packages. Generated files stay outside the checkout. ### Build @@ -92,7 +99,7 @@ Use the matching `dev:firefox` or `dev:safari` command for another target. Reloa `src/core/content/navigation-lifecycle.ts` owns navigation state: -- `captureNavigationSnapshot` parses refetched HTML and freezes source sections. +- `captureNavigationSnapshot` retains exact source slices, parser-owned elements, the final response URL, and the document base URL. - `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. @@ -184,4 +191,4 @@ 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). +If this work is useful and you want 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/package-lock.json b/package-lock.json index 4fef15e..5535b16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.1.0", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#a846e3d971ab93bdcc8a9f599fb1987828344983", - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#d25c6d3c2d0f4d67483da20853f22e94a11b89cc", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", "js-sha256": "^0.11.0", @@ -790,21 +790,28 @@ }, "node_modules/@htmltrust/browser-client": { "version": "0.1.2", - "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#a846e3d971ab93bdcc8a9f599fb1987828344983", - "integrity": "sha512-tsYFw9rPhxILM525gsUgBSIr3e1fDCnhdwaeQSFSaw1f7qmUXNNw9zV2ZnqF6Y5L+XwtbSPsD+GN66OZyoT5vQ==", + "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#d25c6d3c2d0f4d67483da20853f22e94a11b89cc", + "integrity": "sha512-yOH+YegJ8kmQAQufHi/SN3STAJcmcfIkcKaGhxPkGd4j9SNdCp7s/mvWysYBcDm/8NydiWhu+dAUQxVAgcn7og==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz" + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "parse5": "7.3.0" }, "peerDependencies": { - "@htmltrust/canonicalization": "^0.2.2" + "@htmltrust/canonicalization": "^0.3.0" } }, "node_modules/@htmltrust/canonicalization": { - "version": "0.2.2", - "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", - "integrity": "sha512-qKOx4PipywaLx3R/Bc6S+IWSZZhD/DQ4LQaPC7bHREq40iRIjeVkkSSrHFp2cTyqdPvsPZF2ufvT5YUz9II8RA==", - "license": "LicenseRef-PolyForm-Noncommercial-1.0.0" + "version": "0.3.0", + "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "integrity": "sha512-oeZyQepl+Xub2j0Q+i84jxL21L6z7l/6CHtavTAarqHivCAbA0OZGnIwdt265GMMqtN7co9l1dK6yGtNtBLZ8g==", + "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", + "dependencies": { + "parse5": "7.3.0" + }, + "engines": { + "node": ">=22" + } }, "node_modules/@humanfs/core": { "version": "0.19.2", @@ -3835,7 +3842,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -7564,7 +7570,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" diff --git a/package.json b/package.json index c93e426..4b10d07 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,8 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#a846e3d971ab93bdcc8a9f599fb1987828344983", - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#d25c6d3c2d0f4d67483da20853f22e94a11b89cc", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", "js-sha256": "^0.11.0", diff --git a/src/background/index.ts b/src/background/index.ts index 04573ba..38762e3 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -29,6 +29,7 @@ import { import { AuthService } from "../core/auth"; import { ContentSigningClient } from "../core/api"; import { ContentProcessor } from "../core/content"; +import { extractRawSignedSections } from "../core/content/navigation-lifecycle"; import { PlatformAdapter, MessageContext } from "../platforms/common"; // Import platform-specific adapter @@ -74,6 +75,49 @@ function createVerifierFetch(): typeof fetch { }; } +type PristineSection = { + sectionHtml: string; + documentUrl: string; + baseUrl: string; +}; + +/** Fetch the response body so popup verification never signs live DOM HTML. */ +async function fetchPristineSection(url: string): Promise { + const requested = new URL(url); + if (requested.protocol !== 'https:') return null; + const response = await fetch(requested.href, { + cache: 'force-cache', + credentials: 'include', + referrer: '', + referrerPolicy: 'no-referrer', + redirect: 'error', + }); + if (!response.ok) return null; + const documentUrl = response.url || requested.href; + const finalUrl = new URL(documentUrl); + if (finalUrl.protocol !== 'https:' || finalUrl.origin !== requested.origin) return null; + const html = await response.text(); + const sectionHtml = extractRawSignedSections(html)[0]; + if (!sectionHtml) return null; + + // Service workers do not expose DOMParser in every target. The response URL + // remains the correct base when no document can be parsed here; the + // content-script path computes a parser-backed base for normal page loads. + let baseUrl = documentUrl; + if (typeof DOMParser !== 'undefined') { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const base = parsed.querySelector('base[href]'); + if (base) { + try { + baseUrl = new URL(base.getAttribute('href') ?? '', documentUrl).href; + } catch { + baseUrl = documentUrl; + } + } + } + return { sectionHtml, documentUrl, baseUrl }; +} + /** * Initialize the background script */ @@ -251,23 +295,13 @@ async function getVerificationStatus(url: string): Promise { */ async function verifyContent(url: string): Promise { try { - const currentTab = await platformAdapter.getCurrentTab(); - - // Step 1: pull the signed-section's outerHTML out of the page. The lib - // accepts an HTML fragment string, so we don't need to round-trip a - // full DOM Element across the messaging boundary. Returns null when - // the page has no signed-section, which we map to a clear failure. - // executeScript wraps the body in a function, so the body needs an - // explicit top-level return (not just an IIFE expression). - const sectionHtml = await platformAdapter.executeScript( - currentTab.id, - `const section = document.querySelector('signed-section[signature]'); - return section ? section.outerHTML : null;`, - ); + // Step 1: fetch the response body. DOM outerHTML is a repaired + // serialization and cannot preserve source-level parser ambiguities. + const pristine = await fetchPristineSection(url); let verificationResult: VerificationResult; - if (!sectionHtml) { + if (!pristine) { verificationResult = { verified: false, reason: "No signed-section found on this page", @@ -286,11 +320,14 @@ async function verifyContent(url: string): Promise { fetch: createVerifierFetch(), }); - const verify = await verifySignedSection(sectionHtml, { + const verify = await verifySignedSection(pristine.sectionHtml, { keyResolvers: resolverChain, - domain: serializedOrigin(url), + domain: serializedOrigin(pristine.documentUrl), + origin: serializedOrigin(pristine.documentUrl), + documentUrl: pristine.documentUrl, + baseUrl: pristine.baseUrl, debug: settings.developerDebugLogging === true, - }); + } as Parameters[1]); // Best-effort author name lookup. The author DB is server-side and // optional; if we can't fetch it (the keyid isn't a server URL or diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts index d0cbd97..1192c9b 100644 --- a/src/content-scripts/index.ts +++ b/src/content-scripts/index.ts @@ -40,7 +40,7 @@ import { observeSignedSection, outermostSignedSection, SIGNED_SECTION_SELECTOR, - sourceElementForSnapshot, + sourceHTMLForSnapshot, type NavigationSnapshot, } from '../core/content/navigation-lifecycle'; import { PlatformAdapter, MessageContext } from '../platforms/common'; @@ -447,7 +447,8 @@ async function fetchPristineSignedSections(settings: Settings): Promise<{ async function verifySectionWithState( section: Element, - sourceElement: Element | null, + sourceHTML: string | null, + sourceDocumentUrl: string | null, sourceBaseUrl: string | null, resolverChain: KeyResolver[], settings: Settings, @@ -458,11 +459,12 @@ async function verifySectionWithState( keyResolvers: resolverChain, domain: origin, origin, + documentUrl: sourceDocumentUrl ?? window.location.href, baseUrl: window.location.href, debug: settings.developerDebugLogging === true, }; - if (!sourceElement) { + if (!sourceHTML) { // A live DOM is not an accepted Layer-1 source. Page script can construct // or rewrite it after navigation, so treating it as verified would make a // valid indicator attacker-controlled. @@ -489,7 +491,7 @@ async function verifySectionWithState( }; } - const sourceVerify = await verifySignedSection(sourceElement, { + const sourceVerify = await verifySignedSection(sourceHTML, { ...options, baseUrl: sourceBaseUrl ?? options.baseUrl, renderedBaseUrl: documentBaseUrl(document, window.location.href), @@ -612,7 +614,8 @@ async function autoVerifyPage( const match = mapped.matches.find((candidate) => candidate.live === section); const run = await verifySectionWithState( section, - mapped.complete ? (match ? sourceElementForSnapshot(match.source) : null) : null, + mapped.complete ? (match ? sourceHTMLForSnapshot(match.source) : null) : null, + fetchedSnapshot?.url ?? null, fetchedSnapshot?.baseUrl ?? null, resolverChain, settings, @@ -656,7 +659,8 @@ async function autoVerifyPage( pageVerificationBySection.set(section, pageVerification); armSectionMutationInvalidation( section, - mapped.complete ? (match ? sourceElementForSnapshot(match.source) : null) : null, + mapped.complete ? (match ? sourceHTMLForSnapshot(match.source) : null) : null, + fetchedSnapshot?.url ?? null, fetchedSnapshot?.baseUrl ?? null, resolverChain, settings, @@ -685,7 +689,7 @@ async function autoVerifyPage( }; pageVerifications.push(pageVerification); pageVerificationBySection.set(section, pageVerification); - armSectionMutationInvalidation(section, null, null, resolverChain, settings); + armSectionMutationInvalidation(section, null, null, null, resolverChain, settings); } i++; } @@ -786,7 +790,8 @@ function clearSectionStatusUI(section: Element): void { /** Re-verify a section after live content changes, against its frozen source. */ function armSectionMutationInvalidation( section: Element, - sourceElement: Element | null, + sourceHTML: string | null, + sourceDocumentUrl: string | null, sourceBaseUrl: string | null, resolverChain: KeyResolver[], settings: Settings, @@ -810,7 +815,8 @@ function armSectionMutationInvalidation( try { const run = await verifySectionWithState( changedSection, - sourceElement, + sourceHTML, + sourceDocumentUrl, sourceBaseUrl, currentResolverChain.length ? currentResolverChain : resolverChain, activeSettings, diff --git a/src/core/content/navigation-lifecycle.test.ts b/src/core/content/navigation-lifecycle.test.ts index 4fbb9f3..e1daa8e 100644 --- a/src/core/content/navigation-lifecycle.test.ts +++ b/src/core/content/navigation-lifecycle.test.ts @@ -6,6 +6,7 @@ import { mutationTouchesDocumentBase, outermostSignedSection, sourceElementForSnapshot, + sourceHTMLForSnapshot, SIGNED_SECTION_SELECTOR, } from './navigation-lifecycle'; @@ -19,6 +20,9 @@ describe('navigation lifecycle snapshots', () => { expect(snapshot.sections).toHaveLength(1); expect(snapshot.sections[0].outerHTML).toContain('

source

'); + expect(sourceHTMLForSnapshot(snapshot.sections[0])).toBe( + '

source

', + ); expect(Object.isFrozen(snapshot)).toBe(true); expect(Object.isFrozen(snapshot.sections)).toBe(true); expect(Object.isFrozen(snapshot.sections[0])).toBe(true); @@ -33,6 +37,16 @@ describe('navigation lifecycle snapshots', () => { expect(snapshot.baseUrl).toBe('https://cdn.example/assets/'); expect(snapshot.sections.map((section) => section.identity.includes('signature=')).every(Boolean)).toBe(true); expect(sourceElementForSnapshot(snapshot.sections[0])?.querySelector('signed-section')?.getAttribute('signature')).toBe('inner'); + expect(sourceHTMLForSnapshot(snapshot.sections[0])).toContain(''); + expect(sourceHTMLForSnapshot(snapshot.sections[0])).toContain('inner'); + }); + + it('retains source ambiguities beside the repaired parser node', () => { + const html = ''; + const snapshot = captureNavigationSnapshot(html, 'https://example.test/article'); + + expect(sourceHTMLForSnapshot(snapshot.sections[0])).toBe(html); + expect(sourceElementForSnapshot(snapshot.sections[0])?.getAttribute('profile')).toBe('htmltrust-signature-v1'); }); it('maps reordered live sections by signed identity rather than array position', () => { diff --git a/src/core/content/navigation-lifecycle.ts b/src/core/content/navigation-lifecycle.ts index 5ada391..70c2428 100644 --- a/src/core/content/navigation-lifecycle.ts +++ b/src/core/content/navigation-lifecycle.ts @@ -23,6 +23,8 @@ const IDENTITY_ATTRIBUTES = [ export interface SignedSectionSnapshot { readonly index: number; readonly identity: string; + /** Exact source slice from the response body, never DOM outerHTML. */ + readonly sourceHTML: string; readonly outerHTML: string; } @@ -31,6 +33,75 @@ export interface SignedSectionSnapshot { // serializing nested sections and reparsing them through a different path. const sourceElements = new WeakMap(); +/** Extract balanced source slices without letting DOMParser rewrite them. */ +export function extractRawSignedSections(html: string): string[] { + const found: Array<{ start: number; end: number }> = []; + const openSections: number[] = []; + const rawElements = new Set(['script', 'style', 'textarea', 'title', 'iframe']); + let scan = 0; + let rawName: string | null = null; + + const tagEnd = (start: number): number => { + let quote = ''; + for (let index = start + 1; index < html.length; index++) { + const character = html[index]; + if (quote) { + if (character === quote) quote = ''; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '>') { + return index + 1; + } + } + return -1; + }; + + while (scan < html.length) { + const start = html.indexOf('<', scan); + if (start < 0) break; + if (rawName) { + if (!new RegExp(`^', start + 4); + if (end < 0) break; + scan = end + 3; + continue; + } + const end = tagEnd(start); + if (end < 0) break; + const token = html.slice(start, end); + if (/^$/.test(token)) { + openSections.push(start); + } else if (!closing && rawElements.has(name) && !/\/\s*>$/.test(token)) { + rawName = name; + } + scan = end; + } + return found + .sort((left, right) => left.start - right.start) + .map(({ start, end }) => html.slice(start, end)); +} + export interface NavigationSnapshot { readonly url: string; readonly origin: string; @@ -63,6 +134,7 @@ export function captureNavigationSnapshot( } const parsed = new DOMParser().parseFromString(html, 'text/html'); + const sourceSlices = extractRawSignedSections(html); const baseElement = parsed.querySelector('base[href]'); let baseUrl = url; if (baseElement) { @@ -79,6 +151,7 @@ export function captureNavigationSnapshot( const snapshot = { index, identity: sectionIdentity(section), + sourceHTML: sourceSlices[index] ?? '', outerHTML: section.outerHTML, }; const frozen = Object.freeze(snapshot); @@ -101,6 +174,11 @@ export function sourceElementForSnapshot(snapshot: SignedSectionSnapshot): Eleme return sourceElements.get(snapshot) ?? null; } +/** Retrieve the exact source slice captured for a snapshot section. */ +export function sourceHTMLForSnapshot(snapshot: SignedSectionSnapshot): string | null { + return snapshot.sourceHTML || null; +} + /** * Pair source sections with their current live counterparts. *