From feb70b21b34202e0c0c9e65a2c9e347061a10825 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 15:00:17 -0500 Subject: [PATCH 1/8] feat(e2e): exercise federated directories and local signing --- .github/workflows/ci.yml | 5 +- .gitignore | 3 + Dockerfile.nginx | 2 +- Dockerfile.trust-server | 2 +- Dockerfile.trust-server.dockerignore | 10 + Dockerfile.wordpress | 29 ++- Dockerfile.wordpress.dockerignore | 9 + README.md | 144 ++++++++---- analysis/analyze.py | 25 +- analysis/test_analyze.py | 59 +++++ docker-compose.yml | 135 ++++++++--- package-lock.json | 19 +- package.json | 15 +- scenario-small.yaml | 27 ++- scenario.yaml | 27 ++- scripts/run-e2e.sh | 42 +++- scripts/wordpress-entrypoint.sh | 12 + scripts/wordpress-local-signing-test.ts | 158 +++++++++++++ src/lib/local-signing.ts | 32 +++ src/lib/nginx-config.ts | 20 +- src/lib/playwright-session.ts | 291 +++++++++++++----------- src/lib/scenario.ts | 137 ++++++++++- src/lib/trust-api.ts | 44 +++- src/orchestrator.ts | 5 +- src/phases/consumers.ts | 14 +- src/phases/infrastructure.ts | 143 ++++++++++-- src/phases/post-report.ts | 6 +- src/phases/publish.ts | 67 +++--- src/phases/researcher.ts | 83 +++++-- src/phases/validate.ts | 49 +++- src/prepare-nginx.ts | 8 +- src/run-phases-3-5.ts | 22 +- src/smoke-test.ts | 116 +++++++--- src/types.ts | 73 +++--- src/wordpress-local-signing-fixture.ts | 110 +++++++++ tests/lib/local-signing.test.ts | 40 ++++ tests/lib/nginx-config.test.ts | 42 +++- tests/lib/playwright-session.test.ts | 124 +++++++++- tests/lib/scenario.test.ts | 75 ++++-- tests/lib/trust-api.test.ts | 24 +- 40 files changed, 1769 insertions(+), 479 deletions(-) create mode 100644 Dockerfile.trust-server.dockerignore create mode 100644 Dockerfile.wordpress.dockerignore create mode 100644 analysis/test_analyze.py create mode 100644 scripts/wordpress-entrypoint.sh create mode 100644 scripts/wordpress-local-signing-test.ts create mode 100644 src/lib/local-signing.ts create mode 100644 src/wordpress-local-signing-fixture.ts create mode 100644 tests/lib/local-signing.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05d59c3..7680163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,14 +22,14 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: HTMLTrust/htmltrust-canonicalization - ref: 5e51040dcaaf50935e245702bdefbc18a1d542ce + ref: 760593d4a02e9fffa56dc4d002eb52ab2ade1b49 path: htmltrust-canonicalization persist-credentials: false - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: HTMLTrust/htmltrust-browser-client - ref: f21504e170c6b29e91eda3bb491bf4580e5f5a86 + ref: 70c5ddb6ed23c06c0b1c46d5284618fb99a28aac path: htmltrust-browser-client persist-credentials: false @@ -55,6 +55,7 @@ jobs: working-directory: htmltrust-e2e run: | npm test + npm run test:analysis npm run build - name: Audit dependencies diff --git a/.gitignore b/.gitignore index 32eb56d..0603883 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules/ dist/ +.env +.env.* +!.env.example results/ output/ hugo-sites/ diff --git a/Dockerfile.nginx b/Dockerfile.nginx index 05eaadc..cd64d99 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -1,4 +1,4 @@ -FROM nginx:1.29.1-alpine +FROM nginx:1.29.1-alpine@sha256:42a516af16b852e33b7682d5ef8acbd5d13fe08fecadc7ed98605ba5e3b26ab8 # Test-only wildcard certificate for the isolated htmltrust Docker network. # Browsers in the harness explicitly allow this self-signed certificate. diff --git a/Dockerfile.trust-server b/Dockerfile.trust-server index df8c808..dd656d9 100644 --- a/Dockerfile.trust-server +++ b/Dockerfile.trust-server @@ -1,4 +1,4 @@ -FROM node:22-slim +FROM node:22-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 WORKDIR /app # Copy the local canonicalization package first diff --git a/Dockerfile.trust-server.dockerignore b/Dockerfile.trust-server.dockerignore new file mode 100644 index 0000000..6e8fad2 --- /dev/null +++ b/Dockerfile.trust-server.dockerignore @@ -0,0 +1,10 @@ +** +!htmltrust-canonicalization/ +!htmltrust-canonicalization/javascript/ +!htmltrust-canonicalization/javascript/** +htmltrust-canonicalization/javascript/node_modules/ +!htmltrust-server-reference/ +!htmltrust-server-reference/package.json +!htmltrust-server-reference/package-lock.json +!htmltrust-server-reference/src/ +!htmltrust-server-reference/src/** diff --git a/Dockerfile.wordpress b/Dockerfile.wordpress index 58d4e85..ce4d8e1 100644 --- a/Dockerfile.wordpress +++ b/Dockerfile.wordpress @@ -1,4 +1,28 @@ -FROM wordpress:6-php8.2-apache +FROM composer:2.8.11@sha256:68e926a477000f12e8645e82a020b84904d49071c895c4951551fe80eed5d103 AS composer + +FROM wordpress:6-php8.5-apache@sha256:d0503978e17bd0724f9b06b1ef6e5f7af80e83c98fa6f3eaea92780927d702cf + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +RUN apt-get update \ + && apt-get install --no-install-recommends --yes git libicu-dev libonig-dev unzip \ + && docker-php-ext-install intl mbstring \ + && rm -rf /var/lib/apt/lists/* + +# Build a self-contained plugin from the sibling checkout. The custom +# entrypoint refreshes this exact directory in the test WordPress volume on +# every container start, including when a previous run left the volume up. +COPY htmltrust-cms-reference/wordpress /opt/htmltrust-content-signing +RUN composer install \ + --working-dir=/opt/htmltrust-content-signing \ + --no-dev \ + --prefer-dist \ + --no-interaction \ + --no-progress \ + --optimize-autoloader + +COPY htmltrust-e2e/scripts/wordpress-entrypoint.sh /usr/local/bin/htmltrust-wordpress-entrypoint +RUN chmod 0755 /usr/local/bin/htmltrust-wordpress-entrypoint # Install wp-cli from a pinned release, verified against the sha512 published # with that release. The previous source (the wp-cli/builds gh-pages branch) @@ -43,3 +67,6 @@ RUN printf '%s\n' \ ' remove_filter("the_excerpt", "wpautop");' \ '}, 99);' \ > /usr/src/wordpress/wp-content/mu-plugins/disable-content-filters.php + +ENTRYPOINT ["/usr/local/bin/htmltrust-wordpress-entrypoint"] +CMD ["apache2-foreground"] diff --git a/Dockerfile.wordpress.dockerignore b/Dockerfile.wordpress.dockerignore new file mode 100644 index 0000000..278f6cc --- /dev/null +++ b/Dockerfile.wordpress.dockerignore @@ -0,0 +1,9 @@ +** +!htmltrust-cms-reference/ +!htmltrust-cms-reference/wordpress/ +!htmltrust-cms-reference/wordpress/** +htmltrust-cms-reference/wordpress/vendor/ +htmltrust-cms-reference/wordpress/.phpunit.result.cache +!htmltrust-e2e/ +!htmltrust-e2e/scripts/ +!htmltrust-e2e/scripts/wordpress-entrypoint.sh diff --git a/README.md b/README.md index 6bc9901..48956f4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - For: contributors and continuous integration maintainers - Reading time: 8 minutes -This harness publishes v1 signed content through WordPress and Hugo, serves it over test HTTPS, verifies the original response source in Chromium, applies trust policy, and records research output. It uses local packages from sibling checkouts. +This harness publishes v1 signed content through WordPress and Hugo, serves it over test HTTPS, verifies the original response source in Chromium, and records research output. Simulated authors generate Ed25519 keys locally. The alpha directory receives public keys only. Browser policy combines weighted opinions from independent alpha and beta directory databases. ## Choose a path @@ -18,6 +18,8 @@ This harness publishes v1 signed content through WordPress and Hugo, serves it o - Run `npm run test:browser` for the same lifecycle checks in the production DOM walker. This uses the checked-in Playwright Docker image and does not start the integration stack; `npm test` remains browser-download-free. +- Run `npm run test:wordpress-local-signing` after the smoke setup to exercise + the CMS plugin's admin UI, local key document, and emitted signature. - Run `npm run e2e:small` for the complete three-author simulation. - Use the split commands below when you need to inspect the stack between publication and browser verification. @@ -35,46 +37,73 @@ htmltrust/ └── htmltrust-server-reference/ ``` -Create that layout from an empty parent directory: +Create or refresh that layout from its parent directory. The loop is safe to +rerun: existing checkouts are fetched, and missing checkouts are cloned. ```bash -mkdir htmltrust && cd htmltrust -git clone https://github.com/HTMLTrust/htmltrust-canonicalization.git -git clone https://github.com/HTMLTrust/htmltrust-browser-client.git -git clone https://github.com/HTMLTrust/htmltrust-browser-reference.git -git clone https://github.com/HTMLTrust/htmltrust-cms-reference.git -git clone https://github.com/HTMLTrust/htmltrust-e2e.git -git clone https://github.com/HTMLTrust/htmltrust-server-reference.git +mkdir -p htmltrust +cd htmltrust +for repository in \ + htmltrust-canonicalization \ + htmltrust-browser-client \ + htmltrust-browser-reference \ + htmltrust-cms-reference \ + htmltrust-e2e \ + htmltrust-server-reference; do + if git -C "$repository" rev-parse --git-dir >/dev/null 2>&1; then + git -C "$repository" fetch --all --tags + elif [ -e "$repository" ]; then + printf '%s\n' "$repository exists but is not a Git checkout" >&2 + exit 1 + else + git clone "https://github.com/HTMLTrust/$repository.git" "$repository" + fi +done ``` The frozen v1 integration uses these immutable revisions: ```bash -git -C htmltrust-canonicalization checkout 5e51040dcaaf50935e245702bdefbc18a1d542ce -git -C htmltrust-browser-client checkout f21504e170c6b29e91eda3bb491bf4580e5f5a86 -git -C htmltrust-browser-reference checkout 407bace3ad792384ba623b5db795f3f32acd16ca -git -C htmltrust-cms-reference checkout 69aafdfad2c81766f2717b88525f2569370f96cd -git -C htmltrust-server-reference checkout 56ab5c06e901f8f48753e3a511dd9dda755b9bac +git -C htmltrust-canonicalization checkout 760593d4a02e9fffa56dc4d002eb52ab2ade1b49 +git -C htmltrust-browser-client checkout 70c5ddb6ed23c06c0b1c46d5284618fb99a28aac +git -C htmltrust-browser-reference checkout b9ec8a2af7d495ece58b5027b4f4cb97c7e5f3ff +git -C htmltrust-cms-reference checkout cf050a9679610cf58103e641aab14a739c7d4503 +git -C htmltrust-server-reference checkout 07a286dfd0a219e75286e983315d5a886e9e1a2d ``` -The one-command runner checks these revisions and requires clean sibling working -trees. This keeps a recorded run tied to the source versions above. When you are -developing a sibling package, set `HTMLTRUST_ALLOW_UNPINNED=1` for that run and -record the actual revision and working-tree state with the result. +The revisions in this block are the frozen set exercised by the small scenario. +The one-command runner checks each revision and requires clean sibling working +trees. When you are developing a sibling package, set +`HTMLTRUST_ALLOW_UNPINNED=1` for that run and record the actual revision and +working-tree state with the result. ## Prerequisites -For tests and the TypeScript build: +For unit tests and the TypeScript build: - Node.js 22 and npm -- the sibling canonicalization and browser-client checkouts above +- the sibling canonicalization and browser-client checkouts above, because the + harness manifest uses local `file:` dependencies -For the full simulation, also install: +For browser lifecycle tests, also check out and build the sibling +`htmltrust-browser-reference`. The unit suite does not need a browser or that +extension checkout. For the full simulation, also install: - Docker Engine with Compose v2 - Hugo on the host - Ollama with the model named by the scenario +Verify Compose v2 before starting the stack: + +```bash +docker compose version +``` + +Hugo 0.128.0 or newer is supported, matching the module requirement in the +Hugo integration repository. This harness writes its own temporary Hugo sites +and uses the Hugo partial bundled in `htmltrust-cms-reference`; it does not +consume the separate `htmltrust-hugo` repository. + The browser phase uses the sibling browser-reference checkout and its Chromium build. The one-command runner builds it before starting Docker. ## Install and check the harness @@ -85,7 +114,7 @@ ignored by Git, and npm needs it when it installs the local `file:` dependency. ```bash cd ../htmltrust-canonicalization/javascript -npm install --package-lock=false +npm install --package-lock=false --ignore-scripts --no-audit --no-fund cd ../htmltrust-browser-client npm ci @@ -97,7 +126,9 @@ npm test npm run build ``` -These checks need the two sibling directories. They do not start Docker, Hugo, or Ollama. Install the browser-reference extension only for the browser flow: +These checks need the canonicalization and browser-client sibling directories. +They do not start Docker, Hugo, or Ollama. Install and build the +browser-reference extension only for the browser flow: ```bash cd ../htmltrust-browser-reference @@ -108,6 +139,11 @@ cd ../htmltrust-e2e The explicit flag allows the pinned Git dependency to build its `dist/` directory when npm is configured globally to skip lifecycle scripts. +The WordPress services do not need a host PHP, Composer, or CMS checkout at run +time. `Dockerfile.wordpress` copies the sibling CMS plugin into the image and +runs Composer during the image build, so the plugin and its production +dependencies are bundled in each WordPress image. + ## Run the small simulation Start Ollama in another terminal and load the model configured in @@ -146,15 +182,27 @@ Use this split flow when you want to inspect publication output before browser v npm run config:nginx -- scenario-small.yaml docker compose up -d --build --wait npx tsx src/smoke-test.ts scenario-small.yaml +npm run test:wordpress-local-signing docker compose run --rm --entrypoint npx playwright tsx src/run-phases-3-5.ts scenario-small.yaml ``` Nginx writes no tracked source file. The generated configuration lives at -`.runtime/nginx.conf`. It proxies article hosts and the test directory hostname -`https://trust.htmltrust.test`, which lets the browser exercise the verifier's -HTTPS-only key retrieval policy. - -The smoke test creates `results/ground-truth.json`. The second command runs consumer browsing, researcher reports, post-report visits, and validation. Chromium accepts the test-only wildcard certificate generated by `Dockerfile.nginx`. +`.runtime/nginx.conf`. It proxies article hosts plus +`https://trust-a.htmltrust.test` and `https://trust-b.htmltrust.test`. This lets +the browser exercise HTTPS-only key retrieval and weighted directory queries. + +The smoke test creates `results/ground-truth.json` and an isolated +`results/wordpress-local-signing.json` fixture record. The local signing check +logs into the first WordPress site, signs that fixture through the plugin's +Sign Now control, fetches its public key document, and verifies the published +section. The final command runs consumer browsing, researcher reports, +post-report visits, and validation. Chromium accepts the test-only wildcard +certificate generated by `Dockerfile.nginx`. + +The one-command runner maps Playwright to the host user, so generated results +remain removable without root access. Direct Compose commands default to UID +and GID 1000. Set `HTMLTRUST_HOST_UID=$(id -u)` and +`HTMLTRUST_HOST_GID=$(id -g)` first when your account uses different values. ## Run individual checks and services @@ -165,11 +213,11 @@ npm test npm run build ``` -Start only the trust directory and MongoDB while working on the server image: +Start both isolated trust directories and MongoDB while working on the server image: ```bash -docker compose up -d --build mongodb trust-server -docker compose logs -f trust-server +docker compose up -d --build mongodb trust-directory-alpha trust-directory-beta +docker compose logs -f trust-directory-alpha trust-directory-beta ``` Start the WordPress database and one site while working on the CMS mount: @@ -181,24 +229,38 @@ docker compose up -d --build wp-db wp-1 Analyze results after a simulation has produced the three input files: ```bash -uv run python analysis/analyze.py results +npm run analyze ``` +The analyzer uses Python 3.11 or newer and has no third-party dependencies. +Run its regression test with `npm run test:analysis`. + ## Configuration -Both scenarios are YAML files. Override local service settings for one run: +Both scenarios are YAML files. Directory-specific variables follow the +`HTMLTRUST_DIRECTORY__` pattern. Override alpha for one run like +this: ```bash -HTMLTRUST_TRUST_SERVER_URL=http://localhost:3000 \ -HTMLTRUST_GENERAL_API_KEY=my-general-key \ -HTMLTRUST_ADMIN_API_KEY=my-admin-key \ +HTMLTRUST_DIRECTORY_ALPHA_URL=http://localhost:3000 \ +HTMLTRUST_DIRECTORY_ALPHA_GENERAL_API_KEY=my-general-key \ +HTMLTRUST_DIRECTORY_ALPHA_ADMIN_API_KEY=my-admin-key \ npm run e2e:small ``` -Compose also accepts `HTMLTRUST_TRUST_PORT`, `HTMLTRUST_PROXY_PORT`, -`HTMLTRUST_TLS_PROXY_PORT`, `HTMLTRUST_DIRECTORY_BASE_URL`, `WP_DB_ROOT_PASSWORD`, `WP_DB_PASSWORD`, -`HTMLTRUST_GENERAL_API_KEY`, and `HTMLTRUST_ADMIN_API_KEY`. The checked-in -credentials are for local testing only. +Use the same names with `BETA` for the second directory. Compose accepts +`HTMLTRUST_DIRECTORY_ALPHA_PORT`, `HTMLTRUST_DIRECTORY_BETA_PORT`, +`HTMLTRUST_DIRECTORY_ALPHA_PUBLIC_URL`, +`HTMLTRUST_DIRECTORY_BETA_PUBLIC_URL`, `HTMLTRUST_PROXY_PORT`, +`HTMLTRUST_TLS_PROXY_PORT`, `WP_DB_ROOT_PASSWORD`, and `WP_DB_PASSWORD`. +The checked-in credentials are for local testing only. + +`URL` is the host-side API origin. `CONTAINER_URL` is the matching origin on +the Compose network. `PUBLIC_URL` is the HTTPS origin written into browser +subscriptions and key documents. The bundled Compose network resolves +`trust-a.htmltrust.test` and `trust-b.htmltrust.test`; another public hostname +needs a matching network alias or external DNS that the Playwright container +can resolve. ## Troubleshooting @@ -218,7 +280,7 @@ Inspect service state and logs: ```bash docker compose ps -docker compose logs trust-server nginx wp-1 wp-2 wp-3 +docker compose logs trust-directory-alpha trust-directory-beta nginx wp-1 wp-2 wp-3 ``` ## Cleanup diff --git a/analysis/analyze.py b/analysis/analyze.py index d481d4f..e8ee4fa 100644 --- a/analysis/analyze.py +++ b/analysis/analyze.py @@ -1,8 +1,21 @@ #!/usr/bin/env python3 -"""Analyze HTMLTrust E2E simulation results.""" -import json, sys -from pathlib import Path from collections import defaultdict +import json +from pathlib import Path +import sys + + +def format_directory_reputation(entries: list[dict]) -> str: + """Format the independent score reported by each configured directory.""" + formatted = [] + for entry in entries: + score = entry["trustScore"] + bar = "#" * int(score * 20) + formatted.append( + f"{entry['directoryId']}={score:.3f} {bar} " + f"(weight={entry['weight']:.2f}, reports={entry['reports']})" + ) + return "; ".join(formatted) def main(results_dir: Path) -> None: data = json.loads((results_dir / "data.json").read_text()) @@ -10,12 +23,12 @@ def main(results_dir: Path) -> None: gt = json.loads((results_dir / "ground-truth.json").read_text()) print("\n" + "=" * 60) - print(" Trust Score Distribution") + print(" Directory Reputation Distribution") print("=" * 60) for a in data["authorSummary"]: - bar = "#" * int(a["trustScore"] * 40) mal = f" [MAL {a['maliciousPct']*100:.0f}%]" if a["maliciousPct"] > 0 else "" - print(f" {a['name']:15s} ({a['cmsType']:9s}) {a['trustScore']:.3f} {bar}{mal}") + reputation = format_directory_reputation(a["directoryReputation"]) + print(f" {a['name']:15s} ({a['cmsType']:9s}) {reputation}{mal}") det = data["detectionStats"] print(f"\n{'='*60}\n Detection: P={det['precision']*100:.1f}% R={det['recall']*100:.1f}% TP={det['tp']} FP={det['fp']} FN={det['fn']}\n{'='*60}") diff --git a/analysis/test_analyze.py b/analysis/test_analyze.py new file mode 100644 index 0000000..6298297 --- /dev/null +++ b/analysis/test_analyze.py @@ -0,0 +1,59 @@ +import io +import json +from pathlib import Path +import tempfile +import unittest +from contextlib import redirect_stdout + +from analyze import main + + +class AnalyzeTest(unittest.TestCase): + def test_reports_each_directory_reputation(self) -> None: + data = { + "authorSummary": [{ + "authorId": "author-1", + "name": "Author 1", + "cmsType": "wordpress", + "maliciousPct": 1.0, + "directoryReputation": [ + {"directoryId": "alpha", "weight": 0.6, "trustScore": 0.52, "reports": 0}, + {"directoryId": "beta", "weight": 0.4, "trustScore": 0.36, "reports": 3}, + ], + }], + "detectionStats": {"precision": 1.0, "recall": 1.0, "tp": 1, "fp": 0, "fn": 0}, + "consumerStats": { + "totalPageVisits": 2, + "verificationSuccesses": 2, + "verificationFailures": 0, + "totalVotes": 1, + }, + } + sessions = [{"votesCast": [{"authorId": "author-1", "vote": "TRUST"}]}] + ground_truth = { + "articles": [{ + "title": "Example", + "authorId": "author-1", + "isMalicious": True, + "maliciousReason": "fixture", + }], + } + + with tempfile.TemporaryDirectory() as temporary_directory: + results_dir = Path(temporary_directory) + (results_dir / "data.json").write_text(json.dumps(data), encoding="utf-8") + (results_dir / "session-logs.json").write_text(json.dumps(sessions), encoding="utf-8") + (results_dir / "ground-truth.json").write_text(json.dumps(ground_truth), encoding="utf-8") + output = io.StringIO() + with redirect_stdout(output): + main(results_dir) + + rendered = output.getvalue() + self.assertIn("alpha=0.520", rendered) + self.assertIn("beta=0.360", rendered) + self.assertIn("weight=0.60", rendered) + self.assertIn("reports=3", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/docker-compose.yml b/docker-compose.yml index 7a613b0..9108403 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ volumes: services: mongodb: - image: mongo:7 + image: mongo:7@sha256:b6421fd6d1c5ded6377b397d8983e2f82e2100dc5123332dcfda2065a472be5b networks: - htmltrust volumes: @@ -28,7 +28,7 @@ services: retries: 5 start_period: 20s - trust-server: + trust-directory-alpha: build: context: .. dockerfile: htmltrust-e2e/Dockerfile.trust-server @@ -38,26 +38,51 @@ services: mongodb: condition: service_healthy environment: - MONGO_URI: mongodb://mongodb:27017/htmltrust + MONGO_URI: mongodb://mongodb:27017/htmltrust_alpha # Overridable per run, e.g. - # HTMLTRUST_GENERAL_API_KEY=$(openssl rand -hex 24) \ - # HTMLTRUST_ADMIN_API_KEY=$(openssl rand -hex 24) docker compose up + # HTMLTRUST_DIRECTORY_ALPHA_GENERAL_API_KEY=$(openssl rand -hex 24) \ + # HTMLTRUST_DIRECTORY_ALPHA_ADMIN_API_KEY=$(openssl rand -hex 24) docker compose up # (or the same two lines in htmltrust-e2e/.env, which compose reads). # # Scenario loading applies the same environment variables, so callers can # replace the checked-in simulation credentials for a run. - GENERAL_API_KEY: ${HTMLTRUST_GENERAL_API_KEY:-sim_general_key} - ADMIN_API_KEY: ${HTMLTRUST_ADMIN_API_KEY:-sim_admin_key} - AUTHOR_API_KEY_PEPPER: ${HTMLTRUST_AUTHOR_API_KEY_PEPPER:-sim_author_api_key_pepper} + GENERAL_API_KEY: ${HTMLTRUST_DIRECTORY_ALPHA_GENERAL_API_KEY:-sim_general_key} + ADMIN_API_KEY: ${HTMLTRUST_DIRECTORY_ALPHA_ADMIN_API_KEY:-sim_admin_key} + AUTHOR_API_KEY_PEPPER: ${HTMLTRUST_DIRECTORY_ALPHA_AUTHOR_API_KEY_PEPPER:-sim_author_api_key_pepper} # Signed key URLs must be reachable by a production-policy verifier, # which accepts HTTPS only. Nginx proxies this test hostname internally. - DIRECTORY_BASE_URL: ${HTMLTRUST_DIRECTORY_BASE_URL:-https://trust.htmltrust.test} + DIRECTORY_BASE_URL: ${HTMLTRUST_DIRECTORY_ALPHA_PUBLIC_URL:-https://trust-a.htmltrust.test} PORT: "3000" ports: # Loopback only. "3000:3000" publishes on 0.0.0.0, exposing the trust # directory API -- including the admin key's endpoints -- to anything # that can reach this machine. - - "127.0.0.1:${HTMLTRUST_TRUST_PORT:-3000}:3000" + - "127.0.0.1:${HTMLTRUST_DIRECTORY_ALPHA_PORT:-3000}:3000" + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + + trust-directory-beta: + build: + context: .. + dockerfile: htmltrust-e2e/Dockerfile.trust-server + networks: + - htmltrust + depends_on: + mongodb: + condition: service_healthy + environment: + MONGO_URI: mongodb://mongodb:27017/htmltrust_beta + GENERAL_API_KEY: ${HTMLTRUST_DIRECTORY_BETA_GENERAL_API_KEY:-sim_general_key} + ADMIN_API_KEY: ${HTMLTRUST_DIRECTORY_BETA_ADMIN_API_KEY:-sim_admin_key} + AUTHOR_API_KEY_PEPPER: ${HTMLTRUST_DIRECTORY_BETA_AUTHOR_API_KEY_PEPPER:-sim_beta_author_api_key_pepper} + DIRECTORY_BASE_URL: ${HTMLTRUST_DIRECTORY_BETA_PUBLIC_URL:-https://trust-b.htmltrust.test} + PORT: "3000" + ports: + - "127.0.0.1:${HTMLTRUST_DIRECTORY_BETA_PORT:-3001}:3000" healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] interval: 10s @@ -66,7 +91,7 @@ services: start_period: 15s wp-db: - image: mariadb:11 + image: mariadb:11@sha256:2439dcd7d14010ecd1ff7a4e1c5abe8e208c34fe35290744deeeaac3569043c3 networks: - htmltrust volumes: @@ -92,8 +117,8 @@ services: wp-1: build: - context: . - dockerfile: Dockerfile.wordpress + context: .. + dockerfile: htmltrust-e2e/Dockerfile.wordpress networks: - htmltrust depends_on: @@ -106,12 +131,17 @@ services: WORDPRESS_DB_NAME: wp1 volumes: - wp-1-data:/var/www/html - - ../htmltrust-cms-reference/wordpress:/var/www/html/wp-content/plugins/content-signing + healthcheck: + test: ["CMD", "php", "-r", "$$headers=@get_headers('http://127.0.0.1/'); exit(is_array($$headers) && count($$headers) > 0 ? 0 : 1);"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s wp-2: build: - context: . - dockerfile: Dockerfile.wordpress + context: .. + dockerfile: htmltrust-e2e/Dockerfile.wordpress networks: - htmltrust depends_on: @@ -124,12 +154,17 @@ services: WORDPRESS_DB_NAME: wp2 volumes: - wp-2-data:/var/www/html - - ../htmltrust-cms-reference/wordpress:/var/www/html/wp-content/plugins/content-signing + healthcheck: + test: ["CMD", "php", "-r", "$$headers=@get_headers('http://127.0.0.1/'); exit(is_array($$headers) && count($$headers) > 0 ? 0 : 1);"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s wp-3: build: - context: . - dockerfile: Dockerfile.wordpress + context: .. + dockerfile: htmltrust-e2e/Dockerfile.wordpress networks: - htmltrust depends_on: @@ -142,12 +177,17 @@ services: WORDPRESS_DB_NAME: wp3 volumes: - wp-3-data:/var/www/html - - ../htmltrust-cms-reference/wordpress:/var/www/html/wp-content/plugins/content-signing + healthcheck: + test: ["CMD", "php", "-r", "$$headers=@get_headers('http://127.0.0.1/'); exit(is_array($$headers) && count($$headers) > 0 ? 0 : 1);"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s wp-4: build: - context: . - dockerfile: Dockerfile.wordpress + context: .. + dockerfile: htmltrust-e2e/Dockerfile.wordpress networks: - htmltrust depends_on: @@ -160,12 +200,17 @@ services: WORDPRESS_DB_NAME: wp4 volumes: - wp-4-data:/var/www/html - - ../htmltrust-cms-reference/wordpress:/var/www/html/wp-content/plugins/content-signing + healthcheck: + test: ["CMD", "php", "-r", "$$headers=@get_headers('http://127.0.0.1/'); exit(is_array($$headers) && count($$headers) > 0 ? 0 : 1);"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s wp-5: build: - context: . - dockerfile: Dockerfile.wordpress + context: .. + dockerfile: htmltrust-e2e/Dockerfile.wordpress networks: - htmltrust depends_on: @@ -178,13 +223,19 @@ services: WORDPRESS_DB_NAME: wp5 volumes: - wp-5-data:/var/www/html - - ../htmltrust-cms-reference/wordpress:/var/www/html/wp-content/plugins/content-signing + healthcheck: + test: ["CMD", "php", "-r", "$$headers=@get_headers('http://127.0.0.1/'); exit(is_array($$headers) && count($$headers) > 0 ? 0 : 1);"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s # Playwright container: runs on demand via `docker compose run playwright` # Resolves *.htmltrust.test via Docker DNS, no host state needed playwright: - image: mcr.microsoft.com/playwright:v1.59.1-jammy + image: mcr.microsoft.com/playwright:v1.59.1-jammy@sha256:8a0360d39d1973be506dd59002904a774f6d697d4946c94063b3fd006461c8ff working_dir: /workspace + user: "${HTMLTRUST_HOST_UID:-1000}:${HTMLTRUST_HOST_GID:-1000}" volumes: - .:/workspace - ../htmltrust-browser-reference/build/chromium:/extension:ro @@ -197,6 +248,7 @@ services: environment: E2E_DIR: /workspace EXTENSION_PATH: /extension + HOME: /tmp/htmltrust-home NPM_CONFIG_CACHE: /tmp/.npm # Override default command so the container stays idle; we launch scripts via `docker compose run` entrypoint: ["sleep", "infinity"] @@ -219,14 +271,29 @@ services: - author8.htmltrust.test - author9.htmltrust.test - author10.htmltrust.test - - trust.htmltrust.test + - trust-a.htmltrust.test + - trust-b.htmltrust.test depends_on: - - trust-server - - wp-1 - - wp-2 - - wp-3 - - wp-4 - - wp-5 + trust-directory-alpha: + condition: service_healthy + trust-directory-beta: + condition: service_healthy + wp-1: + condition: service_healthy + wp-2: + condition: service_healthy + wp-3: + condition: service_healthy + wp-4: + condition: service_healthy + wp-5: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s ports: # Loopback only. This fronts five WordPress installs whose admin # credentials are fixed sim values; publishing these ports to the whole diff --git a/package-lock.json b/package-lock.json index b31092c..5d72662 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,22 +10,22 @@ "dependencies": { "@htmltrust/browser-client": "file:../htmltrust-browser-client", "@htmltrust/canonicalization": "file:../htmltrust-canonicalization/javascript", - "playwright": "^1.52.0", - "yaml": "^2.7.0" + "playwright": "1.59.1", + "yaml": "2.8.3" }, "devDependencies": { - "@types/node": "^22.0.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0", - "vitest": "^3.2.7" + "@types/node": "22.19.17", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "3.2.7" } }, "../htmltrust-browser-client": { "name": "@htmltrust/browser-client", - "version": "0.1.2", + "version": "0.2.0", "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" }, "devDependencies": { @@ -42,6 +42,9 @@ "dependencies": { "parse5": "7.3.0" }, + "bin": { + "htmltrust-portable-preflight": "bin/portable-authoring.js" + }, "engines": { "node": ">=22" } diff --git a/package.json b/package.json index c71b7d3..5654fbf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "stack:down": "docker compose down -v", "smoke": "tsx src/smoke-test.ts scenario-small.yaml", "browser:small": "docker compose run --rm --entrypoint npx playwright tsx src/run-phases-3-5.ts scenario-small.yaml", + "analyze": "python3 analysis/analyze.py results", + "test:analysis": "python3 -m unittest discover -s analysis -p 'test_*.py'", "test:browser": "docker compose run --rm --no-deps --entrypoint npx playwright tsx scripts/browser-lifecycle-test.ts", + "test:wordpress-local-signing": "docker compose run --rm --entrypoint npx playwright tsx scripts/wordpress-local-signing-test.ts", "e2e:small": "./scripts/run-e2e.sh scenario-small.yaml", "test": "vitest run", "test:watch": "vitest" @@ -20,13 +23,13 @@ "dependencies": { "@htmltrust/browser-client": "file:../htmltrust-browser-client", "@htmltrust/canonicalization": "file:../htmltrust-canonicalization/javascript", - "playwright": "^1.52.0", - "yaml": "^2.7.0" + "playwright": "1.59.1", + "yaml": "2.8.3" }, "devDependencies": { - "@types/node": "^22.0.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0", - "vitest": "^3.2.7" + "@types/node": "22.19.17", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "3.2.7" } } diff --git a/scenario-small.yaml b/scenario-small.yaml index fe402be..0cf7d7d 100644 --- a/scenario-small.yaml +++ b/scenario-small.yaml @@ -21,7 +21,7 @@ authors: consumers: count: 5 - trusted_authors: [0, 2] + personal_trust_keys: [0, 2] visit_pct: [0.5, 1.0] vote_probability: 0.5 batch_size: 2 @@ -33,10 +33,27 @@ researcher: post_report_consumers: 2 -trust_server: - url: "http://localhost:3000" - general_api_key: sim_general_key - admin_api_key: sim_admin_key +trust_directories: + - id: alpha + url: "http://localhost:3000" + container_url: "http://trust-directory-alpha:3000" + public_url: "https://trust-a.htmltrust.test" + weight: 1.0 + publisher: true + reports: false + initial_opinion: support + general_api_key: sim_general_key + admin_api_key: sim_admin_key + - id: beta + url: "http://localhost:3001" + container_url: "http://trust-directory-beta:3000" + public_url: "https://trust-b.htmltrust.test" + weight: 0.75 + publisher: false + reports: true + initial_opinion: challenge + general_api_key: sim_general_key + admin_api_key: sim_admin_key ollama: model: "llama3.2:1b" diff --git a/scenario.yaml b/scenario.yaml index 3ebde6c..fdd09f6 100644 --- a/scenario.yaml +++ b/scenario.yaml @@ -21,7 +21,7 @@ authors: consumers: count: 1000 - trusted_authors: [0, 5] + personal_trust_keys: [0, 5] visit_pct: [0.3, 0.8] vote_probability: 0.4 batch_size: 20 @@ -33,10 +33,27 @@ researcher: post_report_consumers: 50 -trust_server: - url: "http://trust-server:3000" - general_api_key: sim_general_key - admin_api_key: sim_admin_key +trust_directories: + - id: alpha + url: "http://localhost:3000" + container_url: "http://trust-directory-alpha:3000" + public_url: "https://trust-a.htmltrust.test" + weight: 1.0 + publisher: true + reports: false + initial_opinion: support + general_api_key: sim_general_key + admin_api_key: sim_admin_key + - id: beta + url: "http://localhost:3001" + container_url: "http://trust-directory-beta:3000" + public_url: "https://trust-b.htmltrust.test" + weight: 0.75 + publisher: false + reports: true + initial_opinion: challenge + general_api_key: sim_general_key + admin_api_key: sim_admin_key ollama: model: "llama3.2:3b" diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index 5053cda..6cd19a6 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -5,6 +5,28 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" scenario="${1:-scenario-small.yaml}" cd "$repo_dir" +for command_name in git node npm docker hugo; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Missing required command: $command_name" >&2 + exit 2 + fi +done + +if ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'; then + echo "Node.js 22 or newer is required; found $(node --version)" >&2 + exit 2 +fi + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose v2 is required; 'docker compose' is unavailable" >&2 + exit 2 +fi + +if ! docker info >/dev/null 2>&1; then + echo "The Docker daemon is unavailable for the current user" >&2 + exit 2 +fi + case "$scenario" in /*|*..*) echo "Scenario must be a path inside htmltrust-e2e" >&2 @@ -21,11 +43,11 @@ required_siblings=( ) declare -A expected_revisions=( - [htmltrust-canonicalization]=5e51040dcaaf50935e245702bdefbc18a1d542ce - [htmltrust-browser-client]=f21504e170c6b29e91eda3bb491bf4580e5f5a86 - [htmltrust-browser-reference]=407bace3ad792384ba623b5db795f3f32acd16ca - [htmltrust-cms-reference]=69aafdfad2c81766f2717b88525f2569370f96cd - [htmltrust-server-reference]=56ab5c06e901f8f48753e3a511dd9dda755b9bac + [htmltrust-canonicalization]=760593d4a02e9fffa56dc4d002eb52ab2ade1b49 + [htmltrust-browser-client]=70c5ddb6ed23c06c0b1c46d5284618fb99a28aac + [htmltrust-browser-reference]=b9ec8a2af7d495ece58b5027b4f4cb97c7e5f3ff + [htmltrust-cms-reference]=cf050a9679610cf58103e641aab14a739c7d4503 + [htmltrust-server-reference]=07a286dfd0a219e75286e983315d5a886e9e1a2d ) for repository in "${required_siblings[@]}"; do @@ -54,9 +76,11 @@ if [[ ! -f "$repo_dir/$scenario" ]]; then exit 2 fi +export HTMLTRUST_HOST_UID="${HTMLTRUST_HOST_UID:-$(id -u)}" +export HTMLTRUST_HOST_GID="${HTMLTRUST_HOST_GID:-$(id -g)}" + echo "Building the pinned browser packages" -npm --prefix "$repo_dir/../htmltrust-canonicalization/javascript" install \ - --package-lock=false --ignore-scripts --no-audit --no-fund +npm --prefix "$repo_dir/../htmltrust-canonicalization" ci --ignore-scripts --no-audit --no-fund npm --prefix "$repo_dir/../htmltrust-browser-client" ci npm --prefix "$repo_dir/../htmltrust-browser-client" run build npm --prefix "$repo_dir/../htmltrust-browser-reference" ci --ignore-scripts=false @@ -74,6 +98,10 @@ docker compose --project-directory "$repo_dir" up -d --build --wait echo "Publishing signed content" node --import tsx "$repo_dir/src/smoke-test.ts" "$repo_dir/$scenario" +echo "Checking WordPress browser-local signing" +docker compose --project-directory "$repo_dir" run --rm --entrypoint npx playwright \ + tsx scripts/wordpress-local-signing-test.ts + echo "Running browser, reporting, and validation phases" docker compose --project-directory "$repo_dir" run --rm --entrypoint npx playwright \ tsx src/run-phases-3-5.ts "$scenario" diff --git a/scripts/wordpress-entrypoint.sh b/scripts/wordpress-entrypoint.sh new file mode 100644 index 0000000..57e6756 --- /dev/null +++ b/scripts/wordpress-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +plugin_target=/var/www/html/wp-content/plugins/content-signing +if [ "${1:-}" = "apache2-foreground" ] || [ "${1:-}" = "php-fpm" ]; then + rm -rf "$plugin_target" + mkdir -p "$(dirname "$plugin_target")" + cp -a /opt/htmltrust-content-signing "$plugin_target" + chown -R www-data:www-data "$plugin_target" +fi + +exec /usr/local/bin/docker-entrypoint.sh "$@" diff --git a/scripts/wordpress-local-signing-test.ts b/scripts/wordpress-local-signing-test.ts new file mode 100644 index 0000000..49fd588 --- /dev/null +++ b/scripts/wordpress-local-signing-test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { createPublicKey } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "playwright"; +import { extractSignedSections, verifySignedSection, type KeyResolver } from "@htmltrust/browser-client"; +import type { WordPressLocalSigningFixture } from "../src/wordpress-local-signing-fixture.js"; + +const e2eDir = process.env.E2E_DIR || process.cwd(); +const fixturePath = path.join(e2eDir, "results", "wordpress-local-signing.json"); + +function sectionAttribute(source: string, name: string): string { + const openTag = source.match(/^]*>/i)?.[0]; + if (!openTag) throw new Error("wordpress-local-signing: signed-section opening tag is missing"); + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = openTag.match(new RegExp(`\\b${escaped}\\s*=\\s*([\"'])(.*?)\\1`, "i")); + if (!match) throw new Error(`wordpress-local-signing: signed-section is missing ${name}`); + return match[2]; +} + +function assertCanonicalUnpaddedBase64(value: string, label: string): void { + assert.notEqual(value, "", `${label} must be nonempty`); + assert.match(value, /^[A-Za-z0-9+/]+$/, `${label} must use unpadded standard Base64`); + assert.equal(Buffer.from(value, "base64").toString("base64").replace(/=+$/g, ""), value, `${label} must be canonical Base64`); +} + +async function main(): Promise { + const fixture = JSON.parse(await readFile(fixturePath, "utf8")) as WordPressLocalSigningFixture; + assert.equal(fixture.publicUrl.startsWith("https://"), true); + assert.equal(fixture.editUrl.startsWith("https://"), true); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + let confirmationSeen = false; + const dialogMessages: string[] = []; + const browserErrors: string[] = []; + page.on("dialog", (dialog) => { + dialogMessages.push(`${dialog.type()}: ${dialog.message()}`); + if (dialog.type() === "confirm") confirmationSeen = true; + void dialog.accept(); + }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") browserErrors.push(message.text()); + }); + + try { + await page.goto(fixture.editUrl, { waitUntil: "domcontentloaded" }); + if (page.url().includes("wp-login.php")) { + await page.locator("#user_login").fill("admin"); + await page.locator("#user_pass").fill("admin"); + await Promise.all([ + page.waitForNavigation({ waitUntil: "domcontentloaded" }), + page.locator("#wp-submit").click(), + ]); + } + assert.match(page.url(), /\/wp-admin\/post\.php\?/); + + const editorModal = page.locator(".components-modal__screen-overlay"); + if (await editorModal.isVisible()) { + await page.keyboard.press("Escape"); + await editorModal.waitFor({ state: "hidden", timeout: 5_000 }); + } + + const signButton = page.locator(".content-signing-meta-box .sign-post"); + await signButton.waitFor({ state: "visible", timeout: 30_000 }); + try { + await page.waitForFunction(() => { + const button = document.querySelector(".content-signing-meta-box .sign-post"); + const jquery = (window as typeof window & { + jQuery?: { _data?: (element: Element, key: string) => { click?: unknown[] } }; + }).jQuery; + const events = button && jquery?._data ? jquery._data(button, "events") : undefined; + return Boolean(events?.click?.length); + }, undefined, { timeout: 10_000 }); + } catch (error) { + const diagnostics = await page.evaluate(() => ({ + readyState: document.readyState, + localizedConfig: typeof (window as typeof window & { content_signing_post_meta_box?: unknown }).content_signing_post_meta_box, + jquery: typeof (window as typeof window & { jQuery?: unknown }).jQuery, + scripts: Array.from(document.scripts, (script) => script.src).filter((source) => source.includes("content-signing")), + })); + throw new Error( + `WordPress signing controls did not initialize: ${JSON.stringify(diagnostics)}. Browser errors: ${browserErrors.join(" | ") || "none"}`, + { cause: error }, + ); + } + await signButton.click(); + assert.equal(confirmationSeen, true, "Sign Now must require confirmation"); + try { + await page.locator(".content-signing-meta-box .signature-status-signed").waitFor({ timeout: 30_000 }); + } catch (error) { + throw new Error( + `WordPress did not display a signed status. Dialogs: ${dialogMessages.join(" | ") || "none"}. Browser errors: ${browserErrors.join(" | ") || "none"}`, + { cause: error }, + ); + } + + const response = await context.request.get(fixture.publicUrl); + assert.equal(response.status(), 200, "fixture page must be published"); + const finalUrl = response.url(); + assert.equal(finalUrl, fixture.publicUrl, "fixture page must not redirect to a different URL"); + const html = await response.text(); + const sections = extractSignedSections(html); + assert.equal(sections.length, 1, "fixture page must have one signed section"); + const section = sections[0]; + + assert.equal(sectionAttribute(section, "profile"), "htmltrust-signature-v1"); + assert.equal(sectionAttribute(section, "signature-scope"), "url"); + const keyid = sectionAttribute(section, "keyid"); + assert.equal(new URL(keyid).protocol, "https:"); + assert.equal(sectionAttribute(section, "algorithm"), "ed25519"); + const signature = sectionAttribute(section, "signature"); + assertCanonicalUnpaddedBase64(signature, "signature"); + const contentHash = sectionAttribute(section, "content-hash"); + assert.match(contentHash, /^sha256:[A-Za-z0-9+/]+$/); + assertCanonicalUnpaddedBase64(contentHash.slice("sha256:".length), "content hash"); + + const keyResponse = await context.request.get(keyid); + assert.equal(keyResponse.status(), 200, "the keyid document must be public"); + const keyDocument = await keyResponse.json() as { + id?: unknown; + keyid?: unknown; + algorithm?: unknown; + publicKey?: unknown; + publicKeyEncoding?: unknown; + }; + assert.equal(keyDocument.algorithm, "ed25519"); + assert.equal(keyDocument.publicKeyEncoding, "spki-der"); + assert.equal(keyDocument.keyid, keyid); + assert.equal(typeof keyDocument.publicKey, "string"); + assertCanonicalUnpaddedBase64(keyDocument.publicKey as string, "SPKI public key"); + const publicKeyPem = createPublicKey({ + key: Buffer.from(keyDocument.publicKey as string, "base64"), + format: "der", + type: "spki", + }).export({ type: "spki", format: "pem" }).toString(); + + const resolver: KeyResolver = { + async resolve(candidate) { + return candidate === keyid ? { keyid, publicKeyPem, algorithm: "ed25519" } : null; + }, + }; + const verification = await verifySignedSection(section, { + keyResolvers: [resolver], + documentUrl: finalUrl, + baseUrl: finalUrl, + }); + assert.equal(verification.valid, true, `plugin signature must verify: ${verification.reason || "unknown failure"}`); + console.log(`WordPress browser-local signing passed: post ${fixture.postId}, key ${keyid}`); + } finally { + await context.close(); + await browser.close(); + } +} + +await main(); diff --git a/src/lib/local-signing.ts b/src/lib/local-signing.ts new file mode 100644 index 0000000..a26c745 --- /dev/null +++ b/src/lib/local-signing.ts @@ -0,0 +1,32 @@ +import { generateKeyPairSync, sign, type KeyObject } from "node:crypto"; + +const privateKeys = new Map(); + +/** + * Create an Ed25519 signing key for one simulated author. + * + * Only the public half leaves this module. The private KeyObject stays in + * process memory for the Phase 1 to Phase 2 handoff and is never serialized + * into the ground-truth manifest. + */ +export function createLocalSigner(authorId: string): string { + if (!authorId) throw new Error("local-signer-invalid: authorId is required"); + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + privateKeys.set(authorId, privateKey); + return publicKey.export({ type: "spki", format: "pem" }).toString(); +} + +/** Sign the exact UTF-8 bytes of a canonical v1 payload. */ +export function signLocalPayload(authorId: string, payload: string): string { + const privateKey = privateKeys.get(authorId); + if (!privateKey) { + throw new Error(`local-signer-missing: no in-memory key for ${authorId}`); + } + return sign(null, Buffer.from(payload, "utf-8"), privateKey) + .toString("base64") + .replace(/=+$/, ""); +} + +export function clearLocalSigners(): void { + privateKeys.clear(); +} diff --git a/src/lib/nginx-config.ts b/src/lib/nginx-config.ts index 234d57c..ebd79ce 100644 --- a/src/lib/nginx-config.ts +++ b/src/lib/nginx-config.ts @@ -1,13 +1,17 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import type { AuthorProfile } from "../types.js"; +import type { AuthorProfile, TrustDirectoryConfig } from "../types.js"; /** * Generate nginx.conf based on the author list. * WordPress authors get reverse-proxied to their wp-N container. * Hugo authors serve static files from /var/www/hugo/. */ -export async function generateNginxConfig(authors: AuthorProfile[], outputPath: string): Promise { +export async function generateNginxConfig( + authors: AuthorProfile[], + directories: TrustDirectoryConfig[], + outputPath: string, +): Promise { const blocks: string[] = []; const listeners = "listen 80; listen 443 ssl;"; const tls = "ssl_certificate /etc/nginx/certs/htmltrust.test.crt; ssl_certificate_key /etc/nginx/certs/htmltrust.test.key;"; @@ -28,9 +32,13 @@ export async function generateNginxConfig(authors: AuthorProfile[], outputPath: } } - blocks.push( - ` server { ${listeners} ${tls} server_name trust.htmltrust.test; location / { proxy_pass http://trust-server:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }` - ); + for (const directory of directories) { + const publicUrl = new URL(directory.public_url); + const containerUrl = new URL(directory.container_url); + blocks.push( + ` server { ${listeners} ${tls} server_name ${publicUrl.hostname}; location / { proxy_pass ${containerUrl.origin}; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }` + ); + } const config = `events { worker_connections 1024; @@ -42,7 +50,7 @@ http { ${blocks.join("\n")} - server { listen 80 default_server; listen 443 ssl default_server; ${tls} return 404; } + server { listen 80 default_server; listen 443 ssl default_server; ${tls} location = /healthz { return 200 'ok'; } location / { return 404; } } } `; diff --git a/src/lib/playwright-session.ts b/src/lib/playwright-session.ts index 60264fd..b4b8e12 100644 --- a/src/lib/playwright-session.ts +++ b/src/lib/playwright-session.ts @@ -8,24 +8,24 @@ import { evaluateTrustPolicy, type VerifyResult, type TrustEvaluation, - type TrustInput, } from "@htmltrust/browser-client"; import { directUrlResolver } from "@htmltrust/canonicalization"; -import type { ConsumerProfile, AuthorProfile, Article, SessionLog, TrustIndicator } from "../types.js"; +import type { + ConsumerProfile, + AuthorProfile, + Article, + SessionLog, + TrustIndicator, + TrustDirectoryConfig, + DirectoryQueryResult, +} from "../types.js"; interface SessionOptions { consumer: ConsumerProfile; authors: AuthorProfile[]; articles: Article[]; screenshotDir: string; - /** - * Trust directories the consumer is subscribed to. Used both for - * key resolution (the resolver chain) and reputation lookups. The - * orchestrator may pass either a single URL string or an array; the - * session normalizes both forms. - */ - trustDirectoryUrls: string[] | string; - generalApiKey: string; + directories: TrustDirectoryConfig[]; } /** Shape returned by the Node-side __htmltrustVerifyAndScore helper. */ @@ -36,6 +36,7 @@ interface VerifyAndScoreResult { authorId: string | null; /** Aggregated report count across queried directories. */ reports: number; + directoryResults: DirectoryQueryResult[]; } /** @@ -44,9 +45,11 @@ interface VerifyAndScoreResult { * certificate validation. The production extension uses the browser trust * store and never calls this helper. */ -async function fetchTestKeyDocument(input: RequestInfo | URL, init?: RequestInit): Promise { +async function fetchTestDirectoryResource(input: RequestInfo | URL, init?: RequestInit): Promise { const url = new URL(input instanceof Request ? input.url : String(input)); - if (url.hostname !== "trust.htmltrust.test") return fetch(input, init); + if (!url.hostname.startsWith("trust-") || !url.hostname.endsWith(".htmltrust.test")) { + return fetch(input, init); + } if (url.protocol !== "https:") throw new Error("test directory key URL must use HTTPS"); return new Promise((resolve, reject) => { @@ -54,6 +57,7 @@ async function fetchTestKeyDocument(input: RequestInfo | URL, init?: RequestInit method: init?.method || "GET", headers: Object.fromEntries(new Headers(init?.headers).entries()), rejectUnauthorized: false, + ...(init?.signal ? { signal: init.signal } : {}), }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); @@ -142,55 +146,102 @@ export function mapSourceSnapshot( return { complete, sourceByLiveIndex: complete ? sourceByLiveIndex : liveIdentities.map(() => null) }; } -/** - * Reputation lookup that mirrors the e2e prototype's two-step shape: - * 1. GET /api/authors/{authorId}/public-key -> { id: keyId, ... } - * 2. GET /api/directory/keys/{keyId}/reputation - * - * The browser-client lib's evaluateTrustPolicy expects the reputation URL - * to be `/keys//reputation` (the spec shape). The e2e - * trust server uses a different path that requires the keyid lookup - * round-trip above, so we layer the reports/score handling on top of the - * lib's evaluateTrustPolicy output instead of relying on its built-in - * directory subscription path. - * - * TODO(directory-shape): once the trust server is extended (or replaced) - * with a spec-compliant `/keys/{keyid}/reputation` endpoint, switch this - * to `directorySubscriptions: directories.map(url => ({ url, weight: 1 }))` - * and drop this helper. - */ -async function fetchReputationFromE2eServer( - directoryBase: string, - authorId: string, -): Promise<{ trustScore: number; reports: number } | null> { - try { - const keyRes = await fetch(`${directoryBase}/api/authors/${authorId}/public-key`); - if (!keyRes.ok) return null; - const key = (await keyRes.json()) as { id?: string }; - if (!key.id) return null; - const repRes = await fetch(`${directoryBase}/api/directory/keys/${key.id}/reputation`); - if (!repRes.ok) return null; - const rep = (await repRes.json()) as { trustScore?: number; reports?: number }; - return { - trustScore: typeof rep.trustScore === "number" ? rep.trustScore : 0.5, - reports: typeof rep.reports === "number" ? rep.reports : 0, - }; - } catch { - return null; - } +function authorIdFromKeyid(keyid: string, authors: AuthorProfile[]): string | null { + return authors.find((author) => author.keyId === keyid)?.id ?? null; } -/** Resolve the legacy author-key URL or the canonical directory-key URL. */ -function authorIdFromKeyid(keyid: string, authors: AuthorProfile[]): string | null { - const m = keyid.match(/\/authors\/([^/]+)/); - if (m) return m[1]; +export function createDirectoryEvidenceFetch( + directories: TrustDirectoryConfig[], + transport: typeof fetch = fetchTestDirectoryResource, +): { + fetchImpl: typeof fetch; + results: DirectoryQueryResult[]; +} { + const results: DirectoryQueryResult[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const directory = directories.find((candidate) => [ + candidate.public_url, + candidate.url, + candidate.container_url, + ].some((candidateUrl) => new URL(candidateUrl).origin === url.origin)); + if (!directory) return transport(input, init); + + const started = Date.now(); + try { + const response = await transport(input, init); + const result: DirectoryQueryResult = { + directoryId: directory.id, + url: directory.public_url, + weight: directory.weight, + status: response.ok ? "ok" : "unavailable", + latencyMs: Date.now() - started, + }; + if (response.ok) { + try { + const body = await response.clone().json() as { + score?: unknown; + trustScore?: unknown; + reports?: unknown; + }; + const score = typeof body.score === "number" ? body.score : body.trustScore; + if (typeof score !== "number" || !Number.isFinite(score) || score < 0 || score > 1) { + result.status = "malformed"; + } else { + result.score = score; + if (typeof body.reports === "number" && Number.isFinite(body.reports) && body.reports >= 0) { + result.reports = body.reports; + } + } + } catch { + result.status = "malformed"; + } + } + results.push(result); + return response; + } catch (error) { + results.push({ + directoryId: directory.id, + url: directory.public_url, + weight: directory.weight, + status: "unavailable", + latencyMs: Date.now() - started, + }); + throw error; + } + }; + return { fetchImpl, results }; +} - try { - const keyRecordId = new URL(keyid).pathname.match(/\/keys\/([^/]+)$/)?.[1]; - return authors.find((author) => author.keyId === keyRecordId)?.id ?? null; - } catch { - return null; +export async function evaluateFederatedTrust( + verify: VerifyResult, + consumer: Pick, + directories: TrustDirectoryConfig[], + transport: typeof fetch = fetchTestDirectoryResource, +): Promise<{ trust: TrustEvaluation; directoryResults: DirectoryQueryResult[]; reports: number }> { + const evidence = createDirectoryEvidenceFetch(directories, transport); + const trust = await evaluateTrustPolicy(verify, { + personalTrustList: consumer.personalTrustList, + trustedDomains: [], + directorySubscriptions: consumer.directorySubscriptions.map((subscription) => ({ + url: subscription.url, + weight: subscription.weight, + })), + fetch: evidence.fetchImpl, + }); + for (const result of evidence.results) { + const input = trust.inputs.find((candidate) => candidate.source === `directory:${result.url}`); + if (input) result.contribution = input.contribution; } + const directoryResults = [...evidence.results].sort((left, right) => + directories.findIndex((directory) => directory.id === left.directoryId) - + directories.findIndex((directory) => directory.id === right.directoryId) + ); + return { + trust, + directoryResults, + reports: directoryResults.reduce((sum, result) => sum + (result.reports ?? 0), 0), + }; } /** @@ -368,6 +419,7 @@ export const DOM_SCRIPT_BODY = ` trustScore: score.trust.score, indicator: indicator, reports: score.reports, + directoryResults: score.directoryResults, verificationInputState: score.verify.inputState, verificationReason: score.verify.reason, }); @@ -391,17 +443,11 @@ export const DOM_SCRIPT_BODY = ` `; export async function runConsumerSession(opts: SessionOptions): Promise { - const { consumer, authors, articles, screenshotDir } = opts; - const directoryUrls = Array.isArray(opts.trustDirectoryUrls) - ? opts.trustDirectoryUrls - : [opts.trustDirectoryUrls]; - // The first directory in the list is the "primary" used for vote POSTs in - // the simulation. Multi-directory voting is out of scope for now. - const primaryDirectoryUrl = directoryUrls[0]; + const { consumer, authors, articles, screenshotDir, directories } = opts; const log: SessionLog = { consumerId: consumer.id, - trustedAuthors: consumer.trustedAuthors, + personalTrustList: consumer.personalTrustList, pagesVisited: [], votesCast: [], screenshots: [], @@ -418,7 +464,7 @@ export async function runConsumerSession(opts: SessionOptions): Promise/keys//reputation`) - // doesn't match the e2e trust server's two-step lookup. Reports are - // applied as an override below — see fetchReputationFromE2eServer. - const trust = await evaluateTrustPolicy(verify, { - personalTrustList: [], // keyids; e2e maps trust by authorId, layered below - trustedDomains: [], - directorySubscriptions: [], - }); - - // Layer the e2e prototype's personal-trust-list (by authorId) on top - // of evaluateTrustPolicy's score. The lib only knows about keyid - // matches; the simulation tracks consumer.trustedAuthors as authorIds. - const extraInputs: TrustInput[] = []; - let score = trust.score; - if (verify.valid && authorId && consumer.trustedAuthors.includes(authorId)) { - score = Math.min(100, score + 40); - extraInputs.push({ - source: "personal-trust-list", - contribution: 40, - rationale: "author in personal trust list (option A)", - }); - } - - // Reputation from the e2e trust server, aggregated across directories. - // Any reports trigger the spec's "researcher-flag → red" override. - let totalReports = 0; - if (verify.valid && authorId) { - for (const dir of directoryUrls) { - const rep = await fetchReputationFromE2eServer(dir, authorId); - if (!rep) continue; - totalReports += rep.reports; - if (rep.reports > 0) { - // Mirror the previous penalty curve: 35 for the first report, - // +15 per additional, capped at 60. Keeps phase-3 vs phase-4 - // expectations stable. - const penalty = Math.min(60, 35 + (rep.reports - 1) * 15); - score = Math.max(0, score - penalty); - extraInputs.push({ - source: "directory-reports", - contribution: -penalty, - rationale: `${rep.reports} report(s) filed against author at ${dir}`, - }); - } - } - } - - // Recompute the indicator from the layered score; reports force red. - let indicator: TrustEvaluation["indicator"] = trust.indicator; - if (verify.valid) { - indicator = score < 20 ? "red" : score >= 70 ? "green" : "yellow"; - } - if (totalReports > 0) indicator = "red"; + const policy = await evaluateFederatedTrust(verify, consumer, directories); return { verify, - trust: { - score, - indicator, - inputs: [...trust.inputs, ...extraInputs], - }, + trust: policy.trust, authorId, - reports: totalReports, + reports: policy.reports, + directoryResults: policy.directoryResults, }; }, ); @@ -552,6 +542,7 @@ export async function runConsumerSession(opts: SessionOptions): Promise; @@ -577,11 +568,13 @@ export async function runConsumerSession(opts: SessionOptions): Promise(); + const publicOrigins = new Set(); + const hostOrigins = new Set(); + const containerOrigins = new Set(); + let publishers = 0; + for (const directory of config.trust_directories) { + if (!directory.id || !/^[a-z0-9][a-z0-9-]*$/.test(directory.id)) { + throw new Error("scenario-invalid: every directory id must use lowercase letters, digits, and hyphens"); + } + if (ids.has(directory.id)) { + throw new Error(`scenario-invalid: duplicate directory id ${directory.id}`); + } + ids.add(directory.id); + const hostUrl = validateDirectoryUrl(directory.url, `trust_directories.${directory.id}.url`, false); + if (hostOrigins.has(hostUrl.origin)) { + throw new Error(`scenario-invalid: duplicate directory host origin ${hostUrl.origin}`); + } + hostOrigins.add(hostUrl.origin); + const containerUrl = validateDirectoryUrl(directory.container_url, `trust_directories.${directory.id}.container_url`, false); + if (containerOrigins.has(containerUrl.origin)) { + throw new Error(`scenario-invalid: duplicate directory container origin ${containerUrl.origin}`); + } + containerOrigins.add(containerUrl.origin); + const publicUrl = validateDirectoryUrl( + directory.public_url, + `trust_directories.${directory.id}.public_url`, + true, + ); + if (publicOrigins.has(publicUrl.origin)) { + throw new Error(`scenario-invalid: duplicate public directory origin ${publicUrl.origin}`); + } + publicOrigins.add(publicUrl.origin); + if (!Number.isFinite(directory.weight) || directory.weight <= 0 || directory.weight > 1) { + throw new Error(`scenario-invalid: trust_directories.${directory.id}.weight must be greater than 0 and at most 1`); + } + if (!directory.general_api_key || !directory.admin_api_key) { + throw new Error(`scenario-invalid: trust_directories.${directory.id} requires API keys`); + } + if (!new Set(["support", "challenge", "neutral"]).has(directory.initial_opinion)) { + throw new Error(`scenario-invalid: trust_directories.${directory.id}.initial_opinion is invalid`); + } + if (typeof directory.publisher !== "boolean" || typeof directory.reports !== "boolean") { + throw new Error(`scenario-invalid: trust_directories.${directory.id} requires boolean publisher and reports fields`); + } + if (directory.publisher) publishers += 1; + } + if (publishers !== 1) { + throw new Error("scenario-invalid: exactly one trust directory must have publisher: true"); + } + return config; +} + +export function publisherDirectory(config: ScenarioConfig): TrustDirectoryConfig { + const directory = config.trust_directories.find((candidate) => candidate.publisher); + if (!directory) throw new Error("scenario-invalid: publishing directory is missing"); + return directory; +} export async function loadScenario(path: string): Promise { const raw = await readFile(path, "utf-8"); const config = parse(raw) as ScenarioConfig; - config.trust_server.url = process.env.HTMLTRUST_TRUST_SERVER_URL || config.trust_server.url; - config.trust_server.general_api_key = process.env.HTMLTRUST_GENERAL_API_KEY || config.trust_server.general_api_key; - config.trust_server.admin_api_key = process.env.HTMLTRUST_ADMIN_API_KEY || config.trust_server.admin_api_key; + if ((config as ScenarioConfig & { trust_server?: unknown }).trust_server) { + throw new Error("scenario-invalid: trust_server was replaced by trust_directories"); + } + if (Array.isArray(config.trust_directories)) { + config.trust_directories = config.trust_directories.map((directory) => { + const prefix = directoryEnvPrefix(directory.id); + return { + ...directory, + url: process.env[`${prefix}_URL`] || directory.url, + container_url: process.env[`${prefix}_CONTAINER_URL`] || directory.container_url, + public_url: process.env[`${prefix}_PUBLIC_URL`] || directory.public_url, + general_api_key: process.env[`${prefix}_GENERAL_API_KEY`] || directory.general_api_key, + admin_api_key: process.env[`${prefix}_ADMIN_API_KEY`] || directory.admin_api_key, + }; + }); + } config.nginx_proxy_url = process.env.HTMLTRUST_NGINX_PROXY_URL || config.nginx_proxy_url; - return config; + return validateScenario(config); } function createRng(seed: number): () => number { @@ -73,10 +182,10 @@ export function generateAuthorProfiles(config: ScenarioConfig): AuthorProfile[] const cmsType: CmsType = i < wordpress ? "wordpress" : "hugo"; authors.push({ - id: "", + id: `author-${i + 1}`, name: `Author ${i + 1}`, - authorApiKey: "", keyId: "", + directoryIdentities: {}, cmsType, domain: `author${i + 1}.htmltrust.test`, malicious_pct: maliciousPcts[i], @@ -93,12 +202,13 @@ export function generateConsumerProfiles( ): ConsumerProfile[] { const rng = createRng(config.seed + 1000); const consumers: ConsumerProfile[] = []; - const authorIds = authors.map((a) => a.id || a.name); + const authorIds = authors.map((a) => a.id); + const keyIds = authors.map((a) => a.keyId || a.id); for (let i = 0; i < config.consumers.count; i++) { - const [minTrust, maxTrust] = config.consumers.trusted_authors; + const [minTrust, maxTrust] = config.consumers.personal_trust_keys; const trustCount = randInt(rng, minTrust, maxTrust); - const trustedAuthors = shuffle(rng, authorIds).slice(0, trustCount); + const personalTrustList = shuffle(rng, keyIds).slice(0, trustCount); const [minVisit, maxVisit] = config.consumers.visit_pct; const visitPct = minVisit + rng() * (maxVisit - minVisit); @@ -107,7 +217,12 @@ export function generateConsumerProfiles( consumers.push({ id: `consumer-${i + 1}`, - trustedAuthors, + personalTrustList, + directorySubscriptions: config.trust_directories.map((directory) => ({ + id: directory.id, + url: directory.public_url, + weight: directory.weight, + })), visitAuthors, willVote: rng() < config.consumers.vote_probability, captureScreenshots: rng() < config.screenshot_sample_pct / 100, diff --git a/src/lib/trust-api.ts b/src/lib/trust-api.ts index 478d925..8895755 100644 --- a/src/lib/trust-api.ts +++ b/src/lib/trust-api.ts @@ -5,15 +5,14 @@ import type { CreateAuthorResponse, SignContentResponse, KeyReputationResponse, * * Historically called "trust server" in this codebase; the spec terminology * is "trust directory" (a federated convenience registry per §2.4). The - * class name is kept generic — `TrustApiClient` — because the same + * generic `TrustApiClient` name reflects that the same * surface covers author creation, content signing, voting, key reputation, * and reporting. */ export class TrustApiClient { /** - * @param baseUrl Trust directory base URL (e.g. `http://trust-server:3000`). - * Despite the legacy hostname, this is treated as a single - * trust directory in the new multi-directory model. + * @param baseUrl Trust directory base URL, for example + * `http://trust-directory-alpha:3000`. */ constructor(private baseUrl: string, private generalApiKey: string, private adminApiKey: string) {} @@ -30,7 +29,14 @@ export class TrustApiClient { return res.json() as Promise; } - async createAuthor(data: { name: string; keyType: string; description?: string; url?: string; keyAlgorithm?: string }): Promise { + async createAuthor(data: { + name: string; + keyType: string; + description?: string; + url?: string; + keyAlgorithm?: string; + publicKey?: string; + }): Promise { return this.request("/api/authors", { method: "POST", headers: { "X-API-KEY": this.generalApiKey }, body: data }); } @@ -77,14 +83,42 @@ export class TrustApiClient { return this.request("/api/votes", { method: "POST", headers: { "X-API-KEY": this.generalApiKey }, body: data }); } + async voteSigner(data: { signerId: string; voteType: VoteType; reason?: string }): Promise { + return this.request("/api/directory/signer-votes", { + method: "POST", + headers: { "X-API-KEY": this.generalApiKey }, + body: data, + }); + } + async getKeyReputation(keyId: string): Promise { return this.request(`/api/directory/keys/${keyId}/reputation`, { method: "GET" }); } + async getSignerReputation(signerId: string): Promise<{ + keyid: string; + score: number; + reports: number; + verifiedSignatures: number; + asOf: string; + components: string[]; + methodology: string; + }> { + return this.request(`/signers/${encodeURIComponent(signerId)}/reputation`, { method: "GET" }); + } + async reportKey(keyId: string, data: { reason: string; details: string; evidence: string }): Promise<{ reportId: string; status: string }> { return this.request(`/api/directory/keys/${keyId}/report`, { method: "POST", headers: { "X-API-KEY": this.generalApiKey }, body: data }); } + async reportSigner(signerId: string, data: { reason: string; details: string; evidence: string }): Promise<{ reportId: string; status: string }> { + return this.request("/api/directory/signer-reports", { + method: "POST", + headers: { "X-API-KEY": this.generalApiKey }, + body: { signerId, ...data }, + }); + } + async reportContent(data: { contentHash: string; sourceUrl: string; targetUrl: string; reason: string; details?: string }): Promise<{ reportId: string; status: string }> { return this.request("/api/directory/content/report", { method: "POST", headers: { "X-API-KEY": this.generalApiKey }, body: data }); } diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 75e348e..18059b5 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -42,14 +42,13 @@ async function main(): Promise { const articles = tracker.getManifest().articles; - const trustDirectoryUrls = [config.trust_server.url]; - const { result: p3, sessionLogs } = await runPhase3(config, authors, articles, consumers, trustDirectoryUrls, E2E_DIR); + const { result: p3, sessionLogs } = await runPhase3(config, authors, articles, consumers, config.trust_directories, E2E_DIR); results.push(p3); const { result: p35, reports } = await runPhase35(config, authors, articles, E2E_DIR); results.push(p35); - const { result: p4, sessionLogs: postLogs } = await runPhase4(config, authors, articles, consumers, sessionLogs, reports, trustDirectoryUrls, E2E_DIR); + const { result: p4, sessionLogs: postLogs } = await runPhase4(config, authors, articles, consumers, sessionLogs, reports, config.trust_directories, E2E_DIR); results.push(p4); const p5 = await runPhase5(config, authors, articles, sessionLogs, postLogs, reports, E2E_DIR); diff --git a/src/phases/consumers.ts b/src/phases/consumers.ts index 796b4cc..aacf7a8 100644 --- a/src/phases/consumers.ts +++ b/src/phases/consumers.ts @@ -1,11 +1,11 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { runConsumerSession } from "../lib/playwright-session.js"; -import type { ScenarioConfig, AuthorProfile, Article, ConsumerProfile, SessionLog, PhaseResult } from "../types.js"; +import type { ScenarioConfig, AuthorProfile, Article, ConsumerProfile, SessionLog, PhaseResult, TrustDirectoryConfig } from "../types.js"; export async function runPhase3( config: ScenarioConfig, authors: AuthorProfile[], articles: Article[], - consumers: ConsumerProfile[], trustDirectoryUrls: string[], e2eDir: string + consumers: ConsumerProfile[], directories: TrustDirectoryConfig[], e2eDir: string ): Promise<{ result: PhaseResult; sessionLogs: SessionLog[] }> { const errors: string[] = []; const start = Date.now(); @@ -20,7 +20,7 @@ export async function runPhase3( const batch = consumers.slice(b * bs, (b + 1) * bs); console.log(`[Phase 3] Batch ${b + 1}/${total} (${batch.length} consumers)...`); const results = await Promise.allSettled(batch.map((c) => - runConsumerSession({ consumer: c, authors, articles, trustDirectoryUrls, screenshotDir: ssDir, generalApiKey: config.trust_server.general_api_key }) + runConsumerSession({ consumer: c, authors, articles, directories, screenshotDir: ssDir }) )); for (const r of results) { if (r.status === "fulfilled") sessionLogs.push(r.value); @@ -34,7 +34,13 @@ export async function runPhase3( for (const log of sessionLogs) { for (const v of log.pagesVisited) { if (!v.signatureValid) sigFails++; - const expected = log.trustedAuthors.includes(v.authorId) ? "trusted" : "verified-unknown"; + const author = authors.find((candidate) => candidate.id === v.authorId); + const hasReport = v.directoryResults.some((result) => (result.reports ?? 0) > 0); + const expected = hasReport + ? "warning" + : author && log.personalTrustList.includes(author.keyId) + ? "trusted" + : "verified-unknown"; if (v.trustIndicator !== expected) indMismatch++; } } diff --git a/src/phases/infrastructure.ts b/src/phases/infrastructure.ts index c7438d2..e21e576 100644 --- a/src/phases/infrastructure.ts +++ b/src/phases/infrastructure.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { composeUp, composeExec } from "../lib/docker.js"; import { generateNginxConfig } from "../lib/nginx-config.js"; +import { createLocalSigner } from "../lib/local-signing.js"; +import { publisherDirectory } from "../lib/scenario.js"; import { TrustApiClient } from "../lib/trust-api.js"; import type { ScenarioConfig, AuthorProfile, PhaseResult } from "../types.js"; @@ -9,37 +11,111 @@ export async function runPhase1(config: ScenarioConfig, authors: AuthorProfile[] const start = Date.now(); console.log("[Phase 1] Bringing up Docker infrastructure..."); - await generateNginxConfig(authors, path.join(e2eDir, ".runtime", "nginx.conf")); + await generateNginxConfig(authors, config.trust_directories, path.join(e2eDir, ".runtime", "nginx.conf")); await composeUp(e2eDir); // composeUp is also valid against an existing stack. Reload so a changed // scenario cannot leave nginx serving the previous author set. await composeExec(e2eDir, "nginx", ["nginx", "-s", "reload"]); - const client = new TrustApiClient(config.trust_server.url, config.trust_server.general_api_key, config.trust_server.admin_api_key); + const directories = config.trust_directories; + const publisher = publisherDirectory(config); + const clients = new Map(directories.map((directory) => [ + directory.id, + new TrustApiClient(directory.url, directory.general_api_key, directory.admin_api_key), + ])); // Seed claim types console.log("[Phase 1] Creating claim types..."); - for (const claim of [ - { name: "ContentType", description: "Type of content", possibleValues: ["Article", "Opinion", "News"] }, - { name: "License", description: "Content license", possibleValues: ["MIT", "CC-BY-4.0", "All Rights Reserved"] }, - { name: "AIAssistance", description: "AI involvement level", possibleValues: ["None", "Human+AI", "AI-only"] }, - ]) { - await client.createClaimType(claim); + for (const client of clients.values()) { + for (const claim of [ + { name: "ContentType", description: "Type of content", possibleValues: ["Article", "Opinion", "News"] }, + { name: "License", description: "Content license", possibleValues: ["MIT", "CC-BY-4.0", "All Rights Reserved"] }, + { name: "AIAssistance", description: "AI involvement level", possibleValues: ["None", "Human+AI", "AI-only"] }, + ]) { + await client.createClaimType(claim); + } } - // Create authors on trust server - console.log("[Phase 1] Creating authors..."); + // Create each publisher identity once. Other directories store opinions + // under the exact signed keyid without receiving public or private key + // material from the publishing directory. + console.log("[Phase 1] Registering authors across directories..."); for (const author of authors) { - const result = await client.createAuthor({ + const publisherClient = clients.get(publisher.id)!; + const publicKey = createLocalSigner(author.id); + const result = await publisherClient.createAuthor({ name: author.name, keyType: "HUMAN", keyAlgorithm: "ED25519", description: `${author.cmsType} author for E2E simulation`, url: `https://${author.domain}`, + publicKey, }); - author.id = result.author.id; - author.authorApiKey = result.authorApiKey; - const pubKey = await client.getAuthorPublicKey(author.id); - author.keyId = pubKey.id; - console.log(` ${author.name} (${author.cmsType}, mal=${author.malicious_pct}) -> ${author.id}`); + const pubKey = await publisherClient.getAuthorPublicKey(result.author.id); + author.keyId = `${publisher.public_url.replace(/\/$/, "")}/keys/${encodeURIComponent(pubKey.id)}`; + author.directoryIdentities[publisher.id] = { + signerId: author.keyId, + authorId: result.author.id, + keyRecordId: pubKey.id, + }; + + // Prove the publisher received only the public half. Its convenience + // signing endpoint must reject this author before any content is built. + try { + await publisherClient.signContent(result.authorApiKey, { + contentHash: "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + sourceURL: `https://${author.domain}/custody-check`, + scope: "url", + signedAt: "2026-08-28T12:00:00Z", + claims: [], + }); + errors.push(`${author.name}: publishing directory unexpectedly signed with a local private key`); + } catch (error) { + if (!String(error).includes("400")) { + errors.push(`${author.name}: public-key custody check returned an unexpected error: ${error}`); + } + } + + for (const directory of directories) { + if (directory.id === publisher.id) continue; + author.directoryIdentities[directory.id] = { + signerId: author.keyId, + }; + } + + console.log(` ${author.name} (${author.cmsType}, mal=${author.malicious_pct}) -> ${author.keyId}`); + } + + // Seed opposite directory opinions. This produces a deterministic conflict + // before consumer visits and gives the policy layer a real federation case. + for (const directory of directories) { + if (directory.initial_opinion === "neutral") continue; + const client = clients.get(directory.id)!; + for (const [authorIndex, author] of authors.entries()) { + const identity = author.directoryIdentities[directory.id]; + if (identity.authorId) { + const voteType = directory.initial_opinion === "support" ? "TRUST" : "DISTRUST"; + await client.vote({ + userId: `scenario-seed-${directory.id}`, + targetType: "AUTHOR", + targetId: identity.authorId, + voteType, + reason: `federation-${directory.initial_opinion}`, + }); + } else if (authorIndex === 0) { + const voteType = directory.initial_opinion === "support" ? "TRUST" : "DISTRUST"; + await client.voteSigner({ + signerId: author.keyId, + voteType, + reason: `federation-${directory.initial_opinion}`, + }); + if (directory.initial_opinion === "challenge") { + await client.reportSigner(author.keyId, { + reason: "OTHER", + details: "Deterministic conflicting opinion for the federation scenario", + evidence: `https://${author.domain}/`, + }); + } + } + } } // Create WordPress databases @@ -52,16 +128,26 @@ export async function runPhase1(config: ScenarioConfig, authors: AuthorProfile[] // Wait for WordPress to be ready after DB creation console.log("[Phase 1] Waiting for WordPress containers..."); + const readyContainers = new Set(); for (const author of wpAuthors) { const c = author.wpContainerName!; + let ready = false; + let lastError: unknown; for (let attempt = 0; attempt < 30; attempt++) { try { await composeExec(e2eDir, c, ["php", "-r", "echo 'ready';"]); + ready = true; break; - } catch { + } catch (error) { + lastError = error; await new Promise((r) => setTimeout(r, 2000)); } } + if (!ready) { + errors.push(`WordPress container ${c} did not become ready within 60 seconds: ${lastError}`); + } else { + readyContainers.add(c); + } } // Configure WordPress instances @@ -69,6 +155,7 @@ export async function runPhase1(config: ScenarioConfig, authors: AuthorProfile[] for (const author of wpAuthors) { try { const c = author.wpContainerName!; + if (!readyContainers.has(c)) continue; await composeExec(e2eDir, c, ["wp", "core", "install", `--url=https://${author.domain}`, `--title=${author.name} Blog`, "--admin_user=admin", "--admin_password=admin", `--admin_email=admin@${author.domain}`, @@ -90,13 +177,23 @@ export async function runPhase1(config: ScenarioConfig, authors: AuthorProfile[] } } - // Validation: all authors have keys + // Validation: every directory can state an opinion about the same signed + // key identifier, even though only the publisher generated the key pair. for (const author of authors) { - try { - const pk = await client.getAuthorPublicKey(author.id); - if (!pk.key) errors.push(`${author.name}: no public key`); - } catch (err) { - errors.push(`${author.name}: key fetch failed: ${err}`); + for (const directory of directories) { + try { + const identity = author.directoryIdentities[directory.id]; + if (identity.authorId) { + const pk = await clients.get(directory.id)!.getAuthorPublicKey(identity.authorId); + if (!pk.key) errors.push(`${author.name}: no public key in ${directory.id}`); + } + const isConflictSample = directory.initial_opinion === "challenge" && author === authors[0]; + if (identity.authorId || isConflictSample) { + await clients.get(directory.id)!.getSignerReputation(author.keyId); + } + } catch (err) { + errors.push(`${author.name}: federation registration failed in ${directory.id}: ${err}`); + } } } diff --git a/src/phases/post-report.ts b/src/phases/post-report.ts index 3343e00..7ce3090 100644 --- a/src/phases/post-report.ts +++ b/src/phases/post-report.ts @@ -1,13 +1,13 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; import { runConsumerSession } from "../lib/playwright-session.js"; -import type { ScenarioConfig, AuthorProfile, Article, ConsumerProfile, SessionLog, PhaseResult } from "../types.js"; +import type { ScenarioConfig, AuthorProfile, Article, ConsumerProfile, SessionLog, PhaseResult, TrustDirectoryConfig } from "../types.js"; import type { ResearcherReport } from "./researcher.js"; export async function runPhase4( config: ScenarioConfig, authors: AuthorProfile[], articles: Article[], allConsumers: ConsumerProfile[], prevLogs: SessionLog[], reports: ResearcherReport[], - trustDirectoryUrls: string[], e2eDir: string + directories: TrustDirectoryConfig[], e2eDir: string ): Promise<{ result: PhaseResult; sessionLogs: SessionLog[] }> { const errors: string[] = []; const start = Date.now(); @@ -27,7 +27,7 @@ export async function runPhase4( for (let i = 0; i < selected.length; i += config.consumers.batch_size) { const batch = selected.slice(i, i + config.consumers.batch_size); const results = await Promise.allSettled(batch.map((c) => - runConsumerSession({ consumer: c, authors, articles, trustDirectoryUrls, screenshotDir: ssDir, generalApiKey: config.trust_server.general_api_key }) + runConsumerSession({ consumer: c, authors, articles, directories, screenshotDir: ssDir }) )); for (const r of results) { if (r.status === "fulfilled") sessionLogs.push(r.value); diff --git a/src/phases/publish.ts b/src/phases/publish.ts index 9dee57b..7dddc3f 100644 --- a/src/phases/publish.ts +++ b/src/phases/publish.ts @@ -9,7 +9,7 @@ import { import { canonicalizeSignedContent } from "@htmltrust/browser-client"; import { generateArticle } from "../lib/ollama.js"; import { HugoPublisher } from "../lib/hugo-publisher.js"; -import { TrustApiClient } from "../lib/trust-api.js"; +import { signLocalPayload } from "../lib/local-signing.js"; import { GroundTruthTracker } from "../lib/ground-truth.js"; import { composeExec, writeFileToContainer } from "../lib/docker.js"; import type { ScenarioConfig, AuthorProfile, AIAssistance, ArticleMetadata, PhaseResult } from "../types.js"; @@ -139,7 +139,6 @@ export async function runPhase2( const errors: string[] = []; const start = Date.now(); const rng = createRng(config.seed + 3000); - const trustClient = new TrustApiClient(config.trust_server.url, config.trust_server.general_api_key, config.trust_server.admin_api_key); const [minArt, maxArt] = config.authors.articles_per_author; for (const author of authors) { @@ -166,15 +165,9 @@ export async function runPhase2( const signedAt = v1Timestamp(); const signedClaims = buildSignedClaims(author.name, signedAt, declaredMeta); - const signedClaimRecords = claimRecords(signedClaims); const innerContentHtml = `

