diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14138df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +* +!Dockerfile.nginx +!Dockerfile.wordpress diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a01e04..1dbc0f3 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: 79b0d52fecd958f8fc7ade713fe0799ca1e79626 + ref: b0c8f305425de190a7f209ac117d34f88c2b1946 path: htmltrust-canonicalization persist-credentials: false - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: HTMLTrust/htmltrust-browser-client - ref: 698a6fba7ada94ea1e26348dda0e1c87e8dd8fc9 + ref: d25c6d3c2d0f4d67483da20853f22e94a11b89cc path: htmltrust-browser-client persist-credentials: false @@ -37,6 +37,10 @@ jobs: with: node-version: 22 + - name: Install canonicalization runtime dependency + working-directory: htmltrust-canonicalization/javascript + run: npm install --package-lock=false --ignore-scripts --no-audit --no-fund + - name: Build browser client working-directory: htmltrust-browser-client run: | diff --git a/.gitignore b/.gitignore index ef2d7e9..32eb56d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ results/ +output/ hugo-sites/ hugo-sources/ +.runtime/ .superpowers/ diff --git a/Dockerfile.nginx b/Dockerfile.nginx new file mode 100644 index 0000000..05eaadc --- /dev/null +++ b/Dockerfile.nginx @@ -0,0 +1,13 @@ +FROM nginx:1.29.1-alpine + +# Test-only wildcard certificate for the isolated htmltrust Docker network. +# Browsers in the harness explicitly allow this self-signed certificate. +RUN set -eux; \ + apk add --no-cache openssl; \ + mkdir -p /etc/nginx/certs; \ + openssl req -x509 -nodes -newkey rsa:2048 -sha256 -days 3650 \ + -subj "/CN=*.htmltrust.test" \ + -addext "subjectAltName=DNS:*.htmltrust.test,DNS:htmltrust.test" \ + -keyout /etc/nginx/certs/htmltrust.test.key \ + -out /etc/nginx/certs/htmltrust.test.crt; \ + apk del openssl diff --git a/Dockerfile.trust-server b/Dockerfile.trust-server index 98f1c09..df8c808 100644 --- a/Dockerfile.trust-server +++ b/Dockerfile.trust-server @@ -2,13 +2,13 @@ FROM node:22-slim WORKDIR /app # Copy the local canonicalization package first -COPY htmltrust-canonicalization/javascript /tmp/canonicalization +COPY htmltrust-canonicalization/javascript /app/local-packages/canonicalization # Copy server source COPY htmltrust-server-reference/package*.json ./ -# The published package.json points @htmltrust/canonicalization at a private -# GitHub repo; this image builds against the sibling checkout instead. That +# The published package.json pins a GitHub archive. This image builds against +# the sibling checkout instead. That # swap used to be a `sed` on package.json, which fails open -- if the # dependency line ever moves or reformats, the substitution silently does # nothing and the build falls back to fetching whatever the GitHub ref resolves @@ -21,10 +21,13 @@ COPY htmltrust-server-reference/package*.json ./ # the two as npm workspaces so the lockfile already describes the local path. # Until then --ignore-scripts is what keeps dependency install hooks from # running during the image build. -RUN npm pkg set "dependencies.@htmltrust/canonicalization=file:///tmp/canonicalization" \ +RUN npm pkg set "dependencies.@htmltrust/canonicalization=file:///app/local-packages/canonicalization" \ && npm install --omit=dev --ignore-scripts --no-audit --no-fund -COPY htmltrust-server-reference/ . +# Copy runtime source explicitly. Copying the whole checkout also copies a +# developer's node_modules directory when the parent workspace is the Docker +# build context, which can overwrite the dependencies installed above. +COPY htmltrust-server-reference/src ./src EXPOSE 3000 # Drop root. The server only reads its source and talks to Mongo over the diff --git a/README.md b/README.md index d23eefc..df0b6c8 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ # HTMLTrust end-to-end harness -Run the HTMLTrust simulation from a fresh checkout, or run its unit tests and -TypeScript build. This repository uses local packages from sibling checkouts. +- Maintainer: Jason Grey +- Updated: 2026-08-28 +- Version: 0.1.0, frozen v1 profile +- Status: Active integration harness +- For: contributors and continuous integration maintainers +- Reading time: 8 minutes -Status: active integration harness -Primary readers: contributors and CI maintainers -Start here: use the pinned v0.2.2-compatible layout below +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. ## Choose a path -- For a quick local check, prepare the sibling packages, then run `npm test` - and `npm run build`. -- For the integration smoke test, complete the sibling checkout, pin, and - install steps below. Then install Docker, Hugo, and Ollama before running - `npm start -- scenario-small.yaml`. -- For the browser phase in a Playwright container, use the split flow below. +- Run `npm test && npm run build` when you are changing TypeScript helpers. +- 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. ## Checkout layout and compatible revisions @@ -42,21 +41,20 @@ git clone https://github.com/HTMLTrust/htmltrust-e2e.git git clone https://github.com/HTMLTrust/htmltrust-server-reference.git ``` -This harness and its CI currently use the v0.2.2-compatible stack. Pin the two -JavaScript dependencies to the revisions used by CI before installing. Pin the -extension too when running the browser phase: +The frozen v1 integration uses these immutable revisions: ```bash -git -C htmltrust-canonicalization checkout 79b0d52fecd958f8fc7ade713fe0799ca1e79626 -git -C htmltrust-browser-client checkout 698a6fba7ada94ea1e26348dda0e1c87e8dd8fc9 -git -C htmltrust-browser-reference checkout 3851ff16302a1b0d6a16d38cf46ee7a1a9e3b0f5 +git -C htmltrust-canonicalization checkout b0c8f305425de190a7f209ac117d34f88c2b1946 +git -C htmltrust-browser-client checkout d25c6d3c2d0f4d67483da20853f22e94a11b89cc +git -C htmltrust-browser-reference checkout 5237f07098da8b6542f0fd8f1c613ae8dbf4e6dd +git -C htmltrust-cms-reference checkout 69aafdfad2c81766f2717b88525f2569370f96cd +git -C htmltrust-server-reference checkout f84f51482ba2a925d9b5ff148185adf6dedef566 ``` -The current canonicalization `main` contains the newer 0.3.x package, while -the browser client still declares a 0.2.2 peer dependency. Do not combine -those mains with this harness unless you have verified and updated the full -stack together. The pinned browser-reference revision declares the same -browser-client commit and the canonicalization v0.2.2 package. +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. ## Prerequisites @@ -71,16 +69,18 @@ For the full simulation, also install: - Hugo on the host - Ollama with the model named by the scenario -The extension-aware browser phase also needs the pinned browser-reference -checkout and its Chromium build. +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 -Build both local package dependencies before installing this repository. The -browser client's `dist/` directory is ignored by Git, and npm needs it when it -installs the local `file:` dependency. +Install the canonicalizer's parser dependency and build the browser client +before installing this repository. The browser client's `dist/` directory is +ignored by Git, and npm needs it when it installs the local `file:` dependency. ```bash +cd ../htmltrust-canonicalization/javascript +npm install --package-lock=false + cd ../htmltrust-browser-client npm ci npm run build @@ -91,9 +91,7 @@ npm test npm run build ``` -These checks still 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 two sibling directories. They do not start Docker, Hugo, or Ollama. Install the browser-reference extension only for the browser flow: ```bash cd ../htmltrust-browser-reference @@ -102,10 +100,7 @@ npm run build:chromium 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 -browser-reference lock file resolves the same browser-client revision used by -the harness. +The explicit flag allows the pinned Git dependency to build its `dist/` directory when npm is configured globally to skip lifecycle scripts. ## Run the small simulation @@ -114,49 +109,46 @@ Start Ollama in another terminal and load the model configured in ```bash ollama serve -ollama pull llama3.2:3b +ollama pull llama3.2:1b ``` -From `htmltrust-e2e`, run the complete host-side simulation: +From `htmltrust-e2e`, run the complete simulation: ```bash -npm start -- scenario-small.yaml +npm run e2e:small ``` -The orchestrator builds the Compose stack, publishes the test sites, runs the -consumer and researcher phases, validates the results, and writes ignored -output under `results/`, `hugo-sources/`, and `hugo-sites/`. It runs the Python -analysis when `uv` is installed; that optional analysis does not decide the -simulation exit status. +The runner installs and builds the sibling browser packages, checks this repository, builds the Compose stack, publishes the test sites, and runs browser verification in the Playwright container. It writes ignored output under `results/`, `hugo-sources/`, and `hugo-sites/`. The stack stays up after the run so you can inspect logs. ## 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. Copy it before changing the Ollama endpoint or model: ```bash cp scenario.yaml scenario-local.yaml # Edit scenario-local.yaml, including ollama.host or ollama.model. -npm start -- scenario-local.yaml +./scripts/run-e2e.sh scenario-local.yaml ``` -For a host-side run, use `http://localhost:11434` as the Ollama host. The -checked-in full scenario uses `host.docker.internal` for Docker-oriented runs. +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. ## Run browser phases in Docker -Use this flow when the host lacks a Playwright browser. Run it from this -repository after completing the pinned sibling setup, `npm ci`, and the -browser-reference Chromium build: +Use this split flow when you want to inspect publication output before browser verification: ```bash +npm run config:nginx -- scenario-small.yaml docker compose up -d --build --wait npx tsx src/smoke-test.ts scenario-small.yaml -docker compose run --rm playwright npx tsx src/run-phases-3-5.ts scenario-small.yaml +docker compose run --rm --entrypoint npx playwright tsx src/run-phases-3-5.ts scenario-small.yaml ``` -The smoke test creates `results/ground-truth.json`. The second command runs -consumer browsing, researcher reports, post-report visits, and validation. +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`. ## Run individual checks and services @@ -194,11 +186,11 @@ Both scenarios are YAML files. Override local service settings for one run: HTMLTRUST_TRUST_SERVER_URL=http://localhost:3000 \ HTMLTRUST_GENERAL_API_KEY=my-general-key \ HTMLTRUST_ADMIN_API_KEY=my-admin-key \ -npm start -- scenario-small.yaml +npm run e2e:small ``` Compose also accepts `HTMLTRUST_TRUST_PORT`, `HTMLTRUST_PROXY_PORT`, -`HTMLTRUST_DIRECTORY_BASE_URL`, `WP_DB_ROOT_PASSWORD`, `WP_DB_PASSWORD`, +`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. @@ -210,9 +202,10 @@ credentials are for local testing only. from `htmltrust-e2e`. - Hugo publication fails: confirm that `hugo version` works on the host. - Article generation fails: run `curl http://localhost:11434/api/tags`, pull - `llama3.2:3b`, and check that the scenario's Ollama host is reachable. + `llama3.2:1b`, and check that the scenario's Ollama host is reachable. - Browser phases cannot resolve author hosts: run them in the Playwright service with `docker compose run --rm playwright ...`. +- HTTPS publication fails: confirm that port `18443` is free and inspect `docker compose logs nginx`. - A previous run left stale databases: use the cleanup command below. Inspect service state and logs: @@ -241,3 +234,5 @@ with `docker compose up -d --build --wait`. - [`analysis/analyze.py`](analysis/analyze.py) analyzes simulation output. - [CI workflow](.github/workflows/ci.yml) records the currently tested package revisions. + +Open an issue with the failing phase, command output, scenario file, and `results/ground-truth.json`. Do not include API keys from a non-test deployment. diff --git a/docker-compose.yml b/docker-compose.yml index 4b244ac..7a613b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,7 +49,9 @@ services: 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} - DIRECTORY_BASE_URL: ${HTMLTRUST_DIRECTORY_BASE_URL:-http://trust-server:3000} + # 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} PORT: "3000" ports: # Loopback only. "3000:3000" publishes on 0.0.0.0, exposing the trust @@ -186,6 +188,10 @@ services: volumes: - .:/workspace - ../htmltrust-browser-reference/build/chromium:/extension:ro + # npm records sibling file dependencies as relative symlinks. From + # /workspace/node_modules those links resolve to these root paths. + - ../htmltrust-browser-client:/htmltrust-browser-client:ro + - ../htmltrust-canonicalization:/htmltrust-canonicalization:ro networks: - htmltrust environment: @@ -197,7 +203,9 @@ services: profiles: ["tools"] # Only starts when explicitly requested nginx: - image: nginx:alpine + build: + context: . + dockerfile: Dockerfile.nginx networks: htmltrust: aliases: @@ -211,7 +219,9 @@ services: - author8.htmltrust.test - author9.htmltrust.test - author10.htmltrust.test + - trust.htmltrust.test depends_on: + - trust-server - wp-1 - wp-2 - wp-3 @@ -219,9 +229,10 @@ services: - wp-5 ports: # Loopback only. This fronts five WordPress installs whose admin - # credentials are fixed sim values; "8080:80" served them to the whole + # credentials are fixed sim values; publishing these ports to the whole # network the host is on. - - "127.0.0.1:${HTMLTRUST_PROXY_PORT:-8080}:80" + - "127.0.0.1:${HTMLTRUST_PROXY_PORT:-18080}:80" + - "127.0.0.1:${HTMLTRUST_TLS_PROXY_PORT:-18443}:443" volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./.runtime/nginx.conf:/etc/nginx/nginx.conf:ro - ./hugo-sites:/var/www/hugo:ro diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 121a6c1..0000000 --- a/nginx.conf +++ /dev/null @@ -1,14 +0,0 @@ -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - server { listen 80; server_name author1.htmltrust.test; location / { proxy_pass http://wp-1:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } - server { listen 80; server_name author2.htmltrust.test; location / { proxy_pass http://wp-2:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } - server { listen 80; server_name author3.htmltrust.test; root /var/www/hugo/author3; index index.html; location / { try_files $uri $uri/ =404; } } - - server { listen 80 default_server; return 404; } -} diff --git a/package-lock.json b/package-lock.json index 58d0163..30c2724 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,19 +25,26 @@ "version": "0.1.2", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz" + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "parse5": "7.3.0" }, "devDependencies": { "typescript": "^5.5.0" }, "peerDependencies": { - "@htmltrust/canonicalization": "^0.2.2" + "@htmltrust/canonicalization": "^0.3.0" } }, "../htmltrust-canonicalization/javascript": { "name": "@htmltrust/canonicalization", - "version": "0.2.2", - "license": "LicenseRef-PolyForm-Noncommercial-1.0.0" + "version": "0.3.0", + "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", + "dependencies": { + "parse5": "7.3.0" + }, + "engines": { + "node": ">=22" + } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", diff --git a/package.json b/package.json index b087572..048a8f9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,14 @@ "type": "module", "scripts": { "build": "tsc", + "check": "npm test && npm run build && docker compose config --quiet", "start": "node --import tsx src/orchestrator.ts", + "config:nginx": "tsx src/prepare-nginx.ts", + "stack:up": "npm run config:nginx -- scenario-small.yaml && docker compose up -d --build --wait", + "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", + "e2e:small": "./scripts/run-e2e.sh scenario-small.yaml", "test": "vitest run", "test:watch": "vitest" }, diff --git a/scenario-small.yaml b/scenario-small.yaml index d9cb3aa..fe402be 100644 --- a/scenario-small.yaml +++ b/scenario-small.yaml @@ -39,7 +39,7 @@ trust_server: admin_api_key: sim_admin_key ollama: - model: "llama3.2:3b" + model: "llama3.2:1b" host: "http://localhost:11434" -nginx_proxy_url: "http://localhost:8080" +nginx_proxy_url: "https://localhost:18443" diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh new file mode 100755 index 0000000..e938cbe --- /dev/null +++ b/scripts/run-e2e.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +scenario="${1:-scenario-small.yaml}" +cd "$repo_dir" + +case "$scenario" in + /*|*..*) + echo "Scenario must be a path inside htmltrust-e2e" >&2 + exit 2 + ;; +esac + +required_siblings=( + htmltrust-browser-client + htmltrust-browser-reference + htmltrust-canonicalization + htmltrust-cms-reference + htmltrust-server-reference +) + +declare -A expected_revisions=( + [htmltrust-canonicalization]=b0c8f305425de190a7f209ac117d34f88c2b1946 + [htmltrust-browser-client]=d25c6d3c2d0f4d67483da20853f22e94a11b89cc + [htmltrust-browser-reference]=5237f07098da8b6542f0fd8f1c613ae8dbf4e6dd + [htmltrust-cms-reference]=69aafdfad2c81766f2717b88525f2569370f96cd + [htmltrust-server-reference]=f84f51482ba2a925d9b5ff148185adf6dedef566 +) + +for repository in "${required_siblings[@]}"; do + if [[ ! -d "$repo_dir/../$repository" ]]; then + echo "Missing sibling checkout: $repo_dir/../$repository" >&2 + exit 2 + fi + + actual_revision="$(git -C "$repo_dir/../$repository" rev-parse HEAD)" + dirty_state="$(git -C "$repo_dir/../$repository" status --porcelain)" + if [[ "$actual_revision" != "${expected_revisions[$repository]}" || -n "$dirty_state" ]]; then + if [[ "${HTMLTRUST_ALLOW_UNPINNED:-0}" != "1" ]]; then + echo "Sibling checkout does not match the clean frozen revision: $repository" >&2 + echo " expected: ${expected_revisions[$repository]}" >&2 + echo " actual: $actual_revision" >&2 + [[ -z "$dirty_state" ]] || echo " working tree has local changes" >&2 + echo "Set HTMLTRUST_ALLOW_UNPINNED=1 only when intentionally testing local dependency work." >&2 + exit 2 + fi + echo "Warning: testing an unpinned or dirty $repository checkout" >&2 + fi +done + +if [[ ! -f "$repo_dir/$scenario" ]]; then + echo "Scenario does not exist: $repo_dir/$scenario" >&2 + exit 2 +fi + +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-browser-client" ci +npm --prefix "$repo_dir/../htmltrust-browser-client" run build +npm --prefix "$repo_dir/../htmltrust-browser-reference" ci --ignore-scripts=false +npm --prefix "$repo_dir/../htmltrust-browser-reference" run build:chromium + +echo "Installing and checking the harness" +npm --prefix "$repo_dir" ci +npm --prefix "$repo_dir" test +npm --prefix "$repo_dir" run build +npm --prefix "$repo_dir" run config:nginx -- "$scenario" + +echo "Starting the integration stack" +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 "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" + +echo "Results: $repo_dir/results" +echo "Cleanup: docker compose --project-directory $repo_dir down -v" diff --git a/src/lib/hugo-publisher.ts b/src/lib/hugo-publisher.ts index 61d52ed..d3f47aa 100644 --- a/src/lib/hugo-publisher.ts +++ b/src/lib/hugo-publisher.ts @@ -14,7 +14,7 @@ export class HugoPublisher { await mkdir(path.join(this.siteDir, "layouts/_default"), { recursive: true }); await writeFile(path.join(this.siteDir, "hugo.toml"), - `baseURL = "http://${this.domain}/"\ntitle = "${this.authorName} Blog"\ntheme = []\n\n[params]\n author = "${this.authorName}"\n`); + `baseURL = "https://${this.domain}/"\ntitle = "${this.authorName} Blog"\ntheme = []\n\n[params]\n author = "${this.authorName}"\n`); await writeFile(path.join(this.siteDir, "layouts/_default/single.html"), `\n{{ .Title }}\n\n

