diff --git a/README.md b/README.md index 2252eae..eb2fcb2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,12 @@ After each page load or same-document navigation, the content script refetches t - npm - Chromium, Firefox, or Safari for loading a built extension -The published dependencies pin browser client commit `39dc873c` and canonicalization commit `5e51040d`. A sibling browser-client checkout is optional. Use one when developing both repositories together. +The published dependencies pin browser client commit +`70c5ddb6ed23c06c0b1c46d5284618fb99a28aac` and canonicalization commit +`760593d4a02e9fffa56dc4d002eb52ab2ade1b49`. These are the revisions recorded +in `package.json` and `package-lock.json`; update both files together when the +release revision changes. A sibling browser-client checkout is optional. Use +one when developing both repositories together. For a standalone checkout: @@ -64,8 +69,13 @@ restore the pinned commit. npm test -- --runInBand npm run typecheck npm run lint +npm run build:chromium ``` +The last command is a clean verification build for the default supported +runtime, Chromium. `npm run build:all` builds and archives all three packaging +targets. + Tests use jsdom for DOM behavior. Run the complete check in a Node 22 container with: @@ -91,7 +101,18 @@ Build one browser with `npm run build:chromium`, `npm run build:firefox`, or `np npm run build:all ``` -The unpacked extension is written to `build//`. For Chromium, open `chrome://extensions/`, enable Developer mode, choose **Load unpacked**, and select `build/chromium/`. +The unpacked extension is written to `build//`, with a zip archive in +`build/`. Chromium is the implemented runtime adapter. Firefox and Safari +currently have packaging targets and manifests, but their runtime adapters are +pending, so those builds do not claim working extension support in those +browsers. For Chromium, open `chrome://extensions/`, enable Developer mode, +choose **Load unpacked**, and select `build/chromium/`. + +Each production build enforces byte budgets for the shipped JavaScript: +244 KiB each for `background.js` and `content.js`, 215 KiB for `popup.js`, and +230 KiB for `options.js`. Unexpected JavaScript chunks fail the build because +the manifests would not load them. After a Chromium build, rerun the gate +alone with `npm run check:bundle`. ### Development @@ -108,9 +129,24 @@ Use the matching `dev:firefox` or `dev:safari` command for another target. Reloa - `captureNavigationSnapshot` retains exact source slices, parser-owned elements, the final response URL, and the document base URL. - `mapSnapshotToLiveSections` pairs source sections with live elements by signed attributes, so page reordering does not pair one signature with another. - `observeSignedSection` watches only the live signed element. Mutations trigger re-verification against the immutable source section. History changes and replacement of signed sections trigger a fresh page refetch. -- The content script inserts markers beside the outermost signed element. The marker, tooltip, and vote controls are outside signed content, including when sections are nested. +- The content script inserts a marker beside the outermost signed element. The marker stays outside signed content, including when sections are nested. The popup receives copied result records. It cannot mutate the content script's verification cache. +The content script is the only verification entry point. It sends an aggregate +validity bit to the service worker after each page run or signed-content +mutation; the service worker uses that result for the toolbar badge. + +## Trust directory policy + +Open **Options**, add a directory, choose a weight from 0 to 1, and leave the +subscription disabled until you want it consulted. The extension stores these +rows in browser storage. The content verifier queries each enabled row at +`GET /signers/{id}/reputation`; the page marker and popup +show cryptographic validity separately from the resulting directory trust. +Only the signer identifier is sent to a configured HTTPS directory. Invalid +URLs and weights are rejected before settings are saved. A timeout, malformed +response, unavailable directory, or conflicting result leaves the other policy +inputs visible and does not turn a valid signature into an invalid one. ## Architecture diff --git a/docs/api_integration_plan.md b/docs/api_integration_plan.md index 7cc13dc..ca2b7f4 100644 --- a/docs/api_integration_plan.md +++ b/docs/api_integration_plan.md @@ -31,7 +31,7 @@ | **Verify Content** (Placeholder) | `POST /content/verify` (Uses active server URL) | **Major Change:** Replace placeholder. Plugin needs to find the signature (e.g., from page metadata, directory lookup), extract necessary fields (`contentHash`, `domain`, `authorId`, `signature`), and call the API on the *active server*. | | **Content Extraction/Hashing** | N/A (Client-side responsibility) | **No Change:** `ContentProcessor` logic remains relevant for preparing `contentHash`. | | **Metadata Extraction** | N/A (Client-side, potentially used for claims) | **No Change:** `ContentProcessor` logic remains. Extracted metadata could pre-populate claims for signing. | -| **Trust Directory Lookup** (Unused) | `/directory/*` endpoints (Uses active server URL) | **Opportunity:** Can now implement features using `/directory/keys`, `/directory/content`, `/directory/keys/{keyId}/reputation` against the *active server* for richer verification context. | +| **Trust Directory Lookup** | User-selected HTTPS directories using `/signers/{id}/reputation` | The extension keeps weighted subscriptions in browser storage and treats reputation as a policy input after local signature verification. | | **Settings Management** | N/A (Client-side) | **Change:** Settings need to be extended to manage a list of server configurations (URL, optional ApiKey, optional AuthorId, active status). | | **Sign Out** | N/A (Client-side action) | **Change:** Signing out means deleting the stored `AuthorApiKey` and `authorId` *for the active server configuration*. | | **Key Reporting** (N/A) | `POST /directory/keys/{keyId}/report` | **New Feature:** Can add UI/functionality to report keys using this endpoint (requires `GeneralApiKey`, potentially also managed per server or globally). | @@ -97,7 +97,7 @@ graph TD end subgraph Directory [Directory Interaction (Optional)] - HH[Verification Flow] --> II(Call GET /directory/keys/{keyId}/reputation on Active Server); + HH[Verification Flow] --> II(Call GET /signers/{id}/reputation on each enabled configured directory); II --> JJ[Display Key Reputation]; KK[User Action: Report Key/Content] --> LL{GeneralApiKey Present?}; LL -- Yes --> MM[Call POST /directory/.../report on Active Server]; @@ -180,4 +180,4 @@ graph TD * Unit tests for server configuration logic. * Integration tests for signing and verification flows (potentially using mock API responses or a staging API environment). * Integration tests for switching active servers. - * End-to-end tests simulating user actions in the browser (managing servers, signing, verifying). \ No newline at end of file + * End-to-end tests simulating user actions in the browser (managing servers, signing, verifying). diff --git a/package-lock.json b/package-lock.json index c59e6f5..375846d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,8 @@ "version": "0.1.0", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", - "@simplewebauthn/typescript-types": "^8.3.4", - "axios": "^1.9.0", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#70c5ddb6ed23c06c0b1c46d5284618fb99a28aac", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/760593d4a02e9fffa56dc4d002eb52ab2ade1b49.tar.gz", "js-sha256": "^0.11.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -789,12 +787,12 @@ } }, "node_modules/@htmltrust/browser-client": { - "version": "0.1.2", - "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", - "integrity": "sha512-uVpf48nk0vnXTUe3sosj8OZJilvdW8hwLt+vZesJlZm/JujcTJD6Upm4h/Kr0LnCx78lUx1IRESYS1XxWsipZw==", + "version": "0.2.0", + "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#70c5ddb6ed23c06c0b1c46d5284618fb99a28aac", + "integrity": "sha512-9lSOsmWbFcjv/MPiHhKCNoIReucAqk0XG5hW1xm94ymA0Sqat9zBfGSqgo/2hjId49X336PvPdGq2/7gNO3OSQ==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/760593d4a02e9fffa56dc4d002eb52ab2ade1b49.tar.gz", "parse5": "7.3.0" }, "peerDependencies": { @@ -803,12 +801,15 @@ }, "node_modules/@htmltrust/canonicalization": { "version": "0.3.0", - "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", - "integrity": "sha512-omTofsbv/S5XJBXzhOwirxpLD2uSkpVeAyINgQ+eQTi7qmjvWUoA6zTeHFbyQ5d7iXScmQO8/f66hDvfaeYdbA==", + "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/760593d4a02e9fffa56dc4d002eb52ab2ade1b49.tar.gz", + "integrity": "sha512-KL/G0LIVaexheok7kKeVu6X+kV3d/mm+BMYX9kfTwJIFG0v52AboNYPkTy8FuGXpwbQFj3wflnd66jZtB5kNEw==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "parse5": "7.3.0" }, + "bin": { + "htmltrust-portable-preflight": "javascript/bin/portable-authoring.js" + }, "engines": { "node": ">=22" } @@ -1402,13 +1403,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@simplewebauthn/typescript-types": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@simplewebauthn/typescript-types/-/typescript-types-8.3.4.tgz", - "integrity": "sha512-38xtca0OqfRVNloKBrFB5LEM6PN5vzFbJG6rAutPVrtGHFYxPdiV3btYWq0eAZAZmP+dqFPYJxJWeJrGfmYHng==", - "deprecated": "This package has been renamed to @simplewebauthn/types. Please install @simplewebauthn/types instead to ensure you receive future updates.", - "license": "MIT" - }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -2224,6 +2218,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -2550,6 +2545,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -2568,18 +2564,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -2971,6 +2955,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3201,6 +3186,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3549,6 +3535,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3641,6 +3628,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3787,6 +3775,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3965,6 +3954,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3974,6 +3964,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4018,6 +4009,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4030,6 +4022,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4675,26 +4668,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4715,6 +4688,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4753,6 +4727,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4826,6 +4801,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4860,6 +4836,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5005,6 +4982,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5098,6 +5076,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5110,6 +5089,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5125,6 +5105,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5267,6 +5248,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -7091,6 +7073,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7121,6 +7104,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7130,6 +7114,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -7178,6 +7163,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -7937,15 +7923,6 @@ "dev": true, "license": "MIT" }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", diff --git a/package.json b/package.json index 5188834..e7e7310 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,12 @@ "build:firefox": "node scripts/build.js firefox", "build:safari": "node scripts/build.js safari", "build:all": "node scripts/build.js", + "check:bundle": "node scripts/check-bundle-size.js chromium", "dev": "webpack --mode=development --watch", "dev:chromium": "cross-env TARGET_BROWSER=chromium webpack --mode=development --watch", "dev:firefox": "cross-env TARGET_BROWSER=firefox webpack --mode=development --watch", "dev:safari": "cross-env TARGET_BROWSER=safari webpack --mode=development --watch", - "lint": "eslint . --ext .ts,.tsx", + "lint": "eslint . --ext .ts,.tsx --max-warnings=0", "typecheck": "tsc --noEmit" }, "keywords": [], @@ -51,10 +52,8 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", - "@simplewebauthn/typescript-types": "^8.3.4", - "axios": "^1.9.0", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#70c5ddb6ed23c06c0b1c46d5284618fb99a28aac", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/760593d4a02e9fffa56dc4d002eb52ab2ade1b49.tar.gz", "js-sha256": "^0.11.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/scripts/build.js b/scripts/build.js index 6cb2a3a..73d595a 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -43,6 +43,11 @@ function buildExtension(browser) { stdio: 'inherit', }); + execSync(`node scripts/check-bundle-size.js ${browser}`, { + cwd: path.resolve(__dirname, '..'), + stdio: 'inherit', + }); + // Create a zip file for the extension createZipFile(browser); diff --git a/scripts/check-bundle-size.js b/scripts/check-bundle-size.js new file mode 100644 index 0000000..e125b1c --- /dev/null +++ b/scripts/check-bundle-size.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); + +const KIB = 1024; +const BUNDLE_BUDGETS = Object.freeze({ + 'background.js': 244 * KIB, + 'content.js': 244 * KIB, + 'popup.js': 215 * KIB, + 'options.js': 230 * KIB, +}); + +function validateBundleAssets(assets) { + const errors = []; + for (const [name, budget] of Object.entries(BUNDLE_BUDGETS)) { + const size = assets[name]; + if (size === undefined) { + errors.push(`missing required bundle: ${name}`); + } else if (size > budget) { + errors.push(`${name} is ${size} bytes; budget is ${budget} bytes`); + } + } + for (const name of Object.keys(assets)) { + if (name.endsWith('.js') && !(name in BUNDLE_BUDGETS)) { + errors.push(`unlisted JavaScript bundle: ${name}`); + } + } + return errors; +} + +function checkBundleDirectory(directory) { + const assets = Object.fromEntries( + fs.readdirSync(directory) + .filter((name) => name.endsWith('.js')) + .map((name) => [name, fs.statSync(path.join(directory, name)).size]), + ); + const errors = validateBundleAssets(assets); + if (errors.length > 0) throw new Error(errors.join('\n')); + return assets; +} + +if (require.main === module) { + const browser = process.argv[2]; + if (!browser) { + console.error('Usage: node scripts/check-bundle-size.js '); + process.exit(2); + } + const directory = path.resolve(__dirname, '..', 'build', browser); + try { + const assets = checkBundleDirectory(directory); + const summary = Object.entries(assets) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, size]) => `${name}=${size}`) + .join(', '); + console.log(`Bundle budgets passed for ${browser}: ${summary}`); + } catch (error) { + console.error(`Bundle budget failed for ${browser}:\n${error.message}`); + process.exit(1); + } +} + +module.exports = { BUNDLE_BUDGETS, checkBundleDirectory, validateBundleAssets }; diff --git a/src/assets/content.css b/src/assets/content.css index 671072f..447bc49 100644 --- a/src/assets/content.css +++ b/src/assets/content.css @@ -71,105 +71,3 @@ background-color: #FFC107; color: #333; } - -/* Trust badges */ -.cs-trust-badge { - color: white; -} - -/* Trusted badge */ -.cs-trust-badge-trusted { - background-color: #2196F3; -} - -/* Untrusted badge */ -.cs-trust-badge-untrusted { - background-color: #9C27B0; -} - -/* Unknown trust badge */ -.cs-trust-badge-unknown { - background-color: #FFC107; - color: #333; -} - -/* Tooltip styles */ -.cs-tooltip { - position: absolute; - top: 100%; - right: 0; - background-color: #333; - color: white; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - white-space: nowrap; - z-index: 1001; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; - pointer-events: none; -} - -.cs-verification-badge:hover .cs-tooltip { - opacity: 1; - visibility: visible; -} - -/* Vote buttons container */ -.cs-vote-buttons { - display: flex; - gap: 4px; - margin-top: 4px; - justify-content: center; -} - -/* Vote button base styles */ -.cs-vote-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 4px; - background-color: #555; - color: white; - font-size: 14px; - cursor: pointer; - border: none; - transition: background-color 0.2s ease; - padding: 0; -} - -/* Upvote button */ -.cs-upvote-button { - background-color: #555; -} - -.cs-upvote-button:hover { - background-color: #4CAF50; -} - -.cs-upvote-button.cs-vote-button-active { - background-color: #4CAF50; -} - -/* Downvote button */ -.cs-downvote-button { - background-color: #555; -} - -.cs-downvote-button:hover { - background-color: #F44336; -} - -.cs-downvote-button.cs-vote-button-active { - background-color: #F44336; -} - -/* Make tooltip clickable when it contains vote buttons */ -.cs-verification-badge .cs-tooltip { - pointer-events: auto; - min-width: 120px; - text-align: center; -} diff --git a/src/background/index.ts b/src/background/index.ts index 38762e3..bbbe787 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,20 +1,13 @@ /** * Background script entry point */ -import { - verifySignedSection, - defaultResolverChain, - isPrivateHost, -} from "@htmltrust/browser-client"; import { Settings, VerificationResult, ServerConfig, - VoteType, - AuthorVote, - BatchedVotesPayload, - BatchVoteResult, - getTrustDirectoryUrls, + ClaimMap, + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, buildKeyidUrl, requireCanonicalBase64, requireContentHash, @@ -24,13 +17,11 @@ import { import { STORAGE_KEYS, DEFAULT_SETTINGS, - MESSAGE_TYPES, } from "../core/common/constants"; import { AuthService } from "../core/auth"; -import { ContentSigningClient } from "../core/api"; -import { ContentProcessor } from "../core/content"; -import { extractRawSignedSections } from "../core/content/navigation-lifecycle"; -import { PlatformAdapter, MessageContext } from "../platforms/common"; +import { extractSigningContent, hashSigningContent } from "../core/content/signing-extraction"; +import { PlatformAdapter, MessageContext, ExtensionMessage } from "../platforms/common"; +import { parseContentMessage, parseOptionsMessage, parsePopupMessage } from "./messages"; // Import platform-specific adapter // This will be replaced with the correct adapter at build time @@ -45,79 +36,15 @@ const authService = new AuthService({ storage, }); -let contentProcessor: ContentProcessor; let settings: Settings = DEFAULT_SETTINGS; -let contentSigningClient: ContentSigningClient | null = null; +function assertNever(value: never): never { + throw new Error(`Unhandled message: ${String(value)}`); +} 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", - }); - }; -} - -type PristineSection = { - sectionHtml: string; - documentUrl: string; - baseUrl: string; -}; - -/** Fetch the response body so popup verification never signs live DOM HTML. */ -async function fetchPristineSection(url: string): Promise { - const requested = new URL(url); - if (requested.protocol !== 'https:') return null; - const response = await fetch(requested.href, { - cache: 'force-cache', - credentials: 'include', - referrer: '', - referrerPolicy: 'no-referrer', - redirect: 'error', - }); - if (!response.ok) return null; - const documentUrl = response.url || requested.href; - const finalUrl = new URL(documentUrl); - if (finalUrl.protocol !== 'https:' || finalUrl.origin !== requested.origin) return null; - const html = await response.text(); - const sectionHtml = extractRawSignedSections(html)[0]; - if (!sectionHtml) return null; - - // Service workers do not expose DOMParser in every target. The response URL - // remains the correct base when no document can be parsed here; the - // content-script path computes a parser-backed base for normal page loads. - let baseUrl = documentUrl; - if (typeof DOMParser !== 'undefined') { - const parsed = new DOMParser().parseFromString(html, 'text/html'); - const base = parsed.querySelector('base[href]'); - if (base) { - try { - baseUrl = new URL(base.getAttribute('href') ?? '', documentUrl).href; - } catch { - baseUrl = documentUrl; - } - } - } - return { sectionHtml, documentUrl, baseUrl }; -} - /** * Initialize the background script */ @@ -127,9 +54,6 @@ async function initialize() { const storedSettings = await storage.get(STORAGE_KEYS.SETTINGS); settings = storedSettings || DEFAULT_SETTINGS; - // Initialize the content processor - contentProcessor = new ContentProcessor(); - // Initialize the auth service await authService.initialize(); @@ -139,10 +63,6 @@ async function initialize() { // Set up badge updateBadge(); - // Set up alarm for periodic vote submission - setupVoteSubmissionAlarm(); - - console.log("Content Signing background script initialized"); } catch (error) { console.error("Failed to initialize background script:", error); } @@ -164,39 +84,36 @@ function registerMessageListeners() { * @param message The message to handle * @returns A promise that resolves with the response */ -async function handlePopupMessage(message: any): Promise { - switch (message.type) { - case "GET_VERIFICATION_STATUS": - return getVerificationStatus(message.url); - case "VERIFY_CONTENT": - return verifyContent(message.url); +async function handlePopupMessage(message: ExtensionMessage): Promise { + const parsed = parsePopupMessage(message); + switch (parsed.type) { case "SIGN_CONTENT": - return signContent(message.url, message.claims); + return signContent(parsed.url, parsed.claims); case "CREATE_AUTHOR": return createAuthor( - message.name, - message.keyType, - message.description, - message.url, + parsed.name, + parsed.keyType, + parsed.description, + parsed.url, ); case "ASSOCIATE_API_KEY": - return associateApiKey(message.authorId, message.apiKey); + return associateApiKey(parsed.authorId, parsed.apiKey); case "SIGN_OUT": return signOut(); case "GET_ACTIVE_SERVER": return getActiveServer(); case "SET_ACTIVE_SERVER": - return setActiveServer(message.serverId); + return setActiveServer(parsed.serverId); case "GET_ALL_SERVERS": return getAllServers(); case "ADD_SERVER": - return addServer(message.name, message.url, message.setAsActive); + return addServer(parsed.name, parsed.url, parsed.setAsActive); case "UPDATE_SERVER": - return updateServer(message.id, message.updates); + return updateServer(parsed.id, parsed.updates); case "REMOVE_SERVER": - return removeServer(message.id); + return removeServer(parsed.id); default: - throw new Error(`Unknown message type: ${message.type}`); + return assertNever(parsed); } } @@ -205,22 +122,9 @@ async function handlePopupMessage(message: any): Promise { * @param message The message to handle * @returns A promise that resolves with the response */ -async function handleContentMessage(message: any): Promise { - switch (message.type) { - case MESSAGE_TYPES.CONTENT_DETECTED: - return handleContentDetected(message.url, message.content); - case MESSAGE_TYPES.SUBMIT_VOTE: - return handleVoteSubmission( - message.authorId, - message.vote, - message.url, - message.contentHash, - ); - case "GET_AUTHOR_VOTE": - return getAuthorVote(message.authorId); - default: - throw new Error(`Unknown message type: ${message.type}`); - } +async function handleContentMessage(message: ExtensionMessage): Promise { + const parsed = parseContentMessage(message); + return handleContentDetected(parsed.url, parsed.verified); } /** @@ -228,13 +132,9 @@ async function handleContentMessage(message: any): Promise { * @param message The message to handle * @returns A promise that resolves with the response */ -async function handleOptionsMessage(message: any): Promise { - switch (message.type) { - case "UPDATE_SETTINGS": - return updateSettings(message.settings); - default: - throw new Error(`Unknown message type: ${message.type}`); - } +async function handleOptionsMessage(message: ExtensionMessage): Promise { + const parsed = parseOptionsMessage(message); + return updateSettings(parsed.settings); } /** @@ -242,7 +142,7 @@ async function handleOptionsMessage(message: any): Promise { * @param url The URL to get the verification status for * @returns The verification status */ -async function getVerificationStatus(url: string): Promise { +async function getVerificationStatus(url: string) { try { // Check if we have a cached verification result const verificationResults = @@ -274,136 +174,6 @@ async function getVerificationStatus(url: string): Promise { } } -/** - * 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 { - // Step 1: fetch the response body. DOM outerHTML is a repaired - // serialization and cannot preserve source-level parser ambiguities. - const pristine = await fetchPristineSection(url); - - let verificationResult: VerificationResult; - - if (!pristine) { - verificationResult = { - verified: false, - reason: "No signed-section found on this page", - verifiedAt: Date.now(), - domain: serializedOrigin(url), - trustStatus: "unknown", - }; - } 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, - fetch: createVerifierFetch(), - }); - - const verify = await verifySignedSection(pristine.sectionHtml, { - keyResolvers: resolverChain, - domain: serializedOrigin(pristine.documentUrl), - origin: serializedOrigin(pristine.documentUrl), - documentUrl: pristine.documentUrl, - baseUrl: pristine.baseUrl, - debug: settings.developerDebugLogging === true, - } as Parameters[1]); - - // Best-effort author name lookup. The author DB is server-side and - // optional; if we can't fetch it (the keyid isn't a server URL or - // 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. - } - } - - verificationResult = { - verified: true, - verifiedAt: Date.now(), - domain: serializedOrigin(url), - user: { - id: userId, - name: userName, - email: "", - publicKey: "", - verified: true, - }, - trustStatus: "trusted", - }; - } else { - verificationResult = { - verified: false, - reason: verify.reason || "Signature verification failed", - verifiedAt: Date.now(), - domain: serializedOrigin(url), - trustStatus: "untrusted", - }; - } - } - - // Cache the verification result - const verificationResults = - (await storage.get>( - STORAGE_KEYS.VERIFICATION_RESULTS, - )) || {}; - verificationResults[url] = verificationResult; - await storage.set(STORAGE_KEYS.VERIFICATION_RESULTS, verificationResults); - - updateBadge(); - - return { - verified: verificationResult.verified, - status: verificationResult.verified - ? "Verified" - : verificationResult.reason || "Not verified", - result: verificationResult, - }; - } catch (error) { - console.error("Failed to verify content:", error); - return { - verified: false, - status: "Error: " + (error as Error).message, - result: null, - }; - } -} - /** * Sign content at a URL * @param url The URL to sign content at @@ -412,8 +182,8 @@ async function verifyContent(url: string): Promise { */ async function signContent( url: string, - claims: Record = {}, -): Promise { + claims: ClaimMap = {}, +) { try { // Check if the user is authenticated if (!authService.isAuthenticated()) { @@ -423,16 +193,15 @@ async function signContent( // Get the current tab const currentTab = await platformAdapter.getCurrentTab(); - // Execute a script to extract the content - const extractedContent = await platformAdapter.executeScript( + // Extract in the page, then normalize and hash in the extension service + // worker. Passing a function avoids interpolating page-controlled data + // into executable source. + const extractedContent = await platformAdapter.executeFunction( currentTab.id, - ` - (() => { - const contentProcessor = new ContentProcessor(); - return contentProcessor.extractContent(document); - })() - `, + extractSigningContent, + [], ); + const contentHash = await hashSigningContent(extractedContent.content); // Get the Content Signing client const contentSigningClient = authService.getContentSigningClient(); @@ -441,32 +210,15 @@ async function signContent( } // If no claims provided, use some defaults based on extracted metadata - if (Object.keys(claims).length === 0 && extractedContent.metadata) { + if (Object.keys(claims).length === 0) { claims = { title: extractedContent.title, }; - - // Add Dublin Core metadata if available - if (extractedContent.structuredMetadata?.dublinCore) { - const dc = extractedContent.structuredMetadata.dublinCore; - if (dc.creator) claims.creator = dc.creator; - if (dc.description) claims.description = dc.description; - if (dc.subject) claims.subject = dc.subject; - if (dc.type) claims.contentType = dc.type; - } - - // Add Schema.org metadata if available - if (extractedContent.structuredMetadata?.schemaOrg) { - const schema = extractedContent.structuredMetadata.schemaOrg; - if (schema.datePublished) claims.datePublished = schema.datePublished; - if (schema.dateModified) claims.dateModified = schema.dateModified; - if (schema.author?.name) claims.author = schema.author.name; - } } // Sign the content const signature = await contentSigningClient.signContent( - extractedContent.contentHash, + contentHash, serializedOrigin(url), claims, ); @@ -593,7 +345,7 @@ async function createAuthor( keyType: "HUMAN" | "AI" | "HUMAN_AI_MIX" | "ORGANIZATION", description?: string, url?: string, -): Promise { +) { try { const author = await authService.createAuthor( name, @@ -620,7 +372,7 @@ async function createAuthor( * @param apiKey The API key to associate * @returns A promise that resolves with the author details */ -async function associateApiKey(authorId: string, apiKey: string): Promise { +async function associateApiKey(authorId: string, apiKey: string) { try { const author = await authService.associateApiKey(authorId, apiKey); return { @@ -640,7 +392,7 @@ async function associateApiKey(authorId: string, apiKey: string): Promise { * Sign out the current user * @returns A promise that resolves when the user is signed out */ -async function signOut(): Promise { +async function signOut() { try { await authService.signOut(); return { @@ -659,7 +411,7 @@ async function signOut(): Promise { * Get the active server configuration * @returns The active server configuration */ -async function getActiveServer(): Promise { +async function getActiveServer() { try { const activeServer = authService.getActiveServerConfig(); return { @@ -680,7 +432,7 @@ async function getActiveServer(): Promise { * @param serverId The ID of the server configuration to set as active * @returns A promise that resolves when the active server is set */ -async function setActiveServer(serverId: string): Promise { +async function setActiveServer(serverId: string) { try { await authService.setActiveServer(serverId); return { @@ -699,7 +451,7 @@ async function setActiveServer(serverId: string): Promise { * Get all server configurations * @returns An array of all server configurations */ -async function getAllServers(): Promise { +async function getAllServers() { try { const servers = authService.getAllServerConfigs(); return { @@ -726,7 +478,7 @@ async function addServer( name: string, url: string, setAsActive = false, -): Promise { +) { try { const serverId = await authService.addServerConfig(name, url, setAsActive); return { @@ -751,7 +503,7 @@ async function addServer( async function updateServer( id: string, updates: Partial>, -): Promise { +) { try { await authService.updateServerConfig(id, updates); return { @@ -771,7 +523,7 @@ async function updateServer( * @param id The ID of the server configuration to remove * @returns A promise that resolves when the server configuration is removed */ -async function removeServer(id: string): Promise { +async function removeServer(id: string) { try { await authService.removeServerConfig(id); return { @@ -792,7 +544,20 @@ async function removeServer(id: string): Promise { * @returns A promise that resolves when the settings are updated */ async function updateSettings(newSettings: Settings): Promise { - settings = newSettings; + if (Array.isArray(newSettings.trustDirectorySubscriptions)) { + const invalid = newSettings.trustDirectorySubscriptions + .map(validateTrustDirectorySubscription) + .find((message): message is string => message !== null); + if (invalid) throw new Error(invalid); + } + const subscriptions = getTrustDirectorySubscriptions(newSettings); + if (Array.isArray(newSettings.trustDirectorySubscriptions) && subscriptions.length !== newSettings.trustDirectorySubscriptions.length) { + throw new Error("Invalid trust directory subscription; use an HTTPS URL and a weight between 0 and 1"); + } + settings = { + ...newSettings, + trustDirectorySubscriptions: subscriptions, + }; await storage.set(STORAGE_KEYS.SETTINGS, settings); // Update the badge @@ -800,22 +565,40 @@ async function updateSettings(newSettings: Settings): Promise { } /** - * Handle content detected - * @param url The URL where content was detected - * @param content The detected content - * @returns A promise that resolves with the verification result + * Cache the aggregate produced by the content script's source-based verifier. + * Keeping one verifier avoids loading the canonicalizer and resolver stack in + * both extension entry points. */ -async function handleContentDetected(url: string, content: any): Promise { +async function handleContentDetected( + url: string, + verified = false, +) { try { - // If auto-verify is enabled, verify the content - if (settings.autoVerify) { - return verifyContent(url); - } + const isVerified = settings.autoVerify && verified; + const verificationResult: VerificationResult = { + verified: isVerified, + cryptoValid: isVerified, + reason: isVerified + ? undefined + : settings.autoVerify + ? "No verified signed sections" + : "Auto-verification disabled", + verifiedAt: Date.now(), + domain: serializedOrigin(url), + trustStatus: "unknown", + }; + const verificationResults = + (await storage.get>( + STORAGE_KEYS.VERIFICATION_RESULTS, + )) || {}; + verificationResults[url] = verificationResult; + await storage.set(STORAGE_KEYS.VERIFICATION_RESULTS, verificationResults); + await updateBadge(); return { - verified: false, - status: "Auto-verification disabled", - result: null, + verified: isVerified, + status: isVerified ? "Verified" : verificationResult.reason, + result: verificationResult, }; } catch (error) { console.error("Failed to handle content detected:", error); @@ -852,157 +635,5 @@ async function updateBadge(): Promise { } } -/** - * Set up the alarm for periodic vote submission - */ -function setupVoteSubmissionAlarm(): void { - // Clear any existing alarms - chrome.alarms.clear("syncVotesAlarm"); - - // Create a new alarm that fires every 5 minutes - chrome.alarms.create("syncVotesAlarm", { - periodInMinutes: 5, - }); - - // Add an alarm listener - chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "syncVotesAlarm") { - submitPendingVotes(); - } - }); - - // Also trigger on browser startup - chrome.runtime.onStartup.addListener(() => { - submitPendingVotes(); - }); -} - -/** - * Handle vote submission from content script - * @param authorId The ID of the author to vote on - * @param vote The type of vote to cast - * @param url Optional URL where the vote was cast - * @param contentHash Optional content hash where the vote was cast - * @returns A promise that resolves with the result - */ -async function handleVoteSubmission( - authorId: string, - vote: VoteType, - url?: string, - contentHash?: string, -): Promise { - try { - // Create the vote object - const authorVote: AuthorVote = { - authorId, - vote, - timestamp: Date.now(), - url, - contentHash, - }; - - // Update local state based on vote type - if (vote === VoteType.NEUTRAL) { - // Remove the vote if it's neutral (retraction) - await storage.remove(`${STORAGE_KEYS.AUTHOR_VOTES}:${authorId}`); - } else { - // Store the vote - await storage.set(`${STORAGE_KEYS.AUTHOR_VOTES}:${authorId}`, authorVote); - } - - // Add to pending votes queue - const pendingVotes = - (await storage.get(STORAGE_KEYS.PENDING_VOTES)) || - {}; - pendingVotes[authorId] = vote; - await storage.set(STORAGE_KEYS.PENDING_VOTES, pendingVotes); - - // Send acknowledgment back to content script - platformAdapter.sendMessage(MessageContext.CONTENT, { - type: MESSAGE_TYPES.VOTE_ACKNOWLEDGED, - authorId, - success: true, - }); - - return { success: true }; - } catch (error) { - console.error("Failed to handle vote submission:", error); - return { - success: false, - error: (error as Error).message, - }; - } -} - -/** - * Get the current vote for an author - * @param authorId The ID of the author - * @returns A promise that resolves with the vote - */ -async function getAuthorVote(authorId: string): Promise { - try { - const vote = await storage.get( - `${STORAGE_KEYS.AUTHOR_VOTES}:${authorId}`, - ); - return { vote: vote?.vote || null }; - } catch (error) { - console.error("Failed to get author vote:", error); - return { vote: null }; - } -} - -/** - * Submit pending votes to the server - * @returns A promise that resolves when the votes are submitted - */ -async function submitPendingVotes(): Promise { - try { - // Get the pending votes - const pendingVotes = await storage.get( - STORAGE_KEYS.PENDING_VOTES, - ); - - // If there are no pending votes, return - if (!pendingVotes || Object.keys(pendingVotes).length === 0) { - return; - } - - // Get the Content Signing client - if (!contentSigningClient) { - contentSigningClient = authService.getContentSigningClient(); - } - - if (!contentSigningClient) { - console.error("Content Signing client not initialized"); - return; - } - - // Submit the votes - const result = await contentSigningClient.submitBatchedVotes(pendingVotes); - - // If successful, clear the pending votes - if (result.success) { - await storage.set(STORAGE_KEYS.PENDING_VOTES, {}); - console.log("Successfully submitted pending votes"); - } else if (result.results) { - // Handle partial success - remove successful votes from pending - const updatedPendingVotes: BatchedVotesPayload = {}; - - for (const [authorId, vote] of Object.entries(pendingVotes)) { - const voteResult = result.results[authorId]; - if (!voteResult || !voteResult.success) { - // Keep votes that failed in the pending queue - updatedPendingVotes[authorId] = vote; - } - } - - await storage.set(STORAGE_KEYS.PENDING_VOTES, updatedPendingVotes); - console.log("Partially submitted pending votes"); - } - } catch (error) { - console.error("Failed to submit pending votes:", error); - } -} - // Initialize the background script initialize(); diff --git a/src/background/messages.test.ts b/src/background/messages.test.ts new file mode 100644 index 0000000..873192c --- /dev/null +++ b/src/background/messages.test.ts @@ -0,0 +1,89 @@ +import { DEFAULT_SETTINGS } from '../core/common/constants'; +import { isExtensionMessage } from '../platforms/common'; +import { parseContentMessage, parseOptionsMessage, parsePopupMessage } from './messages'; + +describe('runtime message parsing', () => { + it('validates the extension message envelope', () => { + expect(isExtensionMessage({ type: 'SIGN_OUT' })).toBe(true); + expect(isExtensionMessage({})).toBe(false); + expect(isExtensionMessage({ type: 1 })).toBe(false); + expect(isExtensionMessage([])).toBe(false); + }); + + it('accepts scalar signing claims and rejects nested values', () => { + expect(parsePopupMessage({ + type: 'SIGN_CONTENT', + url: 'https://example.test/article', + claims: { reviewed: true, revision: 2, title: 'Example' }, + })).toEqual({ + type: 'SIGN_CONTENT', + url: 'https://example.test/article', + claims: { reviewed: true, revision: 2, title: 'Example' }, + }); + + expect(() => parsePopupMessage({ + type: 'SIGN_CONTENT', + url: 'https://example.test/article', + claims: { nested: { unsafe: true } }, + })).toThrow('claim nested must be a JSON scalar'); + }); + + it('preserves omitted server update fields', () => { + expect(parsePopupMessage({ + type: 'UPDATE_SERVER', + id: 'server-1', + updates: { name: 'Renamed' }, + })).toEqual({ type: 'UPDATE_SERVER', id: 'server-1', updates: { name: 'Renamed' } }); + }); + + it('validates content verification summaries', () => { + expect(parseContentMessage({ + type: 'CONTENT_DETECTED', + url: 'https://example.test/article', + verified: false, + })).toEqual({ + type: 'CONTENT_DETECTED', + url: 'https://example.test/article', + verified: false, + }); + + expect(() => parseContentMessage({ + type: 'CONTENT_DETECTED', + url: 'https://example.test/article', + verified: 'yes', + })).toThrow('verified must be a boolean'); + }); + + it('rejects removed vote-control messages', () => { + expect(() => parseContentMessage({ + type: 'SUBMIT_VOTE', + authorId: 'author-1', + vote: 'upvote', + })).toThrow('Unknown content message type: SUBMIT_VOTE'); + }); + + it('requires primitive enum values at message boundaries', () => { + expect(() => parsePopupMessage({ + type: 'CREATE_AUTHOR', + name: 'Alice', + keyType: new String('HUMAN'), + })).toThrow('keyType is invalid'); + + expect(() => parseOptionsMessage({ + type: 'UPDATE_SETTINGS', + settings: { ...DEFAULT_SETTINGS, authMethod: new String('apikey') }, + } as unknown as Parameters[0])).toThrow('settings.authMethod is invalid'); + }); + + it('validates settings before they reach storage', () => { + expect(parseOptionsMessage({ + type: 'UPDATE_SETTINGS', + settings: DEFAULT_SETTINGS, + }).settings).toMatchObject(DEFAULT_SETTINGS); + + expect(() => parseOptionsMessage({ + type: 'UPDATE_SETTINGS', + settings: { ...DEFAULT_SETTINGS, autoVerify: 'yes' }, + })).toThrow('settings.autoVerify must be a boolean'); + }); +}); diff --git a/src/background/messages.ts b/src/background/messages.ts new file mode 100644 index 0000000..16a42a3 --- /dev/null +++ b/src/background/messages.ts @@ -0,0 +1,221 @@ +import type { ExtensionMessage } from '../platforms/common'; +import { + type ClaimMap, + type ClaimValue, + type DirectorySubscription, + type ServerConfig, + type Settings, +} from '../core/common/types'; + +type AuthorKeyType = 'HUMAN' | 'AI' | 'HUMAN_AI_MIX' | 'ORGANIZATION'; +type ServerUpdates = Partial>; + +export type PopupMessage = + | { type: 'SIGN_CONTENT'; url: string; claims?: ClaimMap } + | { type: 'CREATE_AUTHOR'; name: string; keyType: AuthorKeyType; description?: string; url?: string } + | { type: 'ASSOCIATE_API_KEY'; authorId: string; apiKey: string } + | { type: 'SIGN_OUT' } + | { type: 'GET_ACTIVE_SERVER' } + | { type: 'SET_ACTIVE_SERVER'; serverId: string } + | { type: 'GET_ALL_SERVERS' } + | { type: 'ADD_SERVER'; name: string; url: string; setAsActive?: boolean } + | { type: 'UPDATE_SERVER'; id: string; updates: ServerUpdates } + | { type: 'REMOVE_SERVER'; id: string }; + +export type ContentMessage = + | { type: 'CONTENT_DETECTED'; url: string; verified?: boolean }; + +export type OptionsMessage = { type: 'UPDATE_SETTINGS'; settings: Settings }; + +function record(value: unknown, field: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${field} must be an object`); + } + return value as Record; +} + +function stringField( + message: ExtensionMessage | Record, + field: string, + label = field, +): string { + const value = message[field]; + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } + return value; +} + +function optionalString(message: Record, field: string): string | undefined { + const value = message[field]; + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') throw new TypeError(`${field} must be a string`); + return value; +} + +function optionalBoolean(message: Record, field: string): boolean | undefined { + const value = message[field]; + if (value === undefined || value === null) return undefined; + if (typeof value !== 'boolean') throw new TypeError(`${field} must be a boolean`); + return value; +} + +function booleanField(message: Record, field: string): boolean { + const value = message[field]; + if (typeof value !== 'boolean') throw new TypeError(`settings.${field} must be a boolean`); + return value; +} + +function stringArray(value: unknown, field: string): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new TypeError(`${field} must be an array of strings`); + } + return [...value]; +} + +function claimMap(value: unknown): ClaimMap | undefined { + if (value === undefined || value === null) return undefined; + const claims = record(value, 'claims'); + for (const [name, claim] of Object.entries(claims)) { + const scalar = typeof claim === 'string' || typeof claim === 'boolean' || + (typeof claim === 'number' && Number.isFinite(claim)); + if (!scalar) throw new TypeError(`claim ${name} must be a JSON scalar`); + } + return claims as Record; +} + +function serverConfig(value: unknown, field: string): ServerConfig { + const config = record(value, field); + const result: ServerConfig = { + id: stringField(config, 'id', `${field}.id`), + name: stringField(config, 'name', `${field}.name`), + url: stringField(config, 'url', `${field}.url`), + isActive: config.isActive === true, + }; + if (config.isActive !== true && config.isActive !== false) { + throw new TypeError(`${field}.isActive must be a boolean`); + } + result.authorApiKey = optionalString(config, 'authorApiKey'); + result.authorId = optionalString(config, 'authorId'); + result.generalApiKey = optionalString(config, 'generalApiKey'); + return result; +} + +function serverUpdates(value: unknown): ServerUpdates { + const updates = record(value, 'updates'); + const result: ServerUpdates = {}; + if ('name' in updates) result.name = optionalString(updates, 'name'); + if ('url' in updates) result.url = optionalString(updates, 'url'); + if ('authorApiKey' in updates) result.authorApiKey = optionalString(updates, 'authorApiKey'); + if ('authorId' in updates) result.authorId = optionalString(updates, 'authorId'); + if ('generalApiKey' in updates) result.generalApiKey = optionalString(updates, 'generalApiKey'); + if ('isActive' in updates) result.isActive = optionalBoolean(updates, 'isActive'); + return result; +} + +function subscriptions(value: unknown): DirectorySubscription[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new TypeError('trustDirectorySubscriptions must be an array'); + return value.map((item, index) => { + const subscription = record(item, `trustDirectorySubscriptions[${index}]`); + const url = stringField(subscription, 'url', `trustDirectorySubscriptions[${index}].url`); + if (typeof subscription.weight !== 'number' || !Number.isFinite(subscription.weight)) { + throw new TypeError(`trustDirectorySubscriptions[${index}].weight must be a finite number`); + } + if (typeof subscription.enabled !== 'boolean') { + throw new TypeError(`trustDirectorySubscriptions[${index}].enabled must be a boolean`); + } + return { url, weight: subscription.weight, enabled: subscription.enabled }; + }); +} + +function settings(value: unknown): Settings { + const input = record(value, 'settings'); + const authMethod = input.authMethod; + if (authMethod !== 'apikey' && authMethod !== 'webauthn' && authMethod !== 'password') { + throw new TypeError('settings.authMethod is invalid'); + } + if (!Array.isArray(input.serverConfigs)) throw new TypeError('settings.serverConfigs must be an array'); + const result: Settings = { + autoVerify: booleanField(input, 'autoVerify'), + showBadges: booleanField(input, 'showBadges'), + highlightVerified: booleanField(input, 'highlightVerified'), + highlightUnverified: booleanField(input, 'highlightUnverified'), + authMethod, + serverConfigs: input.serverConfigs.map((item, index) => serverConfig(item, `settings.serverConfigs[${index}]`)), + }; + result.trustDirectoryUrl = optionalString(input, 'trustDirectoryUrl'); + result.trustDirectoryUrls = stringArray(input.trustDirectoryUrls, 'settings.trustDirectoryUrls'); + result.trustDirectorySubscriptions = subscriptions(input.trustDirectorySubscriptions); + result.personalTrustList = stringArray(input.personalTrustList, 'settings.personalTrustList'); + result.trustedDomains = stringArray(input.trustedDomains, 'settings.trustedDomains'); + result.activeServerId = optionalString(input, 'activeServerId'); + result.developerDebugLogging = optionalBoolean(input, 'developerDebugLogging'); + return result; +} + +export function parsePopupMessage(message: ExtensionMessage): PopupMessage { + switch (message.type) { + case 'SIGN_CONTENT': + return { type: message.type, url: stringField(message, 'url'), claims: claimMap(message.claims) }; + case 'CREATE_AUTHOR': { + const keyType = message.keyType; + if (keyType !== 'HUMAN' && keyType !== 'AI' && keyType !== 'HUMAN_AI_MIX' && keyType !== 'ORGANIZATION') { + throw new TypeError('keyType is invalid'); + } + return { + type: message.type, + name: stringField(message, 'name'), + keyType, + description: optionalString(message, 'description'), + url: optionalString(message, 'url'), + }; + } + case 'ASSOCIATE_API_KEY': + return { + type: message.type, + authorId: stringField(message, 'authorId'), + apiKey: stringField(message, 'apiKey'), + }; + case 'SIGN_OUT': + case 'GET_ACTIVE_SERVER': + case 'GET_ALL_SERVERS': + return { type: message.type }; + case 'SET_ACTIVE_SERVER': + return { type: message.type, serverId: stringField(message, 'serverId') }; + case 'ADD_SERVER': + return { + type: message.type, + name: stringField(message, 'name'), + url: stringField(message, 'url'), + setAsActive: optionalBoolean(message, 'setAsActive'), + }; + case 'UPDATE_SERVER': + return { type: message.type, id: stringField(message, 'id'), updates: serverUpdates(message.updates) }; + case 'REMOVE_SERVER': + return { type: message.type, id: stringField(message, 'id') }; + default: + throw new TypeError(`Unknown popup message type: ${message.type}`); + } +} + +export function parseContentMessage(message: ExtensionMessage): ContentMessage { + switch (message.type) { + case 'CONTENT_DETECTED': + return { + type: message.type, + url: stringField(message, 'url'), + verified: optionalBoolean(message, 'verified'), + }; + default: + throw new TypeError(`Unknown content message type: ${message.type}`); + } +} + +export function parseOptionsMessage(message: ExtensionMessage): OptionsMessage { + if (message.type !== 'UPDATE_SETTINGS') { + throw new TypeError(`Unknown options message type: ${message.type}`); + } + return { type: message.type, settings: settings(message.settings) }; +} diff --git a/src/content-scripts/auto-verify.test.ts b/src/content-scripts/auto-verify.test.ts index 2fb5ff5..48bfb91 100644 --- a/src/content-scripts/auto-verify.test.ts +++ b/src/content-scripts/auto-verify.test.ts @@ -17,14 +17,7 @@ jest.mock('@htmltrust/browser-client', () => ({ verifySignedSection: jest.fn(), evaluateTrustPolicy: jest.fn(), defaultResolverChain: jest.fn(() => []), -})); - -// The legacy content-extraction path is outside these lifecycle tests. Mocking -// only that leaf avoids pulling the browser-client's ESM canonicalizer into -// Jest while leaving navigation-lifecycle.ts and index.ts production code -// intact. -jest.mock('../core/content/content-processor', () => ({ - ContentProcessor: jest.fn().mockImplementation(() => ({ extractContent: jest.fn() })), + isPrivateHost: jest.fn((hostname: string) => hostname === '127.0.0.1' || hostname === 'localhost'), })); import { @@ -36,7 +29,7 @@ const { applySectionStatusUI, armSectionMutationInvalidation, autoVerifyPage, - buildAutoBadges, + invalidateAutoVerifyGeneration, resetNavigationState, } = require('./index') as typeof import('./index'); @@ -225,9 +218,58 @@ describe('production content-script UI and lifecycle', () => { expect(calls[calls.length - 1]?.[0]).toBe(newHTML); }); - it('keeps the production auto badge builder warning-aware', () => { - const warning = buildAutoBadges(verifyShape({ inputState: 'stale' }), trustShape()); - expect(warning.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_WARNING}`)).not.toBeNull(); - expect(warning.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`)).toBeNull(); + it('does not let an older policy run overwrite results after settings change', async () => { + document.body.innerHTML = 'text'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + url: window.location.href, + text: async () => document.body.innerHTML, + }); + let oldVerificationStarted!: () => void; + const oldStarted = new Promise((resolve) => { oldVerificationStarted = resolve; }); + let releaseOldVerification!: (result: VerifyResult) => void; + const oldVerification = new Promise((resolve) => { + releaseOldVerification = resolve; + }); + let secondRun = false; + (verifySignedSection as jest.Mock).mockImplementation(() => { + if (secondRun) return Promise.resolve(verifyShape()); + oldVerificationStarted(); + return oldVerification; + }); + (evaluateTrustPolicy as jest.Mock).mockResolvedValue(trustShape({ score: 91, indicator: 'green' })); + + const oldRun = autoVerifyPage([], settings); + await oldStarted; + expect(verifySignedSection).toHaveBeenCalled(); + + // This is the same invalidation used by chrome.storage.onChanged, without + // depending on the browser API in this deterministic race test. + invalidateAutoVerifyGeneration(); + document.querySelectorAll(`.${AUTO_BADGE_MARKER}`).forEach((marker) => marker.remove()); + secondRun = true; + await autoVerifyPage([], { ...settings, trustedDomains: ['https://new-policy.example'] }); + expect(verifySignedSection).toHaveBeenCalledTimes(2); + expect(evaluateTrustPolicy).toHaveBeenCalledTimes(1); + expect(document.querySelector(`.${AUTO_BADGE_MARKER}`)?.getAttribute('aria-label')).toContain('Trust: 91%'); + + releaseOldVerification(verifyShape({ keyid: 'old-result.example' })); + await oldRun; + expect(document.querySelector(`.${AUTO_BADGE_MARKER}`)?.getAttribute('aria-label')).toContain('Trust: 91%'); + }); + + it('passes the hardened verifier fetch to every trust-policy evaluation', async () => { + document.body.innerHTML = 'text'; + (verifySignedSection as jest.Mock).mockResolvedValue(verifyShape()); + (evaluateTrustPolicy as jest.Mock).mockResolvedValue(trustShape()); + + await autoVerifyPage([], settings); + + expect(evaluateTrustPolicy).toHaveBeenCalled(); + for (const [, policy] of (evaluateTrustPolicy as jest.Mock).mock.calls) { + expect(policy.fetch).toEqual(expect.any(Function)); + await expect(policy.fetch('http://private.example/')).rejects.toThrow('network-policy-blocked'); + await expect(policy.fetch('https://127.0.0.1/')).rejects.toThrow('network-policy-blocked'); + } }); }); diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts index b0a2263..2d993cf 100644 --- a/src/content-scripts/index.ts +++ b/src/content-scripts/index.ts @@ -10,11 +10,8 @@ * trust policy locally (Layer 2), and inject the corresponding status * marker beside 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). + * 2. Keep the popup informed of the active page and expose per-section + * verification details through extension messages. * * Verification is local: the trust server is never contacted for the * crypto step. Trust directories are consulted only by the resolver chain @@ -24,13 +21,12 @@ import { verifySignedSection, evaluateTrustPolicy, defaultResolverChain, + isPrivateHost, 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 { CSS_CLASSES, STORAGE_KEYS } from '../core/common/constants'; import { captureNavigationSnapshot, documentBaseUrl, @@ -43,14 +39,12 @@ import { sourceHTMLForSnapshot, type NavigationSnapshot, } from '../core/content/navigation-lifecycle'; -import { PlatformAdapter, MessageContext } from '../platforms/common'; +import { PlatformAdapter, MessageContext, ExtensionMessage } from '../platforms/common'; import { - VerificationResult, - TrustStatus, - VoteType, Settings, VerificationInputState, - getTrustDirectoryUrls, + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, } from '../core/common/types'; // Import platform-specific adapter @@ -60,9 +54,6 @@ import { ChromiumAdapter } from '../platforms/chromium'; // Initialize platform adapter const platformAdapter: PlatformAdapter = new ChromiumAdapter(); -// 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'; @@ -84,6 +75,7 @@ type PageVerification = { trustScore: number; trustIndicator: 'green' | 'yellow' | 'red'; trustLabel: string; + trustInputs: Array<{ source: string; contribution: number; rationale: string }>; keyid: string; algorithm: string; signedAt: string; @@ -116,15 +108,10 @@ let lifecycleInstalled = false; let baseObserverDisposer: (() => void) | null = null; const sectionReverifyGeneration = new WeakMap(); -/** - * 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; +function directoryUrls(settings: Settings): string[] { + return getTrustDirectorySubscriptions(settings) + .filter((subscription) => subscription.enabled && !validateTrustDirectorySubscription(subscription)) + .map((subscription) => subscription.url); } /** @@ -134,8 +121,8 @@ function authorIdFromKeyid(keyid: string): string | null { * 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. + * 3. Notify the background script for the popup status cache, then register + * the content-script message handlers. * * 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 @@ -147,14 +134,22 @@ function authorIdFromKeyid(keyid: string): string | null { */ let currentSettings: Settings | null = null; let currentResolverChain: KeyResolver[] = []; +// Settings changes invalidate every in-flight auto-verification. A run may +// await source fetch, key resolution, or directory policy requests, so the +// generation is checked again before it can mutate markers or cached results. +let autoVerifyGeneration = 0; + +/** Invalidate in-flight runs when policy inputs change. */ +export function invalidateAutoVerifyGeneration(): void { + autoVerifyGeneration += 1; + pageVerifications.length = 0; +} async function initialize() { try { - console.log('Content Signing content script initialized'); - // 1. Settings → resolver chain + trust policy inputs currentSettings = await loadSettings(); - const directories = getTrustDirectoryUrls(currentSettings); + const directories = directoryUrls(currentSettings); currentResolverChain = defaultResolverChain({ directories, fetch: createVerifierFetch(), @@ -165,9 +160,8 @@ async function initialize() { // sections that already have an auto badge container next to them. await autoVerifyPage(currentResolverChain, currentSettings, navigationRun); - // 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. + // 3. Keep the popup's page-status cache current. This is best-effort and + // independent of the per-section result above. await notifyContentDetected(); // Listen for messages from the background script @@ -175,7 +169,7 @@ async function initialize() { // Live-update on settings change. When the popup or options page writes // a new SETTINGS value to chrome.storage, we clear our existing - // decorations and re-decorate using the cached verification results, + // decorations and rerun verification against the frozen source snapshot, // so the user sees the effect of toggling on-page badges without // having to reload the page (or the whole extension). if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) { @@ -186,10 +180,18 @@ async function initialize() { if (!next) return; currentSettings = next; currentResolverChain = defaultResolverChain({ - directories: getTrustDirectoryUrls(next), + directories: directoryUrls(next), fetch: createVerifierFetch(), }); - redecoratePage(); + invalidateAutoVerifyGeneration(); + // Settings may change the trust policy or its directory set. Clear + // old markers and rerun against the frozen navigation source so a + // stale page snapshot never displays a result for the previous policy. + document.querySelectorAll(SIGNED_SECTION_SELECTOR).forEach((section) => { + clearSectionStatusUI(section); + }); + void autoVerifyPage(currentResolverChain, next, navigationRun, autoVerifyGeneration) + .then(notifyContentDetected); }); } } catch (error) { @@ -197,59 +199,10 @@ async function initialize() { } } -/** - * Strip the decorations we previously applied and re-apply using the - * currentSettings + cached pageVerifications. Called when the user toggles - * a setting that affects on-page badges. - */ -function redecoratePage(): void { - if (!currentSettings) return; - const sections = document.querySelectorAll(SIGNED_SECTION_SELECTOR); - // Clear our existing additions on every section we've touched. - sections.forEach((section) => { - clearSectionStatusUI(section); - }); - // Re-apply using cached results so we don't rerun verification. - const list = Array.from(sections); - for (const section of list) { - const cached = pageVerificationBySection.get(section); - if (!cached) continue; - // Reconstruct a minimal VerifyResult/TrustEvaluation shape for the UI - // applier. The cache is intentionally a flat snapshot; the original - // objects don't survive across the listener boundary. - const verifyShape: VerifyResult = { - valid: cached.cryptoValid, - keyid: cached.keyid, - algorithm: cached.algorithm, - contentHash: '', - claimsHash: '', - claims: cached.claims, - signedAt: cached.signedAt, - domain: cached.domain, - 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: [], - }; - const runShape: SectionVerificationRun = { - verify: verifyShape, - inputState: cached.inputState, - sourceVerified: cached.sourceVerified, - renderedVerified: cached.renderedVerified, - displayValid: cached.valid, - reason: cached.reason, - }; - applySectionStatusUI(section, runShape, trustShape, cached.reason, currentSettings); - } -} - /** Reset cached state before a same-document navigation or page rerender. */ export function resetNavigationState(): void { navigationRun += 1; + invalidateAutoVerifyGeneration(); if (rerenderTimer !== null) { clearTimeout(rerenderTimer); rerenderTimer = null; @@ -260,7 +213,6 @@ export function resetNavigationState(): void { clearSectionStatusUI(section); }); observedSections = new Set(); - pageVerifications.length = 0; navigationSnapshot = null; } @@ -270,8 +222,9 @@ function scheduleNavigationRefresh(): void { lastObservedUrl = window.location.href; rerenderTimer = setTimeout(() => { rerenderTimer = null; - if (!currentSettings || !currentSettings.autoVerify) return; - void autoVerifyPage(currentResolverChain, currentSettings, navigationRun); + if (!currentSettings) return; + void autoVerifyPage(currentResolverChain, currentSettings, navigationRun) + .then(notifyContentDetected); }, 0); } @@ -346,6 +299,7 @@ async function loadSettings(): Promise { highlightVerified: true, highlightUnverified: false, trustDirectoryUrls: [], + trustDirectorySubscriptions: [], personalTrustList: [], trustedDomains: [], authMethod: 'apikey', @@ -384,19 +338,27 @@ function redactForLog(value: unknown): unknown { function debugLog(settings: Settings, message: string, details?: unknown): void { if (!settings.developerDebugLogging) return; + // This is explicitly opt-in diagnostic output. Keep it at the browser's + // debug level so routine verification does not create warning noise. if (details === undefined) { + // eslint-disable-next-line no-console console.debug(`[htmltrust] ${message}`); } else { + // eslint-disable-next-line no-console 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)); + const inputUrl = typeof input === 'string' || input instanceof URL ? input.toString() : input.url; + const url = new URL(inputUrl); if (url.protocol !== 'https:') { throw new Error('network-policy-blocked: verifier key and directory fetches require HTTPS'); } + if (url.username || url.password || isPrivateHost(url.hostname)) { + throw new Error('network-policy-blocked: verifier fetches may not target private or credential-bearing URLs'); + } return fetch(input, { ...init, credentials: 'omit', @@ -539,11 +501,16 @@ export async function autoVerifyPage( resolverChain: KeyResolver[], settings: Settings, expectedNavigationRun = navigationRun, + expectedAutoVerifyGeneration = autoVerifyGeneration, ): Promise { // `autoVerify` gates the entire content-script verification path. When off, // the page is left untouched and the popup's "Verifying…" state stays put // until the user explicitly triggers verification. - if (!settings.autoVerify || expectedNavigationRun !== navigationRun) { + if ( + !settings.autoVerify || + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) { return; } @@ -580,7 +547,10 @@ export async function autoVerifyPage( // cache catches per RFC 7234 when the origin sets reasonable cache headers. const { snapshot: fetchedSnapshot, error: pristineFetchError } = await fetchPristineSignedSections(settings); - if (expectedNavigationRun !== navigationRun) return; + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; navigationSnapshot = fetchedSnapshot; const liveSections = Array.from(sections); observedSections = new Set(liveSections); @@ -602,7 +572,10 @@ export async function autoVerifyPage( let i = 0; for (const section of liveSections) { - if (expectedNavigationRun !== navigationRun) return; + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; // Idempotency: skip sections we've already decorated. const knownMarker = sectionMarkers.get(section); if (knownMarker && !knownMarker.isConnected) sectionMarkers.delete(section); @@ -623,20 +596,18 @@ export async function autoVerifyPage( ); const verify = run.verify; - // 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: [], + directorySubscriptions: getTrustDirectorySubscriptions(settings), + fetch: createVerifierFetch(), }); + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; + applySectionStatusUI(section, run, trust, null, settings); const pageVerification: PageVerification = { index: i, @@ -647,8 +618,9 @@ export async function autoVerifyPage( renderedVerified: run.renderedVerified, reason: run.reason, trustScore: trust.score, - trustIndicator: trust.indicator, - trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustIndicator: trust.indicator, + trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustInputs: trust.inputs, keyid: verify.keyid, algorithm: verify.algorithm, signedAt: verify.signedAt, @@ -666,6 +638,10 @@ export async function autoVerifyPage( settings, ); } catch (err) { + if ( + expectedNavigationRun !== navigationRun || + expectedAutoVerifyGeneration !== autoVerifyGeneration + ) return; 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 }); @@ -681,6 +657,7 @@ export async function autoVerifyPage( trustScore: 0, trustIndicator: 'red', trustLabel: 'Untrusted', + trustInputs: [], keyid: '', algorithm: '', signedAt: '', @@ -798,6 +775,7 @@ export function armSectionMutationInvalidation( ): void { sectionObserverDisposers.get(section)?.(); const observerNavigationRun = navigationRun; + const observerAutoVerifyGeneration = autoVerifyGeneration; const dispose = observeSignedSection(section, (changedSection) => { const generation = (sectionReverifyGeneration.get(changedSection) ?? 0) + 1; sectionReverifyGeneration.set(changedSection, generation); @@ -824,11 +802,13 @@ export function armSectionMutationInvalidation( const trust = await evaluateTrustPolicy(run.verify, { personalTrustList: activeSettings.personalTrustList ?? [], trustedDomains: activeSettings.trustedDomains ?? [], - directorySubscriptions: [], + directorySubscriptions: getTrustDirectorySubscriptions(activeSettings), + fetch: createVerifierFetch(), }); if ( sectionReverifyGeneration.get(changedSection) !== generation || - navigationRun !== observerNavigationRun + navigationRun !== observerNavigationRun || + autoVerifyGeneration !== observerAutoVerifyGeneration ) return; applySectionStatusUI(changedSection, run, trust, null, activeSettings); const existing = pageVerificationBySection.get(changedSection); @@ -843,7 +823,8 @@ export function armSectionMutationInvalidation( reason: run.reason, trustScore: trust.score, trustIndicator: trust.indicator, - trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustLabel: trust.indicator === 'green' ? 'Trusted' : trust.indicator === 'red' ? 'Untrusted' : 'Unknown', + trustInputs: trust.inputs, keyid: run.verify.keyid, algorithm: run.verify.algorithm, signedAt: run.verify.signedAt, @@ -856,7 +837,8 @@ export function armSectionMutationInvalidation( } catch (error) { if ( sectionReverifyGeneration.get(changedSection) !== generation || - navigationRun !== observerNavigationRun + navigationRun !== observerNavigationRun || + autoVerifyGeneration !== observerAutoVerifyGeneration ) return; const reason = error instanceof Error ? error.message : String(error); applySectionStatusUI( @@ -879,114 +861,19 @@ export function armSectionMutationInvalidation( trustScore: 0, trustIndicator: 'red', trustLabel: 'Untrusted', + trustInputs: [], }; pageVerificationBySection.set(changedSection, failed); const index = pageVerifications.indexOf(existing); if (index >= 0) pageVerifications[index] = failed; } } + await notifyContentDetected(); })(); }); sectionObserverDisposers.set(section, dispose); } -/** - * 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. - */ -export 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'); - const renderedValid = verify.valid && verify.inputState === 'rendered-match'; - if (renderedValid) { - sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; - sigBadge.textContent = 'Rendered content verified'; - sigBadge.style.cssText = - 'background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 4px;'; - } else if (verify.valid) { - sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_WARNING} ${CSS_CLASSES.VALIDITY_BADGE}`; - sigBadge.textContent = verify.inputState === 'stale' - ? '⚠ Rendered content INVALID (source differs)' - : '⚠ Source signature valid; rendered content not verified'; - sigBadge.style.cssText = - 'background: #fff3cd; color: #856404; 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;'; - } - 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})`) - .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; -} - /** * Notify background that content was detected. This drives the popup's * "current page" status display and is independent of the auto-verify @@ -994,308 +881,23 @@ function buildVoteButton( */ 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); - - // Best-effort notification. We deliberately ignore the response: the - // auto-verify path above already applied the authoritative UI based on - // the local verifier's result, and the legacy enrichment path would - // happily overwrite that with default "Untrusted / unknown domain" - // markers driven by a stale VerificationResult shape. + // The auto-verify path already owns the per-section UI, so the response + // does not need to cross back into this page. await platformAdapter.sendMessage(MessageContext.CONTENT, { - type: MESSAGE_TYPES.CONTENT_DETECTED, + type: 'CONTENT_DETECTED', url: window.location.href, - content: extractedContent, + verified: pageVerifications.length > 0 && + pageVerifications.every((result) => result.valid), }); - } catch (err) { - // Background may legitimately have no enrichment to offer. Don't pollute - // the console for this case. - console.debug('Content Signing: notifyContentDetected returned no enrichment', err); + } catch { + // Background may legitimately have no enrichment to offer. } } -/** - * 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 { - // Get settings from the verification result - const settings = verificationResult.settings || { - showBadges: true, - highlightVerified: true, - highlightUnverified: false, - }; - - // Find content elements to highlight - const contentElements = findContentElements(); - - // Apply verification UI to each content element - contentElements.forEach(element => { - applyVerificationUIToElement(element, verificationResult, settings); - }); - } catch (error) { - console.error('Failed to apply verification UI:', error); - } -} - -/** - * Find HTMLTrust signed-section elements on the page - * @returns An array of signed-section elements (empty if none found) - */ -function findContentElements(): Element[] { - return Array.from(document.querySelectorAll('signed-section')); -} - -/** - * Apply verification UI to a specific element - */ -function applyVerificationUIToElement( - element: Element, - verificationResult: VerificationResult, - settings: NonNullable -) { - // Add verification badges if enabled - if (settings.showBadges) { - addVerificationBadges(element, verificationResult); - } -} - -/** - * Add verification badges to an element - */ -function addVerificationBadges(element: Element, verificationResult: VerificationResult) { - try { - clearSectionStatusUI(element); - // Create badge container - const badgeContainer = document.createElement('div'); - badgeContainer.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; - - // Add validity badge - const validityBadge = createValidityBadge(verificationResult); - badgeContainer.appendChild(validityBadge); - - // Add trust badge - const trustBadge = createTrustBadge(verificationResult); - badgeContainer.appendChild(trustBadge); - - // Keep extension UI outside the signed element. This prevents a badge or - // tooltip from becoming part of the bytes that the signature protects. - const anchor = outermostSignedSection(element); - anchor.parentNode?.insertBefore(badgeContainer, anchor.nextSibling); - sectionMarkers.set(element, badgeContainer); - } catch (error) { - console.error('Failed to add verification badges:', error); - } -} - -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 = '✓'; - - const tooltip = document.createElement('span'); - tooltip.className = CSS_CLASSES.TOOLTIP; - tooltip.textContent = `Verified by ${verificationResult.user?.name || 'unknown'}`; - - 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 = '✗'; - - const tooltip = document.createElement('span'); - tooltip.className = CSS_CLASSES.TOOLTIP; - tooltip.textContent = verificationResult.reason || 'Not verified'; - badge.appendChild(tooltip); - } - - return badge; -} - -function createTrustBadge(verificationResult: VerificationResult): HTMLElement { - const badge = document.createElement('span'); - badge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.TRUST_BADGE}`; - - const trustStatus = determineTrustStatus(verificationResult); - - switch (trustStatus) { - case TRUST_STATUS.TRUSTED: { - badge.classList.add(CSS_CLASSES.TRUST_BADGE_TRUSTED); - badge.textContent = '🔒'; - 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: { - badge.classList.add(CSS_CLASSES.TRUST_BADGE_UNTRUSTED); - badge.textContent = '⚠️'; - 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: { - badge.classList.add(CSS_CLASSES.TRUST_BADGE_UNKNOWN); - badge.textContent = '?'; - const unknownTooltip = document.createElement('span'); - unknownTooltip.className = CSS_CLASSES.TOOLTIP; - unknownTooltip.textContent = `Unknown source: ${verificationResult.domain || 'unknown domain'}`; - badge.appendChild(unknownTooltip); - break; - } - } - - return badge; -} - -function createVoteButtons(authorId: string): HTMLElement { - const container = document.createElement('div'); - container.className = CSS_CLASSES.VOTE_BUTTONS; - - 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; - - 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; - - upvoteButton.addEventListener('click', handleVoteButtonClick); - downvoteButton.addEventListener('click', handleVoteButtonClick); - - container.appendChild(upvoteButton); - container.appendChild(downvoteButton); - - checkExistingVote(authorId, upvoteButton, downvoteButton); - - return container; -} - -async function checkExistingVote( - authorId: string, - upvoteButton: HTMLButtonElement, - downvoteButton: HTMLButtonElement -): Promise { - try { - const response = await platformAdapter.sendMessage(MessageContext.BACKGROUND, { - type: 'GET_AUTHOR_VOTE', - authorId, - }); - - if (response && response.vote) { - if (response.vote === VoteType.UPVOTE) { - upvoteButton.classList.add(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - downvoteButton.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - } else if (response.vote === VoteType.DOWNVOTE) { - downvoteButton.classList.add(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - upvoteButton.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - } else { - upvoteButton.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - downvoteButton.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - } - } - } catch (error) { - console.error('Failed to check existing vote:', error); - } -} - -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; - } - - const isToggle = button.classList.contains(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - const finalVoteType = isToggle ? VoteType.NEUTRAL : voteType; - - 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 { - const otherButton = voteType === VoteType.UPVOTE ? downvoteButton : upvoteButton; - - if (finalVoteType === VoteType.NEUTRAL) { - button.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - } else { - button.classList.add(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - if (otherButton) { - otherButton.classList.remove(CSS_CLASSES.VOTE_BUTTON_ACTIVE); - } - } - - await platformAdapter.sendMessage(MessageContext.BACKGROUND, { - type: MESSAGE_TYPES.SUBMIT_VOTE, - authorId, - vote: finalVoteType, - url: window.location.href, - contentHash: null, - }); - - console.log(`Vote ${finalVoteType} submitted for author ${authorId}`); - } catch (error) { - console.error('Failed to submit vote:', error); - if (upvoteButton && downvoteButton) { - checkExistingVote(authorId, upvoteButton, downvoteButton); - } - } -} - -function determineTrustStatus(verificationResult: VerificationResult): TrustStatus { - if (verificationResult.trustStatus) { - return verificationResult.trustStatus; - } - - if (!verificationResult.verified) { - return TRUST_STATUS.UNTRUSTED; - } - - if (verificationResult.trustDirectoryEntry) { - return TRUST_STATUS.TRUSTED; - } - - if (verificationResult.user) { - return verificationResult.user.verified ? TRUST_STATUS.TRUSTED : TRUST_STATUS.UNTRUSTED; - } - - return TRUST_STATUS.UNKNOWN; -} - function listenForMessages() { platformAdapter.registerMessageListeners({ - [MessageContext.BACKGROUND]: async (message: any) => { + [MessageContext.BACKGROUND]: async (message: ExtensionMessage) => { switch (message.type) { - case 'UPDATE_VERIFICATION_UI': - applyVerificationUI(message.verificationResult); - return { success: true }; case 'GET_PAGE_VERIFICATIONS': { // Popup reads the per-section results from here. Snapshot to keep @@ -1311,26 +913,6 @@ function listenForMessages() { results: Object.freeze(results), }; } - case MESSAGE_TYPES.VOTE_ACKNOWLEDGED: - if (message.authorId) { - const upvoteButtons = document.querySelectorAll( - `.${CSS_CLASSES.UPVOTE_BUTTON}[data-author-id="${message.authorId}"]` - ); - const downvoteButtons = document.querySelectorAll( - `.${CSS_CLASSES.DOWNVOTE_BUTTON}[data-author-id="${message.authorId}"]` - ); - - upvoteButtons.forEach((upvoteButton) => { - downvoteButtons.forEach((downvoteButton) => { - checkExistingVote( - message.authorId, - upvoteButton as HTMLButtonElement, - downvoteButton as HTMLButtonElement - ); - }); - }); - } - return { success: true }; default: throw new Error(`Unknown message type: ${message.type}`); } diff --git a/src/core/api/content-signing-client.test.ts b/src/core/api/content-signing-client.test.ts index 62f5359..eda3ffa 100644 --- a/src/core/api/content-signing-client.test.ts +++ b/src/core/api/content-signing-client.test.ts @@ -1,109 +1,26 @@ -/** - * Tests for ContentSigningClient — focused on the local-verification migration. - * - * Asserts: - * 1. verifySignedSectionLocal() delegates to @htmltrust/browser-client's - * verifySignedSection() and forwards the configured resolver chain. This - * is the spec §3.1 path; the assertion is the load-bearing one for the - * migration. - * 2. The deprecated verifyContent() does NOT make a network call and returns - * a structured { valid: false } failure. This guards against accidental - * regression to server-side verification. - * 3. setTrustDirectories() rebuilds the resolver chain. - */ - -// The library is mocked at module level so we can assert call arguments -// without instantiating the real SubtleCrypto-backed verifier. -jest.mock('@htmltrust/browser-client', () => { - const mockResolver = { name: 'mock-resolver' }; - return { - verifySignedSection: jest.fn(), - defaultResolverChain: jest.fn(() => [mockResolver]), - evaluateTrustPolicy: jest.fn(), - }; -}); - -import * as browserClient from '@htmltrust/browser-client'; import { ContentSigningClient } from './content-signing-client'; +import type { JsonHttpClient } from './json-http-client'; +import { ERROR_CODES } from '../common/constants'; -describe('ContentSigningClient — local verification (spec §3.1)', () => { +function jsonResponse(status: number, data: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + text: jest.fn().mockResolvedValue(data === undefined ? '' : JSON.stringify(data)), + } as unknown as Response; +} + +const author = { + id: 'author-1', + name: 'Alice', + keyType: 'HUMAN' as const, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +describe('ContentSigningClient', () => { beforeEach(() => { jest.clearAllMocks(); - (browserClient.defaultResolverChain as jest.Mock).mockReturnValue([ - { name: 'mock-resolver' }, - ]); - }); - - describe('constructor', () => { - it('builds a resolver chain from configured trust directories', () => { - const directories = ['https://dir-a.example/', 'https://dir-b.example/']; - new ContentSigningClient({ - baseUrl: 'https://api.example/', - trustDirectories: directories, - }); - expect(browserClient.defaultResolverChain).toHaveBeenCalledWith({ - directories, - fetch: expect.any(Function), - }); - }); - - it('builds a resolver chain with an empty list when no directories are provided', () => { - new ContentSigningClient({ baseUrl: 'https://api.example/' }); - expect(browserClient.defaultResolverChain).toHaveBeenCalledWith({ - directories: [], - fetch: expect.any(Function), - }); - }); - }); - - describe('verifySignedSectionLocal', () => { - it('delegates to verifySignedSection with the configured resolver chain', async () => { - const fakeResult = { valid: true, keyid: 'k1', reason: undefined }; - (browserClient.verifySignedSection as jest.Mock).mockResolvedValueOnce( - fakeResult, - ); - - const client = new ContentSigningClient({ - baseUrl: 'https://api.example/', - trustDirectories: ['https://dir.example/'], - }); - - const section = ''; - const result = await client.verifySignedSectionLocal({ - section, - domain: 'https://example.test', - }); - - expect(result).toBe(fakeResult); - expect(browserClient.verifySignedSection).toHaveBeenCalledTimes(1); - const [arg0, arg1] = (browserClient.verifySignedSection as jest.Mock).mock - .calls[0]; - expect(arg0).toBe(section); - expect(arg1.domain).toBe('https://example.test'); - // The resolver chain must be the one the constructor built. - expect(arg1.keyResolvers).toEqual(client.getResolverChain()); - }); - - it('honors caller-supplied resolver chain over the configured default', async () => { - (browserClient.verifySignedSection as jest.Mock).mockResolvedValueOnce({ - valid: false, - }); - - const client = new ContentSigningClient({ - baseUrl: 'https://api.example/', - trustDirectories: ['https://dir.example/'], - }); - - const customChain = [{ name: 'custom' }] as any; - await client.verifySignedSectionLocal({ - section: '', - keyResolvers: customChain, - }); - - const [, arg1] = (browserClient.verifySignedSection as jest.Mock).mock - .calls[0]; - expect(arg1.keyResolvers).toBe(customChain); - }); }); describe('verifyContent (deprecated server endpoint)', () => { @@ -112,10 +29,11 @@ describe('ContentSigningClient — local verification (spec §3.1)', () => { baseUrl: 'https://api.example/', }); - // Spy on the internal axios client to confirm it is never used for + // Spy on the internal HTTP client to confirm it is never used for // verification. If a regression reintroduces a server call, this fails. - const post = jest.spyOn((client as any).client, 'post'); - const get = jest.spyOn((client as any).client, 'get'); + const internalClient = (client as unknown as { client: JsonHttpClient }).client; + const post = jest.spyOn(internalClient, 'post'); + const get = jest.spyOn(internalClient, 'get'); const result = await client.verifyContent( 'sha256-...', @@ -131,21 +49,71 @@ describe('ContentSigningClient — local verification (spec §3.1)', () => { }); }); - describe('setTrustDirectories', () => { - it('rebuilds the resolver chain when directories change', () => { - const client = new ContentSigningClient({ - baseUrl: 'https://api.example/', - trustDirectories: ['https://old.example/'], + describe('typed API response contracts', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.useRealTimers(); + }); + + it('sends configured auth headers and encodes list query parameters', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(200, { + authors: [author], + pagination: { total: 1, pages: 1, page: 2, limit: 10 }, + })); + const client = new ContentSigningClient({ baseUrl: 'https://api.example/v1' }); + client.setApiKey('author-secret', 'author'); + + await client.listAuthors('Alice Smith', 'HUMAN', 2, 10); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example/v1/authors?name=Alice+Smith&keyType=HUMAN&page=2&limit=10', + expect.objectContaining({ + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-AUTHOR-API-KEY': 'author-secret', + }, + credentials: 'omit', + }), + ); + }); + + it('maps non-success responses to the public auth error contract', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(403, { message: 'Forbidden' })); + const client = new ContentSigningClient({ baseUrl: 'https://api.example/v1' }); + + await expect(client.getAuthor('author-1')).rejects.toMatchObject({ + code: ERROR_CODES.AUTH_ERROR, + message: 'Forbidden', }); - (browserClient.defaultResolverChain as jest.Mock).mockClear(); + }); + + it('rejects malformed successful responses at the typed boundary', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(200, { id: 'author-1' })); + const client = new ContentSigningClient({ baseUrl: 'https://api.example/v1' }); - const next = ['https://new-a.example/', 'https://new-b.example/']; - client.setTrustDirectories(next); + await expect(client.getAuthor('author-1')).rejects.toMatchObject({ + code: ERROR_CODES.UNKNOWN_ERROR, + message: 'Failed to get author with ID author-1', + }); + }); - expect(browserClient.defaultResolverChain).toHaveBeenCalledWith({ - directories: next, - fetch: expect.any(Function), + it('maps an aborted request to the public unknown-error contract', async () => { + jest.useFakeTimers(); + globalThis.fetch = jest.fn((_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + })); + const client = new ContentSigningClient({ baseUrl: 'https://api.example/v1', timeout: 25 }); + + const request = client.getAuthor('author-1'); + const rejection = expect(request).rejects.toMatchObject({ + code: ERROR_CODES.UNKNOWN_ERROR, + message: 'Failed to get author with ID author-1', }); + await jest.advanceTimersByTimeAsync(25); + await rejection; }); }); }); diff --git a/src/core/api/content-signing-client.ts b/src/core/api/content-signing-client.ts index 74b46ef..e164f98 100644 --- a/src/core/api/content-signing-client.ts +++ b/src/core/api/content-signing-client.ts @@ -1,65 +1,31 @@ /** - * Content Signing API client. + * Client for author, signing, directory, and voting operations against an + * HTMLTrust server. Browser verification lives in @htmltrust/browser-client + * and is called directly by the content script, keeping the server client out + * of the verification dependency graph. * - * 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. + * The deprecated /api/content/verify endpoint is never called. The legacy + * verifyContent() method remains as a fail-closed compatibility shim. */ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import { - verifySignedSection, - defaultResolverChain, - isPrivateHost, - 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 { Author, PublicKey, ContentSignature, Claim, ClaimMap, KeyReputation, ContentOccurrence, VoteType, BatchedVotesPayload, BatchVoteResult } from '../common/types'; 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', - }); - }; -} +import { JsonHttpClient, JsonHttpError } from './json-http-client'; +import { + isAuthor, + isAuthorListResponse, + isBatchVoteResult, + isClaim, + isClaimListResponse, + isContentSearchResponse, + isContentSignature, + isKeyReputation, + isKeySearchResponse, + isOccurrenceResponse, + isReportResponse, + isCreateAuthorResponse, + isPublicKey, +} from './response-validation'; /** * Content Signing API client options @@ -69,85 +35,37 @@ 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; - /** 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[]; - /** - * 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; -} +type Pagination = { total: number; pages: number; page: number; limit: number }; +type AuthorListResponse = { authors: Author[]; pagination: Pagination }; +type ClaimListResponse = { claims: Claim[]; pagination: Pagination }; +type KeySearchResponse = { + keys: Array; + pagination: Pagination; +}; +type ContentSearchResponse = { + signatures: Array; + pagination: Pagination; +}; +type OccurrenceResponse = { occurrences: ContentOccurrence[]; pagination: Pagination }; +type ReportResponse = { + reportId: string; + status: 'PENDING' | 'UNDER_REVIEW' | 'ACCEPTED' | 'REJECTED'; +}; /** * Content Signing API client */ export class ContentSigningClient { - private client: AxiosInstance; - private baseUrl: string; - private trustDirectories: string[]; - private resolverChain: KeyResolver[]; - private verifierFetch: typeof fetch; + private client: JsonHttpClient; /** * Create a new Content Signing API client * @param options The client options */ 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, - fetch: this.verifierFetch, - }); - - const config: AxiosRequestConfig = { - baseURL: options.baseUrl, - timeout: options.timeout || 10000, - headers: { - 'Content-Type': 'application/json', - }, - }; - - 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, - fetch: this.verifierFetch, - }); - } - - /** Get the configured resolver chain (for callers that want to reuse it). */ - getResolverChain(): KeyResolver[] { - return this.resolverChain; + this.client = new JsonHttpClient(options.baseUrl, options.timeout ?? 10_000); } /** @@ -162,7 +80,7 @@ export class ContentSigningClient { ? 'X-ADMIN-API-KEY' : 'X-API-KEY'; - this.client.defaults.headers.common[headerName] = apiKey; + this.client.setHeader(headerName, apiKey); } /** @@ -176,22 +94,7 @@ export class ContentSigningClient { ? '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 ?? defaultSerializedOrigin(), - hash: opts.hash, - }); + this.client.clearHeader(headerName); } /** @@ -217,7 +120,7 @@ export class ContentSigningClient { description, url, keyAlgorithm - }); + }, isCreateAuthorResponse); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to create author'); @@ -232,15 +135,15 @@ export class ContentSigningClient { keyType?: 'HUMAN' | 'AI' | 'HUMAN_AI_MIX' | 'ORGANIZATION', page?: number, limit?: number - ): Promise<{ authors: Author[]; pagination: { total: number; pages: number; page: number; limit: number } }> { + ): Promise { try { - const params: Record = {}; + const params: Record = {}; if (name) params.name = name; if (keyType) params.keyType = keyType; if (page) params.page = page; if (limit) params.limit = limit; - const response = await this.client.get(API_ENDPOINTS.AUTHORS, { params }); + const response = await this.client.get(API_ENDPOINTS.AUTHORS, { params }, isAuthorListResponse); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to list authors'); @@ -252,7 +155,7 @@ export class ContentSigningClient { */ async getAuthor(authorId: string): Promise { try { - const response = await this.client.get(`${API_ENDPOINTS.AUTHORS}/${authorId}`); + const response = await this.client.get(`${API_ENDPOINTS.AUTHORS}/${authorId}`, undefined, isAuthor); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get author with ID ${authorId}`); @@ -267,7 +170,7 @@ export class ContentSigningClient { updates: { name?: string; description?: string; url?: string } ): Promise { try { - const response = await this.client.put(`${API_ENDPOINTS.AUTHORS}/${authorId}`, updates); + const response = await this.client.put(`${API_ENDPOINTS.AUTHORS}/${authorId}`, updates, isAuthor); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to update author with ID ${authorId}`); @@ -293,7 +196,7 @@ export class ContentSigningClient { */ async getAuthorPublicKey(authorId: string): Promise { try { - const response = await this.client.get(`${API_ENDPOINTS.AUTHORS}/${authorId}/public-key`); + const response = await this.client.get(`${API_ENDPOINTS.AUTHORS}/${authorId}/public-key`, undefined, isPublicKey); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get public key for author with ID ${authorId}`); @@ -306,14 +209,14 @@ export class ContentSigningClient { async signContent( contentHash: string, domain: string, - claims: Record + claims: ClaimMap ): Promise { try { const response = await this.client.post(API_ENDPOINTS.CONTENT_SIGN, { contentHash, domain, claims - }); + }, isContentSignature); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to sign content'); @@ -326,9 +229,7 @@ export class ContentSigningClient { * @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. + * indicating that callers should use @htmltrust/browser-client directly. * * @returns Always { valid: false } with a descriptive reason. */ @@ -337,14 +238,14 @@ export class ContentSigningClient { _domain: string, _authorId: string, _signature: string - ): Promise<{ valid: boolean; author?: Author; claims?: Record; reason?: string }> { + ): Promise<{ valid: boolean; author?: Author; claims?: ClaimMap; 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', + 'verifyContent() is deprecated; use @htmltrust/browser-client verifySignedSection for spec §3.1 local verification', }; } @@ -354,13 +255,13 @@ export class ContentSigningClient { async listClaimTypes( page?: number, limit?: number - ): Promise<{ claims: Claim[]; pagination: { total: number; pages: number; page: number; limit: number } }> { + ): Promise { try { - const params: Record = {}; + const params: Record = {}; if (page) params.page = page; if (limit) params.limit = limit; - const response = await this.client.get(API_ENDPOINTS.CLAIMS, { params }); + const response = await this.client.get(API_ENDPOINTS.CLAIMS, { params }, isClaimListResponse); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to list claim types'); @@ -372,7 +273,7 @@ export class ContentSigningClient { */ async getClaimType(claimId: string): Promise { try { - const response = await this.client.get(`${API_ENDPOINTS.CLAIMS}/${claimId}`); + const response = await this.client.get(`${API_ENDPOINTS.CLAIMS}/${claimId}`, undefined, isClaim); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get claim type with ID ${claimId}`); @@ -389,12 +290,9 @@ export class ContentSigningClient { minTrustScore?: number; page?: number; limit?: number; - }): Promise<{ - keys: Array; - pagination: { total: number; pages: number; page: number; limit: number } - }> { + }): Promise { try { - const response = await this.client.get(API_ENDPOINTS.DIRECTORY_KEYS, { params }); + const response = await this.client.get(API_ENDPOINTS.DIRECTORY_KEYS, { params }, isKeySearchResponse); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to search public keys'); @@ -406,7 +304,7 @@ export class ContentSigningClient { */ async getKeyReputation(keyId: string): Promise { try { - const response = await this.client.get(`${API_ENDPOINTS.DIRECTORY_KEYS}/${keyId}/reputation`); + const response = await this.client.get(`${API_ENDPOINTS.DIRECTORY_KEYS}/${keyId}/reputation`, undefined, isKeyReputation); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get reputation for key with ID ${keyId}`); @@ -421,13 +319,13 @@ export class ContentSigningClient { reason: 'IMPERSONATION' | 'MISINFORMATION' | 'SPAM' | 'OTHER', details?: string, evidence?: string - ): Promise<{ reportId: string; status: 'PENDING' | 'UNDER_REVIEW' | 'ACCEPTED' | 'REJECTED' }> { + ): Promise { try { const response = await this.client.post(`${API_ENDPOINTS.DIRECTORY_KEYS}/${keyId}/report`, { reason, details, evidence - }); + }, isReportResponse); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to report key with ID ${keyId}`); @@ -444,12 +342,9 @@ export class ContentSigningClient { claim?: string; page?: number; limit?: number; - }): Promise<{ - signatures: Array; - pagination: { total: number; pages: number; page: number; limit: number } - }> { + }): Promise { try { - const response = await this.client.get(API_ENDPOINTS.DIRECTORY_CONTENT, { params }); + const response = await this.client.get(API_ENDPOINTS.DIRECTORY_CONTENT, { params }, isContentSearchResponse); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to search signed content'); @@ -463,16 +358,13 @@ export class ContentSigningClient { contentHash: string, page?: number, limit?: number - ): Promise<{ - occurrences: ContentOccurrence[]; - pagination: { total: number; pages: number; page: number; limit: number } - }> { + ): Promise { try { - const params: Record = {}; + const params: Record = {}; if (page) params.page = page; if (limit) params.limit = limit; - const response = await this.client.get(`${API_ENDPOINTS.DIRECTORY_CONTENT}/${contentHash}/occurrences`, { params }); + const response = await this.client.get(`${API_ENDPOINTS.DIRECTORY_CONTENT}/${contentHash}/occurrences`, { params }, isOccurrenceResponse); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to find occurrences for content hash ${contentHash}`); @@ -487,14 +379,14 @@ export class ContentSigningClient { sourceUrl: string, targetUrl: string, reason: 'COPYRIGHT_VIOLATION' | 'UNAUTHORIZED_USE' | 'IMPERSONATION' | 'OTHER' - ): Promise<{ reportId: string; status: 'PENDING' | 'UNDER_REVIEW' | 'ACCEPTED' | 'REJECTED' }> { + ): Promise { try { const response = await this.client.post(`${API_ENDPOINTS.DIRECTORY_CONTENT}/report`, { contentHash, sourceUrl, targetUrl, reason - }); + }, isReportResponse); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to report content misuse'); @@ -508,7 +400,7 @@ export class ContentSigningClient { try { const response = await this.client.post(API_ENDPOINTS.VOTES_BATCH, { votes - }); + }, isBatchVoteResult); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to submit votes'); @@ -532,10 +424,10 @@ export class ContentSigningClient { /** * Handle API errors */ - 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; + private handleApiError(error: unknown, defaultMessage: string): never { + if (error instanceof JsonHttpError) { + const status = error.status; + const message = error.message || defaultMessage; if (status === 401 || status === 403) { throw createError(ERROR_CODES.AUTH_ERROR, message, error); diff --git a/src/core/api/json-http-client.test.ts b/src/core/api/json-http-client.test.ts new file mode 100644 index 0000000..e80bf05 --- /dev/null +++ b/src/core/api/json-http-client.test.ts @@ -0,0 +1,81 @@ +import { JsonHttpClient, JsonHttpError } from './json-http-client'; + +function jsonResponse(status: number, data: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + text: jest.fn().mockResolvedValue(data === undefined ? '' : JSON.stringify(data)), + } as unknown as Response; +} + +describe('JsonHttpClient', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.useRealTimers(); + }); + + it('joins the base path, encodes query parameters, and parses JSON', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(200, { authors: [] })); + const client = new JsonHttpClient('https://api.example/v1/'); + + const response = await client.get('/authors', { + params: { name: 'Alice Smith', page: 2, omitted: undefined }, + }); + + expect(response).toEqual({ data: { authors: [] }, status: 200 }); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example/v1/authors?name=Alice+Smith&page=2', + expect.objectContaining({ method: 'GET', credentials: 'omit' }), + ); + }); + + it('sends configured headers and a JSON body', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(201, { id: 'author-1' })); + const client = new JsonHttpClient('https://api.example'); + client.setHeader('X-AUTHOR-API-KEY', 'secret'); + + await client.post('/authors', { name: 'Alice' }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example/authors', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-AUTHOR-API-KEY': 'secret', + }, + body: '{"name":"Alice"}', + }), + ); + }); + + it('returns the server message and status for an HTTP failure', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(403, { message: 'Forbidden' })); + const client = new JsonHttpClient('https://api.example'); + + await expect(client.get('/authors')).rejects.toMatchObject({ + name: 'JsonHttpError', + message: 'Forbidden', + status: 403, + responseData: { message: 'Forbidden' }, + }); + }); + + it('aborts requests after the configured timeout', async () => { + jest.useFakeTimers(); + globalThis.fetch = jest.fn((_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + })); + const client = new JsonHttpClient('https://api.example', 25); + + const request = client.get('/authors'); + const rejection = expect(request).rejects.toEqual(expect.objectContaining({ + name: 'JsonHttpError', + message: 'Request timed out after 25 ms', + })); + await jest.advanceTimersByTimeAsync(25); + await rejection; + }); +}); diff --git a/src/core/api/json-http-client.ts b/src/core/api/json-http-client.ts new file mode 100644 index 0000000..a9fd1f6 --- /dev/null +++ b/src/core/api/json-http-client.ts @@ -0,0 +1,148 @@ +export type QueryValue = string | number | boolean | null | undefined; + +export interface JsonRequestOptions { + params?: Record; +} + +export interface JsonResponse { + data: T; + status: number; +} + +export type JsonResponseValidator = (value: unknown) => value is T; + +/** Error returned for HTTP failures, timeouts, and transport failures. */ +export class JsonHttpError extends Error { + readonly status?: number; + readonly responseData?: unknown; + readonly originalError?: unknown; + + constructor( + message: string, + options: { status?: number; responseData?: unknown; originalError?: unknown } = {}, + ) { + super(message); + this.name = 'JsonHttpError'; + this.status = options.status; + this.responseData = options.responseData; + this.originalError = options.originalError; + } +} + +function responseMessage(data: unknown, status: number): string { + if (data && typeof data === 'object') { + const message = (data as Record).message; + if (typeof message === 'string' && message.trim()) return message; + } + if (typeof data === 'string' && data.trim()) return data; + return `Request failed with status ${status}`; +} + +async function parseResponse(response: Response): Promise { + if (response.status === 204) return undefined; + const text = await response.text(); + if (!text) return undefined; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +/** Minimal JSON client for extension-to-server API calls. */ +export class JsonHttpClient { + private readonly baseUrl: string; + private readonly timeout: number; + private readonly headers: Record = { + 'Content-Type': 'application/json', + }; + + constructor(baseUrl: string, timeout = 10_000) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.timeout = timeout; + } + + setHeader(name: string, value: string): void { + this.headers[name] = value; + } + + clearHeader(name: string): void { + delete this.headers[name]; + } + + get(path: string, options?: JsonRequestOptions): Promise>; + get(path: string, options: JsonRequestOptions | undefined, validate: JsonResponseValidator): Promise>; + get(path: string, options?: JsonRequestOptions, validate?: JsonResponseValidator): Promise | JsonResponse> { + return this.request('GET', path, undefined, options, validate); + } + + post(path: string, body?: unknown): Promise>; + post(path: string, body: unknown, validate: JsonResponseValidator): Promise>; + post(path: string, body?: unknown, validate?: JsonResponseValidator): Promise | JsonResponse> { + return this.request('POST', path, body, undefined, validate); + } + + put(path: string, body?: unknown): Promise>; + put(path: string, body: unknown, validate: JsonResponseValidator): Promise>; + put(path: string, body?: unknown, validate?: JsonResponseValidator): Promise | JsonResponse> { + return this.request('PUT', path, body, undefined, validate); + } + + delete(path: string): Promise> { + return this.request('DELETE', path); + } + + private buildUrl(path: string, params?: Record): string { + const url = new URL(`${this.baseUrl}/${path.replace(/^\/+/, '')}`); + for (const [name, value] of Object.entries(params ?? {})) { + if (value !== undefined && value !== null) url.searchParams.set(name, String(value)); + } + return url.toString(); + } + + private request(method: string, path: string, body?: unknown, options?: JsonRequestOptions): Promise>; + private request(method: string, path: string, body: unknown, options: JsonRequestOptions | undefined, validate: JsonResponseValidator): Promise>; + private request(method: string, path: string, body: unknown, options: JsonRequestOptions | undefined, validate?: JsonResponseValidator): Promise | JsonResponse>; + private async request( + method: string, + path: string, + body?: unknown, + options?: JsonRequestOptions, + validate?: JsonResponseValidator, + ): Promise | JsonResponse> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + try { + const response = await fetch(this.buildUrl(path, options?.params), { + method, + headers: { ...this.headers }, + body: body === undefined ? undefined : JSON.stringify(body), + credentials: 'omit', + signal: controller.signal, + }); + const data = await parseResponse(response); + if (!response.ok) { + throw new JsonHttpError(responseMessage(data, response.status), { + status: response.status, + responseData: data, + }); + } + if (validate && !validate(data)) { + throw new JsonHttpError(`Invalid JSON response for ${method} ${path}`, { + status: response.status, + responseData: data, + }); + } + return { data, status: response.status }; + } catch (error) { + if (error instanceof JsonHttpError) throw error; + const timedOut = controller.signal.aborted; + throw new JsonHttpError( + timedOut ? `Request timed out after ${this.timeout} ms` : 'Network request failed', + { originalError: error }, + ); + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/src/core/api/response-validation.test.ts b/src/core/api/response-validation.test.ts new file mode 100644 index 0000000..3209abd --- /dev/null +++ b/src/core/api/response-validation.test.ts @@ -0,0 +1,34 @@ +import { + isAuthor, + isPublicKey, + isReportResponse, + isVerificationResult, +} from './response-validation'; + +describe('API response enum validation', () => { + it('requires primitive string enum values', () => { + expect(isAuthor({ + id: 'author-1', + name: 'Alice', + keyType: new String('HUMAN'), + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + })).toBe(false); + expect(isPublicKey({ + id: 'key-1', + authorId: 'author-1', + key: 'public-key', + algorithm: new String('ED25519'), + createdAt: '2026-01-01T00:00:00.000Z', + })).toBe(false); + expect(isVerificationResult({ + verified: true, + verifiedAt: 1, + trustStatus: new String('trusted'), + })).toBe(false); + expect(isReportResponse({ + reportId: 'report-1', + status: new String('PENDING'), + })).toBe(false); + }); +}); diff --git a/src/core/api/response-validation.ts b/src/core/api/response-validation.ts new file mode 100644 index 0000000..c6de254 --- /dev/null +++ b/src/core/api/response-validation.ts @@ -0,0 +1,187 @@ +import type { + Author, + BatchVoteResult, + Claim, + ClaimMap, + ContentOccurrence, + ContentSignature, + KeyReputation, + PublicKey, + TrustDirectoryEntry, + User, + VerificationResult, +} from '../common/types'; + +type ReportStatus = 'PENDING' | 'UNDER_REVIEW' | 'ACCEPTED' | 'REJECTED'; +type Pagination = { total: number; pages: number; page: number; limit: number }; + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function string(value: unknown): value is string { + return typeof value === 'string'; +} + +function finiteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function stringEnum(value: unknown, allowed: readonly T[]): value is T { + return typeof value === 'string' && allowed.includes(value as T); +} + +function optional(value: Record, key: string, guard: (value: unknown) => boolean): boolean { + return value[key] === undefined || guard(value[key]); +} + +function claimMap(value: unknown): value is ClaimMap { + return record(value) && Object.values(value).every((claim) => + string(claim) || typeof claim === 'boolean' || finiteNumber(claim)); +} + +function isTrustInput(value: unknown): value is NonNullable[number] { + if (!record(value)) return false; + return string(value.source) && finiteNumber(value.contribution) && string(value.rationale); +} + +function isVerificationSettings(value: unknown): value is NonNullable { + if (!record(value)) return false; + return optional(value, 'autoVerify', (item) => typeof item === 'boolean') && + optional(value, 'showBadges', (item) => typeof item === 'boolean') && + optional(value, 'highlightVerified', (item) => typeof item === 'boolean') && + optional(value, 'highlightUnverified', (item) => typeof item === 'boolean'); +} + +export function isAuthor(value: unknown): value is Author { + if (!record(value)) return false; + return string(value.id) && string(value.name) && + stringEnum(value.keyType, ['HUMAN', 'AI', 'HUMAN_AI_MIX', 'ORGANIZATION']) && + string(value.createdAt) && string(value.updatedAt) && + optional(value, 'description', string) && optional(value, 'url', string); +} + +export function isPublicKey(value: unknown): value is PublicKey { + if (!record(value)) return false; + return string(value.id) && string(value.authorId) && string(value.key) && + stringEnum(value.algorithm, ['RSA', 'ECDSA', 'ED25519']) && + string(value.createdAt) && optional(value, 'expiresAt', string); +} + +export function isClaim(value: unknown): value is Claim { + if (!record(value)) return false; + return string(value.id) && string(value.name) && string(value.description) && + string(value.createdAt) && string(value.updatedAt) && + (value.possibleValues === undefined || + (Array.isArray(value.possibleValues) && value.possibleValues.every(string))); +} + +export function isContentSignature(value: unknown): value is ContentSignature { + if (!record(value)) return false; + return string(value.contentHash) && string(value.domain) && string(value.authorId) && + string(value.signature) && claimMap(value.claims) && optional(value, 'createdAt', string); +} + +export function isKeyReputation(value: unknown): value is KeyReputation { + if (!record(value)) return false; + return string(value.keyId) && finiteNumber(value.trustScore) && + finiteNumber(value.verifiedSignatures) && optional(value, 'reports', finiteNumber) && + optional(value, 'lastUpdated', string); +} + +export function isContentOccurrence(value: unknown): value is ContentOccurrence { + if (!record(value)) return false; + return string(value.url) && string(value.domain) && string(value.firstSeen) && + optional(value, 'lastSeen', string) && optional(value, 'authorId', string) && + optional(value, 'signatureValid', (item) => typeof item === 'boolean'); +} + +export function isTrustDirectoryEntry(value: unknown): value is TrustDirectoryEntry { + if (!record(value)) return false; + return string(value.id) && string(value.userId) && string(value.domain) && + string(value.publicKey) && finiteNumber(value.createdAt) && finiteNumber(value.updatedAt) && + typeof value.active === 'boolean'; +} + +export function isTrustDirectoryEntryList(value: unknown): value is TrustDirectoryEntry[] { + return Array.isArray(value) && value.every(isTrustDirectoryEntry); +} + +export function isUser(value: unknown): value is User { + if (!record(value)) return false; + return string(value.id) && string(value.name) && string(value.email) && + string(value.publicKey) && typeof value.verified === 'boolean'; +} + +export function isVerificationResult(value: unknown): value is VerificationResult { + if (!record(value)) return false; + return typeof value.verified === 'boolean' && finiteNumber(value.verifiedAt) && + optional(value, 'reason', string) && optional(value, 'user', isUser) && + optional(value, 'trustDirectoryEntry', isTrustDirectoryEntry) && + optional(value, 'trustStatus', (item) => stringEnum(item, ['trusted', 'untrusted', 'unknown'])) && + optional(value, 'cryptoValid', (item) => typeof item === 'boolean') && + optional(value, 'trustScore', finiteNumber) && + optional(value, 'trustIndicator', (item) => + stringEnum(item, ['trusted', 'unknown', 'untrusted', 'green', 'yellow', 'red'])) && + optional(value, 'domain', string) && + optional(value, 'trustInputs', (item) => Array.isArray(item) && item.every(isTrustInput)) && + optional(value, 'settings', isVerificationSettings); +} + +export function isPagination(value: unknown): value is { total: number; pages: number; page: number; limit: number } { + if (!record(value)) return false; + return finiteNumber(value.total) && finiteNumber(value.pages) && + finiteNumber(value.page) && finiteNumber(value.limit); +} + +export function isAuthorListResponse(value: unknown): value is { authors: Author[]; pagination: Pagination } { + return record(value) && Array.isArray(value.authors) && value.authors.every(isAuthor) && isPagination(value.pagination); +} + +export function isCreateAuthorResponse(value: unknown): value is { author: Author; authorApiKey: string } { + return record(value) && isAuthor(value.author) && string(value.authorApiKey); +} + +export function isClaimListResponse(value: unknown): value is { claims: Claim[]; pagination: Pagination } { + return record(value) && Array.isArray(value.claims) && value.claims.every(isClaim) && isPagination(value.pagination); +} + +export function isKeySearchResponse(value: unknown): value is { + keys: Array; + pagination: Pagination; +} { + return record(value) && Array.isArray(value.keys) && value.keys.every((key) => + record(key) && isPublicKey(key) && isAuthor(key.author) && finiteNumber(key.trustScore)) && isPagination(value.pagination); +} + +export function isContentSearchResponse(value: unknown): value is { + signatures: Array; + pagination: Pagination; +} { + return record(value) && Array.isArray(value.signatures) && value.signatures.every((signature) => + record(signature) && isContentSignature(signature) && isAuthor(signature.author) && finiteNumber(signature.occurrences)) && + isPagination(value.pagination); +} + +export function isOccurrenceResponse(value: unknown): value is { + occurrences: ContentOccurrence[]; + pagination: Pagination; +} { + return record(value) && Array.isArray(value.occurrences) && value.occurrences.every(isContentOccurrence) && + isPagination(value.pagination); +} + +export function isReportResponse(value: unknown): value is { reportId: string; status: ReportStatus } { + return record(value) && string(value.reportId) && + stringEnum(value.status, ['PENDING', 'UNDER_REVIEW', 'ACCEPTED', 'REJECTED']); +} + +export function isBatchVoteResult(value: unknown): value is BatchVoteResult { + if (!record(value) || typeof value.success !== 'boolean') return false; + if (!optional(value, 'error', string)) return false; + if (value.results === undefined) return true; + return record(value.results) && Object.values(value.results).every((result) => { + if (!record(result) || typeof result.success !== 'boolean') return false; + return optional(result, 'error', string); + }); +} diff --git a/src/core/api/trust-directory-client.test.ts b/src/core/api/trust-directory-client.test.ts new file mode 100644 index 0000000..e20f3cf --- /dev/null +++ b/src/core/api/trust-directory-client.test.ts @@ -0,0 +1,58 @@ +import { ERROR_CODES } from '../common/constants'; +import { TrustDirectoryClient } from './trust-directory-client'; + +function jsonResponse(status: number, data: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + text: jest.fn().mockResolvedValue(data === undefined ? '' : JSON.stringify(data)), + } as unknown as Response; +} + +describe('TrustDirectoryClient response contracts', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns valid trust directory entries', async () => { + const entry = { + id: 'entry-1', + userId: 'user-1', + domain: 'example.test', + publicKey: 'public-key', + createdAt: 1, + updatedAt: 2, + active: true, + }; + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(200, [entry])); + const client = new TrustDirectoryClient({ baseUrl: 'https://directory.example' }); + + await expect(client.getAllEntries()).resolves.toEqual([entry]); + }); + + it('rejects malformed trust directory entries at the typed boundary', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(200, [{ id: 'entry-1' }])); + const client = new TrustDirectoryClient({ baseUrl: 'https://directory.example' }); + + await expect(client.getAllEntries()).rejects.toMatchObject({ + code: ERROR_CODES.UNKNOWN_ERROR, + message: 'Failed to get trust directory entries', + }); + }); + + it('rejects malformed optional verification fields at the typed boundary', async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse(200, { + verified: true, + verifiedAt: 1, + trustInputs: [{ source: 'signature', contribution: '100', rationale: 'valid' }], + })); + const client = new TrustDirectoryClient({ baseUrl: 'https://directory.example' }); + + await expect(client.verifySignature('example.test', 'hash', 'signature', 'public-key')).rejects.toMatchObject({ + code: ERROR_CODES.UNKNOWN_ERROR, + message: 'Failed to verify signature', + }); + }); +}); diff --git a/src/core/api/trust-directory-client.ts b/src/core/api/trust-directory-client.ts index 1264bb1..00ba9a2 100644 --- a/src/core/api/trust-directory-client.ts +++ b/src/core/api/trust-directory-client.ts @@ -1,10 +1,16 @@ /** * Trust Directory API client */ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { TrustDirectoryEntry, User, VerificationResult } from '../common/types'; import { ERROR_CODES } from '../common/constants'; import { createError } from '../common/utils'; +import { JsonHttpClient, JsonHttpError } from './json-http-client'; +import { + isTrustDirectoryEntry, + isTrustDirectoryEntryList, + isUser, + isVerificationResult, +} from './response-validation'; /** * Trust Directory API client options @@ -22,32 +28,17 @@ export interface TrustDirectoryClientOptions { * Trust Directory API client */ export class TrustDirectoryClient { - private client: AxiosInstance; - private baseUrl: string; + private client: JsonHttpClient; /** * Create a new Trust Directory API client * @param options The client options */ constructor(options: TrustDirectoryClientOptions) { - this.baseUrl = options.baseUrl; - - const config: AxiosRequestConfig = { - baseURL: options.baseUrl, - timeout: options.timeout || 10000, - headers: { - 'Content-Type': 'application/json', - }, - }; - + this.client = new JsonHttpClient(options.baseUrl, options.timeout ?? 10_000); if (options.apiKey) { - config.headers = { - ...config.headers, - 'Authorization': `Bearer ${options.apiKey}`, - }; + this.client.setHeader('Authorization', `Bearer ${options.apiKey}`); } - - this.client = axios.create(config); } /** @@ -56,7 +47,7 @@ export class TrustDirectoryClient { */ async getAllEntries(): Promise { try { - const response = await this.client.get('/api/v1/trust-directory'); + const response = await this.client.get('/api/v1/trust-directory', undefined, isTrustDirectoryEntryList); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to get trust directory entries'); @@ -70,7 +61,7 @@ export class TrustDirectoryClient { */ async getEntryById(id: string): Promise { try { - const response = await this.client.get(`/api/v1/trust-directory/${id}`); + const response = await this.client.get(`/api/v1/trust-directory/${id}`, undefined, isTrustDirectoryEntry); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get trust directory entry with ID ${id}`); @@ -84,7 +75,7 @@ export class TrustDirectoryClient { */ async getEntriesByDomain(domain: string): Promise { try { - const response = await this.client.get(`/api/v1/trust-directory/domain/${domain}`); + const response = await this.client.get(`/api/v1/trust-directory/domain/${domain}`, undefined, isTrustDirectoryEntryList); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get trust directory entries for domain ${domain}`); @@ -98,7 +89,7 @@ export class TrustDirectoryClient { */ async getEntriesByUser(userId: string): Promise { try { - const response = await this.client.get(`/api/v1/trust-directory/user/${userId}`); + const response = await this.client.get(`/api/v1/trust-directory/user/${userId}`, undefined, isTrustDirectoryEntryList); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get trust directory entries for user ${userId}`); @@ -112,7 +103,7 @@ export class TrustDirectoryClient { */ async createEntry(entry: Omit): Promise { try { - const response = await this.client.post('/api/v1/trust-directory', entry); + const response = await this.client.post('/api/v1/trust-directory', entry, isTrustDirectoryEntry); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to create trust directory entry'); @@ -127,7 +118,7 @@ export class TrustDirectoryClient { */ async updateEntry(id: string, entry: Partial): Promise { try { - const response = await this.client.put(`/api/v1/trust-directory/${id}`, entry); + const response = await this.client.put(`/api/v1/trust-directory/${id}`, entry, isTrustDirectoryEntry); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to update trust directory entry with ID ${id}`); @@ -167,7 +158,7 @@ export class TrustDirectoryClient { contentHash, signature, publicKey, - }); + }, isVerificationResult); return response.data; } catch (error) { throw this.handleApiError(error, 'Failed to verify signature'); @@ -181,7 +172,7 @@ export class TrustDirectoryClient { */ async getUserById(id: string): Promise { try { - const response = await this.client.get(`/api/v1/users/${id}`); + const response = await this.client.get(`/api/v1/users/${id}`, undefined, isUser); return response.data; } catch (error) { throw this.handleApiError(error, `Failed to get user with ID ${id}`); @@ -194,10 +185,10 @@ export class TrustDirectoryClient { * @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; + private handleApiError(error: unknown, defaultMessage: string): never { + if (error instanceof JsonHttpError) { + const status = error.status; + const message = error.message || defaultMessage; if (status === 401 || status === 403) { throw createError(ERROR_CODES.AUTH_ERROR, message, error); @@ -210,4 +201,4 @@ export class TrustDirectoryClient { 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 5f4c27e..d4e794d 100644 --- a/src/core/common/constants.ts +++ b/src/core/common/constants.ts @@ -31,6 +31,7 @@ export const DEFAULT_SETTINGS = { highlightUnverified: false, trustDirectoryUrls: [] as string[], trustDirectoryUrl: '', // legacy, unused if trustDirectoryUrls is populated + trustDirectorySubscriptions: [] as Array<{ url: string; weight: number; enabled: boolean }>, personalTrustList: [] as string[], trustedDomains: [] as string[], authMethod: 'apikey' as const, @@ -58,21 +59,6 @@ export const ERROR_CODES = { UNKNOWN_ERROR: 'UNKNOWN_ERROR', }; -/** - * Message types for communication between extension components - */ -export const MESSAGE_TYPES = { - VERIFY_CONTENT: 'VERIFY_CONTENT', - SIGN_CONTENT: 'SIGN_CONTENT', - UPDATE_SETTINGS: 'UPDATE_SETTINGS', - GET_SETTINGS: 'GET_SETTINGS', - AUTH_REQUEST: 'AUTH_REQUEST', - AUTH_RESPONSE: 'AUTH_RESPONSE', - CONTENT_DETECTED: 'CONTENT_DETECTED', - SUBMIT_VOTE: 'SUBMIT_VOTE', - VOTE_ACKNOWLEDGED: 'VOTE_ACKNOWLEDGED', -}; - /** * Storage keys */ @@ -83,8 +69,6 @@ export const STORAGE_KEYS = { VERIFICATION_RESULTS: 'verificationResults', PROFILES: 'profiles', ACTIVE_PROFILE: 'activeProfile', - AUTHOR_VOTES: 'authorVotes', // Prefix for storing votes per author (e.g., 'authorVotes:authorId123') - PENDING_VOTES: 'pendingVotes', // Queue of votes pending submission to the server }; /** @@ -119,16 +103,6 @@ export const CSS_CLASSES = { 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', - TRUST_BADGE_UNKNOWN: 'cs-trust-badge-unknown', - TOOLTIP: 'cs-tooltip', - VOTE_BUTTONS: 'cs-vote-buttons', - VOTE_BUTTON: 'cs-vote-button', - UPVOTE_BUTTON: 'cs-upvote-button', - DOWNVOTE_BUTTON: 'cs-downvote-button', - VOTE_BUTTON_ACTIVE: 'cs-vote-button-active', }; /** diff --git a/src/core/common/hash.ts b/src/core/common/hash.ts new file mode 100644 index 0000000..487dca8 --- /dev/null +++ b/src/core/common/hash.ts @@ -0,0 +1,6 @@ +import { sha256 } from 'js-sha256'; + +/** Generate a SHA-256 hex digest for legacy heuristic content processing. */ +export function hashContent(content: string): string { + return sha256(content); +} diff --git a/src/core/common/index.ts b/src/core/common/index.ts index 3e84c6b..e2950d5 100644 --- a/src/core/common/index.ts +++ b/src/core/common/index.ts @@ -5,4 +5,5 @@ export * from './types'; export * from './utils'; export * from './constants'; -export * from './signature-fields'; \ No newline at end of file +export * from './signature-fields'; +export * from './metadata-claims'; diff --git a/src/core/common/metadata-claims.test.ts b/src/core/common/metadata-claims.test.ts new file mode 100644 index 0000000..3548ffe --- /dev/null +++ b/src/core/common/metadata-claims.test.ts @@ -0,0 +1,15 @@ +import { metadataToClaims } from './metadata-claims'; + +describe('metadataToClaims', () => { + it('keeps namespaces distinct and drops empty form values', () => { + expect(metadataToClaims({ + dublinCore: { title: 'DC title', empty: ' ' }, + openGraph: { title: 'OG title' }, + schemaOrg: { datePublished: '2026-08-28' }, + })).toEqual({ + 'dc:title': 'DC title', + 'og:title': 'OG title', + 'schema:datePublished': '2026-08-28', + }); + }); +}); diff --git a/src/core/common/metadata-claims.ts b/src/core/common/metadata-claims.ts new file mode 100644 index 0000000..25155bf --- /dev/null +++ b/src/core/common/metadata-claims.ts @@ -0,0 +1,22 @@ +import type { ClaimMap } from './types'; + +export interface SigningMetadata { + dublinCore: Record; + openGraph: Record; + schemaOrg: Record; +} + +/** Preserve metadata namespaces when flattening popup fields into claims. */ +export function metadataToClaims(metadata: SigningMetadata): ClaimMap { + const claims: ClaimMap = {}; + for (const [namespace, values] of [ + ['dc', metadata.dublinCore], + ['og', metadata.openGraph], + ['schema', metadata.schemaOrg], + ] as const) { + for (const [name, value] of Object.entries(values)) { + if (value.trim()) claims[`${namespace}:${name}`] = value; + } + } + return claims; +} diff --git a/src/core/common/trust-directory.test.ts b/src/core/common/trust-directory.test.ts new file mode 100644 index 0000000..1678372 --- /dev/null +++ b/src/core/common/trust-directory.test.ts @@ -0,0 +1,36 @@ +import { MemoryStorage } from '../storage'; +import { + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, + type DirectorySubscription, +} from './types'; + +describe('trust directory subscriptions', () => { + it('persists weighted enabled state through extension storage', async () => { + const storage = new MemoryStorage(); + const configured: DirectorySubscription[] = [ + { url: 'https://directory.example', weight: 0.75, enabled: true }, + { url: 'https://paused.example', weight: 0.25, enabled: false }, + ]; + + await storage.set('settings', { trustDirectorySubscriptions: configured }); + const settings = await storage.get<{ trustDirectorySubscriptions: DirectorySubscription[] }>('settings'); + + expect(getTrustDirectorySubscriptions(settings!)).toEqual(configured); + }); + + it('migrates legacy URL-only settings with an enabled neutral subscription', () => { + expect(getTrustDirectorySubscriptions({ trustDirectoryUrls: [' https://legacy.example/ '] })).toEqual([ + { url: 'https://legacy.example/', weight: 1, enabled: true }, + ]); + }); + + it('rejects insecure, credential-bearing, and out-of-range subscriptions', () => { + expect(validateTrustDirectorySubscription({ url: 'http://directory.example', weight: 1 })).toMatch(/HTTPS/); + expect(validateTrustDirectorySubscription({ url: 'https://user:pass@directory.example', weight: 1 })).toMatch(/credentials/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example?tenant=one', weight: 1 })).toMatch(/query/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example#tenant', weight: 1 })).toMatch(/fragment/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example', weight: 2 })).toMatch(/between 0 and 1/); + expect(validateTrustDirectorySubscription({ url: 'https://directory.example', weight: 0.5 })).toBeNull(); + }); +}); diff --git a/src/core/common/types.ts b/src/core/common/types.ts index 52b45ce..d53fc0a 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -106,6 +106,9 @@ export interface Claim { /** * Represents a content signature in the Content Signing API */ +export type ClaimValue = string | number | boolean; +export type ClaimMap = Record; + export interface ContentSignature { /** Hash of the normalized content */ contentHash: string; @@ -116,7 +119,7 @@ export interface ContentSignature { /** Cryptographic signature binding content, hash, domain, and author key */ signature: string; /** Claims about the content */ - claims: Record; + claims: ClaimMap; /** Creation timestamp */ createdAt?: string; } @@ -206,6 +209,16 @@ export type TrustStatus = 'trusted' | 'untrusted' | 'unknown'; */ export type VerificationInputState = 'source-only' | 'stale' | 'rendered-match'; +/** A user-selected trust directory and its policy weight. */ +export interface DirectorySubscription { + /** HTTPS directory base URL. */ + url: string; + /** Contribution multiplier. Values outside 0..1 are rejected. */ + weight: number; + /** Keep the subscription visible while preventing network requests. */ + enabled: boolean; +} + /** * Represents the result of a content verification */ @@ -222,6 +235,12 @@ export interface VerificationResult { verifiedAt: number; /** The trust status of the verification */ trustStatus?: TrustStatus; + /** Cryptographic validity remains separate from this policy result. */ + cryptoValid?: boolean; + /** Local trust-policy result, if policy evaluation was requested. */ + trustScore?: number; + trustIndicator?: 'trusted' | 'unknown' | 'untrusted' | 'green' | 'yellow' | 'red'; + trustInputs?: Array<{ source: string; contribution: number; rationale: string }>; /** The domain of the content */ domain?: string; /** Settings for displaying verification UI */ @@ -262,6 +281,8 @@ export interface Settings { * directory that resolves a keyid wins. */ trustDirectoryUrls?: string[]; + /** Weighted, user-controlled reputation subscriptions. */ + trustDirectorySubscriptions?: DirectorySubscription[]; /** * User's personal trust list, expressed as keyid strings (typically * did:web identifiers or direct public-key URLs). Empty by default; @@ -296,7 +317,9 @@ export interface Settings { */ export function getTrustDirectoryUrls(settings: Pick): string[] { if (settings.trustDirectoryUrls && settings.trustDirectoryUrls.length > 0) { - return settings.trustDirectoryUrls.filter((u) => u && u.trim().length > 0); + return settings.trustDirectoryUrls + .filter((u) => u && u.trim().length > 0) + .map((u) => u.trim()); } if (settings.trustDirectoryUrl && settings.trustDirectoryUrl.trim().length > 0) { return [settings.trustDirectoryUrl.trim()]; @@ -304,6 +327,52 @@ export function getTrustDirectoryUrls(settings: Pick, +): DirectorySubscription[] { + if (Array.isArray(settings.trustDirectorySubscriptions)) { + return settings.trustDirectorySubscriptions + .map((subscription) => { + if (!subscription || typeof subscription.url !== 'string') return null; + const url = subscription.url.trim(); + const weight = Number(subscription.weight); + if (!url || !Number.isFinite(weight) || weight < 0 || weight > 1) return null; + return { url, weight, enabled: subscription.enabled !== false }; + }) + .filter((subscription): subscription is DirectorySubscription => subscription !== null); + } + return getTrustDirectoryUrls(settings).map((url) => ({ url, weight: 1, enabled: true })); +} + +/** Validate a subscription before it is persisted or used for network I/O. */ +export function validateTrustDirectorySubscription(subscription: Partial): string | null { + if (typeof subscription.url !== 'string' || !subscription.url.trim()) return 'Directory URL is required'; + let parsed: URL; + try { + parsed = new URL(subscription.url.trim()); + } catch { + return 'Directory URL must be an absolute HTTPS URL'; + } + if ( + parsed.protocol !== 'https:' || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + !parsed.hostname + ) { + return 'Directory URL must use HTTPS, cannot contain credentials, query, or fragment'; + } + const weight = Number(subscription.weight); + if (!Number.isFinite(weight) || weight < 0 || weight > 1) return 'Directory weight must be between 0 and 1'; + return null; +} + /** * Represents an error in the extension */ @@ -313,7 +382,7 @@ export interface ExtensionError { /** The error message */ message: string; /** The error details */ - details?: any; + details?: unknown; } /** diff --git a/src/core/common/utils.ts b/src/core/common/utils.ts index b4bdc53..441e258 100644 --- a/src/core/common/utils.ts +++ b/src/core/common/utils.ts @@ -1,18 +1,8 @@ /** * Utility functions for the Content Signing extension */ -import { sha256 } from 'js-sha256'; import { ExtensionError } from './types'; -/** - * Generates a hash of the provided content - * @param content The content to hash - * @returns The SHA-256 hash of the content - */ -export function hashContent(content: string): string { - return sha256(content); -} - /** * Formats a timestamp as a human-readable date string * @param timestamp The timestamp to format @@ -29,7 +19,7 @@ export function formatDate(timestamp: number): string { * @param details Additional error details * @returns An ExtensionError object */ -export function createError(code: string, message: string, details?: any): ExtensionError { +export function createError(code: string, message: string, details?: unknown): ExtensionError { return { code, message, @@ -46,7 +36,7 @@ export function isValidUrl(url: string): boolean { try { new URL(url); return true; - } catch (e) { + } catch { return false; } } @@ -70,13 +60,13 @@ export function truncateString(str: string, maxLength: number): string { * @param wait The time to wait in milliseconds * @returns A debounced function */ -export function debounce any>( - func: T, +export function debounce( + func: (...args: Args) => unknown, wait: number -): (...args: Parameters) => void { +): (...args: Args) => void { let timeout: ReturnType | null = null; - return function(...args: Parameters): void { + return function(...args: Args): void { const later = () => { timeout = null; func(...args); @@ -101,4 +91,4 @@ export function generateId(length: number = 16): string { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; -} \ No newline at end of file +} diff --git a/src/core/content/content-processor.ts b/src/core/content/content-processor.ts index 8c20138..fe8c8c2 100644 --- a/src/core/content/content-processor.ts +++ b/src/core/content/content-processor.ts @@ -1,7 +1,7 @@ /** * Content processing utilities for DOM normalization and hashing */ -import { hashContent } from '../common/utils'; +import { hashContent } from '../common/hash'; import { normalizeText } from '@htmltrust/canonicalization'; import * as simhash from 'simhash-js'; @@ -431,4 +431,4 @@ export class ContentProcessor { } } -} \ No newline at end of file +} diff --git a/src/core/content/signing-extraction.test.ts b/src/core/content/signing-extraction.test.ts new file mode 100644 index 0000000..bad521f --- /dev/null +++ b/src/core/content/signing-extraction.test.ts @@ -0,0 +1,40 @@ +import { webcrypto } from 'node:crypto'; +import { TextEncoder as NodeTextEncoder } from 'node:util'; + +jest.mock('@htmltrust/canonicalization', () => ({ + normalizeText: jest.fn((value: string) => value.trim()), +})); + +import { normalizeText } from '@htmltrust/canonicalization'; +import { extractSigningContent, hashSigningContent } from './signing-extraction'; + +describe('page signing extraction', () => { + it('extracts article text without active, image, link, or comment markup', () => { + document.title = 'Signing example'; + document.body.innerHTML = ` + + `; + + expect(extractSigningContent()).toEqual({ + title: 'Signing example', + content: expect.stringContaining('Hello linked world'), + }); + expect(extractSigningContent().content).not.toContain('outside'); + expect(extractSigningContent().content).not.toContain('ignored'); + }); + + it('normalizes text and returns the legacy lowercase SHA-256 form', async () => { + const originalTextEncoder = globalThis.TextEncoder; + Object.defineProperty(globalThis, 'TextEncoder', { value: NodeTextEncoder, configurable: true }); + try { + await expect(hashSigningContent(' abc ', webcrypto.subtle)).resolves.toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + expect(normalizeText).toHaveBeenCalledWith(' abc '); + } finally { + Object.defineProperty(globalThis, 'TextEncoder', { value: originalTextEncoder, configurable: true }); + } + }); +}); diff --git a/src/core/content/signing-extraction.ts b/src/core/content/signing-extraction.ts new file mode 100644 index 0000000..179bb99 --- /dev/null +++ b/src/core/content/signing-extraction.ts @@ -0,0 +1,41 @@ +import { normalizeText } from '@htmltrust/canonicalization'; + +export interface SigningExtraction { + title: string; + content: string; +} + +/** + * Extract the legacy page-signing input in the page context. + * + * This function is passed directly to chrome.scripting.executeScript, so its + * body must remain self-contained and use only browser globals. + */ +export function extractSigningContent(): SigningExtraction { + const selectors = ['article', 'main', '.content', '#content', '.article', '#article', '.post', '#post']; + const root = selectors + .map((selector) => document.querySelector(selector)) + .find((candidate): candidate is Element => candidate !== null) ?? document.body; + const clone = root.cloneNode(true) as Element; + clone.querySelectorAll('img, script, style, noscript').forEach((element) => element.remove()); + clone.querySelectorAll('a').forEach((link) => { + link.replaceWith(document.createTextNode(link.textContent ?? '')); + }); + const walker = document.createTreeWalker(clone, NodeFilter.SHOW_COMMENT); + const comments: Comment[] = []; + for (let comment = walker.nextNode(); comment; comment = walker.nextNode()) { + comments.push(comment as Comment); + } + comments.forEach((comment) => comment.remove()); + return { title: document.title, content: clone.textContent ?? '' }; +} + +/** Match the legacy ContentProcessor's normalized lowercase-hex SHA-256. */ +export async function hashSigningContent( + content: string, + subtle: Pick = crypto.subtle, +): Promise { + const normalized = normalizeText(content); + const digest = await subtle.digest('SHA-256', new TextEncoder().encode(normalized)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/core/storage/memory-storage.ts b/src/core/storage/memory-storage.ts index efb6ebb..c7d33a0 100644 --- a/src/core/storage/memory-storage.ts +++ b/src/core/storage/memory-storage.ts @@ -8,7 +8,7 @@ import { BaseStorage } from './storage-interface'; * This is primarily used for testing and development */ export class MemoryStorage extends BaseStorage { - private storage: Map = new Map(); + private storage: Map = new Map(); /** * Get a value from storage @@ -56,4 +56,4 @@ export class MemoryStorage extends BaseStorage { async getAllKeys(): Promise { return Array.from(this.storage.keys()); } -} \ No newline at end of file +} diff --git a/src/platforms/chromium/adapter.test.ts b/src/platforms/chromium/adapter.test.ts new file mode 100644 index 0000000..1216d93 --- /dev/null +++ b/src/platforms/chromium/adapter.test.ts @@ -0,0 +1,92 @@ +import { ChromiumAdapter } from './adapter'; +import { MessageContext } from '../common'; + +describe('ChromiumAdapter message routing', () => { + const originalRuntimeId = chrome.runtime.id; + + beforeEach(() => { + Object.defineProperty(chrome.runtime, 'id', { + configurable: true, + value: 'mock-extension-id', + }); + (chrome.runtime.onMessage.addListener as jest.Mock).mockClear(); + }); + + afterAll(() => { + Object.defineProperty(chrome.runtime, 'id', { + configurable: true, + value: originalRuntimeId, + }); + }); + + it('routes a tab message to content even when it spoofs popup context', async () => { + const content = jest.fn().mockResolvedValue('content'); + const popup = jest.fn().mockResolvedValue('popup'); + const adapter = new ChromiumAdapter(); + adapter.registerMessageListeners({ + [MessageContext.CONTENT]: content, + [MessageContext.POPUP]: popup, + }); + + const listener = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]; + listener( + { type: 'SIGN_OUT', context: MessageContext.POPUP }, + { id: 'mock-extension-id', url: 'https://example.test', tab: { id: 1 } }, + jest.fn(), + ); + await Promise.resolve(); + + expect(content).toHaveBeenCalled(); + expect(popup).not.toHaveBeenCalled(); + }); + + it('preserves popup and options routes for same-extension pages', async () => { + const popup = jest.fn().mockResolvedValue('popup'); + const options = jest.fn().mockResolvedValue('options'); + const content = jest.fn().mockResolvedValue('content'); + const adapter = new ChromiumAdapter(); + adapter.registerMessageListeners({ + [MessageContext.POPUP]: popup, + [MessageContext.OPTIONS]: options, + [MessageContext.CONTENT]: content, + }); + const listener = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]; + + listener( + { type: 'SIGN_OUT', context: MessageContext.POPUP }, + { id: 'mock-extension-id', url: 'chrome-extension://mock-extension-id/popup.html' }, + jest.fn(), + ); + listener( + { type: 'UPDATE_SETTINGS', context: MessageContext.OPTIONS }, + { id: 'mock-extension-id', url: 'chrome-extension://mock-extension-id/options.html', tab: { id: 1 } }, + jest.fn(), + ); + await Promise.resolve(); + + expect(popup).toHaveBeenCalled(); + expect(options).toHaveBeenCalled(); + expect(content).not.toHaveBeenCalled(); + }); + + it('does not trust a context claim from another extension page', async () => { + const background = jest.fn().mockResolvedValue('background'); + const popup = jest.fn().mockResolvedValue('popup'); + const adapter = new ChromiumAdapter(); + adapter.registerMessageListeners({ + [MessageContext.BACKGROUND]: background, + [MessageContext.POPUP]: popup, + }); + const listener = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]; + + listener( + { type: 'SIGN_OUT', context: MessageContext.POPUP }, + { id: 'mock-extension-id', url: 'chrome-extension://mock-extension-id/background.html' }, + jest.fn(), + ); + await Promise.resolve(); + + expect(background).toHaveBeenCalled(); + expect(popup).not.toHaveBeenCalled(); + }); +}); diff --git a/src/platforms/chromium/adapter.ts b/src/platforms/chromium/adapter.ts index debbe3e..94ef7ec 100644 --- a/src/platforms/chromium/adapter.ts +++ b/src/platforms/chromium/adapter.ts @@ -7,6 +7,8 @@ import { MessageHandlers, Tab, NotificationOptions, + ExtensionMessage, + isExtensionMessage, } from '../common/platform-adapter'; import { StorageInterface, BaseStorage } from '../../core/storage'; @@ -79,6 +81,29 @@ class ChromiumStorage extends BaseStorage { } } +function trustedExtensionPageContext(sender: chrome.runtime.MessageSender): MessageContext | undefined { + if (!sender.id || sender.id !== chrome.runtime.id || typeof sender.url !== 'string') return undefined; + try { + const url = new URL(sender.url); + if (url.protocol !== 'chrome-extension:' || url.hostname !== chrome.runtime.id) return undefined; + if (url.pathname === '/popup.html') return MessageContext.POPUP; + if (url.pathname === '/options.html') return MessageContext.OPTIONS; + return undefined; + } catch { + return undefined; + } +} + +function resolveMessageContext( + _message: ExtensionMessage, + sender: chrome.runtime.MessageSender, +): MessageContext { + const extensionPageContext = trustedExtensionPageContext(sender); + if (extensionPageContext) return extensionPageContext; + if (sender.tab) return MessageContext.CONTENT; + return MessageContext.BACKGROUND; +} + /** * Chromium platform adapter implementation */ @@ -122,20 +147,14 @@ export class ChromiumAdapter implements PlatformAdapter { */ registerMessageListeners(handlers: MessageHandlers): void { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - // Determine which handler should process this message. The popup and - // options pages tag their outgoing messages with an explicit context - // (popup / options); we honor that first. We only fall back to the - // `sender.tab` heuristic for content scripts that didn't bother - // tagging — without this ordering, the options page (which runs in a - // real tab via open_in_tab: true) would be misrouted as CONTENT. - let context: MessageContext; - if (message.context) { - context = message.context; - } else if (sender.tab) { - context = MessageContext.CONTENT; - } else { - context = MessageContext.BACKGROUND; + if (!isExtensionMessage(message)) { + sendResponse({ error: 'Invalid extension message' }); + return false; } + // Route privileged extension pages from browser-provided sender data. + // The message's context field is only transport metadata and never + // grants access to popup or options handlers. + const context = resolveMessageContext(message, sender); // Get the handler for the context const handler = handlers[context]; @@ -147,8 +166,8 @@ export class ChromiumAdapter implements PlatformAdapter { // Handle the message handler(message) .then(sendResponse) - .catch((error) => { - sendResponse({ error: error.message }); + .catch((error: unknown) => { + sendResponse({ error: error instanceof Error ? error.message : 'Message handler failed' }); }); // Return true to indicate that the response will be sent asynchronously @@ -162,7 +181,7 @@ export class ChromiumAdapter implements PlatformAdapter { * @param message The message to send * @returns A promise that resolves with the response */ - async sendMessage(context: MessageContext, message: any): Promise { + async sendMessage(context: MessageContext, message: ExtensionMessage): Promise { return new Promise((resolve, reject) => { // Add the context to the message const messageWithContext = { @@ -174,8 +193,12 @@ export class ChromiumAdapter implements PlatformAdapter { chrome.runtime.sendMessage(messageWithContext, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); - } else if (response && response.error) { - reject(new Error(response.error)); + } else if ( + response && + typeof response === 'object' && + typeof (response as Record).error === 'string' + ) { + reject(new Error((response as Record).error)); } else { resolve(response); } @@ -284,7 +307,7 @@ export class ChromiumAdapter implements PlatformAdapter { * @param script The script to execute * @returns A promise that resolves with the result of the script */ - async executeScript(tabId: string, script: string): Promise { + async executeScript(tabId: string, script: string): Promise { return new Promise((resolve, reject) => { chrome.scripting.executeScript({ target: { tabId: parseInt(tabId, 10) }, @@ -320,8 +343,8 @@ export class ChromiumAdapter implements PlatformAdapter { return new Promise((resolve, reject) => { chrome.scripting.executeScript({ target: { tabId: parseInt(tabId, 10) }, - func: func as (...injected: any[]) => any, - args: args as any[], + func, + args, }, (results) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); @@ -367,7 +390,7 @@ export class ChromiumAdapter implements PlatformAdapter { const notificationId = `cs-${Date.now()}`; // Create a basic notification with required fields - const notificationOptions = { + const notificationOptions: chrome.notifications.NotificationOptions = { type: options.type, title: options.title, message: options.message, @@ -377,7 +400,7 @@ export class ChromiumAdapter implements PlatformAdapter { // Create the notification chrome.notifications.create( notificationId, - notificationOptions as any, + notificationOptions, (createdId) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); @@ -435,7 +458,7 @@ export class ChromiumAdapter implements PlatformAdapter { * Get the manifest * @returns The manifest */ - getManifest(): any { + getManifest(): chrome.runtime.Manifest { return chrome.runtime.getManifest(); } @@ -454,4 +477,4 @@ export class ChromiumAdapter implements PlatformAdapter { windowId: chromeTab.windowId?.toString() || '', }; } -} \ No newline at end of file +} diff --git a/src/platforms/chromium/manifest.json b/src/platforms/chromium/manifest.json index 5ce9684..57ad278 100644 --- a/src/platforms/chromium/manifest.json +++ b/src/platforms/chromium/manifest.json @@ -26,7 +26,7 @@ "css": ["assets/content.css"] } ], - "permissions": ["storage", "tabs", "notifications", "alarms", "scripting"], + "permissions": ["storage", "tabs", "notifications", "scripting"], "host_permissions": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"], "options_ui": { "page": "options.html", diff --git a/src/platforms/common/platform-adapter.ts b/src/platforms/common/platform-adapter.ts index 722732d..b2319cb 100644 --- a/src/platforms/common/platform-adapter.ts +++ b/src/platforms/common/platform-adapter.ts @@ -37,7 +37,7 @@ export interface PlatformAdapter { * @param message The message to send * @returns A promise that resolves with the response */ - sendMessage(context: MessageContext, message: any): Promise; + sendMessage(context: MessageContext, message: ExtensionMessage): Promise; /** * Get the current tab @@ -84,7 +84,7 @@ export interface PlatformAdapter { * @param script The script to execute * @returns A promise that resolves with the result of the script */ - executeScript(tabId: string, script: string): Promise; + executeScript(tabId: string, script: string): Promise; /** * Execute a function in a tab, passing runtime data as arguments. @@ -144,7 +144,12 @@ export interface PlatformAdapter { * Get the manifest * @returns The manifest */ - getManifest(): any; + getManifest(): ExtensionManifest; +} + +/** Browser-neutral manifest fields consumed by shared extension UI. */ +export interface ExtensionManifest { + version: string; } /** @@ -161,18 +166,30 @@ export enum MessageContext { OPTIONS = 'options', } +/** Runtime message with a discriminating type and context-specific fields. */ +export interface ExtensionMessage { + type: string; + [key: string]: unknown; +} + +/** Validate the message envelope before dispatching untrusted runtime data. */ +export function isExtensionMessage(value: unknown): value is ExtensionMessage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return typeof (value as Record).type === 'string'; +} + /** * Message handlers */ export interface MessageHandlers { /** Handler for messages from the background script */ - [MessageContext.BACKGROUND]?: (message: any) => Promise; + [MessageContext.BACKGROUND]?: (message: ExtensionMessage) => Promise; /** Handler for messages from content scripts */ - [MessageContext.CONTENT]?: (message: any) => Promise; + [MessageContext.CONTENT]?: (message: ExtensionMessage) => Promise; /** Handler for messages from the popup */ - [MessageContext.POPUP]?: (message: any) => Promise; + [MessageContext.POPUP]?: (message: ExtensionMessage) => Promise; /** Handler for messages from the options page */ - [MessageContext.OPTIONS]?: (message: any) => Promise; + [MessageContext.OPTIONS]?: (message: ExtensionMessage) => Promise; } /** @@ -213,4 +230,4 @@ export interface NotificationOptions { items?: { title: string; message: string }[]; /** The notification progress (for progress type) */ progress?: number; -} \ No newline at end of file +} diff --git a/src/tooling/bundle-budget.test.ts b/src/tooling/bundle-budget.test.ts new file mode 100644 index 0000000..b489899 --- /dev/null +++ b/src/tooling/bundle-budget.test.ts @@ -0,0 +1,33 @@ +type BundleBudgetModule = { + BUNDLE_BUDGETS: Readonly>; + validateBundleAssets(assets: Record): string[]; +}; + +const { BUNDLE_BUDGETS, validateBundleAssets } = require('../../scripts/check-bundle-size.js') as BundleBudgetModule; + +const withinBudget = (): Record => Object.fromEntries( + Object.entries(BUNDLE_BUDGETS).map(([name, budget]) => [name, budget]), +); + +describe('bundle budget gate', () => { + it('accepts the four expected entry bundles at their exact budgets', () => { + expect(validateBundleAssets(withinBudget())).toEqual([]); + }); + + it('rejects a missing or oversized entry bundle', () => { + const assets = withinBudget(); + delete assets['content.js']; + assets['background.js'] = BUNDLE_BUDGETS['background.js'] + 1; + + expect(validateBundleAssets(assets)).toEqual(expect.arrayContaining([ + 'missing required bundle: content.js', + expect.stringContaining('background.js is'), + ])); + }); + + it('rejects an unlisted JavaScript chunk', () => { + expect(validateBundleAssets({ ...withinBudget(), 'vendors.js': 1 })).toContain( + 'unlisted JavaScript bundle: vendors.js', + ); + }); +}); diff --git a/src/ui/components/ProfileManager.tsx b/src/ui/components/ProfileManager.tsx index ed27037..c092288 100644 --- a/src/ui/components/ProfileManager.tsx +++ b/src/ui/components/ProfileManager.tsx @@ -1,7 +1,7 @@ /** * Profile management component */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Profile } from '../../core/common/types'; /** @@ -241,4 +241,4 @@ export const ProfileManager: React.FC = ({ )} ); -}; \ No newline at end of file +}; diff --git a/src/ui/options/index.tsx b/src/ui/options/index.tsx index f54be8e..02e4df3 100644 --- a/src/ui/options/index.tsx +++ b/src/ui/options/index.tsx @@ -3,7 +3,13 @@ */ import React, { useState, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; -import { Settings, Profile, getTrustDirectoryUrls } from '../../core/common'; +import { + Settings, + Profile, + DirectorySubscription, + getTrustDirectorySubscriptions, + validateTrustDirectorySubscription, +} 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'; @@ -93,7 +99,7 @@ const Options: React.FC = ({ adapter }) => { }, [adapter]); // Handle setting change - const handleSettingChange = (key: keyof Settings, value: any) => { + const handleSettingChange = (key: keyof Settings, value: Settings[keyof Settings]) => { setState(prevState => ({ ...prevState, settings: { @@ -104,19 +110,46 @@ const Options: React.FC = ({ adapter }) => { })); }; + // Keep raw rows in the form so an invalid URL stays visible until the user + // fixes it. The background validates the same rows before persistence. + const directorySubscriptions: DirectorySubscription[] = Array.isArray(state.settings.trustDirectorySubscriptions) + ? state.settings.trustDirectorySubscriptions + : getTrustDirectorySubscriptions(state.settings); + + const updateDirectorySubscriptions = (subscriptions: DirectorySubscription[]) => { + setState(prevState => ({ + ...prevState, + settings: { + ...prevState.settings, + trustDirectorySubscriptions: subscriptions, + trustDirectoryUrls: subscriptions.map(subscription => subscription.url), + trustDirectoryUrl: '', + }, + isSaved: false, + })); + }; + // Handle save settings const handleSaveSettings = async () => { try { + const invalid = directorySubscriptions + .map(validateTrustDirectorySubscription) + .find((message): message is string => message !== null); + if (invalid) { + setState(prevState => ({ ...prevState, error: invalid })); + return; + } setState(prevState => ({ ...prevState, isLoading: true })); - // Save the settings to storage - const storage = adapter.getStorage(); - await storage.set(STORAGE_KEYS.SETTINGS, state.settings); - // Notify the background script that settings have changed await adapter.sendMessage(MessageContext.OPTIONS, { type: 'UPDATE_SETTINGS', - settings: state.settings, + settings: { + ...state.settings, + trustDirectorySubscriptions: directorySubscriptions, + trustDirectoryUrls: directorySubscriptions.map(subscription => subscription.url), + trustDirectoryUrl: '', + }, }); setState(prevState => ({ @@ -468,38 +501,70 @@ const Options: React.FC = ({ adapter }) => {

Trust Directory Settings

-
- -