${escapeText(content)}

`; if (author.cmsType === "wordpress") { - // Ask the trust server to sign the binding. - // NOTE: per spec §3.1 cryptographic verification SHOULD be local; the - // trust server's /content/sign endpoint is a convenience for holding - // author private keys on the author's behalf in the "signup" use case. - // // TODO(wp-plugin-handoff): Once the content-signing plugin's // publish-time wrapper hook is verified end-to-end, drop the manual // injection below and let the plugin do it. The @@ -207,30 +200,29 @@ export async function runPhase2( throw new Error(`WordPress returned an unexpected final URL for post ${postIdStr}: ${sourceURL}`); } const contentHash = computeContentHashFromHtml(innerContentHtml, sourceURL); - const expectedClaimsHash = computeClaimsHash(signedClaims); - const sigResult = await trustClient.signContent(author.authorApiKey, { + const claimsHash = computeClaimsHash(signedClaims); + const payload = buildV1SigningPayload({ contentHash, - sourceURL, + claimsHash, + documentURL: sourceURL, scope: "url", + keyid: author.keyId, + algorithm: "ed25519", signedAt, - claims: signedClaimRecords, }); - if (sigResult.claimsHash !== expectedClaimsHash) { - throw new Error(`claims hash mismatch for ${sourceURL}: local=${expectedClaimsHash} directory=${sigResult.claimsHash}`); - } + const signature = signLocalPayload(author.id, payload); // Build the signed-section wrapper with the content nested inside. // Using the wrapped form (spec §2.1 example): the // contains the content, so the browser can extract text from within // it unambiguously. - const keyId = sigResult.keyid; const signedSection = buildSignedSectionHtml({ - profile: sigResult.profile, - scope: sigResult.scope, - signature: sigResult.signature, - keyId, + profile: "htmltrust-signature-v1", + scope: "url", + signature, + keyId: author.keyId, contentHash, - algorithm: sigResult.algorithm, + algorithm: "ed25519", author: author.name, signedAt, claims: declaredMeta, @@ -257,7 +249,7 @@ export async function runPhase2( declaredMetadata: declaredMeta, actualMetadata: actualMeta, isMalicious: malCheck.isMalicious, maliciousReason: malCheck.reason, - contentHash, signature: sigResult.signature, + contentHash, signature, }); console.log(` ${title}${malCheck.isMalicious ? " [MALICIOUS]" : ""} -> ${url}`); continue; @@ -304,35 +296,30 @@ export async function runPhase2( const signedAt = v1Timestamp(); const signedClaims = buildSignedClaims(author.name, signedAt, article.declaredMetadata); - const signedClaimRecords = claimRecords(signedClaims); const innerContentHtml = `