{{ .Title }}

\n{{ .Content }}\n
\n{{ partial "htmltrust-signed-section.html" . }}\n`); diff --git a/src/lib/nginx-config.ts b/src/lib/nginx-config.ts index fca6c49..234d57c 100644 --- a/src/lib/nginx-config.ts +++ b/src/lib/nginx-config.ts @@ -1,4 +1,4 @@ -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import type { AuthorProfile } from "../types.js"; @@ -9,23 +9,29 @@ import type { AuthorProfile } from "../types.js"; */ export async function generateNginxConfig(authors: AuthorProfile[], 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;"; for (const author of authors) { const domain = author.domain; if (author.cmsType === "wordpress") { const container = author.wpContainerName!; blocks.push( - ` server { listen 80; server_name ${domain}; location / { proxy_pass http://${container}:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }` + ` server { ${listeners} ${tls} server_name ${domain}; location / { proxy_pass http://${container}:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } }` ); } else { // Hugo: static files, use domain slug (e.g. author3) as directory name const slug = domain.replace(".htmltrust.test", ""); blocks.push( - ` server { listen 80; server_name ${domain}; root /var/www/hugo/${slug}; index index.html; location / { try_files $uri $uri/ =404; } }` + ` server { ${listeners} ${tls} server_name ${domain}; root /var/www/hugo/${slug}; index index.html; location / { try_files $uri $uri/ =404; } }` ); } } + 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; } }` + ); + const config = `events { worker_connections 1024; } @@ -36,9 +42,10 @@ http { ${blocks.join("\n")} - server { listen 80 default_server; return 404; } + server { listen 80 default_server; listen 443 ssl default_server; ${tls} return 404; } } `; + await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, config); } diff --git a/src/lib/playwright-session.ts b/src/lib/playwright-session.ts index 98fd1be..ed5d79a 100644 --- a/src/lib/playwright-session.ts +++ b/src/lib/playwright-session.ts @@ -1,5 +1,6 @@ import { chromium } from "playwright"; import { createHash } from "node:crypto"; +import { request as httpsRequest } from "node:https"; import path from "node:path"; import { verifySignedSection, @@ -37,6 +38,38 @@ interface VerifyAndScoreResult { reports: number; } +/** + * Fetch the isolated test directory through Nginx while accepting its + * generated self-signed certificate. Other destinations retain Node's normal + * certificate validation. The production extension uses the browser trust + * store and never calls this helper. + */ +async function fetchTestKeyDocument(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.protocol !== "https:") throw new Error("test directory key URL must use HTTPS"); + + return new Promise((resolve, reject) => { + const req = httpsRequest(url, { + method: init?.method || "GET", + headers: Object.fromEntries(new Headers(init?.headers).entries()), + rejectUnauthorized: false, + }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + resolve(new Response(Buffer.concat(chunks), { + status: res.statusCode || 500, + statusText: res.statusMessage, + headers: res.headers as HeadersInit, + })); + }); + }); + req.on("error", reject); + req.end(); + }); +} + /** * Convert a Layer 2 indicator + reports override into the SessionLog's * legacy TrustIndicator vocabulary. Kept verbatim with the previous @@ -102,23 +135,41 @@ function authorIdFromKeyid(keyid: string, authors: AuthorProfile[]): string | nu /** * Inline DOM walker + badge renderer. Runs in the page context so that * `document.querySelectorAll`, `window.location`, and DOM mutation are - * available. Per signed-section it ships the section's outerHTML to the - * exposed Node-side helper which calls verifySignedSection / + * available. Per signed-section it ships the exact response source and the + * live outerHTML to the exposed Node-side helper, which calls verifySignedSection / * evaluateTrustPolicy from @htmltrust/browser-client. * - * Why Node-side: the simulation runs over plain HTTP, so SubtleCrypto is - * unavailable in-page; and the lib's resolver chain wants `globalThis.fetch` - * which behaves more predictably from Node than from the page (which is - * subject to CORS and same-origin rules per request). + * Why Node-side: key resolution uses Docker-only directory URLs, and the + * library resolver chain is deliberately exercised outside page CORS. */ const DOM_SCRIPT_BODY = ` const results = []; - const sourceSections = Array.isArray(window.__htmltrustSourceSections) ? window.__htmltrustSourceSections : []; - const sections = document.querySelectorAll("signed-section[signature]"); + const snapshot = window.__htmltrustSourceSnapshot || { html: "", url: window.location.href, sections: [] }; + const sourceDocument = new DOMParser().parseFromString(snapshot.html || "", "text/html"); + const sourceNodes = Array.from(sourceDocument.querySelectorAll("signed-section")); + const identityAttributes = ["profile", "signature-scope", "signature", "keyid", "algorithm", "content-hash"]; + const identity = (section) => identityAttributes.map((name) => name + "=" + (section.getAttribute(name) || "")).join("\\u001f"); + const sourceByIdentity = new Map(); + sourceNodes.forEach((section, index) => { + const key = identity(section); + const queue = sourceByIdentity.get(key) || []; + queue.push(snapshot.sections[index] || ""); + sourceByIdentity.set(key, queue); + }); + const sourceBaseElement = sourceDocument.querySelector("base[href]"); + let sourceBaseUrl = snapshot.url; + if (sourceBaseElement) { + try { + sourceBaseUrl = new URL(sourceBaseElement.getAttribute("href") || "", snapshot.url).href; + } catch { + sourceBaseUrl = snapshot.url; + } + } + const sections = document.querySelectorAll("signed-section"); for (const section of sections) { - const origin = window.location.origin; + const origin = new URL(snapshot.url).origin; const renderedHtml = section.outerHTML; - const sourceHtml = sourceSections[results.length] || ""; + const sourceHtml = (sourceByIdentity.get(identity(section)) || []).shift() || ""; const html = sourceHtml || renderedHtml; // Best-effort author display name from inner , @@ -135,7 +186,9 @@ const DOM_SCRIPT_BODY = ` html, renderedHtml, origin, - baseUrl: window.location.href + baseUrl: sourceBaseUrl, + renderedBaseUrl: document.baseURI, + documentUrl: snapshot.url }); // Map indicator -> legacy class suffix used in tests/reports. @@ -235,25 +288,25 @@ export async function runConsumerSession(opts: SessionOptions): Promise => createHash("sha256").update(canonical, "utf-8").digest("base64").replace(/=+$/, ""); - // Build the resolver chain once per session. The e2e harness intentionally - // uses local plain-HTTP key URLs inside Docker, so it uses the lower-level - // direct URL resolver here instead of the browser client's production - // network-policy wrapper. - const keyResolvers = [directUrlResolver()]; + // Build the resolver once per session. The signed keyid remains an HTTPS + // URL and is fetched through Nginx. Only the generated test certificate is + // accepted by the Node-side verification helper. + const keyResolvers = [directUrlResolver({ fetch: fetchTestKeyDocument })]; await context.exposeFunction( "__htmltrustVerifyAndScore", - async (input: { html: string; renderedHtml: string; origin: string; baseUrl: string }): Promise => { + async (input: { html: string; renderedHtml: string; origin: string; baseUrl: string; renderedBaseUrl: string; documentUrl: string }): Promise => { const verify = await verifySignedSection(input.html, { keyResolvers, origin: input.origin, baseUrl: input.baseUrl, + renderedBaseUrl: input.renderedBaseUrl, + documentUrl: input.documentUrl, renderedSection: input.renderedHtml, hash: nodeHash, debug: process.env.HTMLTRUST_DEBUG === "1", @@ -340,19 +393,28 @@ export async function runConsumerSession(opts: SessionOptions): Promise { - (window as unknown as { __htmltrustSourceSections?: string[] }).__htmltrustSourceSections = sections; - }, sourceSections); + await page.evaluate((snapshot) => { + (window as unknown as { + __htmltrustSourceSnapshot?: { html: string; url: string; sections: string[] }; + }).__htmltrustSourceSnapshot = snapshot; + }, { html: sourceHtml, url: sourceUrl, sections: sourceSections }); const asyncExpression = `(async () => { ${DOM_SCRIPT_BODY} })()`; const results = (await page.evaluate(asyncExpression)) as Array<{ authorId: string | null; diff --git a/src/lib/trust-api.ts b/src/lib/trust-api.ts index acaaedc..478d925 100644 --- a/src/lib/trust-api.ts +++ b/src/lib/trust-api.ts @@ -42,10 +42,10 @@ export class TrustApiClient { authorApiKey: string, data: { contentHash: string; - claimsHash: string; + sourceURL: string; + scope: "url" | "origin"; signedAt: string; - domain: string; - claims: Record; + claims: Array<{ name: string; content: string }>; }, ): Promise { return this.request("/api/content/sign", { method: "POST", headers: { "X-AUTHOR-API-KEY": authorApiKey }, body: data }); diff --git a/src/lib/wordpress-api.ts b/src/lib/wordpress-api.ts index 0131a41..6ce9e24 100644 --- a/src/lib/wordpress-api.ts +++ b/src/lib/wordpress-api.ts @@ -41,7 +41,7 @@ export class WordPressClient { * @param siteUrl - The logical site URL (e.g. http://author1.htmltrust.test) * @param username - WP admin username * @param password - WP admin password - * @param proxyUrl - Optional proxy URL to route requests through (e.g. http://localhost:8080). + * @param proxyUrl - Optional proxy URL to route requests through (e.g. http://localhost:18080). * When set, requests go to the proxy with a Host header matching the site domain. */ constructor(siteUrl: string, username: string, password: string, proxyUrl?: string) { diff --git a/src/phases/infrastructure.ts b/src/phases/infrastructure.ts index 907932a..c7438d2 100644 --- a/src/phases/infrastructure.ts +++ b/src/phases/infrastructure.ts @@ -1,4 +1,6 @@ +import path from "node:path"; import { composeUp, composeExec } from "../lib/docker.js"; +import { generateNginxConfig } from "../lib/nginx-config.js"; import { TrustApiClient } from "../lib/trust-api.js"; import type { ScenarioConfig, AuthorProfile, PhaseResult } from "../types.js"; @@ -7,7 +9,11 @@ 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 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); @@ -27,7 +33,7 @@ export async function runPhase1(config: ScenarioConfig, authors: AuthorProfile[] const result = await client.createAuthor({ name: author.name, keyType: "HUMAN", keyAlgorithm: "ED25519", description: `${author.cmsType} author for E2E simulation`, - url: `http://${author.domain}`, + url: `https://${author.domain}`, }); author.id = result.author.id; author.authorApiKey = result.authorApiKey; @@ -64,9 +70,10 @@ export async function runPhase1(config: ScenarioConfig, authors: AuthorProfile[] try { const c = author.wpContainerName!; await composeExec(e2eDir, c, ["wp", "core", "install", - `--url=http://${author.domain}`, `--title=${author.name} Blog`, + `--url=https://${author.domain}`, `--title=${author.name} Blog`, "--admin_user=admin", "--admin_password=admin", `--admin_email=admin@${author.domain}`, "--skip-email", "--allow-root"]); + await composeExec(e2eDir, c, ["wp", "option", "update", "permalink_structure", "", "--allow-root"]); // Activate the content-signing plugin. The plugin source is mounted into // the container by docker-compose at wp-content/plugins/content-signing. diff --git a/src/phases/publish.ts b/src/phases/publish.ts index fd163f6..9dee57b 100644 --- a/src/phases/publish.ts +++ b/src/phases/publish.ts @@ -2,6 +2,8 @@ import path from "node:path"; import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { + buildSigningPayloadV1, + canonicalizeClaims, normalizeText, } from "@htmltrust/canonicalization"; import { canonicalizeSignedContent } from "@htmltrust/browser-client"; @@ -42,30 +44,32 @@ export function computeContentHashFromHtml(html: string, baseUrl: string): strin /** * Compute the claims hash from the exact direct-child claim map. - * The current draft serializes every normalized claim as `name:content\n`, - * sorted by normalized claim name, and hashes the concatenated byte string. + * The frozen v1 profile serializes every normalized claim as an escaped + * `name:content\n` record, sorted by UTF-8 order, and hashes the result. */ export function computeClaimsHash(claims: Record): string { - const encoder = new TextEncoder(); - const compareUtf8 = (a: string, b: string): number => { - const aa = encoder.encode(a); - const bb = encoder.encode(b); - const len = Math.min(aa.length, bb.length); - for (let i = 0; i < len; i++) { - if (aa[i] !== bb[i]) return aa[i] - bb[i]; - } - return aa.length - bb.length; - }; - const canonical = Object.entries(claims) - .map(([name, content]) => [normalizeText(name), normalizeText(String(content))] as const) - .sort(([a], [b]) => compareUtf8(a, b)) - .map(([name, content]) => `${name}:${content}\n`) - .join(""); - return hashCanonical(canonical); + return hashCanonical(canonicalizeClaims(claims)); +} + +/** + * Construct the frozen v1 RFC 8785 signing payload used by browser clients. + * Keeping this small wrapper in the harness gives publish fixtures one shared + * entry point while the Docker signer migrates from the compatibility API. + */ +export function buildV1SigningPayload(parts: { + contentHash: string; + claimsHash: string; + documentURL: string; + scope: "url" | "origin"; + keyid: string; + algorithm: string; + signedAt: string; +}): string { + return buildSigningPayloadV1(parts); } export function serializedOriginForDomain(domain: string): string { - const candidate = /^[a-z][a-z0-9+.-]*:/i.test(domain) ? domain : `http://${domain}`; + const candidate = /^[a-z][a-z0-9+.-]*:/i.test(domain) ? domain : `https://${domain}`; return new URL(candidate).origin; } @@ -77,7 +81,32 @@ export function buildSignedClaims(author: string, signedAt: string, claims: Arti }; } +export function claimRecords(claims: Record): Array<{ name: string; content: string }> { + return Object.entries(claims).map(([name, content]) => ({ name, content })); +} + +export function v1Timestamp(now = new Date()): string { + return now.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +function escapeAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function escapeText(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + function buildSignedSectionHtml(opts: { + profile: "htmltrust-signature-v1"; + scope: "url" | "origin"; signature: string; keyId: string; contentHash: string; @@ -88,14 +117,14 @@ function buildSignedSectionHtml(opts: { innerContentHtml?: string; }): string { const metas = [ - ``, - ``, - ...Object.entries(opts.claims).map(([k, v]) => ``), + ``, + ``, + ...Object.entries(opts.claims).map(([k, v]) => ``), ].join("\n "); const inner = opts.innerContentHtml ? `\n ${opts.innerContentHtml}\n` : "\n"; - return ` + return ` ${metas}${inner}`; } @@ -135,14 +164,10 @@ export async function runPhase2( const slug = `article-${i + 1}`; let url: string; - // Compute the canonical binding fields per spec. The legacy `domain` - // API field carries the serialized Web origin, not a host-only name. - const signedAt = new Date().toISOString(); - const publicationOrigin = serializedOriginForDomain(author.domain); + const signedAt = v1Timestamp(); const signedClaims = buildSignedClaims(author.name, signedAt, declaredMeta); - const innerContentHtml = `

${content}

`; - const contentHash = computeContentHashFromHtml(innerContentHtml, publicationOrigin); - const claimsHash = computeClaimsHash(signedClaims); + const signedClaimRecords = claimRecords(signedClaims); + const innerContentHtml = `

${escapeText(content)}

`; if (author.cmsType === "wordpress") { // Ask the trust server to sign the binding. @@ -159,11 +184,40 @@ export async function runPhase2( // buildSignedSectionHtml helper, if no other caller uses it) once a // full sim run confirms the plugin emits an equivalent // . + // Create a draft first so the signature can bind the final WordPress + // response URL. The draft body is replaced before publication. + const c = author.wpContainerName!; + const draftFile = `/tmp/post-${Date.now()}-${i}.html`; + await writeFileToContainer(e2eDir, c, draftFile, innerContentHtml); + const postIdStr = (await composeExec(e2eDir, c, [ + "wp", "post", "create", draftFile, + `--post_title=${title}`, + "--post_status=draft", + "--post_type=post", + "--porcelain", + "--allow-root", + ])).trim(); + await composeExec(e2eDir, c, ["rm", draftFile]); + + const sourceURL = (await composeExec(e2eDir, c, [ + "wp", "post", "url", postIdStr, "--allow-root", + ])).trim(); + const parsedSourceURL = new URL(sourceURL); + if (parsedSourceURL.protocol !== "https:" || parsedSourceURL.host !== author.domain) { + 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, { - contentHash, claimsHash, signedAt, - domain: publicationOrigin, - claims: signedClaims, + contentHash, + sourceURL, + scope: "url", + signedAt, + claims: signedClaimRecords, }); + if (sigResult.claimsHash !== expectedClaimsHash) { + throw new Error(`claims hash mismatch for ${sourceURL}: local=${expectedClaimsHash} directory=${sigResult.claimsHash}`); + } // Build the signed-section wrapper with the content nested inside. // Using the wrapped form (spec ยง2.1 example): the @@ -171,6 +225,8 @@ export async function runPhase2( // it unambiguously. const keyId = sigResult.keyid; const signedSection = buildSignedSectionHtml({ + profile: sigResult.profile, + scope: sigResult.scope, signature: sigResult.signature, keyId, contentHash, @@ -180,26 +236,20 @@ export async function runPhase2( claims: declaredMeta, innerContentHtml, }); - - const postBody = signedSection; - - // Write the post body to a temp file inside the WP container via spawn (stdin) - const c = author.wpContainerName!; - const tmpFile = `/tmp/post-${Date.now()}-${i}.html`; - await writeFileToContainer(e2eDir, c, tmpFile, postBody); - - const postIdStr = (await composeExec(e2eDir, c, [ - "wp", "post", "create", tmpFile, - `--post_title=${title}`, + await composeExec(e2eDir, c, [ + "wp", "post", "update", postIdStr, + `--post_content=${signedSection}`, "--post_status=publish", - "--post_type=post", - "--porcelain", "--allow-root", + ]); + const publishedURL = (await composeExec(e2eDir, c, [ + "wp", "post", "url", postIdStr, "--allow-root", ])).trim(); + if (publishedURL !== sourceURL) { + throw new Error(`WordPress changed the signed URL after publication: signed=${sourceURL} published=${publishedURL}`); + } - await composeExec(e2eDir, c, ["rm", tmpFile]); - - url = `http://${author.domain}/?p=${postIdStr}`; + url = sourceURL; const malCheck = tracker.checkMalicious(declaredMeta, actualMeta); tracker.addArticle({ @@ -213,7 +263,7 @@ export async function runPhase2( continue; } else { const artPath = await hugoPub!.addArticle({ slug, title, content, claims: declaredMeta as unknown as Record }); - url = `http://${author.domain}${artPath}`; + url = `https://${author.domain}${artPath}`; } const { isMalicious, reason } = tracker.checkMalicious(declaredMeta, actualMeta); @@ -252,19 +302,24 @@ export async function runPhase2( continue; } - const signedAt = new Date().toISOString(); - const publicationOrigin = serializedOriginForDomain(author.domain); + const signedAt = v1Timestamp(); const signedClaims = buildSignedClaims(author.name, signedAt, article.declaredMetadata); - const innerContentHtml = `

${article.content}

`; - const contentHash = computeContentHashFromHtml(innerContentHtml, publicationOrigin); - const claimsHash = computeClaimsHash(signedClaims); + 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, { - contentHash, claimsHash, signedAt, - domain: publicationOrigin, - claims: signedClaims, + contentHash, + sourceURL: article.url, + scope: "url", + 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; @@ -272,6 +327,8 @@ export async function runPhase2( // 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, contentHash, diff --git a/src/prepare-nginx.ts b/src/prepare-nginx.ts new file mode 100644 index 0000000..34a5436 --- /dev/null +++ b/src/prepare-nginx.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateNginxConfig } from "./lib/nginx-config.js"; +import { generateAuthorProfiles, loadScenario } from "./lib/scenario.js"; + +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); +const output = path.join(directory, ".runtime", "nginx.conf"); + +await generateNginxConfig(authors, 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 882a7d9..efbbd6d 100644 --- a/src/run-phases-3-5.ts +++ b/src/run-phases-3-5.ts @@ -51,11 +51,11 @@ async function main(): Promise { throw new Error("No authors in ground-truth.json. Run Phases 1-2 first."); } - // Rewrite article URLs to use Docker-internal hostnames - // (Phases 1-2 used :8080 via host proxy; inside Docker we hit authorN.htmltrust.test directly on port 80) + // Article URLs use Docker-internal author hostnames and HTTPS. Strip only + // an accidental host proxy port from an older saved manifest. const articles = manifest.articles.map((a) => ({ ...a, - url: a.url.replace(/:8080\//, "/").replace(/localhost/g, new URL(a.url).hostname || ""), + url: a.url.replace(/:(?:8080|8443|18080|18443)\//, "/").replace(/localhost/g, new URL(a.url).hostname || ""), })) as unknown as Array<{ id: string; authorId: string; title: string; content: string; url: string; declaredMetadata: { ContentType: string; License: string; AIAssistance: "None" | "Human+AI" | "AI-only" }; actualMetadata: { ContentType: string; License: string; AIAssistance: "None" | "Human+AI" | "AI-only" }; isMalicious: boolean; maliciousReason?: string; contentHash?: string; signature?: string }>; console.log(`Loaded ${authors.length} authors, ${articles.length} articles`); diff --git a/src/smoke-test.ts b/src/smoke-test.ts index 17a0f45..848c779 100644 --- a/src/smoke-test.ts +++ b/src/smoke-test.ts @@ -5,6 +5,7 @@ */ 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 { TrustApiClient } from "./lib/trust-api.js"; @@ -14,6 +15,7 @@ import { generateNginxConfig } from "./lib/nginx-config.js"; import { runPhase2 } from "./phases/publish.js"; async function rawHttpGet( + proxyProtocol: "http:" | "https:", proxyHost: string, proxyPort: number, siteHost: string, @@ -21,8 +23,16 @@ async function rawHttpGet( maxRedirects = 5 ): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { - const req = http.request( - { hostname: proxyHost, port: proxyPort, path: reqPath, method: "GET", headers: { Host: siteHost } }, + const transport = proxyProtocol === "https:" ? https : http; + const req = transport.request( + { + hostname: proxyHost, + port: proxyPort, + path: reqPath, + method: "GET", + headers: { Host: siteHost }, + ...(proxyProtocol === "https:" ? { rejectUnauthorized: false } : {}), + }, async (res) => { // Follow redirects with Host header preserved if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && maxRedirects > 0) { @@ -32,7 +42,7 @@ async function rawHttpGet( const nextPath = loc.startsWith("http") ? new URL(loc).pathname + new URL(loc).search : loc; - const result = await rawHttpGet(proxyHost, proxyPort, siteHost, nextPath, maxRedirects - 1); + const result = await rawHttpGet(proxyProtocol, proxyHost, proxyPort, siteHost, nextPath, maxRedirects - 1); resolve(result); } catch (err) { reject(err); @@ -66,7 +76,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, "nginx.conf")); + await generateNginxConfig(authors, path.join(E2E_DIR, ".runtime", "nginx.conf")); let reloaded = false; for (let attempt = 0; attempt < 5 && !reloaded; attempt++) { @@ -113,7 +123,7 @@ async function main(): Promise { keyType: "HUMAN", keyAlgorithm: "ED25519", description: `${author.cmsType} author`, - url: `http://${author.domain}`, + url: `https://${author.domain}`, }); author.id = result.author.id; author.authorApiKey = result.authorApiKey; @@ -133,12 +143,13 @@ async function main(): Promise { await new Promise((r) => setTimeout(r, 3000)); await composeExec(E2E_DIR, c, [ "wp", "core", "install", - `--url=http://${wpAuthors[i].domain}`, + `--url=https://${wpAuthors[i].domain}`, `--title=${wpAuthors[i].name} Blog`, "--admin_user=admin", "--admin_password=admin", `--admin_email=admin@test.test`, "--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(); @@ -175,16 +186,19 @@ async function main(): Promise { // Verify articles are accessible and contain signed-section console.log(`\n=== Verification ===`); - const proxyUrl = new URL(config.nginx_proxy_url || "http://localhost:8080"); + const proxyUrl = new URL(config.nginx_proxy_url || "https://localhost:18443"); + if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") { + throw new Error(`Unsupported nginx proxy protocol: ${proxyUrl.protocol}`); + } const proxyHost = proxyUrl.hostname; - const proxyPort = parseInt(proxyUrl.port, 10) || 80; + const proxyPort = parseInt(proxyUrl.port, 10) || (proxyUrl.protocol === "https:" ? 443 : 80); let passing = 0; let totalFetched = 0; for (const article of manifest.articles) { try { const parsed = new URL(article.url); - const res = await rawHttpGet(proxyHost, proxyPort, parsed.host, parsed.pathname + parsed.search); + const res = await rawHttpGet(proxyUrl.protocol, proxyHost, proxyPort, parsed.host, parsed.pathname + parsed.search); totalFetched++; const hasSignedSection = res.body.includes("signed-section"); const marker = hasSignedSection ? "[signed]" : "[UNSIGNED]"; @@ -196,7 +210,11 @@ async function main(): Promise { } console.log(`\n ${passing}/${totalFetched} articles verified (200 + signed-section present)`); - console.log("\nSmoke test complete.\n"); + if (!p2result.success || totalFetched !== manifest.articles.length || passing !== manifest.articles.length) { + throw new Error("Smoke test failed: publication or rendered signed-section checks did not pass"); + } + + console.log("\nSmoke test passed.\n"); } main().catch((err) => { diff --git a/src/types.ts b/src/types.ts index f2412ff..9917542 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,7 +20,7 @@ export interface ScenarioConfig { */ trust_server: TrustDirectoryConfig; ollama: OllamaConfig; - nginx_proxy_url?: string; // e.g. "http://localhost:8080" when running from host + nginx_proxy_url?: string; // e.g. "https://localhost:18443" when running from host } export interface AuthorConfig { @@ -159,15 +159,23 @@ export interface CreateAuthorResponse { } export interface SignContentResponse { + profile: "htmltrust-signature-v1"; + context: "https://htmltrust.org/protocol/signed-section"; + canonicalizationProfile: "htmltrust-c14n-v1"; + attributeProfile: "htmltrust-attrs-v1"; + urlProfile: "htmltrust-safe-url-v1"; contentHash: string; claimsHash: string; signedAt: string; + scope: "url" | "origin"; + location: string; + sourceURL: string; domain: string; authorId: string; signature: string; algorithm: string; keyid: string; - claims: Record; + claims: Array<{ name: string; content: string }>; createdAt: string; } diff --git a/tests/lib/nginx-config.test.ts b/tests/lib/nginx-config.test.ts new file mode 100644 index 0000000..5c7a886 --- /dev/null +++ b/tests/lib/nginx-config.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +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"; + +const scratch: string[] = []; + +afterEach(async () => { + await Promise.all(scratch.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("generateNginxConfig", () => { + it("routes WordPress and Hugo authors over HTTP and test TLS", async () => { + const directory = await mkdtemp(path.join(process.env.TMPDIR || os.tmpdir(), "htmltrust-nginx-config-")); + scratch.push(directory); + const output = path.join(directory, "nginx.conf"); + const authors = [ + { + id: "wp", + name: "WordPress author", + authorApiKey: "key", + keyId: "key-id", + cmsType: "wordpress", + domain: "author1.htmltrust.test", + malicious_pct: 0, + wpContainerName: "wp-1", + }, + { + id: "hugo", + name: "Hugo author", + authorApiKey: "key", + keyId: "key-id", + cmsType: "hugo", + domain: "author2.htmltrust.test", + malicious_pct: 0, + }, + ] satisfies AuthorProfile[]; + + await generateNginxConfig(authors, output); + const config = await readFile(output, "utf8"); + + expect(config).toContain("listen 80; listen 443 ssl;"); + expect(config).toContain("ssl_certificate /etc/nginx/certs/htmltrust.test.crt;"); + expect(config).toContain("server_name author1.htmltrust.test;"); + expect(config).toContain("proxy_pass http://wp-1:80;"); + 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("listen 443 ssl default_server;"); + }); +}); diff --git a/tests/lib/publish.test.ts b/tests/lib/publish.test.ts index c5dd3ee..cd99a0e 100644 --- a/tests/lib/publish.test.ts +++ b/tests/lib/publish.test.ts @@ -2,10 +2,13 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { buildSignedClaims, + buildV1SigningPayload, + claimRecords, computeClaimsHash, computeContentHashFromHtml, hashCanonical, serializedOriginForDomain, + v1Timestamp, } from "../../src/phases/publish.js"; function sha256B64(input: string): string { @@ -21,13 +24,21 @@ describe("publish canonical bindings", () => { }); it("binds signatures to serialized Web origins", () => { - expect(serializedOriginForDomain("Author1.HTMLTrust.Test")).toBe("http://author1.htmltrust.test"); + expect(serializedOriginForDomain("Author1.HTMLTrust.Test")).toBe("https://author1.htmltrust.test"); expect(serializedOriginForDomain("https://Author1.HTMLTrust.Test:8443/path")).toBe( "https://author1.htmltrust.test:8443", ); }); - it("signs every direct child meta claim with draft newline serialization", () => { + it("uses the exact v1 timestamp format and strict claim records", () => { + expect(v1Timestamp(new Date("2026-08-28T19:20:21.987Z"))).toBe("2026-08-28T19:20:21Z"); + expect(claimRecords({ author: "Alice", "signed-at": "2026-08-28T19:20:21Z" })).toEqual([ + { name: "author", content: "Alice" }, + { name: "signed-at", content: "2026-08-28T19:20:21Z" }, + ]); + }); + + it("hashes direct-child claims with the shared v1 serialization", () => { const signedAt = "2026-05-18T12:00:00Z"; const claims = buildSignedClaims("Alice Example", signedAt, { ContentType: "Article", @@ -45,14 +56,38 @@ describe("publish canonical bindings", () => { const canonical = [ "author:Alice Example\n", - "claim:AIAssistance:AI-only\n", - "claim:ContentType:Article\n", - "claim:License:MIT\n", - `signed-at:${signedAt}\n`, + "claim\\:AIAssistance:AI-only\n", + "claim\\:ContentType:Article\n", + "claim\\:License:MIT\n", + `signed-at:2026-05-18T12\\:00\\:00Z\n`, ].join(""); expect(computeClaimsHash(claims)).toBe(`sha256:${sha256B64(canonical)}`); }); + it("builds the frozen v1 RFC8785 payload from the final document URL", () => { + const payload = buildV1SigningPayload({ + contentHash: "sha256:IVAwpRTDujszmYf76W497alVTtxGCgtJtQlasiFSCM8", + claimsHash: "sha256:Fk5udwCnu1au8v5oaBsU+aSB5S2zSLqoF0xXO6HrIn4", + documentURL: "https://example.com/essays/engines#analysis", + scope: "url", + keyid: "https://keys.example/alice-2026.json", + algorithm: "ed25519", + signedAt: "2026-01-15T12:00:00Z", + }); + + expect(payload).toBe( + '{"algorithm":"ed25519","attributeProfile":"htmltrust-attrs-v1",' + + '"canonicalizationProfile":"htmltrust-c14n-v1",' + + '"claimsHash":"sha256:Fk5udwCnu1au8v5oaBsU+aSB5S2zSLqoF0xXO6HrIn4",' + + '"contentHash":"sha256:IVAwpRTDujszmYf76W497alVTtxGCgtJtQlasiFSCM8",' + + '"context":"https://htmltrust.org/protocol/signed-section",' + + '"keyid":"https://keys.example/alice-2026.json",' + + '"location":"https://example.com/essays/engines",' + + '"profile":"htmltrust-signature-v1","scope":"url",' + + '"signedAt":"2026-01-15T12:00:00Z","urlProfile":"htmltrust-safe-url-v1"}', + ); + }); + it("covers signed semantic attributes in content hashes", () => { const origin = "https://author.example"; const original = computeContentHashFromHtml( diff --git a/tests/lib/trust-api.test.ts b/tests/lib/trust-api.test.ts index bfa5e52..35b43b7 100644 --- a/tests/lib/trust-api.test.ts +++ b/tests/lib/trust-api.test.ts @@ -19,13 +19,17 @@ describe("TrustApiClient", () => { }); it("signs content with author API key and new binding format", async () => { - mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ contentHash: "sha256:abc", claimsHash: "sha256:def", signedAt: "2026-04-10T12:00:00Z", signature: "sig123", algorithm: "ed25519", keyid: "https://directory.test/api/keys/k1", authorId: "a1", domain: "https://example.test", claims: {} }) }); + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ profile: "htmltrust-signature-v1", contentHash: "sha256:abc", claimsHash: "sha256:def", signedAt: "2026-04-10T12:00:00Z", scope: "url", location: "https://example.test/article", sourceURL: "https://example.test/article#fragment", signature: "sig123", algorithm: "ed25519", keyid: "https://directory.test/api/keys/k1", authorId: "a1", claims: [] }) }); const result = await client.signContent("author-key-1", { contentHash: "sha256:abc", - claimsHash: "sha256:def", + sourceURL: "https://example.test/article#fragment", + scope: "url", signedAt: "2026-04-10T12:00:00Z", - domain: "https://example.test", - claims: { author: "Test", "signed-at": "2026-04-10T12:00:00Z", "claim:ContentType": "Article" }, + claims: [ + { name: "author", content: "Test" }, + { name: "signed-at", content: "2026-04-10T12:00:00Z" }, + { name: "claim:ContentType", content: "Article" }, + ], }); expect(mockFetch).toHaveBeenCalledWith("http://trust-server:3000/api/content/sign", expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json", "X-AUTHOR-API-KEY": "author-key-1" }, @@ -34,10 +38,16 @@ describe("TrustApiClient", () => { const callArgs = mockFetch.mock.calls[0][1]; const body = JSON.parse(callArgs.body); expect(body.contentHash).toBe("sha256:abc"); - expect(body.claimsHash).toBe("sha256:def"); + expect(body).not.toHaveProperty("claimsHash"); expect(body.signedAt).toBe("2026-04-10T12:00:00Z"); - expect(body.domain).toBe("https://example.test"); - expect(body.claims).toEqual({ author: "Test", "signed-at": "2026-04-10T12:00:00Z", "claim:ContentType": "Article" }); + expect(body.sourceURL).toBe("https://example.test/article#fragment"); + expect(body.scope).toBe("url"); + expect(body).not.toHaveProperty("domain"); + expect(body.claims).toEqual([ + { name: "author", content: "Test" }, + { name: "signed-at", content: "2026-04-10T12:00:00Z" }, + { name: "claim:ContentType", content: "Article" }, + ]); expect(result.signature).toBe("sig123"); expect(result.keyid).toBe("https://directory.test/api/keys/k1"); });