Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<signed-section>` 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).
Expand All @@ -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 `<signed-section>`, 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 `<signed-section>`, so extension UI cannot become signed content.

## Quick start

Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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).
29 changes: 17 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 54 additions & 17 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
VoteType,
AuthorVote,
BatchedVotesPayload,
BatchVoteResult,

Check warning on line 16 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

'BatchVoteResult' is defined but never used
getTrustDirectoryUrls,
buildKeyidUrl,
requireCanonicalBase64,
Expand All @@ -29,6 +29,7 @@
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
Expand All @@ -44,7 +45,7 @@
storage,
});

let contentProcessor: ContentProcessor;

Check warning on line 48 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

'contentProcessor' is assigned a value but never used
let settings: Settings = DEFAULT_SETTINGS;
let contentSigningClient: ContentSigningClient | null = null;

Expand Down Expand Up @@ -74,6 +75,49 @@
};
}

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<PristineSection | null> {
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 <base> 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
*/
Expand All @@ -98,7 +142,7 @@
// Set up alarm for periodic vote submission
setupVoteSubmissionAlarm();

console.log("Content Signing background script initialized");

Check warning on line 145 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected console statement. Only these console methods are allowed: warn, error
} catch (error) {
console.error("Failed to initialize background script:", error);
}
Expand All @@ -120,7 +164,7 @@
* @param message The message to handle
* @returns A promise that resolves with the response
*/
async function handlePopupMessage(message: any): Promise<any> {

Check warning on line 167 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected any. Specify a different type

Check warning on line 167 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected any. Specify a different type
switch (message.type) {
case "GET_VERIFICATION_STATUS":
return getVerificationStatus(message.url);
Expand Down Expand Up @@ -161,7 +205,7 @@
* @param message The message to handle
* @returns A promise that resolves with the response
*/
async function handleContentMessage(message: any): Promise<any> {

Check warning on line 208 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected any. Specify a different type

Check warning on line 208 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected any. Specify a different type
switch (message.type) {
case MESSAGE_TYPES.CONTENT_DETECTED:
return handleContentDetected(message.url, message.content);
Expand All @@ -184,7 +228,7 @@
* @param message The message to handle
* @returns A promise that resolves with the response
*/
async function handleOptionsMessage(message: any): Promise<any> {

Check warning on line 231 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected any. Specify a different type

Check warning on line 231 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected any. Specify a different type
switch (message.type) {
case "UPDATE_SETTINGS":
return updateSettings(message.settings);
Expand All @@ -198,7 +242,7 @@
* @param url The URL to get the verification status for
* @returns The verification status
*/
async function getVerificationStatus(url: string): Promise<any> {

Check warning on line 245 in src/background/index.ts

View workflow job for this annotation

GitHub Actions / Build Extensions

Unexpected any. Specify a different type
try {
// Check if we have a cached verification result
const verificationResults =
Expand Down Expand Up @@ -251,23 +295,13 @@
*/
async function verifyContent(url: string): Promise<any> {
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<string | null>(
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",
Expand All @@ -286,11 +320,14 @@
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<typeof verifySignedSection>[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
Expand Down
24 changes: 15 additions & 9 deletions src/content-scripts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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++;
}
Expand Down Expand Up @@ -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,
Expand All @@ -810,7 +815,8 @@ function armSectionMutationInvalidation(
try {
const run = await verifySectionWithState(
changedSection,
sourceElement,
sourceHTML,
sourceDocumentUrl,
sourceBaseUrl,
currentResolverChain.length ? currentResolverChain : resolverChain,
activeSettings,
Expand Down
14 changes: 14 additions & 0 deletions src/core/content/navigation-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
mutationTouchesDocumentBase,
outermostSignedSection,
sourceElementForSnapshot,
sourceHTMLForSnapshot,
SIGNED_SECTION_SELECTOR,
} from './navigation-lifecycle';

Expand All @@ -19,6 +20,9 @@ describe('navigation lifecycle snapshots', () => {

expect(snapshot.sections).toHaveLength(1);
expect(snapshot.sections[0].outerHTML).toContain('<p>source</p>');
expect(sourceHTMLForSnapshot(snapshot.sections[0])).toBe(
'<signed-section signature="a" keyid="k" algorithm="ed25519" content-hash="h"><p>source</p></signed-section>',
);
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot.sections)).toBe(true);
expect(Object.isFrozen(snapshot.sections[0])).toBe(true);
Expand All @@ -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('<signed-section signature="outer">');
expect(sourceHTMLForSnapshot(snapshot.sections[0])).toContain('<signed-section signature="inner">inner</signed-section>');
});

it('retains source ambiguities beside the repaired parser node', () => {
const html = '<signed-section profile="htmltrust-signature-v1" profile="duplicate" signature="a"></signed-section>';
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', () => {
Expand Down
Loading
Loading