diff --git a/package-lock.json b/package-lock.json index 23b61bd..7b06007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@htmltrust/canonicalization": "github:HTMLTrust/htmltrust-canonicalization#v0.1.0", + "@htmltrust/browser-client": "file:../../htmltrust-browser-client", + "@htmltrust/canonicalization": "file:../../htmltrust-canonicalization/javascript", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", "js-sha256": "^0.11.0", @@ -40,6 +41,25 @@ "webpack-cli": "^6.0.1" } }, + "../../htmltrust-browser-client": { + "name": "@htmltrust/browser-client", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@htmltrust/canonicalization": "file:../htmltrust-canonicalization/javascript" + }, + "devDependencies": { + "typescript": "^5.5.0" + }, + "peerDependencies": { + "@htmltrust/canonicalization": "^0.2.0" + } + }, + "../../htmltrust-canonicalization/javascript": { + "name": "@htmltrust/canonicalization", + "version": "0.2.0", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -769,10 +789,13 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@htmltrust/browser-client": { + "resolved": "../../htmltrust-browser-client", + "link": true + }, "node_modules/@htmltrust/canonicalization": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-canonicalization.git#7babc9610eeb6ca19d9134ea800a7fbe19f80ab9", - "license": "MIT" + "resolved": "../../htmltrust-canonicalization/javascript", + "link": true }, "node_modules/@humanfs/core": { "version": "0.19.1", diff --git a/package.json b/package.json index 6d318e4..f971793 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@htmltrust/canonicalization": "github:HTMLTrust/htmltrust-canonicalization#v0.1.0", + "@htmltrust/browser-client": "file:../../htmltrust-browser-client", + "@htmltrust/canonicalization": "file:../../htmltrust-canonicalization/javascript", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", "js-sha256": "^0.11.0", diff --git a/src/background/index.ts b/src/background/index.ts index 4a8ebd5..414d024 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,15 +1,19 @@ /** * Background script entry point */ +import { + verifySignedSection, + defaultResolverChain, +} from "@htmltrust/browser-client"; import { Settings, VerificationResult, ServerConfig, - ContentSignature, VoteType, AuthorVote, BatchedVotesPayload, BatchVoteResult, + getTrustDirectoryUrls, } from "../core/common"; import { STORAGE_KEYS, @@ -195,166 +199,109 @@ async function getVerificationStatus(url: string): Promise { } /** - * Verify content at a URL - * @param url The URL to verify content at - * @returns The verification result + * Verify content at a URL. + * + * This is the popup-driven verification path: when the user clicks + * "Verify Content" in the popup, the popup messages this function. We do + * the crypto step locally in the page context (where SubtleCrypto is + * available on a secure origin) using @htmltrust/browser-client, and + * cache the result so the popup can display it. + * + * The trust server is NOT contacted for verification (the deprecated + * /api/content/verify endpoint has been removed). Author lookup for the + * "verified by ..." display is best-effort and falls back to the keyid. + * + * The auto-verify content script (content-scripts/index.ts) renders inline + * badges on page load without involving this function; that path is + * preferred for normal browsing. This function exists for the popup's + * explicit on-demand verify and as the source of truth for the cached + * VerificationResult that the popup reads via GET_VERIFICATION_STATUS. */ async function verifyContent(url: string): Promise { try { - // Get the current tab const currentTab = await platformAdapter.getCurrentTab(); - // Execute a script to extract the content - const extractedContent = await platformAdapter.executeScript( + // Step 1: pull the signed-section's outerHTML out of the page. The lib + // accepts an HTML fragment string, so we don't need to round-trip a + // full DOM Element across the messaging boundary. Returns null when + // the page has no signed-section, which we map to a clear failure. + // executeScript wraps the body in a function, so the body needs an + // explicit top-level return (not just an IIFE expression). + const sectionHtml = await platformAdapter.executeScript( currentTab.id, - ` - (() => { - const contentProcessor = new ContentProcessor(); - return contentProcessor.extractContent(document); - })() - `, + `const section = document.querySelector('signed-section[signature]'); + return section ? section.outerHTML : null;`, ); - // Get the active server configuration - const activeServer = authService.getActiveServerConfig(); - if (!activeServer) { - throw new Error("No active server configuration found"); - } - - // Get the Content Signing client - const contentSigningClient = authService.getContentSigningClient(); - if (!contentSigningClient) { - throw new Error("Content Signing client not initialized"); - } - - // Try to find a signature for this content - // This is a simplified approach - in a real implementation, we would need a more robust - // mechanism to discover signatures (e.g., from meta tags, linked manifests, or directory lookup) - let signature: ContentSignature | null = null; - let authorId: string | null = null; - - // Option 1: Check for signature in elements - const metaTags = await platformAdapter.executeScript( - currentTab.id, - ` - (() => { - const signed = document.querySelector('signed-section[signature]'); - if (!signed) return { signature: null, authorId: null, keyid: null, algorithm: null, contentHash: null, innerMeta: {} }; - const keyid = signed.getAttribute('keyid') || ''; - // Extract authorId from keyid URL (last path segment before /public-key) - const keyidParts = keyid.replace(/\\/public-key$/, '').split('/'); - const authorId = keyidParts[keyidParts.length - 1] || null; - - // Read inner metadata from tags - const metas = signed.querySelectorAll('meta'); - const innerMeta = {}; - metas.forEach(m => { - const name = m.getAttribute('name'); - const content = m.getAttribute('content'); - if (name && content) innerMeta[name] = content; - }); - - return { - signature: signed.getAttribute('signature'), - authorId: authorId, - keyid: keyid, - algorithm: signed.getAttribute('algorithm'), - contentHash: signed.getAttribute('content-hash'), - innerMeta: innerMeta - }; - })() - `, - ); + let verificationResult: VerificationResult; - if (metaTags.signature && metaTags.authorId) { - // Build claims from inner meta tags (claim:* entries) - const innerClaims: Record = {}; - const innerMeta = metaTags.innerMeta || {}; - for (const [key, value] of Object.entries(innerMeta)) { - if (key.startsWith("claim:")) { - innerClaims[key.slice("claim:".length)] = value as string; - } - } - signature = { - contentHash: extractedContent.contentHash, + if (!sectionHtml) { + verificationResult = { + verified: false, + reason: "No signed-section found on this page", + verifiedAt: Date.now(), domain: new URL(url).hostname, - authorId: metaTags.authorId, - signature: metaTags.signature, - claims: innerClaims, + trustStatus: "unknown", }; - authorId = metaTags.authorId; - } - - // Option 2: If no signature found in signed-section, try to search the directory - if (!signature && !authorId) { - try { - const searchResult = await contentSigningClient.searchSignedContent({ - contentHash: extractedContent.contentHash, - }); - - if (searchResult.signatures.length > 0) { - // Use the first signature found - const foundSignature = searchResult.signatures[0]; - signature = { - contentHash: foundSignature.contentHash, - domain: foundSignature.domain, - authorId: foundSignature.authorId, - signature: foundSignature.signature, - claims: foundSignature.claims, - }; - authorId = foundSignature.authorId; + } else { + // Step 2: verify locally (Layer 1, spec §3.1). We run in the + // background service worker context, which has SubtleCrypto. The + // 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 verify = await verifySignedSection(sectionHtml, { + keyResolvers: resolverChain, + domain: new URL(url).hostname, + }); + + // Best-effort author name lookup. The author DB is server-side and + // optional; if we can't fetch it (the keyid isn't a server URL or + // the server is unreachable) the verified state still holds — we + // just show the keyid in place of a friendly name. + const keyid = verify.keyid || ""; + const authorIdMatch = keyid.match(/\/authors\/([^/]+)/); + const authorId = authorIdMatch ? authorIdMatch[1] : null; + + if (verify.valid) { + let userName = keyid || "unknown"; + let userId = authorId || keyid; + if (authorId) { + try { + const csClient = authService.getContentSigningClient(); + if (csClient) { + const author = await csClient.getAuthor(authorId); + userName = author.name; + userId = author.id; + } + } catch { + // Author lookup failed; not fatal. Verification status is unaffected. + } } - } catch (error) { - console.error("Failed to search for signatures:", error); - // Continue with verification attempt if we have a signature from meta tags - } - } - - // If we found a signature, verify it - let verificationResult: VerificationResult; - - if (signature && authorId) { - try { - const result = await contentSigningClient.verifyContent( - extractedContent.contentHash, - new URL(url).hostname, - authorId, - signature.signature, - ); verificationResult = { - verified: result.valid, - reason: result.valid ? undefined : "Signature verification failed", + verified: true, verifiedAt: Date.now(), domain: new URL(url).hostname, - user: result.author - ? { - id: result.author.id, - name: result.author.name, - email: "", // Not provided by the API - publicKey: "", // We would need to fetch this separately - verified: true, - } - : undefined, - trustStatus: result.valid ? "trusted" : "untrusted", + user: { + id: userId, + name: userName, + email: "", + publicKey: "", + verified: true, + }, + trustStatus: "trusted", }; - } catch (error) { + } else { verificationResult = { verified: false, - reason: `Verification error: ${(error as Error).message}`, + reason: verify.reason || "Signature verification failed", verifiedAt: Date.now(), domain: new URL(url).hostname, - trustStatus: "unknown", + trustStatus: "untrusted", }; } - } else { - verificationResult = { - verified: false, - reason: "No signature found for this content", - verifiedAt: Date.now(), - domain: new URL(url).hostname, - trustStatus: "unknown", - }; } // Cache the verification result @@ -365,7 +312,6 @@ async function verifyContent(url: string): Promise { verificationResults[url] = verificationResult; await storage.set(STORAGE_KEYS.VERIFICATION_RESULTS, verificationResults); - // Update the badge updateBadge(); return { diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts index c0c3510..c269155 100644 --- a/src/content-scripts/index.ts +++ b/src/content-scripts/index.ts @@ -1,10 +1,44 @@ /** - * Content script entry point + * Content script entry point. + * + * Two responsibilities: + * + * 1. Auto-verify on page load. On DOMContentLoaded (the manifest registers + * this script as document_idle equivalent for content_scripts), find + * every on the page, verify each via + * @htmltrust/browser-client (Layer 1, SubtleCrypto-backed), evaluate the + * trust policy locally (Layer 2), and inject the corresponding badges + * inline next to each section. No popup interaction required. + * + * 2. Preserve the existing popup-driven flow. The background script can + * still push a richer VerificationResult via UPDATE_VERIFICATION_UI, in + * which case we apply the legacy whole-page highlighting/badges. This + * keeps the popup "Verify Content" button working and supports any + * flows that need server-side enrichment (e.g. author name lookups). + * + * Verification is local: the trust server is never contacted for the + * crypto step. Trust directories are consulted only by the resolver chain + * (third in line after did:web and direct URL resolvers). */ +import { + verifySignedSection, + evaluateTrustPolicy, + defaultResolverChain, + type VerifyResult, + type TrustEvaluation, + type TrustInput, + type KeyResolver, +} from '@htmltrust/browser-client'; import { MESSAGE_TYPES, CSS_CLASSES, TRUST_STATUS, STORAGE_KEYS } from '../core/common/constants'; import { ContentProcessor } from '../core/content'; import { PlatformAdapter, MessageContext } from '../platforms/common'; -import { VerificationResult, TrustStatus, VoteType, AuthorVote } from '../core/common/types'; +import { + VerificationResult, + TrustStatus, + VoteType, + Settings, + getTrustDirectoryUrls, +} from '../core/common/types'; // Import platform-specific adapter // This will be replaced with the correct adapter at build time @@ -13,41 +47,288 @@ import { ChromiumAdapter } from '../platforms/chromium'; // Initialize platform adapter const platformAdapter: PlatformAdapter = new ChromiumAdapter(); -// Initialize content processor +// Initialize content processor (used by the legacy heuristic-content path) const contentProcessor = new ContentProcessor(); +/** Marker class on the auto-verify badge container, used to avoid duplicates. */ +const AUTO_BADGE_MARKER = 'cs-auto-verification-badges'; + +/** + * Pull authorId out of a `.../authors/{id}/public-key` keyid URL. Returns + * null for keyids that aren't in this shape (e.g. did:web identifiers). + * Used purely for badge data attributes and vote button wiring. + */ +function authorIdFromKeyid(keyid: string): string | null { + if (!keyid) return null; + const m = keyid.match(/\/authors\/([^/]+)/); + return m ? m[1] : null; +} + /** - * Initialize the content script + * Initialize the content script. + * + * Three things happen here, in this order: + * 1. Read settings from storage (resolver chain needs the directory list, + * policy evaluator needs personal trust list / trusted domains). + * 2. Auto-verify every signed-section on the page. + * 3. Notify the background script that content was detected (for the popup + * status display) and listen for any UPDATE_VERIFICATION_UI follow-ups. + * + * Errors in any single signed-section don't abort the page; each section is + * verified independently, and a failure to load settings falls back to an + * empty resolver chain (still verifies any did:web or direct-URL keyids). */ async function initialize() { try { console.log('Content Signing content script initialized'); - // Extract content from the page + // 1. Settings → resolver chain + trust policy inputs + const settings = await loadSettings(); + const directories = getTrustDirectoryUrls(settings); + const resolverChain = defaultResolverChain({ directories }); + + // 2. Auto-verify on page load. Idempotent: re-running is a no-op for + // sections that already have an auto badge container next to them. + await autoVerifyPage(resolverChain, settings); + + // 3. Legacy popup path: notify background, optionally apply richer UI + // on UPDATE_VERIFICATION_UI messages. This is best-effort and + // independent of the auto-verify result above. + await notifyContentDetected(); + + // Listen for messages from the background script + listenForMessages(); + } catch (error) { + console.error('Failed to initialize content script:', error); + } +} + +/** + * Load settings from extension storage. On any error, returns a minimal + * default that's safe for the resolver chain (no directories) and the + * policy evaluator (empty trust lists). The user can fix this in the + * options page and the next page load picks up the change. + */ +async function loadSettings(): Promise { + try { + const storage = platformAdapter.getStorage(); + const stored = await storage.get(STORAGE_KEYS.SETTINGS); + if (stored) return stored; + } catch (err) { + console.warn('Content Signing: failed to load settings; using defaults', err); + } + // Minimal Settings-shaped default. We can't import DEFAULT_SETTINGS here + // because it pulls in the constants module which may grow other deps; + // the fields below are the only ones this script reads. + return { + autoVerify: true, + showBadges: true, + highlightVerified: true, + highlightUnverified: false, + trustDirectoryUrls: [], + personalTrustList: [], + trustedDomains: [], + authMethod: 'apikey', + serverConfigs: [], + }; +} + +/** + * Walk every on the page and verify it locally. + * + * Each section is verified independently — a failure on one does not skip + * the others. Badges are inserted as the next sibling of the section + * element, matching the e2e harness's visual placement. + * + * Idempotent: if a section already has an auto-badge sibling, it's skipped. + * This protects against re-runs (e.g. the script being injected twice on a + * page that does its own DOM manipulation). + */ +async function autoVerifyPage( + resolverChain: KeyResolver[], + settings: Settings, +): Promise { + const sections = document.querySelectorAll('signed-section[signature]'); + if (sections.length === 0) { + // Graceful no-op: pages without signed-sections are common and not an error. + return; + } + + const domain = window.location.hostname; + const personalTrustList = settings.personalTrustList ?? []; + const trustedDomains = settings.trustedDomains ?? []; + + for (const section of Array.from(sections)) { + // Idempotency: skip if we've already verified this section. + if (section.nextElementSibling?.classList.contains(AUTO_BADGE_MARKER)) { + continue; + } + + try { + const verify = await verifySignedSection(section, { + keyResolvers: resolverChain, + domain, + }); + + // Layer 2: trust policy. directorySubscriptions is intentionally empty + // here — the spec-compliant `/keys//reputation` endpoint + // shape is not yet implemented by the reference trust server. The e2e + // harness layers reports/score on top via a custom server lookup; the + // extension follows the same TODO pattern and stays out of that + // business until the server endpoint exists. + // TODO(directory-shape): wire `directorySubscriptions` once the trust + // server exposes `/keys/{keyid}/reputation` per spec. + const trust = await evaluateTrustPolicy(verify, { + personalTrustList, + trustedDomains, + directorySubscriptions: [], + }); + + const badges = buildAutoBadges(verify, trust); + section.parentNode?.insertBefore(badges, section.nextSibling); + } catch (err) { + console.error('Content Signing: verification failed for a signed-section', err); + // Render an explicit failure badge so the user can see something went + // wrong; without this the section would silently appear unverified. + const errBadges = buildErrorBadges((err as Error).message ?? 'verification error'); + section.parentNode?.insertBefore(errBadges, section.nextSibling); + } + } +} + +/** + * Build the inline badge container for a successful or failed verification. + * + * Matches the e2e harness's visual style (playwright-session.ts lines + * 312-360) so consumer-facing screenshots and the live extension look the + * same. CSS classes also match the existing content.css file so the + * stylesheet shipped with the extension styles them correctly. + */ +function buildAutoBadges(verify: VerifyResult, trust: TrustEvaluation): HTMLElement { + const authorId = verify.keyid ? authorIdFromKeyid(verify.keyid) : null; + + const badges = document.createElement('div'); + badges.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; + badges.setAttribute('data-author-id', authorId ?? ''); + badges.setAttribute('data-trust-score', String(trust.score)); + badges.setAttribute('data-keyid', verify.keyid ?? ''); + badges.style.cssText = + 'display: flex; gap: 8px; padding: 8px; margin: 8px 0; font-family: sans-serif; font-size: 14px; align-items: center; flex-wrap: wrap;'; + + // Signature validity badge + 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.style.cssText = + 'background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 4px;'; + } else { + sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; + sigBadge.textContent = `✗ Signature INVALID${verify.reason ? ` (${verify.reason})` : ''}`; + sigBadge.style.cssText = + 'background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 4px;'; + } + badges.appendChild(sigBadge); + + // Trust badge — color reflects the policy evaluator's indicator. + const trustBadge = document.createElement('span'); + const trustClass = + trust.indicator === 'green' + ? CSS_CLASSES.TRUST_BADGE_TRUSTED + : trust.indicator === 'red' + ? CSS_CLASSES.TRUST_BADGE_UNTRUSTED + : CSS_CLASSES.TRUST_BADGE_UNKNOWN; + trustBadge.className = `${CSS_CLASSES.TRUST_BADGE} ${trustClass}`; + trustBadge.textContent = `Trust: ${trust.score}%`; + if (trust.indicator === 'green') { + trustBadge.style.cssText = + 'background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 4px;'; + } else if (trust.indicator === 'red') { + trustBadge.style.cssText = + 'background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 4px;'; + } else { + trustBadge.style.cssText = + 'background: #fff3cd; color: #856404; padding: 4px 8px; border-radius: 4px;'; + } + // Hover tooltip: per-input rationale, useful for debugging / auditability. + trustBadge.title = trust.inputs + .map((r: TrustInput) => `${r.source}: ${r.contribution} (${r.rationale})`) + .join('\n'); + badges.appendChild(trustBadge); + + // Vote buttons (wired only when we extracted an authorId; did:web keyids + // are skipped because the existing vote API is keyed by authorId, not keyid). + if (authorId) { + badges.appendChild(buildVoteButton(CSS_CLASSES.UPVOTE_BUTTON, '👍 Trust', authorId, VoteType.UPVOTE)); + badges.appendChild(buildVoteButton(CSS_CLASSES.DOWNVOTE_BUTTON, '👎 Distrust', authorId, VoteType.DOWNVOTE)); + } + + return badges; +} + +function buildVoteButton( + cssClass: string, + label: string, + authorId: string, + vote: VoteType, +): HTMLButtonElement { + const btn = document.createElement('button'); + btn.className = `${CSS_CLASSES.VOTE_BUTTON} ${cssClass}`; + btn.textContent = label; + btn.dataset.authorId = authorId; + btn.dataset.voteType = vote; + btn.style.cssText = + 'cursor: pointer; padding: 4px 8px; border: 1px solid #ccc; background: white; border-radius: 4px;'; + btn.addEventListener('click', handleVoteButtonClick); + return btn; +} + +function buildErrorBadges(reason: string): HTMLElement { + const badges = document.createElement('div'); + badges.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; + badges.style.cssText = + 'display: flex; gap: 8px; padding: 8px; margin: 8px 0; font-family: sans-serif; font-size: 14px; align-items: center;'; + const sigBadge = document.createElement('span'); + sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; + sigBadge.textContent = `✗ Verification error: ${reason}`; + sigBadge.style.cssText = + 'background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 4px;'; + badges.appendChild(sigBadge); + return badges; +} + +/** + * Notify background that content was detected. This drives the popup's + * "current page" status display and is independent of the auto-verify + * badges injected above. Failures here are non-fatal. + */ +async function notifyContentDetected(): Promise { + try { + // Use legacy heuristic-based content extraction for the popup; the + // auto-verify path uses the actual signed-section element directly. const extractedContent = contentProcessor.extractContent(document); - // Notify the background script that content was detected const response = await platformAdapter.sendMessage(MessageContext.BACKGROUND, { type: MESSAGE_TYPES.CONTENT_DETECTED, url: window.location.href, content: extractedContent, }); - // If we received a response, apply the verification UI if (response) { applyVerificationUI(response); } - - // Listen for messages from the background script - listenForMessages(); - } catch (error) { - console.error('Failed to initialize content script:', error); + } catch (err) { + // Background may legitimately have no enrichment to offer (no signature + // found in legacy paths). Don't pollute the console for this case. + console.debug('Content Signing: notifyContentDetected returned no enrichment', err); } } /** - * Apply verification UI to the page - * @param verificationResult The verification result + * Apply legacy verification UI driven by the background script. Kept for + * back-compat with the popup → background → content-script enrichment + * flow. The auto-verify path above is what the user sees by default; this + * only runs if the background pushes a result. */ function applyVerificationUI(verificationResult: VerificationResult) { try { @@ -60,7 +341,7 @@ function applyVerificationUI(verificationResult: VerificationResult) { // Find content elements to highlight const contentElements = findContentElements(); - + // Apply verification UI to each content element contentElements.forEach(element => { applyVerificationUIToElement(element, verificationResult, settings); @@ -77,27 +358,24 @@ function applyVerificationUI(verificationResult: VerificationResult) { function findContentElements(): Element[] { // Try to find main content containers first const mainContainers = Array.from(document.querySelectorAll('article, main, [role="main"], .content, #content')); - + if (mainContainers.length > 0) { return mainContainers; } - + // If no main containers found, look for sections or large text blocks const sections = Array.from(document.querySelectorAll('section, .post, #post, .entry, #entry')); - + if (sections.length > 0) { return sections; } - + // If still nothing found, use paragraphs and headings return Array.from(document.querySelectorAll('p, h1, h2, h3, h4, h5, h6')); } /** * Apply verification UI to a specific element - * @param element The element to apply verification UI to - * @param verificationResult The verification result - * @param settings The settings for the verification UI */ function applyVerificationUIToElement( element: Element, @@ -106,7 +384,7 @@ function applyVerificationUIToElement( ) { // Add content outline class element.classList.add(CSS_CLASSES.CONTENT_OUTLINE); - + // Determine verification status class if (verificationResult.verified) { if (settings.highlightVerified) { @@ -117,7 +395,7 @@ function applyVerificationUIToElement( element.classList.add(CSS_CLASSES.UNVERIFIED_CONTENT); } } - + // Add verification badges if enabled if (settings.showBadges) { addVerificationBadges(element, verificationResult); @@ -126,23 +404,21 @@ function applyVerificationUIToElement( /** * Add verification badges to an element - * @param element The element to add badges to - * @param verificationResult The verification result */ function addVerificationBadges(element: Element, verificationResult: VerificationResult) { try { // Create badge container const badgeContainer = document.createElement('div'); badgeContainer.className = CSS_CLASSES.VERIFICATION_BADGES; - + // Add validity badge const validityBadge = createValidityBadge(verificationResult); badgeContainer.appendChild(validityBadge); - + // Add trust badge const trustBadge = createTrustBadge(verificationResult); badgeContainer.appendChild(trustBadge); - + // Add the badge container to the element element.appendChild(badgeContainer); } catch (error) { @@ -150,156 +426,118 @@ function addVerificationBadges(element: Element, verificationResult: Verificatio } } -/** - * Create a validity badge - * @param verificationResult The verification result - * @returns The validity badge element - */ function createValidityBadge(verificationResult: VerificationResult): HTMLElement { const badge = document.createElement('span'); badge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VALIDITY_BADGE}`; - + if (verificationResult.verified) { badge.classList.add(CSS_CLASSES.VERIFICATION_BADGE_VERIFIED); badge.textContent = '✓'; - - // Add tooltip + const tooltip = document.createElement('span'); tooltip.className = CSS_CLASSES.TOOLTIP; tooltip.textContent = `Verified by ${verificationResult.user?.name || 'unknown'}`; - - // Add vote buttons if we have an author ID + if (verificationResult.user?.id) { const voteButtons = createVoteButtons(verificationResult.user.id); tooltip.appendChild(voteButtons); } - + badge.appendChild(tooltip); } else { badge.classList.add(CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED); badge.textContent = '✗'; - - // Add tooltip + const tooltip = document.createElement('span'); tooltip.className = CSS_CLASSES.TOOLTIP; tooltip.textContent = verificationResult.reason || 'Not verified'; badge.appendChild(tooltip); } - + return badge; } -/** - * Create a trust badge - * @param verificationResult The verification result - * @returns The trust badge element - */ function createTrustBadge(verificationResult: VerificationResult): HTMLElement { const badge = document.createElement('span'); badge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.TRUST_BADGE}`; - - // Determine trust status + const trustStatus = determineTrustStatus(verificationResult); - + switch (trustStatus) { - case TRUST_STATUS.TRUSTED: + case TRUST_STATUS.TRUSTED: { badge.classList.add(CSS_CLASSES.TRUST_BADGE_TRUSTED); badge.textContent = '🔒'; - - // Add tooltip const trustedTooltip = document.createElement('span'); trustedTooltip.className = CSS_CLASSES.TOOLTIP; trustedTooltip.textContent = `Trusted source: ${verificationResult.domain || 'unknown domain'}`; badge.appendChild(trustedTooltip); break; - - case TRUST_STATUS.UNTRUSTED: + } + case TRUST_STATUS.UNTRUSTED: { badge.classList.add(CSS_CLASSES.TRUST_BADGE_UNTRUSTED); badge.textContent = '⚠️'; - - // Add tooltip const untrustedTooltip = document.createElement('span'); untrustedTooltip.className = CSS_CLASSES.TOOLTIP; untrustedTooltip.textContent = `Untrusted source: ${verificationResult.domain || 'unknown domain'}`; badge.appendChild(untrustedTooltip); break; - + } case TRUST_STATUS.UNKNOWN: - default: + default: { badge.classList.add(CSS_CLASSES.TRUST_BADGE_UNKNOWN); badge.textContent = '?'; - - // Add tooltip const unknownTooltip = document.createElement('span'); unknownTooltip.className = CSS_CLASSES.TOOLTIP; unknownTooltip.textContent = `Unknown source: ${verificationResult.domain || 'unknown domain'}`; badge.appendChild(unknownTooltip); break; + } } - + return badge; } -/** - * Create vote buttons for an author - * @param authorId The ID of the author to vote on - * @returns The vote buttons container element - */ function createVoteButtons(authorId: string): HTMLElement { - // Create container const container = document.createElement('div'); container.className = CSS_CLASSES.VOTE_BUTTONS; - - // Create upvote button + const upvoteButton = document.createElement('button'); upvoteButton.className = `${CSS_CLASSES.VOTE_BUTTON} ${CSS_CLASSES.UPVOTE_BUTTON}`; upvoteButton.textContent = '👍'; upvoteButton.title = 'Upvote this author'; upvoteButton.dataset.authorId = authorId; upvoteButton.dataset.voteType = VoteType.UPVOTE; - - // Create downvote button + const downvoteButton = document.createElement('button'); downvoteButton.className = `${CSS_CLASSES.VOTE_BUTTON} ${CSS_CLASSES.DOWNVOTE_BUTTON}`; downvoteButton.textContent = '👎'; downvoteButton.title = 'Downvote this author'; downvoteButton.dataset.authorId = authorId; downvoteButton.dataset.voteType = VoteType.DOWNVOTE; - - // Add event listeners + upvoteButton.addEventListener('click', handleVoteButtonClick); downvoteButton.addEventListener('click', handleVoteButtonClick); - - // Add buttons to container + container.appendChild(upvoteButton); container.appendChild(downvoteButton); - - // Check if we have an existing vote for this author and update UI accordingly + checkExistingVote(authorId, upvoteButton, downvoteButton); - + return container; } -/** - * Check if there's an existing vote for an author and update button states - * @param authorId The ID of the author - * @param upvoteButton The upvote button element - * @param downvoteButton The downvote button element - */ async function checkExistingVote( authorId: string, upvoteButton: HTMLButtonElement, downvoteButton: HTMLButtonElement ): Promise { try { - // Request the current vote state from the background script const response = await platformAdapter.sendMessage(MessageContext.BACKGROUND, { type: 'GET_AUTHOR_VOTE', authorId, }); - + if (response && response.vote) { - // Update button states based on current vote if (response.vote === VoteType.UPVOTE) { upvoteButton.classList.add(CSS_CLASSES.VOTE_BUTTON_ACTIVE); downvoteButton.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); @@ -316,100 +554,75 @@ async function checkExistingVote( } } -/** - * Handle vote button click - * @param event The click event - */ async function handleVoteButtonClick(event: MouseEvent): Promise { event.preventDefault(); event.stopPropagation(); - + const button = event.currentTarget as HTMLButtonElement; const authorId = button.dataset.authorId; const voteType = button.dataset.voteType as VoteType; - + if (!authorId || !voteType) { console.error('Missing authorId or voteType in vote button'); return; } - - // Determine if this is a toggle (clicking already active button) + const isToggle = button.classList.contains(CSS_CLASSES.VOTE_BUTTON_ACTIVE); const finalVoteType = isToggle ? VoteType.NEUTRAL : voteType; - - // Find the container and buttons once to use throughout the function + const container = button.parentElement; const upvoteButton = container?.querySelector(`.${CSS_CLASSES.UPVOTE_BUTTON}`) as HTMLButtonElement; const downvoteButton = container?.querySelector(`.${CSS_CLASSES.DOWNVOTE_BUTTON}`) as HTMLButtonElement; - + try { - // Find the other button (to update its state) const otherButton = voteType === VoteType.UPVOTE ? downvoteButton : upvoteButton; - - // Update button states immediately for responsive UI + if (finalVoteType === VoteType.NEUTRAL) { - // Remove active state if toggling off button.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); } else { - // Set this button as active and remove active from other button button.classList.add(CSS_CLASSES.VOTE_BUTTON_ACTIVE); if (otherButton) { otherButton.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); } } - - // Send vote to background script + await platformAdapter.sendMessage(MessageContext.BACKGROUND, { type: MESSAGE_TYPES.SUBMIT_VOTE, authorId, vote: finalVoteType, url: window.location.href, - contentHash: null, // Could be added if we have access to the content hash + contentHash: null, }); - + console.log(`Vote ${finalVoteType} submitted for author ${authorId}`); } catch (error) { console.error('Failed to submit vote:', error); - // Revert UI changes on error if (upvoteButton && downvoteButton) { checkExistingVote(authorId, upvoteButton, downvoteButton); } } } -/** - * Determine the trust status of a verification result - * @param verificationResult The verification result - * @returns The trust status - */ function determineTrustStatus(verificationResult: VerificationResult): TrustStatus { - // If the trust status is already set, use it if (verificationResult.trustStatus) { return verificationResult.trustStatus; } - - // If not verified, it's untrusted + if (!verificationResult.verified) { return TRUST_STATUS.UNTRUSTED; } - - // If there's a trust directory entry, it's trusted + if (verificationResult.trustDirectoryEntry) { return TRUST_STATUS.TRUSTED; } - - // If there's a user but no trust directory entry, check if the user is verified + if (verificationResult.user) { return verificationResult.user.verified ? TRUST_STATUS.TRUSTED : TRUST_STATUS.UNTRUSTED; } - - // Otherwise, it's unknown + return TRUST_STATUS.UNKNOWN; } -/** - * Listen for messages from the background script - */ function listenForMessages() { platformAdapter.registerMessageListeners({ [MessageContext.BACKGROUND]: async (message: any) => { @@ -418,7 +631,6 @@ function listenForMessages() { applyVerificationUI(message.verificationResult); return { success: true }; case MESSAGE_TYPES.VOTE_ACKNOWLEDGED: - // Update vote button states if needed if (message.authorId) { const upvoteButtons = document.querySelectorAll( `.${CSS_CLASSES.UPVOTE_BUTTON}[data-author-id="${message.authorId}"]` @@ -426,7 +638,7 @@ function listenForMessages() { const downvoteButtons = document.querySelectorAll( `.${CSS_CLASSES.DOWNVOTE_BUTTON}[data-author-id="${message.authorId}"]` ); - + upvoteButtons.forEach((upvoteButton) => { downvoteButtons.forEach((downvoteButton) => { checkExistingVote( @@ -445,5 +657,17 @@ function listenForMessages() { }); } -// Initialize the content script -initialize(); \ No newline at end of file +/** + * Run on DOMContentLoaded so we have the full DOM (signed-section elements + * may be near the end of the body). The manifest also registers this + * script as a content_script so it auto-injects on every page load; the + * DOMContentLoaded check handles the rare case where the script is + * injected before the DOM is ready. + */ +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + initialize(); + }); +} else { + initialize(); +} diff --git a/src/core/api/content-signing-client.ts b/src/core/api/content-signing-client.ts index 050ef1c..26482fc 100644 --- a/src/core/api/content-signing-client.ts +++ b/src/core/api/content-signing-client.ts @@ -1,7 +1,34 @@ /** - * Content Signing API client + * Content Signing API client. + * + * Layered into two responsibilities: + * + * 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) + * to fetch keys. NO trust server is contacted for verification. + * + * 2. Author/key/content management operations against a trust server. + * These are the admin/author-side flows (creating authors, signing + * content via remote authorities, voting). These remain server-backed + * because they require server-held secrets (author API keys) and + * mutate server state. + * + * The deprecated /api/content/verify endpoint is no longer called. Callers + * who previously invoked verifyContent() should call verifySignedSectionLocal() + * (or use the lib directly) instead. verifyContent() is preserved as a thin + * compatibility wrapper that delegates to the local verifier when given a + * signed-section element/HTML, and otherwise returns a "verification requires + * the signed-section element, not a server lookup" failure result. */ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { + verifySignedSection, + defaultResolverChain, + type VerifyResult, +} from '@htmltrust/browser-client'; +import type { KeyResolver } from '@htmltrust/browser-client'; import { Author, PublicKey, ContentSignature, Claim, KeyReputation, ContentOccurrence, ServerConfig, VoteType, BatchedVotesPayload, BatchVoteResult } from '../common/types'; import { ERROR_CODES, API_ENDPOINTS } from '../common/constants'; import { createError } from '../common/utils'; @@ -14,6 +41,31 @@ export interface ContentSigningClientOptions { baseUrl: string; /** The timeout for API requests in milliseconds */ timeout?: number; + /** + * Trust directory base URLs to use as a fallback in the resolver chain. + * The default chain (did:web → direct URL) handles most keyids; directories + * are only consulted for keyids that match neither of the first two shapes. + */ + trustDirectories?: string[]; +} + +/** + * Local verification options. Mirrors the lib's VerifyOptions shape but with + * defaults filled in from the client's configured trust directories. + */ +export interface LocalVerifyOptions { + /** The signed-section element or its outerHTML. */ + section: Element | string; + /** Domain to bind the signature to. Defaults to window.location.hostname. */ + domain?: string; + /** Optional override of the resolver chain (overrides client-configured directories). */ + keyResolvers?: KeyResolver[]; + /** + * Optional override of the SHA-256 implementation. Used by environments where + * SubtleCrypto is unavailable (plain HTTP test pages); production browsers + * should always have SubtleCrypto on a secure context. + */ + hash?: (canonical: string) => Promise; } /** @@ -22,6 +74,8 @@ export interface ContentSigningClientOptions { export class ContentSigningClient { private client: AxiosInstance; private baseUrl: string; + private trustDirectories: string[]; + private resolverChain: KeyResolver[]; /** * Create a new Content Signing API client @@ -29,7 +83,12 @@ export class ContentSigningClient { */ constructor(options: ContentSigningClientOptions) { this.baseUrl = options.baseUrl; - + this.trustDirectories = options.trustDirectories ?? []; + // 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 }); + const config: AxiosRequestConfig = { baseURL: options.baseUrl, timeout: options.timeout || 10000, @@ -41,18 +100,32 @@ export class ContentSigningClient { this.client = axios.create(config); } + /** + * Update the trust directory list and rebuild the resolver chain. + * Called when the user edits the directory list in extension settings. + */ + setTrustDirectories(directories: string[]): void { + this.trustDirectories = directories; + this.resolverChain = defaultResolverChain({ directories }); + } + + /** Get the configured resolver chain (for callers that want to reuse it). */ + getResolverChain(): KeyResolver[] { + return this.resolverChain; + } + /** * Set the API key for authenticated requests * @param apiKey The API key to use * @param keyType The type of API key (author, general, admin) */ setApiKey(apiKey: string, keyType: 'author' | 'general' | 'admin'): void { - const headerName = keyType === 'author' - ? 'X-AUTHOR-API-KEY' - : keyType === 'admin' - ? 'X-ADMIN-API-KEY' + const headerName = keyType === 'author' + ? 'X-AUTHOR-API-KEY' + : keyType === 'admin' + ? 'X-ADMIN-API-KEY' : 'X-API-KEY'; - + this.client.defaults.headers.common[headerName] = apiKey; } @@ -61,15 +134,30 @@ export class ContentSigningClient { * @param keyType The type of API key to clear (author, general, admin) */ clearApiKey(keyType: 'author' | 'general' | 'admin'): void { - const headerName = keyType === 'author' - ? 'X-AUTHOR-API-KEY' - : keyType === 'admin' - ? 'X-ADMIN-API-KEY' + const headerName = keyType === 'author' + ? 'X-AUTHOR-API-KEY' + : keyType === 'admin' + ? 'X-ADMIN-API-KEY' : 'X-API-KEY'; - + delete this.client.defaults.headers.common[headerName]; } + /** + * Locally verify a signed-section element using @htmltrust/browser-client. + * + * This is the spec §3.1 path: the browser does its own crypto verification + * via SubtleCrypto, with key resolution handled by the configured resolver + * chain. No trust server is contacted for verification. + */ + async verifySignedSectionLocal(opts: LocalVerifyOptions): Promise { + return verifySignedSection(opts.section, { + keyResolvers: opts.keyResolvers ?? this.resolverChain, + domain: opts.domain, + hash: opts.hash, + }); + } + /** * Create a new author and key pair * @param name The name of the author @@ -80,7 +168,7 @@ export class ContentSigningClient { * @returns A promise that resolves with the created author and API key */ async createAuthor( - name: string, + name: string, keyType: 'HUMAN' | 'AI' | 'HUMAN_AI_MIX' | 'ORGANIZATION', description?: string, url?: string, @@ -102,11 +190,6 @@ export class ContentSigningClient { /** * Get a list of authors - * @param name Optional filter by author name - * @param keyType Optional filter by key type - * @param page Optional page number - * @param limit Optional number of items per page - * @returns A promise that resolves with a list of authors and pagination info */ async listAuthors( name?: string, @@ -130,8 +213,6 @@ export class ContentSigningClient { /** * Get author details - * @param authorId The ID of the author - * @returns A promise that resolves with the author details */ async getAuthor(authorId: string): Promise { try { @@ -144,9 +225,6 @@ export class ContentSigningClient { /** * Update author details - * @param authorId The ID of the author - * @param updates The updates to apply - * @returns A promise that resolves with the updated author */ async updateAuthor( authorId: string, @@ -162,8 +240,6 @@ export class ContentSigningClient { /** * Delete an author - * @param authorId The ID of the author - * @returns A promise that resolves when the author is deleted */ async deleteAuthor(authorId: string): Promise { try { @@ -175,8 +251,9 @@ export class ContentSigningClient { /** * Get an author's public key - * @param authorId The ID of the author - * @returns A promise that resolves with the author's public key + * + * NOTE: this is server-side admin lookup; for verification, prefer the + * resolver chain (which handles did:web, direct URL, and trust directories). */ async getAuthorPublicKey(authorId: string): Promise { try { @@ -188,11 +265,7 @@ export class ContentSigningClient { } /** - * Sign content - * @param contentHash The hash of the normalized content - * @param domain The domain associated with the content - * @param claims Claims about the content - * @returns A promise that resolves with the content signature + * Sign content (server-mediated, requires author API key). */ async signContent( contentHash: string, @@ -212,37 +285,35 @@ export class ContentSigningClient { } /** - * Verify content signature - * @param contentHash The hash of the normalized content - * @param domain The domain associated with the content - * @param authorId The ID of the author who signed the content - * @param signature The cryptographic signature to verify - * @returns A promise that resolves with the verification result + * Verify content signature. + * + * @deprecated The trust server's POST /api/content/verify endpoint has been + * removed; verification is now performed locally per spec §3.1. This method + * is retained as a back-compat shim that returns a structured failure result + * indicating that callers should use verifySignedSectionLocal() instead. The + * background script has been migrated to call verifySignedSectionLocal() + * directly with the page's signed-section element. + * + * @returns Always { valid: false } with a descriptive reason. */ async verifyContent( - contentHash: string, - domain: string, - authorId: string, - signature: string - ): Promise<{ valid: boolean; author?: Author; claims?: Record }> { - try { - const response = await this.client.post(API_ENDPOINTS.CONTENT_VERIFY, { - contentHash, - domain, - authorId, - signature - }); - return response.data; - } catch (error) { - throw this.handleApiError(error, 'Failed to verify content'); - } + _contentHash: string, + _domain: string, + _authorId: string, + _signature: string + ): Promise<{ valid: boolean; author?: Author; claims?: Record; reason?: string }> { + // Intentionally do not contact the server. The deprecated endpoint + // returned { valid, author, claims }; we surface a clear failure so + // legacy code paths fail loudly rather than silently regressing trust. + return { + valid: false, + reason: + 'verifyContent() is deprecated; use verifySignedSectionLocal() (or @htmltrust/browser-client verifySignedSection) for spec §3.1 local verification', + }; } /** * List claim types - * @param page Optional page number - * @param limit Optional number of items per page - * @returns A promise that resolves with a list of claim types and pagination info */ async listClaimTypes( page?: number, @@ -262,8 +333,6 @@ export class ContentSigningClient { /** * Get claim type details - * @param claimId The ID of the claim type - * @returns A promise that resolves with the claim type details */ async getClaimType(claimId: string): Promise { try { @@ -276,8 +345,6 @@ export class ContentSigningClient { /** * Search public keys - * @param params Search parameters - * @returns A promise that resolves with a list of public keys and pagination info */ async searchPublicKeys(params: { authorName?: string; @@ -286,9 +353,9 @@ export class ContentSigningClient { minTrustScore?: number; page?: number; limit?: number; - }): Promise<{ - keys: Array; - pagination: { total: number; pages: number; page: number; limit: number } + }): Promise<{ + keys: Array; + pagination: { total: number; pages: number; page: number; limit: number } }> { try { const response = await this.client.get(API_ENDPOINTS.DIRECTORY_KEYS, { params }); @@ -300,8 +367,6 @@ export class ContentSigningClient { /** * Get key reputation - * @param keyId The ID of the public key - * @returns A promise that resolves with the key reputation */ async getKeyReputation(keyId: string): Promise { try { @@ -314,11 +379,6 @@ export class ContentSigningClient { /** * Report a key - * @param keyId The ID of the public key - * @param reason The reason for reporting - * @param details Optional additional details - * @param evidence Optional URL to evidence - * @returns A promise that resolves with the report status */ async reportKey( keyId: string, @@ -340,8 +400,6 @@ export class ContentSigningClient { /** * Search signed content - * @param params Search parameters - * @returns A promise that resolves with a list of content signatures and pagination info */ async searchSignedContent(params: { contentHash?: string; @@ -350,9 +408,9 @@ export class ContentSigningClient { claim?: string; page?: number; limit?: number; - }): Promise<{ - signatures: Array; - pagination: { total: number; pages: number; page: number; limit: number } + }): Promise<{ + signatures: Array; + pagination: { total: number; pages: number; page: number; limit: number } }> { try { const response = await this.client.get(API_ENDPOINTS.DIRECTORY_CONTENT, { params }); @@ -364,18 +422,14 @@ export class ContentSigningClient { /** * Find content occurrences - * @param contentHash The hash of the content - * @param page Optional page number - * @param limit Optional number of items per page - * @returns A promise that resolves with a list of content occurrences and pagination info */ async findContentOccurrences( contentHash: string, page?: number, limit?: number - ): Promise<{ - occurrences: ContentOccurrence[]; - pagination: { total: number; pages: number; page: number; limit: number } + ): Promise<{ + occurrences: ContentOccurrence[]; + pagination: { total: number; pages: number; page: number; limit: number } }> { try { const params: Record = {}; @@ -391,11 +445,6 @@ export class ContentSigningClient { /** * Report content misuse - * @param contentHash The hash of the content being reported - * @param sourceUrl The original source URL of the content - * @param targetUrl The URL where the content is being misused - * @param reason The reason for reporting - * @returns A promise that resolves with the report status */ async reportContentMisuse( contentHash: string, @@ -418,8 +467,6 @@ export class ContentSigningClient { /** * Submit a batch of author votes - * @param votes A map of author IDs to vote types - * @returns A promise that resolves with the batch vote result */ async submitBatchedVotes(votes: BatchedVotesPayload): Promise { try { @@ -434,9 +481,6 @@ export class ContentSigningClient { /** * Submit a vote for a specific author - * @param authorId The ID of the author to vote on - * @param vote The type of vote to cast - * @returns A promise that resolves when the vote is submitted * @deprecated Use submitBatchedVotes instead */ async submitAuthorVote(authorId: string, vote: VoteType): Promise { @@ -451,15 +495,12 @@ export class ContentSigningClient { /** * Handle API errors - * @param error The error to handle - * @param defaultMessage The default error message - * @returns A standardized error object */ private handleApiError(error: any, defaultMessage: string): never { if (axios.isAxiosError(error)) { const status = error.response?.status; const message = error.response?.data?.message || error.message || defaultMessage; - + if (status === 401 || status === 403) { throw createError(ERROR_CODES.AUTH_ERROR, message, error); } else if (status === 400) { @@ -468,7 +509,7 @@ export class ContentSigningClient { throw createError(ERROR_CODES.NETWORK_ERROR, message, error); } } - + throw createError(ERROR_CODES.UNKNOWN_ERROR, defaultMessage, error); } -} \ No newline at end of file +} diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts index 5aae7e5..8744b4b 100644 --- a/src/core/common/constants.ts +++ b/src/core/common/constants.ts @@ -13,14 +13,25 @@ export const EXTENSION_NAME = 'Content Signing'; export const EXTENSION_VERSION = '1.0.0'; /** - * Default settings for the extension + * Default settings for the extension. + * + * trustDirectoryUrls is the canonical form (list of directory base URLs). + * trustDirectoryUrl is preserved for back-compat with persisted settings + * from older versions; new code should always read via + * getTrustDirectoryUrls(settings) which normalizes both shapes. + * + * personalTrustList and trustedDomains start empty; the user populates them + * via the options page. They feed the lib's evaluateTrustPolicy directly. */ export const DEFAULT_SETTINGS = { autoVerify: true, showBadges: true, highlightVerified: true, highlightUnverified: false, - trustDirectoryUrl: 'https://api.trustdirectory.example.com', + trustDirectoryUrls: [] as string[], + trustDirectoryUrl: '', // legacy, unused if trustDirectoryUrls is populated + personalTrustList: [] as string[], + trustedDomains: [] as string[], authMethod: 'apikey' as const, serverConfigs: [ { diff --git a/src/core/common/types.ts b/src/core/common/types.ts index 2824563..bcb6a8a 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -243,8 +243,30 @@ export interface Settings { highlightVerified: boolean; /** Whether to highlight unverified content */ highlightUnverified: boolean; - /** The trust directory URL */ - trustDirectoryUrl: string; + /** + * The trust directory URL. + * @deprecated Use `trustDirectoryUrls` (a list of directory base URLs). + * Retained for back-compat with persisted settings; on read, normalize to + * the list form via getTrustDirectoryUrls(settings). + */ + trustDirectoryUrl?: string; + /** + * Trust directory base URLs used by the keyid resolver chain (third + * resolver after did:web and direct URL). Order matters: the first + * directory that resolves a keyid wins. + */ + trustDirectoryUrls?: string[]; + /** + * User's personal trust list, expressed as keyid strings (typically + * did:web identifiers or direct public-key URLs). Empty by default; + * keyids in this list contribute +40 to the policy score per spec §3.1. + */ + personalTrustList?: string[]; + /** + * Domains the user explicitly trusts. Empty by default; matching domains + * contribute +30 to the policy score per spec §3.1. + */ + trustedDomains?: string[]; /** The user's preferred authentication method */ authMethod: 'apikey' | 'webauthn' | 'password'; /** Server configurations for the Content Signing API */ @@ -253,6 +275,23 @@ export interface Settings { activeServerId?: string; } +/** + * Normalize the (possibly legacy) trust-directory settings to a list. Returns + * the explicit list if present, otherwise wraps the single legacy URL in a + * one-element array, otherwise returns an empty array. Caller should use this + * as the single source of truth for "the directories the resolver chain and + * the policy evaluator will consult". + */ +export function getTrustDirectoryUrls(settings: Pick): string[] { + if (settings.trustDirectoryUrls && settings.trustDirectoryUrls.length > 0) { + return settings.trustDirectoryUrls.filter((u) => u && u.trim().length > 0); + } + if (settings.trustDirectoryUrl && settings.trustDirectoryUrl.trim().length > 0) { + return [settings.trustDirectoryUrl.trim()]; + } + return []; +} + /** * Represents an error in the extension */ diff --git a/src/platforms/chromium/manifest.json b/src/platforms/chromium/manifest.json index f858ac7..0e3ec69 100644 --- a/src/platforms/chromium/manifest.json +++ b/src/platforms/chromium/manifest.json @@ -27,7 +27,8 @@ } ], "permissions": ["storage", "tabs", "notifications", "alarms"], - "host_permissions": ["https://*/*"], + "_comment_host_permissions": "HTTP is included alongside HTTPS so 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.", + "host_permissions": ["https://*/*", "http://*/*"], "options_ui": { "page": "options.html", "open_in_tab": true diff --git a/src/platforms/firefox/manifest.json b/src/platforms/firefox/manifest.json index 31460fe..b1627b8 100644 --- a/src/platforms/firefox/manifest.json +++ b/src/platforms/firefox/manifest.json @@ -26,11 +26,13 @@ "css": ["assets/content.css"] } ], + "_comment_permissions": "HTTP is included alongside HTTPS in host permissions so 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.", "permissions": [ "storage", "tabs", "notifications", - "https://*/*" + "https://*/*", + "http://*/*" ], "options_ui": { "page": "options.html", diff --git a/src/platforms/safari/manifest.json b/src/platforms/safari/manifest.json index 211cb42..e3afdb6 100644 --- a/src/platforms/safari/manifest.json +++ b/src/platforms/safari/manifest.json @@ -31,8 +31,10 @@ "tabs", "notifications" ], + "_comment_host_permissions": "HTTP is included alongside HTTPS so 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.", "host_permissions": [ - "https://*/*" + "https://*/*", + "http://*/*" ], "options_ui": { "page": "options.html", diff --git a/src/ui/options/index.tsx b/src/ui/options/index.tsx index 532e490..a7a438b 100644 --- a/src/ui/options/index.tsx +++ b/src/ui/options/index.tsx @@ -3,7 +3,7 @@ */ import React, { useState, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; -import { Settings, Profile } from '../../core/common'; +import { Settings, Profile, getTrustDirectoryUrls } from '../../core/common'; import { STORAGE_KEYS, DEFAULT_SETTINGS, DEFAULT_PROFILE } from '../../core/common/constants'; import { PlatformAdapter, MessageContext } from '../../platforms/common'; import { ProfileManager } from '../../ui/components'; @@ -451,19 +451,89 @@ const Options: React.FC = ({ adapter }) => {

Trust Directory Settings

- +
-