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 }) => {