${escapeText(article.content)}

`; const contentHash = computeContentHashFromHtml(innerContentHtml, article.url); - const expectedClaimsHash = computeClaimsHash(signedClaims); - - // Sign the canonical binding via the trust server - const sig = await trustClient.signContent(author.authorApiKey, { + const claimsHash = computeClaimsHash(signedClaims); + const payload = buildV1SigningPayload({ contentHash, - sourceURL: article.url, + claimsHash, + documentURL: article.url, scope: "url", + keyid: author.keyId, + algorithm: "ed25519", signedAt, - claims: signedClaimRecords, }); - if (sig.claimsHash !== expectedClaimsHash) { - throw new Error(`claims hash mismatch for ${article.url}: local=${expectedClaimsHash} directory=${sig.claimsHash}`); - } - - const keyId = sig.keyid; + const signature = signLocalPayload(author.id, payload); // Build a new signed-section that wraps the article text inline. // We replace the Hugo-generated standalone signed-section with our // wrapped version. const newSignedSection = buildSignedSectionHtml({ - profile: sig.profile, - scope: sig.scope, - signature: sig.signature, - keyId, + profile: "htmltrust-signature-v1", + scope: "url", + signature, + keyId: author.keyId, contentHash, - algorithm: sig.algorithm, + algorithm: "ed25519", author: author.name, signedAt, claims: article.declaredMetadata, @@ -355,7 +342,7 @@ export async function runPhase2( } await writeFile(htmlPath, newHtml); - article.signature = sig.signature; + article.signature = signature; article.contentHash = contentHash; } } catch (err) { diff --git a/src/phases/researcher.ts b/src/phases/researcher.ts index 2e3cc9b..682931b 100644 --- a/src/phases/researcher.ts +++ b/src/phases/researcher.ts @@ -2,7 +2,12 @@ import { TrustApiClient } from "../lib/trust-api.js"; import type { ScenarioConfig, AuthorProfile, Article, PhaseResult } from "../types.js"; export interface ResearcherReport { - articleId: string; authorId: string; reportId: string; reason: string; isTruePositive: boolean; + articleId: string; + authorId: string; + directoryId: string; + reportId: string; + reason: string; + isTruePositive: boolean; } export async function runPhase35( @@ -14,11 +19,22 @@ export async function runPhase35( if (!config.researcher.enabled) return { result: { phase: "researcher", success: true, duration: 0, errors: [] }, reports }; - const client = new TrustApiClient(config.trust_server.url, config.trust_server.general_api_key, config.trust_server.admin_api_key); + const reportingDirectories = config.trust_directories.filter((directory) => directory.reports); + const clients = new Map(reportingDirectories.map((directory) => [ + directory.id, + new TrustApiClient(directory.url, directory.general_api_key, directory.admin_api_key), + ])); const preScores: Record = {}; - for (const a of authors) { - if (a.keyId) preScores[a.id] = (await client.getKeyReputation(a.keyId)).trustScore; + for (const directory of reportingDirectories) { + const client = clients.get(directory.id)!; + for (const author of authors) { + try { + preScores[`${directory.id}:${author.id}`] = (await client.getSignerReputation(author.keyId)).score; + } catch { + preScores[`${directory.id}:${author.id}`] = 0.5; + } + } } console.log("[Phase 3.5] Researcher crawling..."); @@ -28,27 +44,50 @@ export async function runPhase35( for (const article of malicious) { const author = authors.find((a) => a.id === article.authorId); if (!author) continue; - try { - const r = await client.reportKey(author.keyId, { - reason: "MISINFORMATION", - details: `"${article.title}": ${article.maliciousReason}`, - evidence: article.url, - }); - reports.push({ articleId: article.id, authorId: author.id, reportId: r.reportId, reason: article.maliciousReason || "", isTruePositive: true }); - console.log(` Reported: ${article.title}`); - } catch (err) { - errors.push(`Report failed for ${article.id}: ${err}`); + for (const directory of reportingDirectories) { + try { + const identity = author.directoryIdentities[directory.id]; + const reportData = { + reason: "MISINFORMATION", + details: `"${article.title}": ${article.maliciousReason}`, + evidence: article.url, + }; + const r = identity.keyRecordId + ? await clients.get(directory.id)!.reportKey(identity.keyRecordId, reportData) + : await clients.get(directory.id)!.reportSigner(author.keyId, reportData); + reports.push({ + articleId: article.id, + authorId: author.id, + directoryId: directory.id, + reportId: r.reportId, + reason: article.maliciousReason || "", + isTruePositive: true, + }); + console.log(` Reported in ${directory.id}: ${article.title}`); + } catch (err) { + errors.push(`Report failed for ${article.id} in ${directory.id}: ${err}`); + } } } - for (const a of authors) { - if (!a.keyId) continue; - const post = (await client.getKeyReputation(a.keyId)).trustScore; - const wasReported = reports.some((r) => r.authorId === a.id); - if (wasReported && post >= (preScores[a.id] ?? 0) && malicious.some((m) => m.authorId === a.id)) - errors.push(`${a.name}: score did not decrease after reports`); - if (!wasReported && a.malicious_pct === 0 && post < (preScores[a.id] ?? 0)) - errors.push(`${a.name}: honest author score decreased`); + for (const directory of reportingDirectories) { + const client = clients.get(directory.id)!; + for (const author of authors) { + let post = 0.5; + try { + post = (await client.getSignerReputation(author.keyId)).score; + } catch { + // A directory with no local opinion contributes the neutral baseline. + } + const wasReported = reports.some((report) => report.authorId === author.id && report.directoryId === directory.id); + const pre = preScores[`${directory.id}:${author.id}`] ?? 0; + if (wasReported && post >= pre && malicious.some((article) => article.authorId === author.id)) { + errors.push(`${author.name}: score did not decrease after reports in ${directory.id}`); + } + if (!wasReported && author.malicious_pct === 0 && post < pre) { + errors.push(`${author.name}: honest author score decreased in ${directory.id}`); + } + } } return { result: { phase: "researcher", success: errors.length === 0, duration: Date.now() - start, errors }, reports }; diff --git a/src/phases/validate.ts b/src/phases/validate.ts index 3558a69..4426e21 100644 --- a/src/phases/validate.ts +++ b/src/phases/validate.ts @@ -13,12 +13,28 @@ export async function runPhase5( const resultsDir = path.join(e2eDir, "results"); await mkdir(resultsDir, { recursive: true }); - const client = new TrustApiClient(config.trust_server.url, config.trust_server.general_api_key, config.trust_server.admin_api_key); + const clients = new Map(config.trust_directories.map((directory) => [ + directory.id, + new TrustApiClient(directory.url, directory.general_api_key, directory.admin_api_key), + ])); const allLogs = [...sessionLogs, ...postReportLogs]; const authorSummary = await Promise.all(authors.map(async (a) => { const arts = articles.filter((ar) => ar.authorId === a.id); - const rep = a.keyId ? await client.getKeyReputation(a.keyId) : { trustScore: 0, reports: 0 }; + const directoryReputation = await Promise.all(config.trust_directories.map(async (directory) => { + let reputation = { score: 0.5, reports: 0 }; + try { + reputation = await clients.get(directory.id)!.getSignerReputation(a.keyId); + } catch { + // A directory with no local opinion contributes the neutral baseline. + } + return { + directoryId: directory.id, + weight: directory.weight, + trustScore: reputation.score, + reports: reputation.reports, + }; + })); let trustV = 0, distrustV = 0; for (const l of allLogs) for (const v of l.votesCast) { if (v.authorId === a.id) { if (v.vote === "TRUST") trustV++; else distrustV++; } @@ -26,10 +42,20 @@ export async function runPhase5( return { authorId: a.id, name: a.name, cmsType: a.cmsType, maliciousPct: a.malicious_pct, totalArticles: arts.length, maliciousArticles: arts.filter((x) => x.isMalicious).length, - trustScore: rep.trustScore, trustVotes: trustV, distrustVotes: distrustV, reports: rep.reports, + directoryReputation, trustVotes: trustV, distrustVotes: distrustV, }; })); + const directoryQueries = allLogs.flatMap((log) => + log.pagesVisited.flatMap((visit) => visit.directoryResults) + ); + const federationConflicts = allLogs.reduce((count, log) => count + log.pagesVisited.filter((visit) => { + const contributions = visit.directoryResults + .filter((result) => result.status === "ok" && typeof result.contribution === "number") + .map((result) => result.contribution as number); + return contributions.some((value) => value > 0) && contributions.some((value) => value < 0); + }).length, 0); + const stats = { totalSessions: allLogs.length, totalPageVisits: allLogs.reduce((s, l) => s + l.pagesVisited.length, 0), @@ -40,6 +66,12 @@ export async function runPhase5( sourceOnlyVerifications: allLogs.reduce((s, l) => s + l.pagesVisited.filter((v) => v.verificationInputState === "source-only").length, 0), totalVotes: allLogs.reduce((s, l) => s + l.votesCast.length, 0), screenshots: allLogs.reduce((s, l) => s + l.screenshots.length, 0), + directoryQueries: directoryQueries.length, + directoryFailures: directoryQueries.filter((query) => query.status !== "ok").length, + averageDirectoryLatencyMs: directoryQueries.length > 0 + ? directoryQueries.reduce((sum, query) => sum + query.latencyMs, 0) / directoryQueries.length + : 0, + federationConflicts, }; const malIds = new Set(articles.filter((a) => a.isMalicious).map((a) => a.authorId)); @@ -53,11 +85,16 @@ export async function runPhase5( await writeFile(path.join(resultsDir, "data.json"), JSON.stringify({ authorSummary, consumerStats: stats, detectionStats: detection }, null, 2)); - const csv = "author_id,name,cms,malicious_pct,articles,malicious,trust_score,trust_votes,distrust_votes,reports\n" - + authorSummary.map((a) => `${a.authorId},${a.name},${a.cmsType},${a.maliciousPct},${a.totalArticles},${a.maliciousArticles},${a.trustScore},${a.trustVotes},${a.distrustVotes},${a.reports}`).join("\n"); + const csv = "author_id,name,cms,malicious_pct,articles,malicious,trust_votes,distrust_votes,directory_reputation\n" + + authorSummary.map((a) => { + const reputation = a.directoryReputation + .map((entry) => `${entry.directoryId}:${entry.trustScore.toFixed(3)}:${entry.reports}`) + .join(";"); + return `${a.authorId},${a.name},${a.cmsType},${a.maliciousPct},${a.totalArticles},${a.maliciousArticles},${a.trustVotes},${a.distrustVotes},${reputation}`; + }).join("\n"); await writeFile(path.join(resultsDir, "summary.csv"), csv); - const report = `# HTMLTrust E2E Results\n\n## Config\n- Seed: ${config.seed}\n- Authors: ${authors.length}\n- Consumers: ${config.consumers.count}\n- Articles: ${articles.length}\n\n## Verification\n- Successes: ${stats.verificationSuccesses}\n- Failures: ${stats.verificationFailures}\n- Source/rendered matches: ${stats.sourceSnapshotMatches}\n- Stale source snapshots: ${stats.staleSourceSnapshots}\n- Source-only verifications: ${stats.sourceOnlyVerifications}\n\n## Detection\n- Precision: ${(detection.precision * 100).toFixed(1)}%\n- Recall: ${(detection.recall * 100).toFixed(1)}%\n- False positives: ${fp}\n\n## Authors\n| Name | CMS | Mal% | Score | Reports |\n|------|-----|------|-------|---------|\n${authorSummary.map((a) => `| ${a.name} | ${a.cmsType} | ${(a.maliciousPct * 100).toFixed(0)}% | ${a.trustScore.toFixed(3)} | ${a.reports} |`).join("\n")}\n`; + const report = `# HTMLTrust E2E Results\n\n## Config\n- Seed: ${config.seed}\n- Authors: ${authors.length}\n- Consumers: ${config.consumers.count}\n- Articles: ${articles.length}\n- Directories: ${config.trust_directories.map((directory) => directory.id).join(", ")}\n\n## Verification\n- Successes: ${stats.verificationSuccesses}\n- Failures: ${stats.verificationFailures}\n- Source/rendered matches: ${stats.sourceSnapshotMatches}\n- Stale source snapshots: ${stats.staleSourceSnapshots}\n- Source-only verifications: ${stats.sourceOnlyVerifications}\n- Directory queries: ${stats.directoryQueries}\n- Directory failures: ${stats.directoryFailures}\n- Average directory latency: ${stats.averageDirectoryLatencyMs.toFixed(1)} ms\n- Visits with conflicting directory contributions: ${stats.federationConflicts}\n\n## Detection\n- Precision: ${(detection.precision * 100).toFixed(1)}%\n- Recall: ${(detection.recall * 100).toFixed(1)}%\n- False positives: ${fp}\n\n## Authors\n| Name | CMS | Mal% | Directory scores and reports |\n|------|-----|------|------------------------------|\n${authorSummary.map((a) => `| ${a.name} | ${a.cmsType} | ${(a.maliciousPct * 100).toFixed(0)}% | ${a.directoryReputation.map((entry) => `${entry.directoryId}: ${entry.trustScore.toFixed(3)} (${entry.reports} reports)`).join("
")} |`).join("\n")}\n`; await writeFile(path.join(resultsDir, "report.md"), report); console.log(`[Phase 5] Results exported to ${resultsDir}`); diff --git a/src/prepare-nginx.ts b/src/prepare-nginx.ts index 34a5436..5ffcd6b 100644 --- a/src/prepare-nginx.ts +++ b/src/prepare-nginx.ts @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { generateNginxConfig } from "./lib/nginx-config.js"; @@ -7,7 +8,12 @@ const directory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".. const scenario = path.resolve(directory, process.argv[2] || "scenario-small.yaml"); const config = await loadScenario(scenario); const authors = generateAuthorProfiles(config); +await Promise.all([ + mkdir(path.join(directory, "hugo-sites"), { recursive: true }), + mkdir(path.join(directory, "hugo-sources"), { recursive: true }), + mkdir(path.join(directory, "results"), { recursive: true }), +]); const output = path.join(directory, ".runtime", "nginx.conf"); -await generateNginxConfig(authors, output); +await generateNginxConfig(authors, config.trust_directories, output); console.log(`Generated ${output} for ${authors.length} authors`); diff --git a/src/run-phases-3-5.ts b/src/run-phases-3-5.ts index efbbd6d..78f02da 100644 --- a/src/run-phases-3-5.ts +++ b/src/run-phases-3-5.ts @@ -2,7 +2,7 @@ * Runs Phases 3, 3.5, 4, and 5 against a running Docker stack. * Designed to be executed INSIDE the playwright container, which has: * - Browser binaries preinstalled - * - Docker network access to trust-server, nginx, author*.htmltrust.test + * - Docker network access to both trust directories, nginx, and author hosts * - /workspace mounted to the e2e project dir * * Prerequisites: @@ -32,12 +32,13 @@ async function main(): Promise { const config = await loadScenario(scenarioPath); - // Override trust directory URL for in-container execution (use Docker DNS) - const trustServerUrl = "http://trust-server:3000"; - // Trust directory list — currently a single entry. Multi-directory - // simulation will populate this from scenario YAML in a follow-up. - const trustDirectoryUrls = [trustServerUrl]; - console.log(`Using trust directory: ${trustServerUrl}\n`); + // Use Docker-network API URLs for server-side calls while preserving each + // HTTPS public URL for browser policy queries and signed key identifiers. + config.trust_directories = config.trust_directories.map((directory) => ({ + ...directory, + url: directory.container_url, + })); + console.log(`Using trust directories: ${config.trust_directories.map((directory) => directory.id).join(", ")}\n`); // Load ground truth from Phases 1-2 const gtPath = path.join(E2E_DIR, "results/ground-truth.json"); @@ -61,9 +62,6 @@ async function main(): Promise { console.log(`Loaded ${authors.length} authors, ${articles.length} articles`); console.log(` Authors: ${authors.map((a) => `${a.name}(${a.cmsType})`).join(", ")}`); - // Also override the scenario's trust server URL for any internal calls - config.trust_server.url = trustServerUrl; - // Generate consumer profiles deterministically const consumers = generateConsumerProfiles(config, authors); console.log(`Generated ${consumers.length} consumer profiles`); @@ -73,7 +71,7 @@ async function main(): Promise { // --- Phase 3: Consumer Browsing --- console.log("\n=== Phase 3: Consumer Browsing ==="); const { result: p3, sessionLogs } = await runPhase3( - config, authors, articles, consumers, trustDirectoryUrls, E2E_DIR + config, authors, articles, consumers, config.trust_directories, E2E_DIR ); results.push(p3); console.log(`Phase 3: ${p3.success ? "PASS" : "FAIL"} (${(p3.duration / 1000).toFixed(1)}s)`); @@ -95,7 +93,7 @@ async function main(): Promise { // --- Phase 4: Post-Report Re-visit --- console.log("\n=== Phase 4: Post-Report Consumer Pass ==="); const { result: p4, sessionLogs: postLogs } = await runPhase4( - config, authors, articles, consumers, sessionLogs, reports, trustDirectoryUrls, E2E_DIR + config, authors, articles, consumers, sessionLogs, reports, config.trust_directories, E2E_DIR ); results.push(p4); console.log(`Phase 4: ${p4.success ? "PASS" : "FAIL"} (${(p4.duration / 1000).toFixed(1)}s) -- ${postLogs.length} sessions`); diff --git a/src/smoke-test.ts b/src/smoke-test.ts index 848c779..0c7a52a 100644 --- a/src/smoke-test.ts +++ b/src/smoke-test.ts @@ -7,12 +7,14 @@ import path from "node:path"; import * as http from "node:http"; import * as https from "node:https"; import { fileURLToPath } from "node:url"; -import { loadScenario, generateAuthorProfiles } from "./lib/scenario.js"; +import { loadScenario, generateAuthorProfiles, publisherDirectory } from "./lib/scenario.js"; import { TrustApiClient } from "./lib/trust-api.js"; import { GroundTruthTracker } from "./lib/ground-truth.js"; import { composeExec } from "./lib/docker.js"; import { generateNginxConfig } from "./lib/nginx-config.js"; +import { createLocalSigner } from "./lib/local-signing.js"; import { runPhase2 } from "./phases/publish.js"; +import { prepareWordPressLocalSigningFixture } from "./wordpress-local-signing-fixture.js"; async function rawHttpGet( proxyProtocol: "http:" | "https:", @@ -59,6 +61,20 @@ async function rawHttpGet( }); } +async function waitForWordPress(e2eDir: string, container: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 30; attempt++) { + try { + await composeExec(e2eDir, container, ["php", "-r", "echo 'ready';"]); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + throw new Error(`WordPress container ${container} did not become ready within 60 seconds: ${lastError}`); +} + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const E2E_DIR = path.resolve(__dirname, ".."); @@ -76,7 +92,7 @@ async function main(): Promise { // fails, fall back to restarting the container, which is slower but // always works. console.log("=== Regenerating nginx.conf ==="); - await generateNginxConfig(authors, path.join(E2E_DIR, ".runtime", "nginx.conf")); + await generateNginxConfig(authors, config.trust_directories, path.join(E2E_DIR, ".runtime", "nginx.conf")); let reloaded = false; for (let attempt = 0; attempt < 5 && !reloaded; attempt++) { @@ -97,50 +113,87 @@ async function main(): Promise { } console.log(" nginx ready\n"); - const client = new TrustApiClient( - config.trust_server.url, - config.trust_server.general_api_key, - config.trust_server.admin_api_key - ); + const publisher = publisherDirectory(config); + const clients = new Map(config.trust_directories.map((directory) => [ + directory.id, + new TrustApiClient(directory.url, directory.general_api_key, directory.admin_api_key), + ])); + const publisherClient = clients.get(publisher.id)!; // --- Phase 1 (manual, since Docker is already running) --- console.log("=== Phase 1: Setup ==="); // Create claim types - for (const claim of [ - { name: "ContentType", description: "Type of content", possibleValues: ["Article", "Opinion", "News"] }, - { name: "License", description: "Content license", possibleValues: ["MIT", "CC-BY-4.0", "All Rights Reserved"] }, - { name: "AIAssistance", description: "AI involvement", possibleValues: ["None", "Human+AI", "AI-only"] }, - ]) { - try { await client.createClaimType(claim); } catch { /* may already exist */ } + for (const client of clients.values()) { + for (const claim of [ + { name: "ContentType", description: "Type of content", possibleValues: ["Article", "Opinion", "News"] }, + { name: "License", description: "Content license", possibleValues: ["MIT", "CC-BY-4.0", "All Rights Reserved"] }, + { name: "AIAssistance", description: "AI involvement", possibleValues: ["None", "Human+AI", "AI-only"] }, + ]) { + try { await client.createClaimType(claim); } catch { /* may already exist */ } + } } console.log(" Claim types created"); // Create authors for (const author of authors) { - const result = await client.createAuthor({ + const publicKey = createLocalSigner(author.id); + const result = await publisherClient.createAuthor({ name: author.name, keyType: "HUMAN", keyAlgorithm: "ED25519", description: `${author.cmsType} author`, url: `https://${author.domain}`, + publicKey, + }); + const pubKey = await publisherClient.getAuthorPublicKey(result.author.id); + author.keyId = `${publisher.public_url.replace(/\/$/, "")}/keys/${encodeURIComponent(pubKey.id)}`; + author.directoryIdentities[publisher.id] = { + signerId: author.keyId, + authorId: result.author.id, + keyRecordId: pubKey.id, + }; + try { + await publisherClient.signContent(result.authorApiKey, { + contentHash: "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + sourceURL: `https://${author.domain}/custody-check`, + scope: "url", + signedAt: "2026-08-28T12:00:00Z", + claims: [], + }); + throw new Error(`${author.name}: directory unexpectedly retained a private key`); + } catch (error) { + if (!String(error).includes("400")) throw error; + } + for (const directory of config.trust_directories) { + author.directoryIdentities[directory.id] ??= { signerId: author.keyId }; + } + console.log(` ${author.name} (${author.cmsType}, mal=${author.malicious_pct}) -> ${author.keyId}`); + } + + for (const directory of config.trust_directories) { + if (directory.publisher || directory.initial_opinion !== "challenge") continue; + await clients.get(directory.id)!.voteSigner({ + signerId: authors[0].keyId, + voteType: "DISTRUST", + reason: "federation-challenge", + }); + await clients.get(directory.id)!.reportSigner(authors[0].keyId, { + reason: "OTHER", + details: "Deterministic conflicting opinion for the federation scenario", + evidence: `https://${authors[0].domain}/`, }); - author.id = result.author.id; - author.authorApiKey = result.authorApiKey; - const pubKey = await client.getAuthorPublicKey(author.id); - author.keyId = pubKey.id; - console.log(` ${author.name} (${author.cmsType}, mal=${author.malicious_pct}) -> ${author.id}`); } // Create WP databases + install const wpAuthors = authors.filter((a) => a.cmsType === "wordpress"); + const wpSetupErrors: string[] = []; for (let i = 0; i < wpAuthors.length; i++) { try { const rootPassword = process.env.WP_DB_ROOT_PASSWORD || "rootpass"; await composeExec(E2E_DIR, "wp-db", ["mariadb", "-uroot", `-p${rootPassword}`, "-e", `CREATE DATABASE IF NOT EXISTS wp${i + 1};`]); const c = wpAuthors[i].wpContainerName!; - // Wait a bit for WP to be ready - await new Promise((r) => setTimeout(r, 3000)); + await waitForWordPress(E2E_DIR, c); await composeExec(E2E_DIR, c, [ "wp", "core", "install", `--url=https://${wpAuthors[i].domain}`, @@ -150,19 +203,28 @@ async function main(): Promise { "--skip-email", "--allow-root", ]); await composeExec(E2E_DIR, c, ["wp", "option", "update", "permalink_structure", "", "--allow-root"]); - const appPw = (await composeExec(E2E_DIR, c, [ - "wp", "user", "application-password", "create", "admin", "e2e-sim", "--porcelain", "--allow-root", - ])).trim(); - wpAuthors[i].wpAppPassword = appPw; - console.log(` Installed ${c} (app password: ${appPw.slice(0, 8)}...)`); + console.log(` Installed ${c}`); } catch (err) { + wpSetupErrors.push(`${wpAuthors[i].name}: ${err}`); console.error(` WP setup failed for ${wpAuthors[i].name}:`, err); } } + if (wpSetupErrors.length > 0) { + throw new Error(`WordPress setup failed: ${wpSetupErrors.join("; ")}`); + } + + if (wpAuthors.length === 0) { + throw new Error("Smoke test requires at least one WordPress author for the browser-local signing fixture"); + } + console.log(" Preparing the isolated WordPress browser-local signing fixture"); + const localSigningFixture = await prepareWordPressLocalSigningFixture(E2E_DIR, wpAuthors[0]); + console.log(` Fixture post ${localSigningFixture.postId}: ${localSigningFixture.editUrl}`); + // Verify for (const author of authors) { - const pk = await client.getAuthorPublicKey(author.id); + const identity = author.directoryIdentities[publisher.id]; + const pk = await publisherClient.getAuthorPublicKey(identity.authorId!); console.log(` ${author.name}: key=${pk.id.slice(0, 8)}... algo=${pk.algorithm}`); } diff --git a/src/types.ts b/src/types.ts index 9917542..d9ab4b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,18 +7,7 @@ export interface ScenarioConfig { consumers: ConsumerConfig; researcher: ResearcherConfig; post_report_consumers: number; - /** - * Trust directory configuration (formerly `trust_server`). For now this - * carries the single-directory shape used by every existing scenario YAML; - * downstream code (playwright-session) accepts a list of directory URLs - * via `trustDirectoryUrls` and treats this as one entry of that list. - * - * TODO(scenario-yaml): scenario.yaml / scenario-small.yaml still use the - * old `trust_server:` key. Migrate them to a `trust_directories:` array - * once the multi-directory simulation work lands. Until then we accept - * both keys here. - */ - trust_server: TrustDirectoryConfig; + trust_directories: TrustDirectoryConfig[]; ollama: OllamaConfig; nginx_proxy_url?: string; // e.g. "https://localhost:18443" when running from host } @@ -38,7 +27,7 @@ export interface MaliciousProfile { export interface ConsumerConfig { count: number; - trusted_authors: [number, number]; + personal_trust_keys: [number, number]; visit_pct: [number, number]; vote_probability: number; batch_size: number; @@ -50,22 +39,27 @@ export interface ResearcherConfig { report_threshold: number; } -/** - * Configuration for a single trust directory (formerly "trust server"). - * - * Renamed to align with the spec terminology — the prototype's "trust - * server" is a special case of a generalized trust directory. See - * src/lib/trust-api.ts for the API shape. - */ export interface TrustDirectoryConfig { + /** Stable scenario identifier used in evidence and environment overrides. */ + id: string; + /** API URL used by the host-side orchestrator. */ url: string; + /** API URL used from containers on the Compose network. */ + container_url: string; + /** HTTPS origin exposed to browser policy and embedded key identifiers. */ + public_url: string; + /** Contribution multiplier applied by consumer trust policy. */ + weight: number; + /** Exactly one directory owns the publishing identity in a scenario. */ + publisher: boolean; + /** Whether researcher reports are submitted to this directory. */ + reports: boolean; + /** Deterministic initial opinion used by the federation conflict scenario. */ + initial_opinion: "support" | "challenge" | "neutral"; general_api_key: string; admin_api_key: string; } -/** @deprecated Use TrustDirectoryConfig. Retained for legacy scenario YAML. */ -export type TrustServerConfig = TrustDirectoryConfig; - export interface OllamaConfig { model: string; host: string; @@ -81,13 +75,19 @@ export type TrustIndicator = "trusted" | "verified-unknown" | "warning"; export interface AuthorProfile { id: string; name: string; - authorApiKey: string; + /** Full key identifier used by signed content and personal trust policy. */ keyId: string; + directoryIdentities: Record; cmsType: CmsType; domain: string; malicious_pct: number; wpContainerName?: string; - wpAppPassword?: string; // WordPress Application Password for REST API +} + +export interface AuthorDirectoryIdentity { + signerId: string; + authorId?: string; + keyRecordId?: string; } export interface Article { @@ -112,7 +112,12 @@ export interface ArticleMetadata { export interface ConsumerProfile { id: string; - trustedAuthors: string[]; + personalTrustList: string[]; + directorySubscriptions: Array<{ + id: string; + url: string; + weight: number; + }>; visitAuthors: string[]; willVote: boolean; captureScreenshots: boolean; @@ -120,7 +125,7 @@ export interface ConsumerProfile { export interface SessionLog { consumerId: string; - trustedAuthors: string[]; + personalTrustList: string[]; pagesVisited: PageVisit[]; votesCast: VoteCast[]; screenshots: string[]; @@ -132,13 +137,27 @@ export interface PageVisit { timestamp: number; signatureValid: boolean; contentHashValid: boolean; + trustScore: number; trustIndicator: TrustIndicator; verificationInputState: "source-only" | "stale" | "rendered-match"; verificationReason?: string; + directoryResults: DirectoryQueryResult[]; +} + +export interface DirectoryQueryResult { + directoryId: string; + url: string; + weight: number; + status: "ok" | "unavailable" | "malformed"; + score?: number; + reports?: number; + contribution?: number; + latencyMs: number; } export interface VoteCast { authorId: string; + directoryId: string; vote: VoteType; timestamp: number; } diff --git a/src/wordpress-local-signing-fixture.ts b/src/wordpress-local-signing-fixture.ts new file mode 100644 index 0000000..5e80db5 --- /dev/null +++ b/src/wordpress-local-signing-fixture.ts @@ -0,0 +1,110 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { AuthorProfile } from "./types.js"; +import { composeExec, writeFileToContainer } from "./lib/docker.js"; + +export interface WordPressLocalSigningFixture { + postId: number; + editUrl: string; + publicUrl: string; +} + +const FIXTURE_TITLE = "HTMLTrust browser-local signing fixture"; +const FIXTURE_CONTENT = "

This post is signed by the WordPress browser-local signing fixture.

"; +const CONTAINER_POST_FILE = "/tmp/htmltrust-browser-local-signing-fixture.html"; + +/** + * Prepare an isolated WordPress post for the real browser signing check. + * + * The profile is deliberately local-only. It has no directory server and no + * API credential. The plugin's own DB class is exercised by the browser flow; + * this setup only inserts the minimum profile row needed by that class. + */ +export async function prepareWordPressLocalSigningFixture( + e2eDir: string, + author: AuthorProfile, +): Promise { + const container = author.wpContainerName; + if (!container) throw new Error("wordpress-local-signing: author has no container"); + + await composeExec(e2eDir, container, ["wp", "plugin", "activate", "content-signing", "--allow-root"]); + for (const [option, value] of [ + ["content_signing_enable_signing", "1"], + ["content_signing_embed_signature", "1"], + // Publishing the fixture must leave the actual signing action to the UI. + ["content_signing_sign_on_publish", "0"], + ]) { + await composeExec(e2eDir, container, ["wp", "option", "update", option, value, "--allow-root"]); + } + + const profileScript = String.raw` +$user = get_user_by('login', 'admin'); +if (!$user) { WP_CLI::error('admin user is missing'); } +global $wpdb; +$table = $wpdb->prefix . 'content_signing_authors'; +$profile = array( + 'wp_user_id' => (int) $user->ID, + 'signing_author_id' => 'htmltrust-browser-local-fixture', + 'server_id' => 0, + 'author_api_key_encrypted' => '', + 'default_key_type' => 'HUMAN', + 'default_claims_json' => '{}', + 'is_site_endorser' => 0, + 'updated_at' => current_time('mysql'), +); +$existing = $wpdb->get_row($wpdb->prepare("SELECT author_profile_id FROM {$table} WHERE wp_user_id = %d LIMIT 1", $user->ID)); +if (!$existing) { + $now = current_time('mysql'); + $profile['created_at'] = $now; + $ok = $wpdb->insert($table, $profile, + array('%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s', '%s')); + if (!$ok) { WP_CLI::error('could not insert local-only author profile: ' . $wpdb->last_error); } + $profileId = $wpdb->insert_id; +} else { + $ok = $wpdb->update($table, $profile, array('author_profile_id' => (int) $existing->author_profile_id), + array('%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s'), array('%d')); + if (false === $ok) { WP_CLI::error('could not reset admin profile to local-only: ' . $wpdb->last_error); } + $profileId = (int) $existing->author_profile_id; +} +echo wp_json_encode(array('profileId' => (int) $profileId, 'userId' => (int) $user->ID)); +`; + const profileResultText = (await composeExec(e2eDir, container, ["wp", "eval", profileScript, "--allow-root"])).trim(); + let profileResult: { profileId?: unknown; userId?: unknown }; + try { + profileResult = JSON.parse(profileResultText) as { profileId?: unknown; userId?: unknown }; + } catch { + throw new Error(`wordpress-local-signing: invalid profile result ${profileResultText}`); + } + const profileId = Number(profileResult.profileId); + const adminUserId = Number(profileResult.userId); + if (!Number.isInteger(profileId) || profileId <= 0 || !Number.isInteger(adminUserId) || adminUserId <= 0) { + throw new Error(`wordpress-local-signing: invalid profile result ${profileResultText}`); + } + + await writeFileToContainer(e2eDir, container, CONTAINER_POST_FILE, FIXTURE_CONTENT); + const postIdText = (await composeExec(e2eDir, container, [ + "wp", "post", "create", CONTAINER_POST_FILE, + `--post_title=${FIXTURE_TITLE}`, + "--post_status=publish", + "--post_type=post", + `--post_author=${adminUserId}`, + "--porcelain", + "--allow-root", + ])).trim(); + await composeExec(e2eDir, container, ["rm", "-f", CONTAINER_POST_FILE]); + if (!/^\d+$/.test(postIdText)) throw new Error(`wordpress-local-signing: invalid post id ${postIdText}`); + const postId = Number(postIdText); + + const publicUrl = (await composeExec(e2eDir, container, ["wp", "post", "url", postIdText, "--allow-root"])).trim(); + const parsed = new URL(publicUrl); + if (parsed.protocol !== "https:" || parsed.host !== author.domain) { + throw new Error(`wordpress-local-signing: unexpected fixture URL ${publicUrl}`); + } + + const editUrl = new URL(`/wp-admin/post.php?post=${postId}&action=edit`, publicUrl).toString(); + const fixture: WordPressLocalSigningFixture = { postId, editUrl, publicUrl }; + const resultsDir = path.join(e2eDir, "results"); + await mkdir(resultsDir, { recursive: true }); + await writeFile(path.join(resultsDir, "wordpress-local-signing.json"), `${JSON.stringify(fixture, null, 2)}\n`); + return fixture; +} diff --git a/tests/lib/local-signing.test.ts b/tests/lib/local-signing.test.ts new file mode 100644 index 0000000..c85e047 --- /dev/null +++ b/tests/lib/local-signing.test.ts @@ -0,0 +1,40 @@ +import { createPublicKey, verify } from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { + clearLocalSigners, + createLocalSigner, + signLocalPayload, +} from "../../src/lib/local-signing.js"; + +afterEach(() => clearLocalSigners()); + +describe("in-memory author signing", () => { + it("signs the exact UTF-8 payload with its registered public key", () => { + const publicKeyPem = createLocalSigner("author-1"); + const payload = '{"profile":"htmltrust-signature-v1","title":"café"}'; + const signature = signLocalPayload("author-1", payload); + + expect(signature).not.toContain("="); + expect(verify( + null, + Buffer.from(payload, "utf-8"), + createPublicKey(publicKeyPem), + Buffer.from(signature, "base64"), + )).toBe(true); + expect(verify( + null, + Buffer.from(`${payload} `, "utf-8"), + createPublicKey(publicKeyPem), + Buffer.from(signature, "base64"), + )).toBe(false); + }); + + it("never exposes a private key and fails after memory is cleared", () => { + const publicKeyPem = createLocalSigner("author-2"); + expect(publicKeyPem).toContain("BEGIN PUBLIC KEY"); + expect(publicKeyPem).not.toContain("PRIVATE KEY"); + + clearLocalSigners(); + expect(() => signLocalPayload("author-2", "payload")).toThrow("local-signer-missing"); + }); +}); diff --git a/tests/lib/nginx-config.test.ts b/tests/lib/nginx-config.test.ts index 5c7a886..46a131f 100644 --- a/tests/lib/nginx-config.test.ts +++ b/tests/lib/nginx-config.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { generateNginxConfig } from "../../src/lib/nginx-config.js"; -import type { AuthorProfile } from "../../src/types.js"; +import type { AuthorProfile, TrustDirectoryConfig } from "../../src/types.js"; const scratch: string[] = []; @@ -20,8 +20,8 @@ describe("generateNginxConfig", () => { { id: "wp", name: "WordPress author", - authorApiKey: "key", keyId: "key-id", + directoryIdentities: {}, cmsType: "wordpress", domain: "author1.htmltrust.test", malicious_pct: 0, @@ -30,15 +30,41 @@ describe("generateNginxConfig", () => { { id: "hugo", name: "Hugo author", - authorApiKey: "key", keyId: "key-id", + directoryIdentities: {}, cmsType: "hugo", domain: "author2.htmltrust.test", malicious_pct: 0, }, ] satisfies AuthorProfile[]; + const directories = [ + { + id: "alpha", + url: "http://localhost:3000", + container_url: "http://trust-directory-alpha:3000", + public_url: "https://trust-a.htmltrust.test", + weight: 1, + publisher: true, + reports: false, + initial_opinion: "support", + general_api_key: "general", + admin_api_key: "admin", + }, + { + id: "beta", + url: "http://localhost:3001", + container_url: "http://trust-directory-beta:3000", + public_url: "https://trust-b.htmltrust.test", + weight: 0.75, + publisher: false, + reports: true, + initial_opinion: "challenge", + general_api_key: "general", + admin_api_key: "admin", + }, + ] satisfies TrustDirectoryConfig[]; - await generateNginxConfig(authors, output); + await generateNginxConfig(authors, directories, output); const config = await readFile(output, "utf8"); expect(config).toContain("listen 80; listen 443 ssl;"); @@ -48,8 +74,12 @@ describe("generateNginxConfig", () => { expect(config).toContain("proxy_set_header X-Forwarded-Proto $scheme;"); expect(config).toContain("server_name author2.htmltrust.test;"); expect(config).toContain("root /var/www/hugo/author2;"); - expect(config).toContain("server_name trust.htmltrust.test;"); - expect(config).toContain("proxy_pass http://trust-server:3000;"); + expect(config).toContain("server_name trust-a.htmltrust.test;"); + expect(config).toContain("proxy_pass http://trust-directory-alpha:3000;"); + expect(config).toContain("server_name trust-b.htmltrust.test;"); + expect(config).toContain("proxy_pass http://trust-directory-beta:3000;"); expect(config).toContain("listen 443 ssl default_server;"); + expect(config).toContain("location = /healthz { return 200 'ok'; }"); + expect(config).toContain("location / { return 404; }"); }); }); diff --git a/tests/lib/playwright-session.test.ts b/tests/lib/playwright-session.test.ts index 92479b3..e7d710d 100644 --- a/tests/lib/playwright-session.test.ts +++ b/tests/lib/playwright-session.test.ts @@ -1,5 +1,51 @@ import { describe, expect, it } from "vitest"; -import { captureSourceSnapshot, mapSourceSnapshot } from "../../src/lib/playwright-session.js"; +import { + captureSourceSnapshot, + createDirectoryEvidenceFetch, + evaluateFederatedTrust, + mapSourceSnapshot, +} from "../../src/lib/playwright-session.js"; +import type { TrustDirectoryConfig } from "../../src/types.js"; + +const directories = [ + { + id: "alpha", + url: "http://localhost:3000", + container_url: "http://trust-directory-alpha:3000", + public_url: "https://trust-a.htmltrust.test", + weight: 1, + publisher: true, + reports: false, + initial_opinion: "support", + general_api_key: "general", + admin_api_key: "admin", + }, + { + id: "beta", + url: "http://localhost:3001", + container_url: "http://trust-directory-beta:3000", + public_url: "https://trust-b.htmltrust.test", + weight: 0.5, + publisher: false, + reports: true, + initial_opinion: "challenge", + general_api_key: "general", + admin_api_key: "admin", + }, +] satisfies TrustDirectoryConfig[]; + +const verify = { + valid: true, + keyid: "https://trust-a.htmltrust.test/keys/k_test", + algorithm: "ed25519", + contentHash: "sha256:content", + claimsHash: "sha256:claims", + claims: {}, + signedAt: "2026-08-28T12:00:00Z", + domain: "https://author.htmltrust.test", + origin: "https://author.htmltrust.test", + inputState: "rendered-match" as const, +}; describe("browser lifecycle evidence", () => { it("fails closed when response source capture or identity mapping is incomplete", () => { @@ -39,3 +85,79 @@ describe("browser lifecycle evidence", () => { expect(failed).toEqual({ html: "", url: "https://example.test/requested-failed", sections: [] }); }); }); + +describe("federated directory evidence", () => { + it("records exact signer routes and conflicting weighted contributions", async () => { + const calls: string[] = []; + const transport: typeof fetch = async (input) => { + const url = String(input); + calls.push(url); + const score = new URL(url).hostname === "trust-a.htmltrust.test" ? 0.9 : 0.1; + return new Response(JSON.stringify({ score, reports: 0 }), { status: 200 }); + }; + + const result = await evaluateFederatedTrust(verify, { + personalTrustList: [], + directorySubscriptions: directories.map((directory) => ({ + id: directory.id, + url: directory.public_url, + weight: directory.weight, + })), + }, directories, transport); + + const encoded = encodeURIComponent(verify.keyid); + expect(calls).toEqual([ + `https://trust-a.htmltrust.test/signers/${encoded}/reputation`, + `https://trust-b.htmltrust.test/signers/${encoded}/reputation`, + ]); + expect(result.directoryResults.map((entry) => entry.status)).toEqual(["ok", "ok"]); + expect(result.directoryResults.map((entry) => entry.contribution)).toEqual([16, -8]); + expect(result.trust.score).toBe(58); + expect(result.directoryResults.every((entry) => entry.latencyMs >= 0)).toBe(true); + }); + + it("records no-opinion and malformed responses without inventing scores", async () => { + const result = await evaluateFederatedTrust(verify, { + personalTrustList: [], + directorySubscriptions: directories.map((directory) => ({ + id: directory.id, + url: directory.public_url, + weight: directory.weight, + })), + }, directories, async (input) => { + return new URL(String(input)).hostname === "trust-a.htmltrust.test" + ? new Response(JSON.stringify({ type: "no-opinion" }), { status: 404 }) + : new Response(JSON.stringify({ score: "high" }), { status: 200 }); + }); + + expect(result.trust.score).toBe(50); + expect(result.directoryResults.map((entry) => entry.status)).toEqual(["unavailable", "malformed"]); + expect(result.directoryResults.every((entry) => entry.score === undefined)).toBe(true); + }); + + it("records reports and preserves the policy override", async () => { + const result = await evaluateFederatedTrust(verify, { + personalTrustList: [verify.keyid], + directorySubscriptions: directories.map((directory) => ({ + id: directory.id, + url: directory.public_url, + weight: directory.weight, + })), + }, directories, async () => new Response(JSON.stringify({ score: 0.5, reports: 1 }), { status: 200 })); + + expect(result.reports).toBe(2); + expect(result.trust.indicator).toBe("red"); + expect(result.directoryResults.map((entry) => entry.reports)).toEqual([1, 1]); + }); + + it("maps container URLs to stable directory identifiers", async () => { + const evidence = createDirectoryEvidenceFetch( + directories, + async () => new Response(JSON.stringify({ score: 0.75 }), { status: 200 }), + ); + await evidence.fetchImpl("http://trust-directory-beta:3000/signers/test/reputation"); + expect(evidence.results).toHaveLength(1); + expect(evidence.results[0].directoryId).toBe("beta"); + expect(evidence.results[0].url).toBe("https://trust-b.htmltrust.test"); + }); +}); diff --git a/tests/lib/scenario.test.ts b/tests/lib/scenario.test.ts index a6af58a..acc225d 100644 --- a/tests/lib/scenario.test.ts +++ b/tests/lib/scenario.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { loadScenario, generateAuthorProfiles, generateConsumerProfiles } from "../../src/lib/scenario.js"; +import { loadScenario, generateAuthorProfiles, generateConsumerProfiles, validateScenario } from "../../src/lib/scenario.js"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,38 +13,69 @@ describe("loadScenario", () => { expect(config.authors.count).toBe(10); expect(config.authors.cms_split.wordpress).toBe(5); expect(config.consumers.count).toBe(1000); - expect(config.trust_server.url).toBe("http://trust-server:3000"); + expect(config.trust_directories).toHaveLength(2); + expect(config.trust_directories[0].id).toBe("alpha"); + expect(config.trust_directories[0].url).toBe("http://localhost:3000"); + expect(config.trust_directories[1].id).toBe("beta"); + expect(config.trust_directories.filter((directory) => directory.publisher)).toHaveLength(1); }); it("applies runtime service and credential overrides", async () => { const previous = { - url: process.env.HTMLTRUST_TRUST_SERVER_URL, - general: process.env.HTMLTRUST_GENERAL_API_KEY, - admin: process.env.HTMLTRUST_ADMIN_API_KEY, + url: process.env.HTMLTRUST_DIRECTORY_ALPHA_URL, + general: process.env.HTMLTRUST_DIRECTORY_ALPHA_GENERAL_API_KEY, + admin: process.env.HTMLTRUST_DIRECTORY_ALPHA_ADMIN_API_KEY, proxy: process.env.HTMLTRUST_NGINX_PROXY_URL, }; - process.env.HTMLTRUST_TRUST_SERVER_URL = "http://trust.test:3001"; - process.env.HTMLTRUST_GENERAL_API_KEY = "runtime-general"; - process.env.HTMLTRUST_ADMIN_API_KEY = "runtime-admin"; + process.env.HTMLTRUST_DIRECTORY_ALPHA_URL = "http://trust.test:3001"; + process.env.HTMLTRUST_DIRECTORY_ALPHA_GENERAL_API_KEY = "runtime-general"; + process.env.HTMLTRUST_DIRECTORY_ALPHA_ADMIN_API_KEY = "runtime-admin"; process.env.HTMLTRUST_NGINX_PROXY_URL = "http://proxy.test:8081"; try { const config = await loadScenario(scenarioPath); - expect(config.trust_server.url).toBe("http://trust.test:3001"); - expect(config.trust_server.general_api_key).toBe("runtime-general"); - expect(config.trust_server.admin_api_key).toBe("runtime-admin"); + expect(config.trust_directories[0].url).toBe("http://trust.test:3001"); + expect(config.trust_directories[0].general_api_key).toBe("runtime-general"); + expect(config.trust_directories[0].admin_api_key).toBe("runtime-admin"); expect(config.nginx_proxy_url).toBe("http://proxy.test:8081"); } finally { - if (previous.url === undefined) delete process.env.HTMLTRUST_TRUST_SERVER_URL; - else process.env.HTMLTRUST_TRUST_SERVER_URL = previous.url; - if (previous.general === undefined) delete process.env.HTMLTRUST_GENERAL_API_KEY; - else process.env.HTMLTRUST_GENERAL_API_KEY = previous.general; - if (previous.admin === undefined) delete process.env.HTMLTRUST_ADMIN_API_KEY; - else process.env.HTMLTRUST_ADMIN_API_KEY = previous.admin; + if (previous.url === undefined) delete process.env.HTMLTRUST_DIRECTORY_ALPHA_URL; + else process.env.HTMLTRUST_DIRECTORY_ALPHA_URL = previous.url; + if (previous.general === undefined) delete process.env.HTMLTRUST_DIRECTORY_ALPHA_GENERAL_API_KEY; + else process.env.HTMLTRUST_DIRECTORY_ALPHA_GENERAL_API_KEY = previous.general; + if (previous.admin === undefined) delete process.env.HTMLTRUST_DIRECTORY_ALPHA_ADMIN_API_KEY; + else process.env.HTMLTRUST_DIRECTORY_ALPHA_ADMIN_API_KEY = previous.admin; if (previous.proxy === undefined) delete process.env.HTMLTRUST_NGINX_PROXY_URL; else process.env.HTMLTRUST_NGINX_PROXY_URL = previous.proxy; } }); + + it("rejects unsafe or ambiguous directory endpoints", async () => { + const original = await loadScenario(scenarioPath); + const withChange = (change: Partial<(typeof original.trust_directories)[number]>) => ({ + ...original, + trust_directories: original.trust_directories.map((directory, index) => + index === 0 ? { ...directory, ...change } : { ...directory }, + ), + }); + + expect(() => validateScenario(withChange({ public_url: "http://directory.example" }))) + .toThrow("must use HTTPS"); + expect(() => validateScenario(withChange({ container_url: "file:///etc/passwd" }))) + .toThrow("must use HTTP or HTTPS"); + expect(() => validateScenario(withChange({ url: "http://user:secret@localhost:3000" }))) + .toThrow("must not contain credentials"); + expect(() => validateScenario(withChange({ public_url: "https://directory.example/api" }))) + .toThrow("must be an origin"); + expect(() => validateScenario(withChange({ weight: 0 }))) + .toThrow("greater than 0"); + expect(() => validateScenario(withChange({ public_url: original.trust_directories[1].public_url }))) + .toThrow("duplicate public directory origin"); + expect(() => validateScenario(withChange({ url: original.trust_directories[1].url }))) + .toThrow("duplicate directory host origin"); + expect(() => validateScenario(withChange({ container_url: original.trust_directories[1].container_url }))) + .toThrow("duplicate directory container origin"); + }); }); describe("generateAuthorProfiles", () => { @@ -82,16 +113,18 @@ describe("generateConsumerProfiles", () => { const consumers1 = generateConsumerProfiles(config, authors); const consumers2 = generateConsumerProfiles(config, authors); expect(consumers1).toHaveLength(1000); - expect(consumers1[0].trustedAuthors).toEqual(consumers2[0].trustedAuthors); + expect(consumers1[0].personalTrustList).toEqual(consumers2[0].personalTrustList); + expect(consumers1[0].directorySubscriptions).toEqual(consumers2[0].directorySubscriptions); }); - it("assigns trusted authors within configured range", async () => { + it("assigns personal trust keys within configured range", async () => { const config = await loadScenario(scenarioPath); const authors = generateAuthorProfiles(config); const consumers = generateConsumerProfiles(config, authors); for (const c of consumers) { - expect(c.trustedAuthors.length).toBeGreaterThanOrEqual(0); - expect(c.trustedAuthors.length).toBeLessThanOrEqual(5); + expect(c.personalTrustList.length).toBeGreaterThanOrEqual(0); + expect(c.personalTrustList.length).toBeLessThanOrEqual(5); + expect(c.directorySubscriptions.map((directory) => directory.id)).toEqual(["alpha", "beta"]); } }); diff --git a/tests/lib/trust-api.test.ts b/tests/lib/trust-api.test.ts index 35b43b7..4353c80 100644 --- a/tests/lib/trust-api.test.ts +++ b/tests/lib/trust-api.test.ts @@ -6,12 +6,12 @@ vi.stubGlobal("fetch", mockFetch); describe("TrustApiClient", () => { let client: TrustApiClient; - beforeEach(() => { mockFetch.mockReset(); client = new TrustApiClient("http://trust-server:3000", "general-key", "admin-key"); }); + beforeEach(() => { mockFetch.mockReset(); client = new TrustApiClient("http://trust-directory-alpha:3000", "general-key", "admin-key"); }); it("creates an author with correct headers", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ author: { id: "a1", name: "Test" }, authorApiKey: "author-key-1" }) }); const result = await client.createAuthor({ name: "Test", keyType: "HUMAN", keyAlgorithm: "ED25519" }); - expect(mockFetch).toHaveBeenCalledWith("http://trust-server:3000/api/authors", { + expect(mockFetch).toHaveBeenCalledWith("http://trust-directory-alpha:3000/api/authors", { method: "POST", headers: { "Content-Type": "application/json", "X-API-KEY": "general-key" }, body: JSON.stringify({ name: "Test", keyType: "HUMAN", keyAlgorithm: "ED25519" }), }); @@ -31,7 +31,7 @@ describe("TrustApiClient", () => { { name: "claim:ContentType", content: "Article" }, ], }); - expect(mockFetch).toHaveBeenCalledWith("http://trust-server:3000/api/content/sign", expect.objectContaining({ + expect(mockFetch).toHaveBeenCalledWith("http://trust-directory-alpha:3000/api/content/sign", expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json", "X-AUTHOR-API-KEY": "author-key-1" }, })); // Verify the new binding fields are in the request body @@ -55,13 +55,27 @@ describe("TrustApiClient", () => { it("casts a vote", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ _id: "v1", voteType: "TRUST" }) }); await client.vote({ userId: "consumer-1", targetType: "AUTHOR", targetId: "a1", voteType: "TRUST" }); - expect(mockFetch).toHaveBeenCalledWith("http://trust-server:3000/api/votes", expect.objectContaining({ method: "POST" })); + expect(mockFetch).toHaveBeenCalledWith("http://trust-directory-alpha:3000/api/votes", expect.objectContaining({ method: "POST" })); + }); + + it("casts an exact-keyid vote for a foreign signer", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ voteId: "v2", voteType: "DISTRUST" }) }); + const signerId = "https://trust-directory-beta.example/keys/name%2Fescaped"; + await client.voteSigner({ signerId, voteType: "DISTRUST", reason: "conflicting opinion" }); + expect(mockFetch).toHaveBeenCalledWith( + "http://trust-directory-alpha:3000/api/directory/signer-votes", + { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-KEY": "general-key" }, + body: JSON.stringify({ signerId, voteType: "DISTRUST", reason: "conflicting opinion" }), + }, + ); }); it("reports a key", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ reportId: "r1", status: "PENDING" }) }); await client.reportKey("key-1", { reason: "MISINFORMATION", details: "Misleading", evidence: "http://example.test" }); - expect(mockFetch).toHaveBeenCalledWith("http://trust-server:3000/api/directory/keys/key-1/report", expect.objectContaining({ method: "POST" })); + expect(mockFetch).toHaveBeenCalledWith("http://trust-directory-alpha:3000/api/directory/keys/key-1/report", expect.objectContaining({ method: "POST" })); }); it("gets key reputation", async () => { From 62664744aded37d4b372c2b6bd9a9ac3ed20368c Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 15:06:33 -0500 Subject: [PATCH 2/8] chore(e2e): refresh frozen CMS pin --- README.md | 2 +- scripts/run-e2e.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 48956f4..ecd07dd 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The frozen v1 integration uses these immutable revisions: git -C htmltrust-canonicalization checkout 760593d4a02e9fffa56dc4d002eb52ab2ade1b49 git -C htmltrust-browser-client checkout 70c5ddb6ed23c06c0b1c46d5284618fb99a28aac git -C htmltrust-browser-reference checkout b9ec8a2af7d495ece58b5027b4f4cb97c7e5f3ff -git -C htmltrust-cms-reference checkout cf050a9679610cf58103e641aab14a739c7d4503 +git -C htmltrust-cms-reference checkout 1b94416250b98123c125e60da92d6a6f2e16a9ce git -C htmltrust-server-reference checkout 07a286dfd0a219e75286e983315d5a886e9e1a2d ``` diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index 6cd19a6..a2cbcfd 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -46,7 +46,7 @@ declare -A expected_revisions=( [htmltrust-canonicalization]=760593d4a02e9fffa56dc4d002eb52ab2ade1b49 [htmltrust-browser-client]=70c5ddb6ed23c06c0b1c46d5284618fb99a28aac [htmltrust-browser-reference]=b9ec8a2af7d495ece58b5027b4f4cb97c7e5f3ff - [htmltrust-cms-reference]=cf050a9679610cf58103e641aab14a739c7d4503 + [htmltrust-cms-reference]=1b94416250b98123c125e60da92d6a6f2e16a9ce [htmltrust-server-reference]=07a286dfd0a219e75286e983315d5a886e9e1a2d ) From 560f3343f144ae874b62bdfee28e5bc5e9efaa2e Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 17:03:30 -0500 Subject: [PATCH 3/8] test(e2e): cover lifecycle across browser engines --- .github/workflows/ci.yml | 4 +++ package.json | 3 ++- scripts/browser-lifecycle-test.ts | 42 +++++++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7680163..29250f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,10 @@ jobs: npm run test:analysis npm run build + - name: Browser lifecycle engines + working-directory: htmltrust-e2e + run: npm run test:browser:engines + - name: Audit dependencies working-directory: htmltrust-e2e run: npm audit --audit-level=low diff --git a/package.json b/package.json index 5654fbf..a3d1c7b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "browser:small": "docker compose run --rm --entrypoint npx playwright tsx src/run-phases-3-5.ts scenario-small.yaml", "analyze": "python3 analysis/analyze.py results", "test:analysis": "python3 -m unittest discover -s analysis -p 'test_*.py'", - "test:browser": "docker compose run --rm --no-deps --entrypoint npx playwright tsx scripts/browser-lifecycle-test.ts", + "test:browser": "npm run test:browser:engines", + "test:browser:engines": "docker compose run --rm --no-deps --entrypoint npx playwright tsx scripts/browser-lifecycle-test.ts", "test:wordpress-local-signing": "docker compose run --rm --entrypoint npx playwright tsx scripts/wordpress-local-signing-test.ts", "e2e:small": "./scripts/run-e2e.sh scenario-small.yaml", "test": "vitest run", diff --git a/scripts/browser-lifecycle-test.ts b/scripts/browser-lifecycle-test.ts index 012d1ce..291ae06 100644 --- a/scripts/browser-lifecycle-test.ts +++ b/scripts/browser-lifecycle-test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chromium, type Page } from "playwright"; +import { chromium, firefox, webkit, type BrowserType, type Page } from "playwright"; import { collectPageSectionIdentities, DOM_SCRIPT_BODY } from "../src/lib/playwright-session.js"; const expression = `(async () => { ${DOM_SCRIPT_BODY} })()`; @@ -27,8 +27,26 @@ async function runWalker(page: Page, html: string, sections: string[]): Promise< return await page.evaluate(expression) as unknown[]; } -async function main(): Promise { - const browser = await chromium.launch({ headless: true }); +type BrowserEngine = "chromium" | "firefox" | "webkit"; + +const browserTypes: Record = { chromium, firefox, webkit }; + +function requestedEngines(): BrowserEngine[] { + const value = process.env.HTMLTRUST_BROWSERS?.trim(); + if (!value) return ["chromium", "firefox", "webkit"]; + const engines = value.split(",").map((name) => name.trim()).filter(Boolean); + if (engines.length === 0 || engines.some((name) => !Object.hasOwn(browserTypes, name))) { + throw new Error("HTMLTRUST_BROWSERS must contain chromium, firefox, and/or webkit"); + } + return engines as BrowserEngine[]; +} + +// Keep the fixture and assertions shared: changing only the Playwright engine +// proves that the production page-context verifier/lifecycle code runs in +// each engine. The Chromium extension is an optional packaging layer and is +// not required for this source/rendered lifecycle contract. +async function runLifecycleChecks(engine: BrowserEngine): Promise { + const browser = await browserTypes[engine].launch({ headless: true }); const page = await browser.newPage(); const calls: ScoreInput[] = []; await page.exposeFunction("__htmltrustVerifyAndScore", async (input: ScoreInput) => { @@ -86,7 +104,10 @@ async function main(): Promise { calls.length = 0; await runWalker(page, mutable, [mutable]); await page.locator("signed-section").evaluate((section) => { section.textContent = "after"; }); - await page.waitForTimeout(0); + await page.locator(".cs-verification-badges").waitFor({ state: "attached" }); + await page.waitForFunction(() => + document.querySelector(".cs-verification-badges")?.getAttribute("data-verification-state") === "stale", + ); assert.equal(await page.locator(".cs-verification-badges").getAttribute("data-verification-state"), "stale"); assert.equal(await page.locator(".cs-validity-badge").textContent(), "⚠ Rendered content INVALID (source differs)"); assert.equal(calls.length, 1); @@ -98,6 +119,9 @@ async function main(): Promise { await navigate(page, first); await runWalker(page, first, [first]); await navigate(page, second); + assert.equal(await page.evaluate(() => + (window as unknown as { __htmltrustSourceSnapshot?: unknown }).__htmltrustSourceSnapshot, + ), undefined); calls.length = 0; const reloadResult = await runWalker(page, second, [second]); assert.equal(calls.length, 1); @@ -111,5 +135,13 @@ async function main(): Promise { } } +async function main(): Promise { + const engines = requestedEngines(); + for (const engine of engines) { + await runLifecycleChecks(engine); + console.log(`${engine} browser lifecycle checks passed`); + } + console.log(`browser lifecycle checks passed (${engines.join(", ")})`); +} + await main(); -console.log("browser lifecycle checks passed"); From 45fd0502d3aa2714585e684fdea91294db3a2e94 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 17:03:39 -0500 Subject: [PATCH 4/8] fix(e2e): use reachable Ollama host in full scenario --- scenario.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario.yaml b/scenario.yaml index fdd09f6..1991a07 100644 --- a/scenario.yaml +++ b/scenario.yaml @@ -57,4 +57,4 @@ trust_directories: ollama: model: "llama3.2:3b" - host: "http://host.docker.internal:11434" + host: "http://localhost:11434" From f8ca8a25bff84d67da1231c1843beca529ed1af7 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 17:03:43 -0500 Subject: [PATCH 5/8] docs(e2e): explain engine and full-scenario runs --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ecd07dd..19e2384 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - For: contributors and continuous integration maintainers - Reading time: 8 minutes -This harness publishes v1 signed content through WordPress and Hugo, serves it over test HTTPS, verifies the original response source in Chromium, and records research output. Simulated authors generate Ed25519 keys locally. The alpha directory receives public keys only. Browser policy combines weighted opinions from independent alpha and beta directory databases. +This harness publishes v1 signed content through WordPress and Hugo, serves it over test HTTPS, verifies the original response source, and records research output. Simulated authors generate Ed25519 keys locally. The alpha directory receives public keys only. Browser policy combines weighted opinions from independent alpha and beta directory databases. ## Choose a path @@ -15,9 +15,10 @@ This harness publishes v1 signed content through WordPress and Hugo, serves it o - Run `npm test -- tests/lib/playwright-session.test.ts && npm run build` for the browser lifecycle evidence checks (source mapping, nested markers, mutation invalidation, and reload snapshot recovery). -- Run `npm run test:browser` for the same lifecycle checks in the production - DOM walker. This uses the checked-in Playwright Docker image and does not - start the integration stack; `npm test` remains browser-download-free. +- Run `npm run test:browser` for lifecycle checks in the production DOM walker + across Chromium, Firefox, and WebKit. This uses the checked-in Playwright + Docker image and does not start the integration stack; `npm test` remains + browser-download-free. - Run `npm run test:wordpress-local-signing` after the smoke setup to exercise the CMS plugin's admin UI, local key document, and emitted signature. - Run `npm run e2e:small` for the complete three-author simulation. @@ -104,7 +105,13 @@ Hugo integration repository. This harness writes its own temporary Hugo sites and uses the Hugo partial bundled in `htmltrust-cms-reference`; it does not consume the separate `htmltrust-hugo` repository. -The browser phase uses the sibling browser-reference checkout and its Chromium build. The one-command runner builds it before starting Docker. +The full simulation uses the sibling browser-reference checkout and its +Chromium build. The one-command runner builds it before starting Docker. The +standalone lifecycle suite does not require the extension build: all three +engines execute the same production verification and lifecycle DOM walker, so +Firefox and WebKit still exercise real source extraction, rendered comparison, +mutation invalidation, and navigation reset behavior despite the reference +extension currently targeting Chromium at runtime. ## Install and check the harness @@ -128,7 +135,7 @@ npm run build These checks need the canonicalization and browser-client sibling directories. They do not start Docker, Hugo, or Ollama. Install and build the -browser-reference extension only for the browser flow: +browser-reference extension for the full simulation: ```bash cd ../htmltrust-browser-reference @@ -164,7 +171,15 @@ The runner installs and builds the sibling browser packages, checks this reposit ## Run the full simulation -The checked-in full scenario uses ten authors and 1,000 consumers. Copy it before changing the Ollama endpoint or model: +The checked-in full scenario uses ten authors and 1,000 consumers. It expects +Ollama on the host with the 3B model: + +```bash +ollama pull llama3.2:3b +./scripts/run-e2e.sh scenario.yaml +``` + +Copy the scenario first if you want to change its endpoint or model: ```bash cp scenario.yaml scenario-local.yaml @@ -172,7 +187,37 @@ cp scenario.yaml scenario-local.yaml ./scripts/run-e2e.sh scenario-local.yaml ``` -Publication runs on the host, so use `http://localhost:11434` as the Ollama host. Browser verification runs inside Docker. The generated article URLs remain `https://authorN.htmltrust.test/...` on the Docker network. +Publication runs on the host, so both checked-in scenarios use +`http://localhost:11434` for Ollama. Browser verification runs inside Docker. +The generated article URLs remain +`https://authorN.htmltrust.test/...` on the Docker network. + +## Run browser lifecycle checks in Docker + +The browser lifecycle command runs all three Playwright engines without +starting the integration stack: + +```bash +npm run test:browser +``` + +This command runs inside the pinned Playwright image, which supplies the +browser binaries. To run one engine while debugging, pass its name to the +script directly: + +```bash +docker compose run --rm --no-deps --env HTMLTRUST_BROWSERS=firefox \ + --entrypoint npx playwright tsx scripts/browser-lifecycle-test.ts +``` + +The suite drives the production `DOM_SCRIPT_BODY` used by consumer sessions. +It keeps the verifier boundary in Node, captures source sections separately +from live `outerHTML`, waits for mutation invalidation, and replaces the +navigation snapshot before checking the next document. The browser-reference +extension is not loaded into Firefox or WebKit because its current runtime +adapter is Chromium-specific. Chromium extension loading remains an optional +integration concern; the cross-engine lifecycle assertions do not depend on +that packaging detail. ## Run browser phases in Docker From ab100b0e6c2ed68895ccf6435e0c5a7bba285073 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 17:44:14 -0500 Subject: [PATCH 6/8] test(e2e): load Chromium extension in smoke run --- README.md | 34 +++-- package.json | 4 +- scripts/chromium-extension-smoke-test.ts | 180 +++++++++++++++++++++++ scripts/run-e2e.sh | 5 +- tsconfig.scripts.json | 9 ++ 5 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 scripts/chromium-extension-smoke-test.ts create mode 100644 tsconfig.scripts.json diff --git a/README.md b/README.md index 19e2384..6100841 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ This harness publishes v1 signed content through WordPress and Hugo, serves it o browser-download-free. - Run `npm run test:wordpress-local-signing` after the smoke setup to exercise the CMS plugin's admin UI, local key document, and emitted signature. +- Run `npm run test:browser:extension` after publication to load the built MV3 + extension and verify the first article in Chromium. - Run `npm run e2e:small` for the complete three-author simulation. - Use the split commands below when you need to inspect the stack between publication and browser verification. @@ -67,7 +69,7 @@ The frozen v1 integration uses these immutable revisions: ```bash git -C htmltrust-canonicalization checkout 760593d4a02e9fffa56dc4d002eb52ab2ade1b49 git -C htmltrust-browser-client checkout 70c5ddb6ed23c06c0b1c46d5284618fb99a28aac -git -C htmltrust-browser-reference checkout b9ec8a2af7d495ece58b5027b4f4cb97c7e5f3ff +git -C htmltrust-browser-reference checkout a048b192f022b19d8d868b521aaf7091a550c217 git -C htmltrust-cms-reference checkout 1b94416250b98123c125e60da92d6a6f2e16a9ce git -C htmltrust-server-reference checkout 07a286dfd0a219e75286e983315d5a886e9e1a2d ``` @@ -86,9 +88,10 @@ For unit tests and the TypeScript build: - the sibling canonicalization and browser-client checkouts above, because the harness manifest uses local `file:` dependencies -For browser lifecycle tests, also check out and build the sibling -`htmltrust-browser-reference`. The unit suite does not need a browser or that -extension checkout. For the full simulation, also install: +For the full simulation, also check out and build the sibling +`htmltrust-browser-reference`. The standalone lifecycle suite uses the pinned +Playwright image and does not need the extension checkout. The unit suite does +not need a browser. For the full simulation, also install: - Docker Engine with Compose v2 - Hugo on the host @@ -105,13 +108,14 @@ Hugo integration repository. This harness writes its own temporary Hugo sites and uses the Hugo partial bundled in `htmltrust-cms-reference`; it does not consume the separate `htmltrust-hugo` repository. -The full simulation uses the sibling browser-reference checkout and its -Chromium build. The one-command runner builds it before starting Docker. The -standalone lifecycle suite does not require the extension build: all three -engines execute the same production verification and lifecycle DOM walker, so -Firefox and WebKit still exercise real source extraction, rendered comparison, -mutation invalidation, and navigation reset behavior despite the reference -extension currently targeting Chromium at runtime. +The one-command runner builds the sibling browser-reference Chromium +extension and runs a one-page extension smoke check after publication. Its +consumer sessions still use the direct production DOM walker. The standalone +lifecycle suite runs that walker in Chromium, Firefox, and WebKit, so Firefox +and WebKit exercise real source extraction, rendered comparison, mutation +invalidation, and navigation reset behavior. The extension smoke is +Chromium-only because the reference extension's current runtime adapter is +Chromium-specific. ## Install and check the harness @@ -214,10 +218,9 @@ The suite drives the production `DOM_SCRIPT_BODY` used by consumer sessions. It keeps the verifier boundary in Node, captures source sections separately from live `outerHTML`, waits for mutation invalidation, and replaces the navigation snapshot before checking the next document. The browser-reference -extension is not loaded into Firefox or WebKit because its current runtime -adapter is Chromium-specific. Chromium extension loading remains an optional -integration concern; the cross-engine lifecycle assertions do not depend on -that packaging detail. +extension is not part of this command. `npm run e2e:small` additionally loads +the built extension in Chromium and checks its marker plus +`GET_PAGE_VERIFICATIONS` result on the first published article. ## Run browser phases in Docker @@ -227,6 +230,7 @@ Use this split flow when you want to inspect publication output before browser v npm run config:nginx -- scenario-small.yaml docker compose up -d --build --wait npx tsx src/smoke-test.ts scenario-small.yaml +npm run test:browser:extension npm run test:wordpress-local-signing docker compose run --rm --entrypoint npx playwright tsx src/run-phases-3-5.ts scenario-small.yaml ``` diff --git a/package.json b/package.json index a3d1c7b..14319c4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "build": "tsc", + "build": "tsc && npm run typecheck:scripts", + "typecheck:scripts": "tsc --project tsconfig.scripts.json", "check": "npm test && npm run build && docker compose config --quiet", "start": "node --import tsx src/orchestrator.ts", "config:nginx": "tsx src/prepare-nginx.ts", @@ -16,6 +17,7 @@ "test:analysis": "python3 -m unittest discover -s analysis -p 'test_*.py'", "test:browser": "npm run test:browser:engines", "test:browser:engines": "docker compose run --rm --no-deps --entrypoint npx playwright tsx scripts/browser-lifecycle-test.ts", + "test:browser:extension": "docker compose run --rm --no-deps --entrypoint npx playwright tsx scripts/chromium-extension-smoke-test.ts", "test:wordpress-local-signing": "docker compose run --rm --entrypoint npx playwright tsx scripts/wordpress-local-signing-test.ts", "e2e:small": "./scripts/run-e2e.sh scenario-small.yaml", "test": "vitest run", diff --git a/scripts/chromium-extension-smoke-test.ts b/scripts/chromium-extension-smoke-test.ts new file mode 100644 index 0000000..62aca40 --- /dev/null +++ b/scripts/chromium-extension-smoke-test.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { lstat, mkdtemp, mkdir, readFile, realpath, rm } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Worker } from "playwright"; +import type { GroundTruthManifest } from "../src/types.js"; + +const e2eDir = process.env.E2E_DIR || process.cwd(); +const configuredExtensionPath = process.env.EXTENSION_PATH; +const extensionPath = configuredExtensionPath ? path.resolve(configuredExtensionPath) : undefined; +const runtimeDir = path.resolve(e2eDir, ".runtime"); +const groundTruthPath = path.join(e2eDir, "results", "ground-truth.json"); + +interface PageVerification { + cryptoValid?: unknown; + sourceVerified?: unknown; +} + +interface PageVerificationResponse { + url?: unknown; + results?: unknown; +} + +interface ExtensionManifestIdentity { + manifest_version: number; + name: string; + version: string; +} + +function assertSafeProfilePath(profilePath: string, runtimeRoot: string): void { + assert.equal(path.dirname(profilePath), runtimeRoot, "extension profile must be directly inside .runtime"); + assert.match(path.basename(profilePath), /^chromium-extension-[A-Za-z0-9]+$/, "unexpected extension profile name"); +} + +async function findExtensionWorker(context: Awaited>): Promise { + const existing = context.serviceWorkers().find((worker) => worker.url().startsWith("chrome-extension://")); + if (existing) return existing; + const worker = await context.waitForEvent("serviceworker", { timeout: 15_000 }); + assert.equal(worker.url().startsWith("chrome-extension://"), true, `unexpected service worker: ${worker.url()}`); + return worker; +} + +async function queryPageVerifications( + worker: Worker, + expectedUrl: string, +): Promise<{ tabUrl: string; response: PageVerificationResponse }> { + return await worker.evaluate(async (targetUrl) => { + const chromeApi = (globalThis as unknown as { + chrome: { + tabs: { + query(query: { active: boolean; currentWindow: boolean }, callback: (tabs: Array<{ id?: number; url?: string }>) => void): void; + sendMessage(tabId: number, message: { type: string }, callback: (response: unknown) => void): void; + }; + runtime: { lastError?: { message?: string } }; + }; + }).chrome; + + const tabs = await new Promise>((resolve, reject) => { + chromeApi.tabs.query({ active: true, currentWindow: true }, (result) => { + const error = chromeApi.runtime.lastError; + if (error) reject(new Error(error.message || "chrome.tabs.query failed")); + else resolve(result); + }); + }); + const normalizedTarget = new URL(targetUrl).href; + const tab = tabs.find((candidate) => { + if (!candidate.url) return false; + try { + return new URL(candidate.url).href === normalizedTarget; + } catch { + return false; + } + }); + if (typeof tab?.id !== "number" || !tab.url) { + throw new Error(`active article tab unavailable for extension query: ${normalizedTarget}`); + } + + const response = await new Promise((resolve, reject) => { + chromeApi.tabs.sendMessage(tab.id as number, { type: "GET_PAGE_VERIFICATIONS" }, (value) => { + const error = chromeApi.runtime.lastError; + if (error) reject(new Error(error.message || "chrome.tabs.sendMessage failed")); + else resolve(value as PageVerificationResponse); + }); + }); + return { tabUrl: tab.url, response }; + }, expectedUrl); +} + +async function main(): Promise { + assert.ok(extensionPath, "EXTENSION_PATH is required for the Chromium extension smoke test"); + let groundTruthText: string; + try { + groundTruthText = await readFile(groundTruthPath, "utf8"); + } catch (error) { + throw new Error(`ground truth is unavailable at ${groundTruthPath}; run the integration publisher first`, { cause: error }); + } + const groundTruth = JSON.parse(groundTruthText) as GroundTruthManifest; + const article = groundTruth.articles[0]; + assert.ok(article?.url, `ground truth has no published article: ${groundTruthPath}`); + const builtManifest = JSON.parse( + await readFile(path.join(extensionPath, "manifest.json"), "utf8"), + ) as Partial; + if (builtManifest.manifest_version !== 3) throw new Error("Chromium smoke requires an MV3 extension build"); + if (builtManifest.name !== "Content Signing") throw new Error("unexpected extension build name"); + if (typeof builtManifest.version !== "string") throw new Error("extension build has no version"); + const expectedManifest: ExtensionManifestIdentity = { + manifest_version: builtManifest.manifest_version, + name: builtManifest.name, + version: builtManifest.version, + }; + + await mkdir(runtimeDir, { recursive: true }); + const runtimeStat = await lstat(runtimeDir); + assert.equal(runtimeStat.isSymbolicLink(), false, ".runtime must not be a symbolic link"); + assert.equal(runtimeStat.isDirectory(), true, ".runtime must be a directory"); + const runtimeRoot = await realpath(runtimeDir); + const profilePath = await mkdtemp(path.join(runtimeRoot, "chromium-extension-")); + assertSafeProfilePath(profilePath, runtimeRoot); + + let context: Awaited> | undefined; + try { + context = await chromium.launchPersistentContext(profilePath, { + channel: "chromium", + headless: true, + ignoreHTTPSErrors: true, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + const page = context.pages()[0] ?? await context.newPage(); + await page.goto(article.url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + await page.bringToFront(); + await page.locator(".cs-auto-verification-badges").first().waitFor({ state: "attached", timeout: 30_000 }); + + const worker = await findExtensionWorker(context); + const extensionId = new URL(worker.url()).hostname; + assert.match(extensionId, /^[a-p]{32}$/, `invalid MV3 extension id: ${extensionId}`); + const loadedManifest = await worker.evaluate(() => { + const runtime = (globalThis as unknown as { + chrome: { runtime: { getManifest(): ExtensionManifestIdentity } }; + }).chrome.runtime; + const manifest = runtime.getManifest(); + return { + manifest_version: manifest.manifest_version, + name: manifest.name, + version: manifest.version, + }; + }); + assert.deepEqual(loadedManifest, expectedManifest, "loaded extension does not match the built manifest"); + const navigatedUrl = new URL(page.url()).href; + const { tabUrl, response } = await queryPageVerifications(worker, navigatedUrl); + assert.equal(new URL(tabUrl).href, navigatedUrl, "extension query used a different active tab"); + assert.equal(response.url, navigatedUrl, "content script reported a different page URL"); + const resultsValue = response && typeof response === "object" ? response.results : undefined; + assert.ok(Array.isArray(resultsValue), "GET_PAGE_VERIFICATIONS returned no results array"); + const results = resultsValue as unknown[]; + assert.ok(results.length > 0, "GET_PAGE_VERIFICATIONS returned no signed-section results"); + const sourceVerified = results.filter((result): result is PageVerification => + result !== null && typeof result === "object" && + (result as PageVerification).cryptoValid === true && + (result as PageVerification).sourceVerified === true, + ).length; + assert.ok(sourceVerified > 0, "no signed-section result was crypto-valid and source-verified"); + + console.log(`Chromium extension smoke passed: extension ${extensionId}, article ${article.id}, source-verified ${sourceVerified}/${results.length}`); + } finally { + try { + await context?.close(); + } finally { + const profileStat = await lstat(profilePath); + assert.equal(profileStat.isSymbolicLink(), false, "refusing to remove a symbolic-link profile"); + assert.equal(profileStat.isDirectory(), true, "extension profile path is no longer a directory"); + const realProfilePath = await realpath(profilePath); + assertSafeProfilePath(realProfilePath, runtimeRoot); + await rm(realProfilePath, { recursive: true, force: true }); + } + } +} + +await main(); diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index a2cbcfd..78989f3 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -45,7 +45,7 @@ required_siblings=( declare -A expected_revisions=( [htmltrust-canonicalization]=760593d4a02e9fffa56dc4d002eb52ab2ade1b49 [htmltrust-browser-client]=70c5ddb6ed23c06c0b1c46d5284618fb99a28aac - [htmltrust-browser-reference]=b9ec8a2af7d495ece58b5027b4f4cb97c7e5f3ff + [htmltrust-browser-reference]=a048b192f022b19d8d868b521aaf7091a550c217 [htmltrust-cms-reference]=1b94416250b98123c125e60da92d6a6f2e16a9ce [htmltrust-server-reference]=07a286dfd0a219e75286e983315d5a886e9e1a2d ) @@ -98,6 +98,9 @@ docker compose --project-directory "$repo_dir" up -d --build --wait echo "Publishing signed content" node --import tsx "$repo_dir/src/smoke-test.ts" "$repo_dir/$scenario" +echo "Checking the built Chromium extension" +npm --prefix "$repo_dir" run test:browser:extension + echo "Checking WordPress browser-local signing" docker compose --project-directory "$repo_dir" run --rm --entrypoint npx playwright \ tsx scripts/wordpress-local-signing-test.ts diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..5ce2e59 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["scripts/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From 6cac40f5d5504a233e8d37077e118bae07917a87 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 17:58:28 -0500 Subject: [PATCH 7/8] fix(e2e): scale rate limits for synthetic clients --- README.md | 8 +++++++- docker-compose.yml | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6100841..1b0ded6 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,11 @@ ollama pull llama3.2:3b ./scripts/run-e2e.sh scenario.yaml ``` +All simulated browsers share the Playwright container's network address. The +Compose services therefore raise the directory's per-IP ceilings to 20,000 +requests and 10,000 writes per minute while keeping both limiters enabled. +The directory server keeps its production defaults outside this harness. + Copy the scenario first if you want to change its endpoint or model: ```bash @@ -301,7 +306,8 @@ Use the same names with `BETA` for the second directory. Compose accepts `HTMLTRUST_DIRECTORY_ALPHA_PORT`, `HTMLTRUST_DIRECTORY_BETA_PORT`, `HTMLTRUST_DIRECTORY_ALPHA_PUBLIC_URL`, `HTMLTRUST_DIRECTORY_BETA_PUBLIC_URL`, `HTMLTRUST_PROXY_PORT`, -`HTMLTRUST_TLS_PROXY_PORT`, `WP_DB_ROOT_PASSWORD`, and `WP_DB_PASSWORD`. +`HTMLTRUST_TLS_PROXY_PORT`, `HTMLTRUST_E2E_RATE_LIMIT_GLOBAL`, +`HTMLTRUST_E2E_RATE_LIMIT_WRITE`, `WP_DB_ROOT_PASSWORD`, and `WP_DB_PASSWORD`. The checked-in credentials are for local testing only. `URL` is the host-side API origin. `CONTAINER_URL` is the matching origin on diff --git a/docker-compose.yml b/docker-compose.yml index 9108403..f897ab2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,12 @@ services: # Signed key URLs must be reachable by a production-policy verifier, # which accepts HTTPS only. Nginx proxies this test hostname internally. DIRECTORY_BASE_URL: ${HTMLTRUST_DIRECTORY_ALPHA_PUBLIC_URL:-https://trust-a.htmltrust.test} + # The Playwright phase represents 1,000 independent consumers from one + # tools-container IP. Keep the limiters active with simulation-scale + # ceilings; the server repository's production defaults remain 600 + # total and 60 writes per IP each minute. + RATE_LIMIT_GLOBAL: ${HTMLTRUST_E2E_RATE_LIMIT_GLOBAL:-20000} + RATE_LIMIT_WRITE: ${HTMLTRUST_E2E_RATE_LIMIT_WRITE:-10000} PORT: "3000" ports: # Loopback only. "3000:3000" publishes on 0.0.0.0, exposing the trust @@ -80,6 +86,8 @@ services: ADMIN_API_KEY: ${HTMLTRUST_DIRECTORY_BETA_ADMIN_API_KEY:-sim_admin_key} AUTHOR_API_KEY_PEPPER: ${HTMLTRUST_DIRECTORY_BETA_AUTHOR_API_KEY_PEPPER:-sim_beta_author_api_key_pepper} DIRECTORY_BASE_URL: ${HTMLTRUST_DIRECTORY_BETA_PUBLIC_URL:-https://trust-b.htmltrust.test} + RATE_LIMIT_GLOBAL: ${HTMLTRUST_E2E_RATE_LIMIT_GLOBAL:-20000} + RATE_LIMIT_WRITE: ${HTMLTRUST_E2E_RATE_LIMIT_WRITE:-10000} PORT: "3000" ports: - "127.0.0.1:${HTMLTRUST_DIRECTORY_BETA_PORT:-3001}:3000" From fb75bc44c801bc286706cf3a7ebb79f112564e47 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 18:08:52 -0500 Subject: [PATCH 8/8] fix(e2e): validate final weighted trust indicator --- src/phases/consumers.ts | 35 ++++++++++++++++++++++++++-------- tests/phases/consumers.test.ts | 26 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 tests/phases/consumers.test.ts diff --git a/src/phases/consumers.ts b/src/phases/consumers.ts index aacf7a8..100bbdb 100644 --- a/src/phases/consumers.ts +++ b/src/phases/consumers.ts @@ -1,7 +1,32 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { runConsumerSession } from "../lib/playwright-session.js"; -import type { ScenarioConfig, AuthorProfile, Article, ConsumerProfile, SessionLog, PhaseResult, TrustDirectoryConfig } from "../types.js"; +import type { + ScenarioConfig, + AuthorProfile, + Article, + ConsumerProfile, + SessionLog, + PhaseResult, + TrustDirectoryConfig, + PageVisit, + TrustIndicator, +} from "../types.js"; + +/** + * Check the browser-client's default score thresholds and report override. + * Directory votes can move a personally trusted signer below the green + * threshold, so membership in the personal list alone is not an expected + * final indicator. + */ +export function expectedTrustIndicator( + visit: Pick, +): TrustIndicator { + if (visit.directoryResults.some((result) => (result.reports ?? 0) > 0)) return "warning"; + if (visit.trustScore < 20) return "warning"; + if (visit.trustScore >= 70) return "trusted"; + return "verified-unknown"; +} export async function runPhase3( config: ScenarioConfig, authors: AuthorProfile[], articles: Article[], @@ -34,13 +59,7 @@ export async function runPhase3( for (const log of sessionLogs) { for (const v of log.pagesVisited) { if (!v.signatureValid) sigFails++; - const author = authors.find((candidate) => candidate.id === v.authorId); - const hasReport = v.directoryResults.some((result) => (result.reports ?? 0) > 0); - const expected = hasReport - ? "warning" - : author && log.personalTrustList.includes(author.keyId) - ? "trusted" - : "verified-unknown"; + const expected = expectedTrustIndicator(v); if (v.trustIndicator !== expected) indMismatch++; } } diff --git a/tests/phases/consumers.test.ts b/tests/phases/consumers.test.ts new file mode 100644 index 0000000..19aec5b --- /dev/null +++ b/tests/phases/consumers.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { expectedTrustIndicator } from "../../src/phases/consumers.js"; + +describe("consumer trust indicator validation", () => { + it("uses the policy score thresholds", () => { + expect(expectedTrustIndicator({ trustScore: 19.9, directoryResults: [] })).toBe("warning"); + expect(expectedTrustIndicator({ trustScore: 20, directoryResults: [] })).toBe("verified-unknown"); + expect(expectedTrustIndicator({ trustScore: 69.9, directoryResults: [] })).toBe("verified-unknown"); + expect(expectedTrustIndicator({ trustScore: 70, directoryResults: [] })).toBe("trusted"); + }); + + it("applies the report override at any score", () => { + expect(expectedTrustIndicator({ + trustScore: 90, + directoryResults: [{ + directoryId: "beta", + url: "https://trust-b.htmltrust.test", + weight: 0.75, + status: "ok", + score: 0.5, + reports: 1, + latencyMs: 1, + }], + })).toBe("warning"); + }); +});