From 8f391756dded0bafd3dfb72972cfd0d1afa44f4f Mon Sep 17 00:00:00 2001
From: Jason Grey
Date: Thu, 27 Aug 2026 15:19:46 -0500
Subject: [PATCH] feat: verify signed sections in browser extensions
---
.github/workflows/ci.yml | 42 ++-
package-lock.json | 6 +-
src/assets/content.css | 8 +-
src/background/index.ts | 117 +++++---
src/content-scripts/auto-verify.test.ts | 47 ++-
src/content-scripts/index.ts | 298 ++++++++++++++++----
src/core/api/content-signing-client.test.ts | 7 +-
src/core/api/content-signing-client.ts | 46 ++-
src/core/common/constants.ts | 7 +-
src/core/common/index.ts | 3 +-
src/core/common/signature-fields.test.ts | 120 ++++++++
src/core/common/signature-fields.ts | 84 ++++++
src/core/common/types.ts | 18 +-
src/platforms/HOST_PERMISSIONS.md | 17 +-
src/platforms/chromium/adapter.ts | 39 ++-
src/platforms/chromium/manifest.json | 8 +-
src/platforms/common/platform-adapter.ts | 25 +-
src/platforms/firefox/manifest.json | 7 +-
src/platforms/safari/manifest.json | 9 +-
src/ui/options/index.tsx | 31 +-
src/ui/popup/index.tsx | 37 ++-
21 files changed, 818 insertions(+), 158 deletions(-)
create mode 100644 src/core/common/signature-fields.test.ts
create mode 100644 src/core/common/signature-fields.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3442c6b..3204c3d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,28 +6,44 @@ on:
pull_request:
branches: [main]
+# Least privilege: this workflow only reads the repo and uploads artifacts.
+permissions:
+ contents: read
+
jobs:
build:
name: Build Extensions
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- - name: Configure private dep access
- env:
- TOKEN: ${{ secrets.HTMLTRUST_PKG_TOKEN }}
- run: |
- git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
- git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/"
-
+ # The package token used to be written to ~/.gitconfig, where every later
+ # step -- including any dependency lifecycle script -- could read it back.
+ # It is now passed through GIT_CONFIG_* environment variables, which git
+ # honours for this process tree only and never persists to disk, and
+ # --ignore-scripts keeps third-party install hooks from running at all
+ # while the token is in the environment. The webpack and eslint steps that
+ # follow run untrusted dependency code, but no longer with the token in
+ # reach.
+ #
+ # HTMLTRUST_PKG_TOKEN must be a fine-grained PAT scoped to the HTMLTrust
+ # package repositories with Contents: Read and nothing else. A classic
+ # `repo`-scoped token grants write access to every repo the owner can
+ # reach and must not be used here.
- name: Install dependencies
- run: npm ci
+ env:
+ GIT_CONFIG_COUNT: "2"
+ GIT_CONFIG_KEY_0: url.https://x-access-token:${{ secrets.HTMLTRUST_PKG_TOKEN }}@github.com/.insteadOf
+ GIT_CONFIG_VALUE_0: https://github.com/
+ GIT_CONFIG_KEY_1: url.https://x-access-token:${{ secrets.HTMLTRUST_PKG_TOKEN }}@github.com/.insteadOf
+ GIT_CONFIG_VALUE_1: ssh://git@github.com/
+ run: npm ci --ignore-scripts
- name: Lint
run: npx eslint src/ --ext .ts,.tsx || true
@@ -41,17 +57,17 @@ jobs:
- name: Build Safari
run: npx webpack --mode=production --env target=safari
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: extension-chromium
path: build/chromium/
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: extension-firefox
path: build/firefox/
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: extension-safari
path: build/safari/
diff --git a/package-lock.json b/package-lock.json
index 5ebeba9..c65538e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,7 @@
"": {
"name": "htmltrust-browser-reference",
"version": "0.1.0",
- "license": "MIT",
+ "license": "LicenseRef-PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@htmltrust/browser-client": "file:../htmltrust-browser-client",
"@htmltrust/canonicalization": "file:../htmltrust-canonicalization/javascript",
@@ -45,7 +45,7 @@
"../htmltrust-browser-client": {
"name": "@htmltrust/browser-client",
"version": "0.1.2",
- "license": "MIT",
+ "license": "LicenseRef-PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@htmltrust/canonicalization": "file:../htmltrust-canonicalization/javascript"
},
@@ -59,7 +59,7 @@
"../htmltrust-canonicalization/javascript": {
"name": "@htmltrust/canonicalization",
"version": "0.2.0",
- "license": "MIT"
+ "license": "LicenseRef-PolyForm-Noncommercial-1.0.0"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
diff --git a/src/assets/content.css b/src/assets/content.css
index 48b9ad7..671072f 100644
--- a/src/assets/content.css
+++ b/src/assets/content.css
@@ -66,6 +66,12 @@
background-color: #F44336;
}
+/* Warning badge */
+.cs-verification-badge-warning {
+ background-color: #FFC107;
+ color: #333;
+}
+
/* Trust badges */
.cs-trust-badge {
color: white;
@@ -166,4 +172,4 @@
pointer-events: auto;
min-width: 120px;
text-align: center;
-}
\ No newline at end of file
+}
diff --git a/src/background/index.ts b/src/background/index.ts
index 50f8ca3..04573ba 100644
--- a/src/background/index.ts
+++ b/src/background/index.ts
@@ -4,6 +4,7 @@
import {
verifySignedSection,
defaultResolverChain,
+ isPrivateHost,
} from "@htmltrust/browser-client";
import {
Settings,
@@ -14,6 +15,11 @@ import {
BatchedVotesPayload,
BatchVoteResult,
getTrustDirectoryUrls,
+ buildKeyidUrl,
+ requireCanonicalBase64,
+ requireContentHash,
+ requireTimestamp,
+ sanitizeClaims,
} from "../core/common";
import {
STORAGE_KEYS,
@@ -42,6 +48,32 @@ let contentProcessor: ContentProcessor;
let settings: Settings = DEFAULT_SETTINGS;
let contentSigningClient: ContentSigningClient | null = null;
+function serializedOrigin(url: string): string {
+ return new URL(url).origin;
+}
+
+function createVerifierFetch(): typeof fetch {
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = new URL(input instanceof Request ? input.url : String(input));
+ if (url.protocol !== "https:") {
+ throw new Error("network-policy-blocked: verifier key and directory fetches require HTTPS");
+ }
+ // An extension's fetch is not bound by page CORS, so a keyid pointing at
+ // loopback, link-local, or RFC 1918 space would reach hosts the page never
+ // could. Refuse those outright.
+ if (isPrivateHost(url.hostname)) {
+ throw new Error("network-policy-blocked: verifier fetches may not target private hosts");
+ }
+ return fetch(input, {
+ ...init,
+ credentials: "omit",
+ referrer: "",
+ referrerPolicy: "no-referrer",
+ redirect: "error",
+ });
+ };
+}
+
/**
* Initialize the background script
*/
@@ -240,7 +272,7 @@ async function verifyContent(url: string): Promise {
verified: false,
reason: "No signed-section found on this page",
verifiedAt: Date.now(),
- domain: new URL(url).hostname,
+ domain: serializedOrigin(url),
trustStatus: "unknown",
};
} else {
@@ -249,11 +281,15 @@ async function verifyContent(url: string): Promise {
// 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 resolverChain = defaultResolverChain({ directories });
+ const resolverChain = defaultResolverChain({
+ directories,
+ fetch: createVerifierFetch(),
+ });
const verify = await verifySignedSection(sectionHtml, {
keyResolvers: resolverChain,
- domain: new URL(url).hostname,
+ domain: serializedOrigin(url),
+ debug: settings.developerDebugLogging === true,
});
// Best-effort author name lookup. The author DB is server-side and
@@ -283,7 +319,7 @@ async function verifyContent(url: string): Promise {
verificationResult = {
verified: true,
verifiedAt: Date.now(),
- domain: new URL(url).hostname,
+ domain: serializedOrigin(url),
user: {
id: userId,
name: userName,
@@ -298,7 +334,7 @@ async function verifyContent(url: string): Promise {
verified: false,
reason: verify.reason || "Signature verification failed",
verifiedAt: Date.now(),
- domain: new URL(url).hostname,
+ domain: serializedOrigin(url),
trustStatus: "untrusted",
};
}
@@ -394,7 +430,7 @@ async function signContent(
// Sign the content
const signature = await contentSigningClient.signContent(
extractedContent.contentHash,
- new URL(url).hostname,
+ serializedOrigin(url),
claims,
);
@@ -412,7 +448,7 @@ async function signContent(
}
: undefined,
verifiedAt: Date.now(),
- domain: new URL(url).hostname,
+ domain: serializedOrigin(url),
trustStatus: "trusted",
};
@@ -427,51 +463,66 @@ async function signContent(
// Update the badge
updateBadge();
- // Inject the signature into the page as a element
+ // Inject the signature into the page as a element.
+ //
+ // Everything below the validation step comes from the trust server's
+ // response. It is passed to executeFunction as structured-cloned arguments,
+ // never interpolated into a script string, so a hostile or compromised
+ // server cannot get code to run in the page. The validation is a second
+ // line of defence and also keeps malformed signatures out of the DOM.
const activeServer = authService.getActiveServerConfig();
const serverUrl = activeServer ? activeServer.url.replace(/\/+$/, "") : "";
- const claimsJson = JSON.stringify(signature.claims || {})
- .replace(/\\/g, "\\\\")
- .replace(/'/g, "\\'");
const signedAt = signature.createdAt || new Date().toISOString();
- await platformAdapter.executeScript(
+ const injection = {
+ signature: requireCanonicalBase64(signature.signature, "signature"),
+ keyid: buildKeyidUrl(serverUrl, signature.authorId),
+ contentHash: requireContentHash(signature.contentHash),
+ signedAt: requireTimestamp(signedAt, "createdAt"),
+ claims: sanitizeClaims(signature.claims),
+ };
+
+ await platformAdapter.executeFunction<[typeof injection], void>(
currentTab.id,
- `
- (() => {
+ (data) => {
// Remove any existing signature elements
- document.querySelectorAll('signed-section[signature]').forEach(el => el.remove());
+ document
+ .querySelectorAll("signed-section[signature]")
+ .forEach((el) => el.remove());
// Find the main content element
- const content = document.querySelector('article') || document.querySelector('main') || document.querySelector('.content') || document.body;
+ const content =
+ document.querySelector("article") ||
+ document.querySelector("main") ||
+ document.querySelector(".content") ||
+ document.body;
// Create a signed-section element with the signature
- const signedSection = document.createElement('signed-section');
- signedSection.setAttribute('signature', '${signature.signature}');
- signedSection.setAttribute('keyid', '${serverUrl}/api/authors/${signature.authorId}/public-key');
- signedSection.setAttribute('algorithm', 'ed25519');
- signedSection.setAttribute('content-hash', '${signature.contentHash}');
+ const signedSection = document.createElement("signed-section");
+ signedSection.setAttribute("signature", data.signature);
+ signedSection.setAttribute("keyid", data.keyid);
+ signedSection.setAttribute("algorithm", "ed25519");
+ signedSection.setAttribute("content-hash", data.contentHash);
// Add timestamp meta
- const timestampMeta = document.createElement('meta');
- timestampMeta.setAttribute('name', 'signed-at');
- timestampMeta.setAttribute('content', '${signedAt}');
+ const timestampMeta = document.createElement("meta");
+ timestampMeta.setAttribute("name", "signed-at");
+ timestampMeta.setAttribute("content", data.signedAt);
signedSection.appendChild(timestampMeta);
// Add claims meta tags
- const claims = JSON.parse('${claimsJson}');
- for (const [key, value] of Object.entries(claims)) {
- const claimMeta = document.createElement('meta');
- claimMeta.setAttribute('name', 'claim:' + key);
- claimMeta.setAttribute('content', String(value));
+ for (const [key, value] of data.claims) {
+ const claimMeta = document.createElement("meta");
+ claimMeta.setAttribute("name", "claim:" + key);
+ claimMeta.setAttribute("content", value);
signedSection.appendChild(claimMeta);
}
- signedSection.style.display = 'none';
+ signedSection.style.display = "none";
// Insert after the content
- content.parentNode.insertBefore(signedSection, content.nextSibling);
- })()
- `,
+ content.parentNode?.insertBefore(signedSection, content.nextSibling);
+ },
+ [injection],
);
return {
diff --git a/src/content-scripts/auto-verify.test.ts b/src/content-scripts/auto-verify.test.ts
index 305912d..d6071a8 100644
--- a/src/content-scripts/auto-verify.test.ts
+++ b/src/content-scripts/auto-verify.test.ts
@@ -58,13 +58,20 @@ function fixture(html: string): void {
* Mirror of buildAutoBadges() — kept in lockstep so this test exercises the
* same class-wiring logic the content script applies in the page.
*/
-function buildAutoBadges(verify: any, trust: any): HTMLElement {
+function buildAutoBadges(
+ verify: any,
+ trust: any,
+ inputState: 'source-only' | 'stale' | 'rendered-match' = 'rendered-match',
+): HTMLElement {
const badges = document.createElement('div');
badges.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`;
const sigBadge = document.createElement('span');
- if (verify.valid) {
+ const renderedValid = verify.valid && inputState === 'rendered-match';
+ if (renderedValid) {
sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`;
+ } else if (verify.valid) {
+ sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_WARNING} ${CSS_CLASSES.VALIDITY_BADGE}`;
} else {
sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`;
}
@@ -106,7 +113,7 @@ async function autoVerifyPage(): Promise {
try {
const verify = await (verifySignedSection as jest.Mock)(section, {
keyResolvers: [],
- domain: 'test.example',
+ domain: 'https://test.example',
});
const trust = await (evaluateTrustPolicy as jest.Mock)(verify, {
personalTrustList: [],
@@ -162,6 +169,25 @@ describe('content script auto-verify (selector and badge wiring)', () => {
expect(evaluateTrustPolicy).toHaveBeenCalledTimes(2);
});
+ it('passes a serialized origin and leaves verifier debug disabled by default', async () => {
+ fixture(`x`);
+ (verifySignedSection as jest.Mock).mockResolvedValue({
+ valid: true,
+ keyid: 'did:web:example.test',
+ });
+ (evaluateTrustPolicy as jest.Mock).mockResolvedValue({
+ score: 80,
+ indicator: 'green',
+ inputs: [],
+ });
+
+ await autoVerifyPage();
+
+ const [, options] = (verifySignedSection as jest.Mock).mock.calls[0];
+ expect(options.domain).toBe('https://test.example');
+ expect(options.debug).toBeUndefined();
+ });
+
it('applies the verified badge classes when the signature is valid', async () => {
fixture(`x`);
(verifySignedSection as jest.Mock).mockResolvedValue({
@@ -229,6 +255,21 @@ describe('content script auto-verify (selector and badge wiring)', () => {
).not.toBeNull();
});
+ it('uses a warning badge for source-valid but stale rendered content', () => {
+ const badges = buildAutoBadges(
+ { valid: true, keyid: 'did:web:example.test' },
+ { score: 80, indicator: 'green', inputs: [] },
+ 'stale',
+ );
+
+ expect(
+ badges.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_WARNING}`),
+ ).not.toBeNull();
+ expect(
+ badges.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`),
+ ).toBeNull();
+ });
+
it('inserts an error badge if verification throws', async () => {
fixture(`x`);
(verifySignedSection as jest.Mock).mockRejectedValue(
diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts
index 72af38e..d619f14 100644
--- a/src/content-scripts/index.ts
+++ b/src/content-scripts/index.ts
@@ -38,6 +38,7 @@ import {
TrustStatus,
VoteType,
Settings,
+ VerificationInputState,
getTrustDirectoryUrls,
} from '../core/common/types';
@@ -61,7 +62,13 @@ const AUTO_BADGE_MARKER = 'cs-auto-verification-badges';
*/
type PageVerification = {
index: number;
+ /** True only when the currently rendered DOM is verified. */
valid: boolean;
+ /** True when the verifier found a valid cryptographic source input. */
+ cryptoValid: boolean;
+ inputState: VerificationInputState;
+ sourceVerified: boolean;
+ renderedVerified: boolean;
reason: string | null;
trustScore: number;
trustIndicator: 'green' | 'yellow' | 'red';
@@ -73,6 +80,15 @@ type PageVerification = {
claims: Record;
};
+type SectionVerificationRun = {
+ verify: VerifyResult;
+ inputState: VerificationInputState;
+ sourceVerified: boolean;
+ renderedVerified: boolean;
+ displayValid: boolean;
+ reason: string | null;
+};
+
/** Module-scoped cache of this page's verification results. */
const pageVerifications: PageVerification[] = [];
@@ -115,7 +131,10 @@ async function initialize() {
// 1. Settings → resolver chain + trust policy inputs
currentSettings = await loadSettings();
const directories = getTrustDirectoryUrls(currentSettings);
- currentResolverChain = defaultResolverChain({ directories });
+ currentResolverChain = defaultResolverChain({
+ directories,
+ fetch: createVerifierFetch(),
+ });
// 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.
@@ -176,7 +195,7 @@ function redecoratePage(): void {
// applier. The cache is intentionally a flat snapshot; the original
// objects don't survive across the listener boundary.
const verifyShape: VerifyResult = {
- valid: cached.valid,
+ valid: cached.cryptoValid,
keyid: cached.keyid,
algorithm: cached.algorithm,
contentHash: '',
@@ -184,14 +203,24 @@ function redecoratePage(): void {
claims: cached.claims,
signedAt: cached.signedAt,
domain: cached.domain,
- reason: cached.reason ?? undefined,
+ origin: cached.domain,
+ inputState: cached.inputState as VerifyResult['inputState'],
+ reason: cached.reason as VerifyResult['reason'],
};
const trustShape: TrustEvaluation = {
score: cached.trustScore,
indicator: cached.trustIndicator,
inputs: [],
};
- applySectionStatusUI(list[i], verifyShape, trustShape, cached.reason, currentSettings);
+ const runShape: SectionVerificationRun = {
+ verify: verifyShape,
+ inputState: cached.inputState,
+ sourceVerified: cached.sourceVerified,
+ renderedVerified: cached.renderedVerified,
+ displayValid: cached.valid,
+ reason: cached.reason,
+ };
+ applySectionStatusUI(list[i], runShape, trustShape, cached.reason, currentSettings);
}
}
@@ -222,6 +251,148 @@ async function loadSettings(): Promise {
trustedDomains: [],
authMethod: 'apikey',
serverConfigs: [],
+ developerDebugLogging: false,
+ };
+}
+
+function currentOrigin(): string {
+ return window.location.origin;
+}
+
+function redactForLog(value: unknown): unknown {
+ if (typeof value === 'string') {
+ if (value.length > 80) return `${value.slice(0, 24)}...[redacted:${value.length}]`;
+ if (/signature|BEGIN PUBLIC KEY|PRIVATE KEY|sha256:/i.test(value)) return '[redacted]';
+ return value;
+ }
+ if (Array.isArray(value)) return value.map(redactForLog);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([key, val]) => [
+ key,
+ /content|signature|key|hash|html|pem/i.test(key) ? '[redacted]' : redactForLog(val),
+ ]),
+ );
+ }
+ return value;
+}
+
+function debugLog(settings: Settings, message: string, details?: unknown): void {
+ if (!settings.developerDebugLogging) return;
+ if (details === undefined) {
+ console.debug(`[htmltrust] ${message}`);
+ } else {
+ console.debug(`[htmltrust] ${message}`, redactForLog(details));
+ }
+}
+
+function createVerifierFetch(): typeof fetch {
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = new URL(input instanceof Request ? input.url : String(input));
+ if (url.protocol !== 'https:') {
+ throw new Error('network-policy-blocked: verifier key and directory fetches require HTTPS');
+ }
+ return fetch(input, {
+ ...init,
+ credentials: 'omit',
+ referrer: '',
+ referrerPolicy: 'no-referrer',
+ redirect: 'error',
+ });
+ };
+}
+
+async function fetchPristineSignedSections(settings: Settings): Promise<{
+ slices: string[];
+ error: string | null;
+}> {
+ const pageUrl = new URL(window.location.href);
+ if (pageUrl.protocol !== 'https:') {
+ return {
+ slices: [],
+ error: 'network-policy-blocked: source refetch requires HTTPS',
+ };
+ }
+
+ try {
+ const pageResp = await fetch(window.location.href, {
+ cache: 'force-cache',
+ credentials: 'same-origin',
+ referrer: '',
+ referrerPolicy: 'no-referrer',
+ redirect: 'error',
+ });
+ if (!pageResp.ok) {
+ return { slices: [], error: `source-refetch-failed: HTTP ${pageResp.status}` };
+ }
+ if (new URL(pageResp.url).origin !== currentOrigin()) {
+ return { slices: [], error: 'network-policy-blocked: source refetch changed origin' };
+ }
+ const pageHTML = await pageResp.text();
+ return { slices: extractSignedSections(pageHTML), 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}` };
+ }
+}
+
+async function verifySectionWithState(
+ section: Element,
+ sourceSlice: string | null,
+ resolverChain: KeyResolver[],
+ settings: Settings,
+): Promise {
+ const origin = currentOrigin();
+ const options = {
+ keyResolvers: resolverChain,
+ domain: origin,
+ origin,
+ baseUrl: window.location.href,
+ debug: settings.developerDebugLogging === true,
+ };
+
+ if (!sourceSlice) {
+ const verify = await verifySignedSection(section, options);
+ const inputState = verify.inputState as VerificationInputState;
+ return {
+ verify,
+ inputState,
+ sourceVerified: false,
+ renderedVerified: verify.valid && inputState === 'rendered-match',
+ displayValid: verify.valid && inputState === 'rendered-match',
+ reason: verify.valid ? null : verify.reason ?? 'unknown',
+ };
+ }
+
+ const sourceVerify = await verifySignedSection(sourceSlice, {
+ ...options,
+ renderedSection: section,
+ });
+ const inputState = sourceVerify.inputState as VerificationInputState;
+ if (!sourceVerify.valid) {
+ return {
+ verify: sourceVerify,
+ inputState,
+ sourceVerified: false,
+ renderedVerified: false,
+ displayValid: false,
+ reason: sourceVerify.reason ?? 'source verification failed',
+ };
+ }
+
+ return {
+ verify: sourceVerify,
+ inputState,
+ sourceVerified: true,
+ renderedVerified: inputState === 'rendered-match',
+ displayValid: inputState === 'rendered-match',
+ reason:
+ inputState === 'rendered-match'
+ ? null
+ : inputState === 'stale'
+ ? 'rendered DOM diverged from verified source'
+ : 'rendered DOM not compared',
};
}
@@ -253,7 +424,6 @@ async function autoVerifyPage(
return;
}
- const domain = window.location.hostname;
const personalTrustList = settings.personalTrustList ?? [];
const trustedDomains = settings.trustedDomains ?? [];
@@ -280,22 +450,9 @@ async function autoVerifyPage(
// 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.
- let pristineSlices: string[] = [];
- let pristineFetchError: string | null = null;
- try {
- const pageResp = await fetch(window.location.href, {
- cache: 'force-cache',
- credentials: 'same-origin',
- });
- if (!pageResp.ok) {
- pristineFetchError = `pristine fetch HTTP ${pageResp.status}`;
- } else {
- const pageHTML = await pageResp.text();
- pristineSlices = extractSignedSections(pageHTML);
- }
- } catch (err) {
- pristineFetchError = err instanceof Error ? err.message : String(err);
- }
+ const { slices: fetchedPristineSlices, error: pristineFetchError } =
+ await fetchPristineSignedSections(settings);
+ let pristineSlices = fetchedPristineSlices;
// If the pristine fetch failed entirely OR returned a different count
// than the DOM (page re-rendered between navigation and our fetch, SPA
@@ -303,17 +460,11 @@ async function autoVerifyPage(
// 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) {
- console.warn('[htmltrust] pristine fetch failed; falling back to DOM verify:', pristineFetchError);
- } else {
- console.warn(
- '[htmltrust] pristine fetch returned',
- pristineSlices.length,
- 'sections but DOM has',
- sections.length,
- '— falling back to DOM verify',
- );
- }
+ debugLog(settings, 'source snapshot unavailable; falling back to rendered DOM verification', {
+ reason: pristineFetchError,
+ sourceSections: pristineSlices.length,
+ renderedSections: sections.length,
+ });
pristineSlices = [];
}
@@ -325,17 +476,13 @@ async function autoVerifyPage(
}
try {
- // Prefer the pristine HTML slice (immune to runtime DOM mutation);
- // fall back to the live DOM element when pristine fetch failed or
- // the counts didn't match.
- const verifyInput: Element | string = pristineSlices.length
- ? pristineSlices[i]
- : section;
- const verify = await verifySignedSection(verifyInput, {
- keyResolvers: resolverChain,
- domain,
- debug: true,
- });
+ const run = await verifySectionWithState(
+ section,
+ pristineSlices.length ? pristineSlices[i] : null,
+ resolverChain,
+ settings,
+ );
+ const verify = run.verify;
// Layer 2: trust policy. directorySubscriptions is intentionally empty
// here — the spec-compliant `/keys//reputation` endpoint
@@ -351,11 +498,15 @@ async function autoVerifyPage(
directorySubscriptions: [],
});
- applySectionStatusUI(section, verify, trust, null, settings);
+ applySectionStatusUI(section, run, trust, null, settings);
pageVerifications.push({
index: i,
- valid: verify.valid,
- reason: verify.valid ? null : verify.reason ?? 'unknown',
+ valid: run.displayValid,
+ cryptoValid: 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',
@@ -366,19 +517,25 @@ async function autoVerifyPage(
claims: verify.claims ?? {},
});
} catch (err) {
- console.error('Content Signing: verification failed for a signed-section', err);
- applySectionStatusUI(section, null, null, (err as Error).message ?? 'verification error', settings);
+ 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({
index: i,
valid: false,
- reason: (err as Error).message ?? 'verification error',
+ cryptoValid: false,
+ inputState: 'source-only',
+ sourceVerified: false,
+ renderedVerified: false,
+ reason,
trustScore: 0,
trustIndicator: 'red',
trustLabel: 'Untrusted',
keyid: '',
algorithm: '',
signedAt: '',
- domain,
+ domain: currentOrigin(),
claims: {},
});
}
@@ -404,7 +561,7 @@ const SECTION_DECORATED_CLASS = 'cs-decorated';
*/
function applySectionStatusUI(
section: Element,
- verify: VerifyResult | null,
+ run: SectionVerificationRun | null,
trust: TrustEvaluation | null,
errorReason: string | null,
settings: Settings,
@@ -416,28 +573,45 @@ function applySectionStatusUI(
// Master kill switch.
if (!settings.showBadges) return;
- const valid = verify?.valid === true;
+ 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);
}
- // Tooltip carries the human-readable status — popup is the rich surface.
- const reason = errorReason ?? verify?.reason ?? null;
+ // Tooltip carries a short warning only. The extension popup is the
+ // authoritative, less-spoofable surface for details.
+ const reason = errorReason ?? run?.reason ?? verify?.reason ?? null;
const trustPart = trust ? ` · Trust: ${trust.score}% (${trust.indicator})` : '';
- const tooltip = valid
- ? `HTMLTrust: ✓ Signature valid${trustPart}`
- : `HTMLTrust: ✗ Signature invalid${reason ? ` (${reason})` : ''}`;
+ const statePart =
+ run?.inputState === 'stale'
+ ? 'Source signature valid; rendered DOM differs'
+ : run?.inputState === 'source-only' && verify?.valid
+ ? 'Source signature valid; rendered DOM not compared'
+ : valid
+ ? 'Rendered content verified'
+ : 'Signature invalid';
+ 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}`;
const sig = document.createElement('span');
sig.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VALIDITY_BADGE} ${
- valid ? CSS_CLASSES.VERIFICATION_BADGE_VERIFIED : CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED
+ valid
+ ? CSS_CLASSES.VERIFICATION_BADGE_VERIFIED
+ : stale || verify?.valid
+ ? CSS_CLASSES.VERIFICATION_BADGE_WARNING
+ : CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED
}`;
- sig.textContent = valid ? '✓' : '✗';
+ sig.textContent = valid ? '✓' : stale || verify?.valid ? '!' : '✗';
sig.title = tooltip;
badges.appendChild(sig);
section.appendChild(badges);
@@ -466,7 +640,7 @@ function buildAutoBadges(verify: VerifyResult, trust: TrustEvaluation): HTMLElem
const sigBadge = document.createElement('span');
if (verify.valid) {
sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`;
- sigBadge.textContent = '✓ Signature valid';
+ sigBadge.textContent = 'Rendered content verified';
sigBadge.style.cssText =
'background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 4px;';
} else {
@@ -497,6 +671,8 @@ function buildAutoBadges(verify: VerifyResult, trust: TrustEvaluation): HTMLElem
trustBadge.style.cssText =
'background: #fff3cd; color: #856404; padding: 4px 8px; border-radius: 4px;';
}
+ sigBadge.title = 'Page marker only; open the extension popup for authoritative verification details.';
+
// Hover tooltip: per-input rationale, useful for debugging / auditability.
trustBadge.title = trust.inputs
.map((r: TrustInput) => `${r.source}: ${r.contribution} (${r.rationale})`)
@@ -868,7 +1044,7 @@ function listenForMessages() {
// the array immutable from the caller's perspective.
return {
url: window.location.href,
- domain: window.location.hostname,
+ domain: currentOrigin(),
results: pageVerifications.slice(),
};
case MESSAGE_TYPES.VOTE_ACKNOWLEDGED:
diff --git a/src/core/api/content-signing-client.test.ts b/src/core/api/content-signing-client.test.ts
index e0a95a2..62f5359 100644
--- a/src/core/api/content-signing-client.test.ts
+++ b/src/core/api/content-signing-client.test.ts
@@ -43,6 +43,7 @@ describe('ContentSigningClient — local verification (spec §3.1)', () => {
});
expect(browserClient.defaultResolverChain).toHaveBeenCalledWith({
directories,
+ fetch: expect.any(Function),
});
});
@@ -50,6 +51,7 @@ describe('ContentSigningClient — local verification (spec §3.1)', () => {
new ContentSigningClient({ baseUrl: 'https://api.example/' });
expect(browserClient.defaultResolverChain).toHaveBeenCalledWith({
directories: [],
+ fetch: expect.any(Function),
});
});
});
@@ -69,7 +71,7 @@ describe('ContentSigningClient — local verification (spec §3.1)', () => {
const section = '';
const result = await client.verifySignedSectionLocal({
section,
- domain: 'example.test',
+ domain: 'https://example.test',
});
expect(result).toBe(fakeResult);
@@ -77,7 +79,7 @@ describe('ContentSigningClient — local verification (spec §3.1)', () => {
const [arg0, arg1] = (browserClient.verifySignedSection as jest.Mock).mock
.calls[0];
expect(arg0).toBe(section);
- expect(arg1.domain).toBe('example.test');
+ expect(arg1.domain).toBe('https://example.test');
// The resolver chain must be the one the constructor built.
expect(arg1.keyResolvers).toEqual(client.getResolverChain());
});
@@ -142,6 +144,7 @@ describe('ContentSigningClient — local verification (spec §3.1)', () => {
expect(browserClient.defaultResolverChain).toHaveBeenCalledWith({
directories: next,
+ fetch: expect.any(Function),
});
});
});
diff --git a/src/core/api/content-signing-client.ts b/src/core/api/content-signing-client.ts
index 26482fc..74b46ef 100644
--- a/src/core/api/content-signing-client.ts
+++ b/src/core/api/content-signing-client.ts
@@ -6,7 +6,7 @@
* 1. Local cryptographic verification of signed-section content. This is
* the spec-aligned (§3.1) path: the extension verifies signatures
* itself via @htmltrust/browser-client, which uses SubtleCrypto and a
- * pluggable resolver chain (did:web → direct URL → trust directories)
+ * pluggable resolver chain (did:web -> direct URL -> trust directories)
* to fetch keys. NO trust server is contacted for verification.
*
* 2. Author/key/content management operations against a trust server.
@@ -26,6 +26,7 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import {
verifySignedSection,
defaultResolverChain,
+ isPrivateHost,
type VerifyResult,
} from '@htmltrust/browser-client';
import type { KeyResolver } from '@htmltrust/browser-client';
@@ -33,6 +34,33 @@ import { Author, PublicKey, ContentSignature, Claim, KeyReputation, ContentOccur
import { ERROR_CODES, API_ENDPOINTS } from '../common/constants';
import { createError } from '../common/utils';
+function defaultSerializedOrigin(): string | undefined {
+ const locationLike = globalThis.location as Location | undefined;
+ return locationLike?.origin;
+}
+
+function createVerifierFetch(): typeof fetch {
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = new URL(input instanceof Request ? input.url : String(input));
+ if (url.protocol !== 'https:') {
+ throw new Error('network-policy-blocked: verifier key and directory fetches require HTTPS');
+ }
+ // An extension's fetch is not bound by page CORS, so a keyid pointing at
+ // loopback, link-local, or RFC 1918 space would reach hosts the page never
+ // could. Refuse those outright.
+ if (isPrivateHost(url.hostname)) {
+ throw new Error('network-policy-blocked: verifier fetches may not target private hosts');
+ }
+ return fetch(input, {
+ ...init,
+ credentials: 'omit',
+ referrer: '',
+ referrerPolicy: 'no-referrer',
+ redirect: 'error',
+ });
+ };
+}
+
/**
* Content Signing API client options
*/
@@ -56,7 +84,7 @@ export interface ContentSigningClientOptions {
export interface LocalVerifyOptions {
/** The signed-section element or its outerHTML. */
section: Element | string;
- /** Domain to bind the signature to. Defaults to window.location.hostname. */
+ /** Serialized Web origin to bind the signature to. Defaults to window.location.origin. */
domain?: string;
/** Optional override of the resolver chain (overrides client-configured directories). */
keyResolvers?: KeyResolver[];
@@ -76,6 +104,7 @@ export class ContentSigningClient {
private baseUrl: string;
private trustDirectories: string[];
private resolverChain: KeyResolver[];
+ private verifierFetch: typeof fetch;
/**
* Create a new Content Signing API client
@@ -84,10 +113,14 @@ export class ContentSigningClient {
constructor(options: ContentSigningClientOptions) {
this.baseUrl = options.baseUrl;
this.trustDirectories = options.trustDirectories ?? [];
+ this.verifierFetch = createVerifierFetch();
// Build the resolver chain once. did:web and directUrl are always present;
// trust directories are appended only when configured (they're a network
// lookup of last resort).
- this.resolverChain = defaultResolverChain({ directories: this.trustDirectories });
+ this.resolverChain = defaultResolverChain({
+ directories: this.trustDirectories,
+ fetch: this.verifierFetch,
+ });
const config: AxiosRequestConfig = {
baseURL: options.baseUrl,
@@ -106,7 +139,10 @@ export class ContentSigningClient {
*/
setTrustDirectories(directories: string[]): void {
this.trustDirectories = directories;
- this.resolverChain = defaultResolverChain({ directories });
+ this.resolverChain = defaultResolverChain({
+ directories,
+ fetch: this.verifierFetch,
+ });
}
/** Get the configured resolver chain (for callers that want to reuse it). */
@@ -153,7 +189,7 @@ export class ContentSigningClient {
async verifySignedSectionLocal(opts: LocalVerifyOptions): Promise {
return verifySignedSection(opts.section, {
keyResolvers: opts.keyResolvers ?? this.resolverChain,
- domain: opts.domain,
+ domain: opts.domain ?? defaultSerializedOrigin(),
hash: opts.hash,
});
}
diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts
index 8744b4b..5f4c27e 100644
--- a/src/core/common/constants.ts
+++ b/src/core/common/constants.ts
@@ -22,6 +22,7 @@ export const EXTENSION_VERSION = '1.0.0';
*
* personalTrustList and trustedDomains start empty; the user populates them
* via the options page. They feed the lib's evaluateTrustPolicy directly.
+ * trustedDomains stores serialized Web origins despite the legacy field name.
*/
export const DEFAULT_SETTINGS = {
autoVerify: true,
@@ -41,7 +42,8 @@ export const DEFAULT_SETTINGS = {
isActive: true
}
],
- activeServerId: 'default'
+ activeServerId: 'default',
+ developerDebugLogging: false,
};
/**
@@ -116,6 +118,7 @@ export const CSS_CLASSES = {
VALIDITY_BADGE: 'cs-validity-badge',
VERIFICATION_BADGE_VERIFIED: 'cs-verification-badge-verified',
VERIFICATION_BADGE_UNVERIFIED: 'cs-verification-badge-unverified',
+ VERIFICATION_BADGE_WARNING: 'cs-verification-badge-warning',
TRUST_BADGE: 'cs-trust-badge',
TRUST_BADGE_TRUSTED: 'cs-trust-badge-trusted',
TRUST_BADGE_UNTRUSTED: 'cs-trust-badge-untrusted',
@@ -223,4 +226,4 @@ export const REPORT_STATUS = {
UNDER_REVIEW: 'UNDER_REVIEW',
ACCEPTED: 'ACCEPTED',
REJECTED: 'REJECTED',
-};
\ No newline at end of file
+};
diff --git a/src/core/common/index.ts b/src/core/common/index.ts
index 351f3b1..3e84c6b 100644
--- a/src/core/common/index.ts
+++ b/src/core/common/index.ts
@@ -4,4 +4,5 @@
export * from './types';
export * from './utils';
-export * from './constants';
\ No newline at end of file
+export * from './constants';
+export * from './signature-fields';
\ No newline at end of file
diff --git a/src/core/common/signature-fields.test.ts b/src/core/common/signature-fields.test.ts
new file mode 100644
index 0000000..037bd9a
--- /dev/null
+++ b/src/core/common/signature-fields.test.ts
@@ -0,0 +1,120 @@
+/**
+ * The signing path writes these values into the page. A trust server that is
+ * hostile or merely broken must not be able to get anything through that isn't
+ * the shape the spec describes.
+ */
+import {
+ buildKeyidUrl,
+ requireCanonicalBase64,
+ requireContentHash,
+ requireTimestamp,
+ sanitizeClaims,
+} from './signature-fields';
+
+const VALID_HASH = 'sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU';
+
+describe('requireCanonicalBase64', () => {
+ it('accepts canonical unpadded standard Base64', () => {
+ expect(requireCanonicalBase64('abcd', 'signature')).toBe('abcd');
+ });
+
+ it.each<[unknown, string]>([
+ ["'; alert(1); //", 'script-breaking punctuation'],
+ ['abcd=', 'padding'],
+ ['ab-cd', 'base64url alphabet'],
+ ['abcde', 'impossible length'],
+ ['', 'empty'],
+ [undefined, 'missing'],
+ [{}, 'non-string'],
+ ])('rejects %p (%s)', (value, _why) => {
+ expect(() => requireCanonicalBase64(value, 'signature')).toThrow(/malformed signature/);
+ });
+});
+
+describe('requireContentHash', () => {
+ it('accepts a sha256 hash of the right length', () => {
+ expect(requireContentHash(VALID_HASH)).toBe(VALID_HASH);
+ });
+
+ it.each([
+ 'sha256:tooshort',
+ 'sha512:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU',
+ "sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuF'",
+ VALID_HASH + 'A',
+ ])('rejects %p', (value) => {
+ expect(() => requireContentHash(value)).toThrow(/malformed contentHash/);
+ });
+});
+
+describe('requireTimestamp', () => {
+ it('accepts RFC3339 date-times', () => {
+ expect(requireTimestamp('2026-04-28T12:00:00Z', 'createdAt')).toBe('2026-04-28T12:00:00Z');
+ expect(requireTimestamp('2026-04-28T12:00:00.500-05:00', 'createdAt')).toBe(
+ '2026-04-28T12:00:00.500-05:00',
+ );
+ });
+
+ it.each([
+ "2026-04-28T12:00:00Z'; alert(1); //",
+ '2026-04-28',
+ 'yesterday',
+ '2026-13-45T99:00:00Z',
+ ])('rejects %p', (value) => {
+ expect(() => requireTimestamp(value, 'createdAt')).toThrow(/malformed createdAt/);
+ });
+});
+
+describe('buildKeyidUrl', () => {
+ it('builds the public-key endpoint for a well-formed author id', () => {
+ expect(buildKeyidUrl('https://trust.example', 'author-123')).toBe(
+ 'https://trust.example/api/authors/author-123/public-key',
+ );
+ });
+
+ it('preserves a base path on the configured server', () => {
+ expect(buildKeyidUrl('https://trust.example/trust/', 'a1')).toBe(
+ 'https://trust.example/trust/api/authors/a1/public-key',
+ );
+ });
+
+ it.each<[string, string]>([
+ ['../../evil', 'path traversal'],
+ ['a/b', 'extra path segment'],
+ ['a?x=1', 'query'],
+ ['a#f', 'fragment'],
+ ["a'", 'quote'],
+ ['', 'empty'],
+ ])('rejects author id %p (%s)', (authorId, _why) => {
+ expect(() => buildKeyidUrl('https://trust.example', authorId)).toThrow(/malformed authorId/);
+ });
+
+ it('rejects a non-http(s) server URL', () => {
+ expect(() => buildKeyidUrl('javascript:alert(1)', 'a1')).toThrow();
+ });
+});
+
+describe('sanitizeClaims', () => {
+ it('returns name/value pairs as strings', () => {
+ expect(sanitizeClaims({ title: 'Hello', count: 3 })).toEqual([
+ ['title', 'Hello'],
+ ['count', '3'],
+ ]);
+ });
+
+ it('drops names a meta element cannot carry', () => {
+ expect(sanitizeClaims({ 'bad name': 'x', 'quote"': 'y', ok: 'z' })).toEqual([['ok', 'z']]);
+ });
+
+ it('drops nested objects and nullish values', () => {
+ expect(sanitizeClaims({ nested: { a: 1 }, missing: null, ok: 'z' })).toEqual([['ok', 'z']]);
+ });
+
+ it('returns an empty list for a non-object', () => {
+ expect(sanitizeClaims(undefined)).toEqual([]);
+ expect(sanitizeClaims('claims')).toEqual([]);
+ });
+
+ it('keeps a quote in a claim value, which is data and never code', () => {
+ expect(sanitizeClaims({ title: "It's fine" })).toEqual([['title', "It's fine"]]);
+ });
+});
diff --git a/src/core/common/signature-fields.ts b/src/core/common/signature-fields.ts
new file mode 100644
index 0000000..74b9a36
--- /dev/null
+++ b/src/core/common/signature-fields.ts
@@ -0,0 +1,84 @@
+/**
+ * Validation of the fields a trust server returns from the signing endpoint.
+ *
+ * These values end up as attributes on a in the page, so they
+ * are untrusted input regardless of how much the user trusts their own server.
+ * Each check rejects rather than sanitizes: a signing response that does not
+ * match the spec's shape is a failure, not something to patch up.
+ *
+ * Injection into the page passes these values as structured-cloned arguments
+ * (PlatformAdapter.executeFunction), never as interpolated script text, so
+ * these checks are the second line of defence rather than the only one.
+ */
+
+/** Canonical unpadded standard Base64, per spec §6.1. */
+const CANONICAL_BASE64_RE = /^[A-Za-z0-9+/]+$/;
+/** `sha256:` followed by 32 bytes of canonical unpadded Base64. */
+const CONTENT_HASH_RE = /^sha256:[A-Za-z0-9+/]{43}$/;
+/** RFC3339 date-time, the form the spec uses for `signed-at`. */
+const TIMESTAMP_RE =
+ /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
+/** Server-issued author identifiers: opaque, but URL-path safe. */
+const AUTHOR_ID_RE = /^[A-Za-z0-9._~-]{1,128}$/;
+/** Claim names that a `` carries unambiguously. */
+const CLAIM_NAME_RE = /^[A-Za-z0-9._:-]{1,128}$/;
+
+const MAX_SIGNATURE_LENGTH = 8192;
+const MAX_CLAIM_VALUE_LENGTH = 4096;
+
+export function requireCanonicalBase64(value: unknown, field: string): string {
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SIGNATURE_LENGTH) {
+ throw new Error(`Trust server returned a malformed ${field}`);
+ }
+ if (!CANONICAL_BASE64_RE.test(value) || value.length % 4 === 1) {
+ throw new Error(`Trust server returned a malformed ${field}`);
+ }
+ return value;
+}
+
+export function requireContentHash(value: unknown): string {
+ if (typeof value !== 'string' || !CONTENT_HASH_RE.test(value)) {
+ throw new Error('Trust server returned a malformed contentHash');
+ }
+ return value;
+}
+
+export function requireTimestamp(value: unknown, field: string): string {
+ if (typeof value !== 'string' || !TIMESTAMP_RE.test(value) || Number.isNaN(Date.parse(value))) {
+ throw new Error(`Trust server returned a malformed ${field}`);
+ }
+ return value;
+}
+
+/**
+ * Build the keyid URL from the configured server and the returned author id.
+ * The author id is pattern-checked rather than escaped so it cannot introduce
+ * path segments, a query, or a fragment that would repoint key resolution.
+ */
+export function buildKeyidUrl(serverUrl: string, authorId: unknown): string {
+ if (typeof authorId !== 'string' || !AUTHOR_ID_RE.test(authorId)) {
+ throw new Error('Trust server returned a malformed authorId');
+ }
+ const base = new URL(serverUrl);
+ if (base.protocol !== 'https:' && base.protocol !== 'http:') {
+ throw new Error('Active trust server URL is not an http(s) URL');
+ }
+ return `${base.origin}${base.pathname.replace(/\/+$/, '')}/api/authors/${authorId}/public-key`;
+}
+
+/**
+ * Flatten the returned claims map to [name, value] string pairs. Claims with a
+ * name a `` cannot carry, and nested objects that have no single sensible
+ * string form, are dropped rather than guessed at.
+ */
+export function sanitizeClaims(claims: unknown): Array<[string, string]> {
+ if (!claims || typeof claims !== 'object') return [];
+ const out: Array<[string, string]> = [];
+ for (const [key, value] of Object.entries(claims as Record)) {
+ if (!CLAIM_NAME_RE.test(key)) continue;
+ if (value === null || value === undefined) continue;
+ if (typeof value === 'object') continue;
+ out.push([key, String(value).slice(0, MAX_CLAIM_VALUE_LENGTH)]);
+ }
+ return out;
+}
diff --git a/src/core/common/types.ts b/src/core/common/types.ts
index bcb6a8a..52b45ce 100644
--- a/src/core/common/types.ts
+++ b/src/core/common/types.ts
@@ -200,6 +200,12 @@ export interface TrustDirectoryEntry {
*/
export type TrustStatus = 'trusted' | 'untrusted' | 'unknown';
+/**
+ * How the verified input relates to the currently rendered page.
+ * Mirrors the provisional browser-client contract in the HTMLTrust specs.
+ */
+export type VerificationInputState = 'source-only' | 'stale' | 'rendered-match';
+
/**
* Represents the result of a content verification
*/
@@ -263,8 +269,9 @@ export interface Settings {
*/
personalTrustList?: string[];
/**
- * Domains the user explicitly trusts. Empty by default; matching domains
- * contribute +30 to the policy score per spec §3.1.
+ * Serialized Web origins the user explicitly trusts. Empty by default;
+ * matching origins contribute +30 to the policy score per spec §3.1.
+ * The field name is legacy and retained for persisted settings.
*/
trustedDomains?: string[];
/** The user's preferred authentication method */
@@ -273,6 +280,11 @@ export interface Settings {
serverConfigs: ServerConfig[];
/** ID of the active server configuration */
activeServerId?: string;
+ /**
+ * Enables verbose verifier diagnostics for developers. Off by default
+ * because verifier debug output can include content snippets and key data.
+ */
+ developerDebugLogging?: boolean;
}
/**
@@ -381,4 +393,4 @@ export interface Profile {
createdAt: number;
/** When this profile was last updated */
updatedAt: number;
-}
\ No newline at end of file
+}
diff --git a/src/platforms/HOST_PERMISSIONS.md b/src/platforms/HOST_PERMISSIONS.md
index e4b2dc2..c61d930 100644
--- a/src/platforms/HOST_PERMISSIONS.md
+++ b/src/platforms/HOST_PERMISSIONS.md
@@ -1,8 +1,15 @@
-# Host permissions: HTTP + HTTPS
+# Host permissions
-The chromium/safari/firefox manifests grant host permissions for `https://*/*` **and** `http://*/*`. This is deliberate.
+The chromium/safari/firefox manifests grant `https://*/*`, plus `http://localhost/*` and `http://127.0.0.1/*`. Content-script injection and `web_accessible_resources` use the same three patterns rather than ``.
-- The extension works against local development servers and the e2e simulation harness, which run plain-HTTP origins.
-- Production HTMLTrust deployments are HTTPS by convention, but the protocol does not require it; the verification logic itself is transport-agnostic.
+Why these three:
-If you tighten this to HTTPS-only, the local dev workflow and the e2e tests stop working.
+- HTTPS is where signed content lives. Verification needs SubtleCrypto, which is only available on a secure context, so the extension cannot verify a plain-HTTP page anyway.
+- The local development servers and the e2e simulation harness run on `http://localhost:3000` and `http://localhost:8080`. Chrome host patterns ignore the port, so `http://localhost/*` covers every local port. `127.0.0.1` is listed separately because Chrome treats it as a distinct host, not an alias.
+
+What was dropped and why:
+
+- `http://*/*` gave the extension read and inject access to every plaintext HTTP site on the web. Nothing in the verification path used it: the verifier's key and directory fetches have always required HTTPS (`createVerifierFetch`), and the crypto needs a secure context. It was permission the extension asked for and never spent.
+- `` in `content_scripts` and `web_accessible_resources` additionally covered `file://`, `ftp://`, and extension-internal schemes.
+
+If you add a dev origin on some other host, add that specific pattern. Do not widen back to `http://*/*`.
diff --git a/src/platforms/chromium/adapter.ts b/src/platforms/chromium/adapter.ts
index c800b84..debbe3e 100644
--- a/src/platforms/chromium/adapter.ts
+++ b/src/platforms/chromium/adapter.ts
@@ -275,7 +275,11 @@ export class ChromiumAdapter implements PlatformAdapter {
}
/**
- * Execute a script in a tab
+ * Execute a fixed script body in a tab.
+ *
+ * `script` is compiled with `new Function`, so it must be a compile-time
+ * constant. Anything carrying runtime data belongs in executeFunction().
+ *
* @param tabId The ID of the tab to execute the script in
* @param script The script to execute
* @returns A promise that resolves with the result of the script
@@ -297,6 +301,39 @@ export class ChromiumAdapter implements PlatformAdapter {
});
}
+ /**
+ * Execute a function in a tab with structured-cloned arguments.
+ *
+ * `args` is transferred as data by chrome.scripting, so values taken from a
+ * network response cannot escape into the injected code.
+ *
+ * @param tabId The ID of the tab to execute the function in
+ * @param func The function to execute in the page
+ * @param args Structured-cloneable arguments passed to `func`
+ * @returns A promise that resolves with the return value of `func`
+ */
+ async executeFunction(
+ tabId: string,
+ func: (...args: Args) => R,
+ args: Args,
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ chrome.scripting.executeScript({
+ target: { tabId: parseInt(tabId, 10) },
+ func: func as (...injected: any[]) => any,
+ args: args as any[],
+ }, (results) => {
+ if (chrome.runtime.lastError) {
+ reject(new Error(chrome.runtime.lastError.message));
+ } else if (!results || results.length === 0) {
+ reject(new Error('Script execution failed'));
+ } else {
+ resolve(results[0].result as R);
+ }
+ });
+ });
+ }
+
/**
* Insert CSS into a tab
* @param tabId The ID of the tab to insert CSS into
diff --git a/src/platforms/chromium/manifest.json b/src/platforms/chromium/manifest.json
index debc387..5ce9684 100644
--- a/src/platforms/chromium/manifest.json
+++ b/src/platforms/chromium/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Content Signing",
"version": "1.0.0",
- "description": "Sign and verify web content using WebAuthn",
+ "description": "Sign and verify HTMLTrust signed-section content in the page",
"icons": {
"16": "assets/icon-16.png",
"48": "assets/icon-48.png",
@@ -21,13 +21,13 @@
},
"content_scripts": [
{
- "matches": [""],
+ "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"js": ["content.js"],
"css": ["assets/content.css"]
}
],
"permissions": ["storage", "tabs", "notifications", "alarms", "scripting"],
- "host_permissions": ["https://*/*", "http://*/*"],
+ "host_permissions": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"options_ui": {
"page": "options.html",
"open_in_tab": true
@@ -35,7 +35,7 @@
"web_accessible_resources": [
{
"resources": ["assets/*"],
- "matches": [""]
+ "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]
}
]
}
diff --git a/src/platforms/common/platform-adapter.ts b/src/platforms/common/platform-adapter.ts
index ea0923d..722732d 100644
--- a/src/platforms/common/platform-adapter.ts
+++ b/src/platforms/common/platform-adapter.ts
@@ -74,13 +74,36 @@ export interface PlatformAdapter {
closeTab(tabId: string): Promise;
/**
- * Execute a script in a tab
+ * Execute a fixed script body in a tab.
+ *
+ * The body is a compile-time constant. Never build it by interpolating
+ * runtime values — that is code injection into the page. Use
+ * executeFunction() whenever the injected code needs runtime data.
+ *
* @param tabId The ID of the tab to execute the script in
* @param script The script to execute
* @returns A promise that resolves with the result of the script
*/
executeScript(tabId: string, script: string): Promise;
+ /**
+ * Execute a function in a tab, passing runtime data as arguments.
+ *
+ * The function is injected by reference and `args` crosses the boundary as
+ * structured-cloned data, so argument values are never parsed as code. This
+ * is the only safe way to inject anything derived from a network response.
+ *
+ * @param tabId The ID of the tab to execute the function in
+ * @param func The function to execute in the page
+ * @param args Structured-cloneable arguments passed to `func`
+ * @returns A promise that resolves with the return value of `func`
+ */
+ executeFunction(
+ tabId: string,
+ func: (...args: Args) => R,
+ args: Args,
+ ): Promise;
+
/**
* Insert CSS into a tab
* @param tabId The ID of the tab to insert CSS into
diff --git a/src/platforms/firefox/manifest.json b/src/platforms/firefox/manifest.json
index 663a653..2ee863e 100644
--- a/src/platforms/firefox/manifest.json
+++ b/src/platforms/firefox/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "Content Signing",
"version": "1.0.0",
- "description": "Sign and verify web content using WebAuthn",
+ "description": "Sign and verify HTMLTrust signed-section content in the page",
"icons": {
"16": "assets/icon-16.png",
"48": "assets/icon-48.png",
@@ -21,7 +21,7 @@
},
"content_scripts": [
{
- "matches": [""],
+ "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"js": ["content.js"],
"css": ["assets/content.css"]
}
@@ -31,7 +31,8 @@
"tabs",
"notifications",
"https://*/*",
- "http://*/*"
+ "http://localhost/*",
+ "http://127.0.0.1/*"
],
"options_ui": {
"page": "options.html",
diff --git a/src/platforms/safari/manifest.json b/src/platforms/safari/manifest.json
index 3603d15..81f63e8 100644
--- a/src/platforms/safari/manifest.json
+++ b/src/platforms/safari/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Content Signing",
"version": "1.0.0",
- "description": "Sign and verify web content using WebAuthn",
+ "description": "Sign and verify HTMLTrust signed-section content in the page",
"icons": {
"16": "assets/icon-16.png",
"48": "assets/icon-48.png",
@@ -21,7 +21,7 @@
},
"content_scripts": [
{
- "matches": [""],
+ "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"js": ["content.js"],
"css": ["assets/content.css"]
}
@@ -34,7 +34,8 @@
],
"host_permissions": [
"https://*/*",
- "http://*/*"
+ "http://localhost/*",
+ "http://127.0.0.1/*"
],
"options_ui": {
"page": "options.html",
@@ -43,7 +44,7 @@
"web_accessible_resources": [
{
"resources": ["assets/*"],
- "matches": [""]
+ "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]
}
],
"browser_specific_settings": {
diff --git a/src/ui/options/index.tsx b/src/ui/options/index.tsx
index 6dcee69..f54be8e 100644
--- a/src/ui/options/index.tsx
+++ b/src/ui/options/index.tsx
@@ -413,10 +413,11 @@ const Options: React.FC = ({ adapter }) => {
checked={state.settings.showBadges}
onChange={(e) => handleSettingChange('showBadges', e.target.checked)}
/>
- Show verification badges
+ Show secondary on-page markers
- Show badges next to verified content
+ Show page-DOM markers next to signed content. Treat the popup
+ as the authoritative verification surface.
@@ -447,6 +448,21 @@ const Options: React.FC = ({ adapter }) => {
Apply a highlight style to unverified content
+
+
+
+
+ Enable verbose verifier diagnostics. Leave off unless debugging;
+ verifier diagnostics can expose content snippets or key material.
+
+
@@ -515,7 +531,7 @@ const Options: React.FC
= ({ adapter }) => {
@@ -577,4 +594,4 @@ const container = document.getElementById('options-root');
if (container) {
const root = createRoot(container);
root.render();
-}
\ No newline at end of file
+}
diff --git a/src/ui/popup/index.tsx b/src/ui/popup/index.tsx
index ce7fe3a..b2e8ee0 100644
--- a/src/ui/popup/index.tsx
+++ b/src/ui/popup/index.tsx
@@ -24,7 +24,12 @@ const platformAdapter: PlatformAdapter = new ChromiumAdapter();
*/
interface PageVerification {
index: number;
+ /** True only when the currently rendered DOM is verified. */
valid: boolean;
+ cryptoValid: boolean;
+ inputState: 'source-only' | 'stale' | 'rendered-match';
+ sourceVerified: boolean;
+ renderedVerified: boolean;
reason: string | null;
trustScore: number;
trustIndicator: 'green' | 'yellow' | 'red';
@@ -413,7 +418,7 @@ const Popup: React.FC = ({ adapter }) => {
Show on-page badges
- Reload the page after toggling to apply.
+ Page markers are secondary; this popup is authoritative.
@@ -426,10 +431,26 @@ const Popup: React.FC = ({ adapter }) => {
) : (
{state.pageVerifications.map((v) => {
- const bg = v.valid
+ const renderedMatch = v.inputState === 'rendered-match' && v.renderedVerified;
+ const sourceOnly = v.cryptoValid && v.inputState === 'source-only';
+ const stale = v.cryptoValid && v.inputState === 'stale';
+ const bg = renderedMatch
? '#d4edda'
+ : sourceOnly || stale
+ ? '#fff3cd'
: '#f8d7da';
- const fg = v.valid ? '#155724' : '#721c24';
+ const fg = renderedMatch
+ ? '#155724'
+ : sourceOnly || stale
+ ? '#856404'
+ : '#721c24';
+ const statusText = renderedMatch
+ ? 'Rendered content verified'
+ : stale
+ ? 'Source verified; rendered content changed'
+ : sourceOnly
+ ? 'Source verified; rendered content not compared'
+ : `Signature invalid${v.reason ? ` (${v.reason})` : ''}`;
const trustBg =
v.trustIndicator === 'green' ? '#d4edda'
: v.trustIndicator === 'red' ? '#f8d7da'
@@ -451,12 +472,16 @@ const Popup: React.FC = ({ adapter }) => {
>
- {v.valid ? '✓ Signature valid' : `✗ Signature invalid${v.reason ? ` (${v.reason})` : ''}`}
+ {statusText}
Trust {v.trustScore}% · {v.trustLabel}
+
+ Input: {v.inputState}
+ {v.reason && v.cryptoValid ? ` (${v.reason})` : ''}
+
{v.keyid ? (
Signer: {v.keyid}
@@ -469,7 +494,7 @@ const Popup: React.FC
= ({ adapter }) => {
) : null}
{v.domain ? (
- Domain: {v.domain}
+ Origin: {v.domain}
) : null}
{Object.keys(v.claims).length > 0 ? (
@@ -552,4 +577,4 @@ const container = document.getElementById('popup-root');
if (container) {
const root = createRoot(container);
root.render();
-}
\ No newline at end of file
+}