diff --git a/.env.example b/.env.example index 94fb60c..2ad53e9 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,42 @@ NODE_ENV=development PORT=3000 MONGO_URI=mongodb://localhost:27017/htmltrust -JWT_SECRET=change_me_to_a_random_secret -JWT_EXPIRE=30d + +# Public base URL of this directory. Used to decide whether a keyid of the +# form https://host/api/keys/{id} names a key held here or somewhere else. +DIRECTORY_BASE_URL=http://localhost:3000 + +# --- Secrets -------------------------------------------------------------- +# Pepper for author API key hashing. REQUIRED when NODE_ENV=production; the +# server refuses to start without it. Changing it invalidates every issued +# author API key. Generate with: openssl rand -hex 32 +AUTHOR_API_KEY_PEPPER=change_me_to_32_random_bytes + +# Supplementary demo/admin shared secrets. Draft §9.8 requires POST endpoints +# to authenticate with an RFC 9421 HTTP Message Signature; these static keys +# are refused when NODE_ENV=production unless HTMLTRUST_ALLOW_API_KEY_AUTH=1. GENERAL_API_KEY=change_me_general_key ADMIN_API_KEY=change_me_admin_key +# HTMLTRUST_ALLOW_API_KEY_AUTH=0 + +# --- Key resolution ------------------------------------------------------- +# Resolving DID and https keyids means dereferencing URLs chosen by whoever +# submits a record, which is a server-side request forgery primitive. Off by +# default: only keys held by this directory resolve. Turn it on only if the +# directory can safely make outbound requests. +# HTMLTRUST_REMOTE_KEY_RESOLUTION=1 + +# --- Rate limiting -------------------------------------------------------- +# Requests per minute per client address. +# RATE_LIMIT_GLOBAL=600 +# RATE_LIMIT_AUTH=30 +# RATE_LIMIT_WRITE=60 +# DISABLE_RATE_LIMIT=1 + +# Number of reverse proxies in front of this server. Leave unset when the +# server is directly exposed: trusting X-Forwarded-For from an untrusted +# client lets it forge a fresh identity per request and bypass rate limits. +# TRUST_PROXY=1 + +# Maximum accepted request body size. +# MAX_REQUEST_BODY=256kb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c79a5b3..9777a7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] +# Least privilege: this workflow only reads the repo and uploads artifacts. +permissions: + contents: read + jobs: build: name: Build & Verify @@ -18,23 +22,36 @@ jobs: - 27017:27017 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" - - name: Configure private dep access + # The package token used to be written to ~/.gitconfig, where every later + # step -- including any dependency lifecycle script -- could read it back. + # It is now passed through GIT_CONFIG_* environment variables, which git + # honours for this process tree only and never persists to disk, and + # --ignore-scripts keeps third-party install hooks from running at all + # while the token is in the environment. + # + # HTMLTRUST_PKG_TOKEN must be a fine-grained PAT scoped to the + # HTMLTrust/htmltrust-canonicalization repository with Contents: Read and + # nothing else. A classic `repo`-scoped token grants write access to every + # repo the owner can reach and must not be used here. + - name: Install dependencies env: - TOKEN: ${{ secrets.HTMLTRUST_PKG_TOKEN }} - run: | - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_KEY_0: url.https://x-access-token:${{ secrets.HTMLTRUST_PKG_TOKEN }}@github.com/.insteadOf + GIT_CONFIG_VALUE_0: https://github.com/ + GIT_CONFIG_KEY_1: url.https://x-access-token:${{ secrets.HTMLTRUST_PKG_TOKEN }}@github.com/.insteadOf + GIT_CONFIG_VALUE_1: ssh://git@github.com/ + run: npm ci --ignore-scripts - - name: Install dependencies - run: npm ci + - name: Run unit tests + run: npm test - name: Verify server starts env: @@ -42,6 +59,7 @@ jobs: PORT: "3000" GENERAL_API_KEY: test_general_key ADMIN_API_KEY: test_admin_key + AUTHOR_API_KEY_PEPPER: test_pepper NODE_ENV: test run: | timeout 10 node src/server.js & @@ -49,10 +67,21 @@ jobs: curl -sf http://localhost:3000/ > /dev/null && echo "Server started successfully" kill %1 2>/dev/null || true + - name: Run conformance suite + env: + MONGO_URI: mongodb://localhost:27017/htmltrust-conformance + GENERAL_API_KEY: conformance_general_key + ADMIN_API_KEY: conformance_admin_key + AUTHOR_API_KEY_PEPPER: conformance_pepper + NODE_ENV: test + run: | + npm --prefix conformance/runner ci + npm run conformance + - name: Validate OpenAPI spec run: npx @redocly/cli lint openapi.yaml --skip-rule no-unused-components || true - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: server-package path: | diff --git a/README.md b/README.md index 5ae513a..cc345c1 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,15 @@ npm run dev # Starts with nodemon (auto-reload) The server starts at `http://localhost:3000`. A demo web UI is available at the root URL. +### Tests + +```sh +npm test # unit tests: JCS, claims canonicalization, RFC 9421 verification +npm run conformance # full API conformance suite against a disposable MongoDB +``` + +`npm test` needs no database. `npm run conformance` boots `mongodb-memory-server` and the reference server itself; set `SERVER_PORT` / `MONGO_PORT` if 3000 or 37017 are taken. + ### Environment Variables See `.env.example` for all options. At minimum you need: @@ -58,8 +67,9 @@ See `.env.example` for all options. At minimum you need: | Variable | Description | |---|---| | `MONGO_URI` | MongoDB connection string | -| `GENERAL_API_KEY` | API key for general authenticated operations | -| `ADMIN_API_KEY` | API key for admin operations (e.g., defining claim types) | +| `AUTHOR_API_KEY_PEPPER` | Pepper for author API key hashing. Required when `NODE_ENV=production`; the server refuses to start without it | +| `GENERAL_API_KEY` | Supplementary demo key for submission endpoints | +| `ADMIN_API_KEY` | Admin key for directory-operator operations (defining claim types, endorsement takedown) | ## API Overview @@ -67,30 +77,62 @@ Full API documentation is in [`openapi.yaml`](openapi.yaml). Key endpoint groups | Path | Description | Auth | |---|---|---| +| `GET /api/.well-known/htmltrust` | Discover directory capabilities | Public | +| `GET /api/content/:hash` | Get draft content record by percent-encoded hash | Public | +| `POST /api/content` | Submit a signed content occurrence | HTTP Message Signature | +| `GET /api/content/:hash/endorsements` | List structured endorsements for a content hash | Public | +| `GET /api/keys/:id` | Get draft key document | Public | +| `GET /api/signers/:id/reputation` | Get draft signer reputation | Public | | `POST /api/authors` | Create author + key pair | General API key | | `GET /api/authors/:id/public-key` | Get author's public key | Public | -| `POST /api/content/sign` | Sign a content hash | Author API key | +| `POST /api/content/sign` | Compatibility helper: sign contentHash + claimsHash | Author API key | | `POST /api/content/verify` | Verify a signature (deprecated, see below) | Public | | `GET /api/directory/keys` | Search public keys | Public | | `GET /api/directory/content` | Search signed content | Public | | `GET /api/endorsements?content-hash=...` | List endorsements for a content hash | Public | -| `POST /api/endorsements` | Submit a signed endorsement | General API key | -| `DELETE /api/endorsements/:id` | Delete an endorsement | General API key | -| `POST /api/votes` | Vote trust/distrust | General API key | +| `POST /api/endorsements` | Submit a signed endorsement | HTTP Message Signature | +| `DELETE /api/endorsements/:id` | Delete an endorsement | Endorser's own key, or admin key | +| `POST /api/votes` | Vote trust/distrust | HTTP Message Signature | ### Deprecated endpoints `POST /api/content/verify` is deprecated. Per [HTMLTrust spec §3.1](https://htmltrust.dev/spec#section-3-1), cryptographic verification is a local operation: clients MUST verify signatures themselves (e.g. via `SubtleCrypto`) using public keys resolved through the directory's key endpoints. A remote yes/no answer from the directory is by definition not a cryptographic guarantee since the directory is not part of the trust root. The endpoint remains as a low-trust convenience for legacy clients, returns the `Deprecation: true` header (RFC 9745), and will be removed in a future major version. The directory's role is to serve public keys, endorsements, and reputation data — not to act as an oracle for signature validity. +### Draft wire-format notes + +Hashes, signatures, and key bytes use canonical unpadded standard Base64, not base64url. JSON fields named `domain` carry the serialized Web origin (`scheme://host[:port]`), not a bare hostname. Content signatures bind `contentHash:claimsHash:domain:signedAt`, where `claimsHash` is the SHA-256 of the draft §4.6 canonical claims serialization over all direct child `meta` claims in the signed section. + +Endorsement signatures cover the RFC 8785 JCS serialization of the endorsement document with the `signature` member omitted (draft §10.2). The directory verifies that signature against the endorser's resolved key before storing anything, and serves the stored document back byte-for-byte: it injects no `_id`, `createdAt`, or `contentHash` alias, because §10.1 requires unrecognised members to be included in the signed payload, so any injected member would break verification for the next reader. The identifier of a newly stored endorsement is returned in the `Location` header of the 201 response. `contentHash` appears only on documents stored by earlier versions of this server. + +Endorsements are append-only. Resubmitting an identical document is idempotent (200 instead of 201); a different document from the same endorser for the same content hash — a revocation, for instance — is stored alongside the original, because §10.3 requires a directory holding both to serve both. + ### Authentication -Three tiers of API key auth via headers: +Draft §9.8 requires POST endpoints to authenticate with an [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) HTTP Message Signature made with a key the directory can resolve per §8. The signature MUST cover the request target, `host`, `date`, and — for requests with a body — `content-digest`: + +``` +Signature-Input: sig1=("@method" "@target-uri" "host" "date" "content-digest");\ + created=1770000000;keyid="https://directory.example/api/keys/k-abc123" +Signature: sig1=:MEUCIQD...: +``` + +The authenticated identity is the resolved key, which is what lets the directory bind a submission, a vote, or an endorsement deletion to a specific signer. + +The static API keys below remain as a supplementary demo and operator scheme. A shared secret says nothing about *who* sent a request, so it cannot carry submitter identity; requests authenticated this way vote as a single collapsed identity and cannot delete another party's endorsement. They are refused when `NODE_ENV=production` unless `HTMLTRUST_ALLOW_API_KEY_AUTH=1` is set. | Header | Purpose | |---|---| -| `X-API-KEY` | General operations (creating authors, voting, reporting) | -| `X-AUTHOR-API-KEY` | Author-specific operations (signing, updating own profile) | -| `X-ADMIN-API-KEY` | Admin operations (managing claim types) | +| `X-API-KEY` | Demo submission key (creating authors, voting, reporting) | +| `X-AUTHOR-API-KEY` | Author-specific operations (directory-side signing, updating own profile) | +| `X-ADMIN-API-KEY` | Directory-operator operations (managing claim types, endorsement takedown) | + +Author API keys are stored as an HMAC-SHA-256 under `AUTHOR_API_KEY_PEPPER` and are shown exactly once, at author creation. Deployments upgrading from a version that stored them in plaintext need the one-time migration described in `src/utils/apiKeys.js`; databases created before endorsements became append-only also need the two unique indexes dropped, as described in `src/models/Endorsement.js`. + +### Key custody + +`POST /api/authors` accepts an optional `publicKey` (SPKI PEM). Supply it to register a key you already hold: the directory then stores no private key for that author, and content is signed locally and submitted through `POST /api/content`. Omit it and the directory generates and holds the key pair, acting as the convenience registry of draft §9.6. + +Resolving `did:` and `https:` keyids means dereferencing URLs chosen by whoever submits a record, which is a server-side request forgery primitive. It is therefore off by default; only keys held by this directory resolve. Set `HTMLTRUST_REMOTE_KEY_RESOLUTION=1` to enable it. ## Project Structure diff --git a/conformance/fixtures/03-signed-content-submission.yaml b/conformance/fixtures/03-signed-content-submission.yaml index 54d79e3..3118242 100644 --- a/conformance/fixtures/03-signed-content-submission.yaml +++ b/conformance/fixtures/03-signed-content-submission.yaml @@ -31,20 +31,23 @@ steps: headers: X-AUTHOR-API-KEY: $authorApiKey body: - contentHash: "sha256:03-$run_nonce-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - claimsHash: "sha256:03-claims-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - domain: "conformance.example.com" + contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc" + claimsHash: "sha256:EOlXUVED7G9RI90/iTXXNtY79KQEW6LxLVOVtsjlHWs" + domain: "https://conformance.example.com" signedAt: "2026-05-12T12:00:00.000Z" claims: + signed-at: "2026-05-12T12:00:00.000Z" ContentType: "Article" License: "CC-BY-4.0" expect: status: 201 schema: ContentSignature body: - contentHash: "sha256:03-$run_nonce-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - domain: "conformance.example.com" + contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc" + domain: "https://conformance.example.com" signature: $nonempty-string + algorithm: "ed25519" + keyid: $nonempty-string claims: ContentType: "Article" License: "CC-BY-4.0" @@ -56,9 +59,9 @@ steps: method: POST path: /content/verify body: - contentHash: "sha256:03-$run_nonce-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - claimsHash: "sha256:03-claims-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - domain: "conformance.example.com" + contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc" + claimsHash: "sha256:EOlXUVED7G9RI90/iTXXNtY79KQEW6LxLVOVtsjlHWs" + domain: "https://conformance.example.com" signedAt: "2026-05-12T12:00:00.000Z" authorId: $authorId signature: $signature diff --git a/conformance/fixtures/04-content-retrieval-by-hash.yaml b/conformance/fixtures/04-content-retrieval-by-hash.yaml index 7ea3113..8ec73b0 100644 --- a/conformance/fixtures/04-content-retrieval-by-hash.yaml +++ b/conformance/fixtures/04-content-retrieval-by-hash.yaml @@ -32,11 +32,12 @@ steps: headers: X-AUTHOR-API-KEY: $authorApiKey body: - contentHash: "sha256:04-$run_nonce-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - claimsHash: "sha256:04-claims-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - domain: "retrieval.example.com" + contentHash: "sha256:82rHSQ/ThLduI0bbHYOVbxn5mEXR0FxMzn6YsVHSwSs" + claimsHash: "sha256:epUf+9l+yWgGFMHwNw++jCpep5Ib/f4T5dZBDO8nb5o" + domain: "https://retrieval.example.com" signedAt: "2026-05-12T12:00:00.000Z" claims: + signed-at: "2026-05-12T12:00:00.000Z" ContentType: "Article" expect: status: 201 @@ -44,7 +45,7 @@ steps: - name: Search the directory by content hash request: method: GET - path: "/directory/content?contentHash=sha256:04-$run_nonce-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + path: "/directory/content?contentHash=sha256:82rHSQ/ThLduI0bbHYOVbxn5mEXR0FxMzn6YsVHSwSs" expect: status: 200 body: @@ -55,6 +56,17 @@ steps: total: $integer pages: $integer + - name: Retrieve the draft content record by hash + request: + method: GET + path: "/content/sha256%3A82rHSQ%2FThLduI0bbHYOVbxn5mEXR0FxMzn6YsVHSwSs" + expect: + status: 200 + body: + contentHash: "sha256:82rHSQ/ThLduI0bbHYOVbxn5mEXR0FxMzn6YsVHSwSs" + signers: $any + endorsementCount: $integer + - name: Query occurrences for an unknown content hash returns 404 request: method: GET diff --git a/conformance/fixtures/05-endorsement-submission.yaml b/conformance/fixtures/05-endorsement-submission.yaml index 2be956d..c7a7b2f 100644 --- a/conformance/fixtures/05-endorsement-submission.yaml +++ b/conformance/fixtures/05-endorsement-submission.yaml @@ -1,65 +1,136 @@ -# 05 — Endorsement submission +# 05 — Structured endorsement submission # -# Note: the OpenAPI spec at openapi.yaml does not currently define the -# /votes endpoints, but the reference implementation exposes them as the -# "endorsement" / trust-distrust mechanism the spec text references. -# This fixture exercises the implementation-level vote submission flow: -# create an author, then submit a TRUST vote against that author. +# Draft §9.7: the directory MUST verify the endorser's signature before +# indexing an endorsement, and MUST reject an invalid one with 400. The happy +# path therefore registers a public key whose private half the runner holds +# and signs the document for real; the negative cases prove that a forged +# signature and an unresolvable endorser are both refused. # -# A future revision of the OpenAPI spec is expected to formalize these -# endpoints; until then the suite asserts only the documented behaviour -# of the implementation (status code + presence of identifying fields). -name: Submit trust endorsement (vote) on an author +# Draft §9.5/§10.1: the stored document is served back verbatim, so the +# response body carries no server-assigned `_id`. The identifier comes from +# the Location header. +name: Submit structured endorsement document description: | - POST /votes accepts a TRUST or DISTRUST vote against an AUTHOR or CONTENT - target. This scenario submits a TRUST vote and verifies it is recorded. + POST /endorsements verifies the endorser signature over JCS(document minus + signature) and stores the document unmodified. steps: - - name: Create author to endorse + - name: Register the endorser's public key request: method: POST path: /authors headers: X-API-KEY: $generalApiKey body: - name: "Endorsee 05-$run_nonce" - keyType: "HUMAN" + name: "Endorser 05-$run_nonce" + keyType: "ORGANIZATION" + keyAlgorithm: "ed25519" + publicKey: $signerPublicKeyPem expect: status: 201 capture: authorId: $.author.id - - name: Submit TRUST endorsement + - name: Learn the directory key id for that public key + request: + method: GET + path: /authors/$authorId/public-key + expect: + status: 200 + schema: PublicKey + capture: + keyId: $.id + + - name: Submit a correctly signed endorsement request: method: POST - path: /votes + path: /endorsements headers: X-API-KEY: $generalApiKey + sign: endorsement body: - userId: "voter-05-$run_nonce" - targetType: "AUTHOR" - targetId: $authorId - voteType: "TRUST" - reason: "Conformance scenario 05" + endorser: "$baseUrl/keys/$keyId" + endorsement: "sha256:jPC/zIv0U/yOO0s9kY2U86pa9xudR17/niqrNOk0Puk" + algorithm: "ed25519" + timestamp: "2026-05-10T09:00:00Z" + claim: "Verified original publication." expect: status: 201 + schema: Endorsement body: - targetType: "AUTHOR" - voteType: "TRUST" + endorser: "$baseUrl/keys/$keyId" + endorsement: "sha256:jPC/zIv0U/yOO0s9kY2U86pa9xudR17/niqrNOk0Puk" + algorithm: "ed25519" + claim: "Verified original publication." + capture: + endorsementId: + header: location + pattern: "([0-9a-f]{24})$" - - name: Submit DISTRUST endorsement (updates existing vote, same user/target) + - name: Resubmitting the identical document is idempotent request: method: POST - path: /votes + path: /endorsements headers: X-API-KEY: $generalApiKey + sign: endorsement body: - userId: "voter-05-$run_nonce" - targetType: "AUTHOR" - targetId: $authorId - voteType: "DISTRUST" - reason: "Changed my mind" + endorser: "$baseUrl/keys/$keyId" + endorsement: "sha256:jPC/zIv0U/yOO0s9kY2U86pa9xudR17/niqrNOk0Puk" + algorithm: "ed25519" + timestamp: "2026-05-10T09:00:00Z" + claim: "Verified original publication." expect: - status: 201 + status: 200 + + - name: A forged signature is rejected with a problem document + request: + method: POST + path: /endorsements + headers: + X-API-KEY: $generalApiKey body: - targetType: "AUTHOR" - voteType: "DISTRUST" + endorser: "$baseUrl/keys/$keyId" + endorsement: "sha256:jPC/zIv0U/yOO0s9kY2U86pa9xudR17/niqrNOk0Puk" + algorithm: "ed25519" + timestamp: "2026-05-10T09:00:00Z" + signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + expect: + status: 400 + schema: Problem + body: + status: 400 + + - name: An endorser this directory cannot resolve is rejected + request: + method: POST + path: /endorsements + headers: + X-API-KEY: $generalApiKey + sign: endorsement + body: + endorser: "did:web:reviewer.example" + endorsement: "sha256:jPC/zIv0U/yOO0s9kY2U86pa9xudR17/niqrNOk0Puk" + algorithm: "ed25519" + timestamp: "2026-05-10T09:00:00Z" + expect: + status: 400 + schema: Problem + + - name: Deleting an endorsement requires more than the submission key + request: + method: DELETE + path: /endorsements/$endorsementId + headers: + X-API-KEY: $generalApiKey + expect: + status: 401 + schema: Problem + + - name: The directory operator can take an endorsement down + request: + method: DELETE + path: /endorsements/$endorsementId + headers: + X-ADMIN-API-KEY: $adminApiKey + expect: + status: 204 diff --git a/conformance/fixtures/06-endorsement-listing.yaml b/conformance/fixtures/06-endorsement-listing.yaml index a6fb311..34523e2 100644 --- a/conformance/fixtures/06-endorsement-listing.yaml +++ b/conformance/fixtures/06-endorsement-listing.yaml @@ -1,81 +1,88 @@ -# 06 — Endorsement listing & statistics +# 06 — Structured endorsement listing # -# Continues the same implementation-level vote API as 05. After submitting -# an endorsement, we list votes for the target and pull aggregate stats. -name: List endorsements and aggregate stats for an author +# Submits a signed endorsement plus a revocation of it, then retrieves them +# through both the legacy query endpoint and the normative content-scoped +# endpoint. Draft §10.3 requires a directory holding both an endorsement and +# its revocation to serve BOTH, so the second submission must not overwrite +# the first. +name: List structured endorsements for content description: | - GET /votes/{targetType}/{targetId} returns paginated votes plus trust/distrust - counts. GET /votes/stats/{targetType}/{targetId} returns aggregate stats. + GET /endorsements?content-hash=... and GET /content/{hash}/endorsements + return structured endorsement documents indexed by their draft endorsement + content hash. steps: - - name: Create author + - name: Register the endorser's public key request: method: POST path: /authors headers: X-API-KEY: $generalApiKey body: - name: "Tallied 06-$run_nonce" - keyType: "HUMAN" + name: "Endorser 06-$run_nonce" + keyType: "ORGANIZATION" + keyAlgorithm: "ed25519" + publicKey: $signerPublicKeyPem expect: status: 201 capture: authorId: $.author.id - - name: Cast a TRUST vote + - name: Learn the directory key id for that public key + request: + method: GET + path: /authors/$authorId/public-key + expect: + status: 200 + capture: + keyId: $.id + + - name: Submit a signed endorsement request: method: POST - path: /votes + path: /endorsements headers: X-API-KEY: $generalApiKey + sign: endorsement body: - userId: "voter-a-06-$run_nonce" - targetType: "AUTHOR" - targetId: $authorId - voteType: "TRUST" + endorser: "$baseUrl/keys/$keyId" + endorsement: "sha256:yYWXTbC+4FCFhYkfp3ltiR41jVmudplLEeozQqman2I" + algorithm: "ed25519" + timestamp: "2026-05-10T09:00:00Z" expect: status: 201 - - name: Cast a DISTRUST vote from a different user + - name: Submit a revocation of it from the same endorser request: method: POST - path: /votes + path: /endorsements headers: X-API-KEY: $generalApiKey + sign: endorsement body: - userId: "voter-b-06-$run_nonce" - targetType: "AUTHOR" - targetId: $authorId - voteType: "DISTRUST" + endorser: "$baseUrl/keys/$keyId" + endorsement: "sha256:yYWXTbC+4FCFhYkfp3ltiR41jVmudplLEeozQqman2I" + algorithm: "ed25519" + timestamp: "2026-05-11T09:00:00Z" + claim: "Withdrawn: the source page changed." + revokedBy: "sha256:yYWXTbC+4FCFhYkfp3ltiR41jVmudplLEeozQqman2I" expect: status: 201 - - name: List votes for the author + - name: List through compatibility query endpoint request: method: GET - path: /votes/AUTHOR/$authorId + path: "/endorsements?content-hash=sha256%3AyYWXTbC%2B4FCFhYkfp3ltiR41jVmudplLEeozQqman2I" expect: status: 200 - body: - votes: $any - counts: - trust: $integer - distrust: $integer - total: $integer - pagination: - page: $integer - limit: $integer - total: $integer - pages: $integer - - name: Aggregate vote stats for the author + - name: List through draft content-scoped endpoint; both documents are served request: method: GET - path: /votes/stats/AUTHOR/$authorId + path: "/content/sha256%3AyYWXTbC%2B4FCFhYkfp3ltiR41jVmudplLEeozQqman2I/endorsements" expect: status: 200 body: - targetType: "AUTHOR" - trustCount: $integer - distrustCount: $integer - totalVotes: $integer - trustScore: $number + - endorser: "$baseUrl/keys/$keyId" + revokedBy: "sha256:yYWXTbC+4FCFhYkfp3ltiR41jVmudplLEeozQqman2I" + - endorser: "$baseUrl/keys/$keyId" + timestamp: "2026-05-10T09:00:00Z" diff --git a/conformance/fixtures/07-key-reputation.yaml b/conformance/fixtures/07-key-reputation.yaml index e7c7cf9..acf745a 100644 --- a/conformance/fixtures/07-key-reputation.yaml +++ b/conformance/fixtures/07-key-reputation.yaml @@ -7,6 +7,20 @@ name: Fetch reputation for an author's key description: | GET /directory/keys/{keyId}/reputation returns a KeyReputation envelope. steps: + - name: Fetch directory discovery document + request: + method: GET + path: /.well-known/htmltrust + expect: + status: 200 + body: + version: "1" + capabilities: + content: true + endorsements: true + keys: true + reputation: true + - name: Create author request: method: POST @@ -40,6 +54,27 @@ steps: status: 200 schema: KeyReputation + - name: Fetch draft key document + request: + method: GET + path: /keys/$keyId + expect: + status: 200 + body: + kid: $keyId + algorithm: "ed25519" + publicKey: $nonempty-string + + - name: Fetch draft signer reputation + request: + method: GET + path: /signers/$keyId/reputation + expect: + status: 200 + body: + keyid: $keyId + score: $number + - name: Reputation for an unknown key returns 404 request: method: GET diff --git a/conformance/fixtures/09-batch-vote-submission.yaml b/conformance/fixtures/09-batch-vote-submission.yaml index aaa420e..765280e 100644 --- a/conformance/fixtures/09-batch-vote-submission.yaml +++ b/conformance/fixtures/09-batch-vote-submission.yaml @@ -1,14 +1,19 @@ -# 09 — Batch vote submission +# 09 — Vote submission and aggregate stats # -# The current spec does not expose a single batch-vote endpoint, so this -# fixture submits several votes sequentially against the same target and -# then asserts the aggregate stats reflect every submission. This is the -# closest single-implementation analogue to a batch endpoint until the -# spec formalizes one. -name: Submit several votes from distinct users and verify aggregate stats +# Votes move a key's trust score, so the voter is derived from the +# authenticated request rather than from a `userId` field in the body. A +# caller holding only the shared submission key has no distinguishable +# identity, so every such caller is one voter: repeated submissions replace +# that voter's ballot instead of stacking. Distinct voters need distinct +# identities, which means an RFC 9421 signature from a resolvable key. +# +# This fixture pins that behaviour — before it, naming a different `userId` +# per request let a single key manufacture unlimited votes against a target. +name: Vote submission is bound to the caller's identity description: | - Sequentially POSTs three TRUST votes and one DISTRUST vote from distinct - user IDs, then asserts the stats endpoint returns the expected counts. + POSTs several votes against one target from a single authenticated caller + and asserts the stats endpoint counts one ballot, not one per body-supplied + user id. steps: - name: Create author request: @@ -24,7 +29,7 @@ steps: capture: authorId: $.author.id - - name: Vote 1 of 4 (TRUST) + - name: First vote (TRUST) request: method: POST path: /votes @@ -37,52 +42,50 @@ steps: voteType: "TRUST" expect: { status: 201 } - - name: Vote 2 of 4 (TRUST) + - name: Aggregate stats after one vote request: - method: POST - path: /votes - headers: - X-API-KEY: $generalApiKey + method: GET + path: /votes/stats/AUTHOR/$authorId + expect: + status: 200 body: - userId: "batch-09-$run_nonce-2" - targetType: "AUTHOR" - targetId: $authorId - voteType: "TRUST" - expect: { status: 201 } + trustCount: 1 + distrustCount: 0 + totalVotes: 1 - - name: Vote 3 of 4 (TRUST) + - name: A second vote naming a different userId does not create a second ballot request: method: POST path: /votes headers: X-API-KEY: $generalApiKey body: - userId: "batch-09-$run_nonce-3" + userId: "batch-09-$run_nonce-2" targetType: "AUTHOR" targetId: $authorId voteType: "TRUST" expect: { status: 201 } - - name: Vote 4 of 4 (DISTRUST) + - name: Changing the vote type replaces the caller's ballot request: method: POST path: /votes headers: X-API-KEY: $generalApiKey body: - userId: "batch-09-$run_nonce-4" + userId: "batch-09-$run_nonce-3" targetType: "AUTHOR" targetId: $authorId voteType: "DISTRUST" expect: { status: 201 } - - name: Confirm aggregate stats reflect all four votes + - name: Confirm the caller still holds exactly one ballot request: method: GET path: /votes/stats/AUTHOR/$authorId expect: status: 200 body: - trustCount: 3 + trustCount: 0 distrustCount: 1 - totalVotes: 4 + totalVotes: 1 diff --git a/conformance/fixtures/10-error-responses.yaml b/conformance/fixtures/10-error-responses.yaml index 5caaaa5..6cc48e2 100644 --- a/conformance/fixtures/10-error-responses.yaml +++ b/conformance/fixtures/10-error-responses.yaml @@ -7,6 +7,10 @@ # * 401 — PUT /authors/{id} without X-AUTHOR-API-KEY # * 403 — PUT /authors/{otherId} with the wrong author's API key # * 404 — GET /authors/{bogus-id} +# +# Draft §9.9 requires errors in the RFC 9457 problem-details format, so the +# authentication and authorization layers return `Problem` rather than the +# legacy `Error` envelope, and 401s carry a WWW-Authenticate challenge. name: Documented error envelopes are returned with correct status codes description: | Asserts the Error schema is returned for unauthorized, forbidden, and @@ -50,7 +54,7 @@ steps: keyType: "HUMAN" expect: status: 401 - schema: Error + schema: Problem - name: 401 — PUT /authors/{id} with no author API key request: @@ -60,7 +64,7 @@ steps: description: "Should fail" expect: status: 401 - schema: Error + schema: Problem - name: 403 — PUT /authors/{authorB} with authorA's key request: @@ -72,7 +76,7 @@ steps: description: "Cross-author update should be forbidden" expect: status: 403 - schema: Error + schema: Problem - name: 404 — GET /authors/{nonexistent} request: diff --git a/conformance/fixtures/12-claim-type-management.yaml b/conformance/fixtures/12-claim-type-management.yaml index 9073c83..b4fce18 100644 --- a/conformance/fixtures/12-claim-type-management.yaml +++ b/conformance/fixtures/12-claim-type-management.yaml @@ -19,7 +19,7 @@ steps: description: "Should fail" expect: status: 401 - schema: Error + schema: Problem - name: Create claim type as admin request: diff --git a/conformance/run-conformance.sh b/conformance/run-conformance.sh index cd12a69..bfe6d22 100755 --- a/conformance/run-conformance.sh +++ b/conformance/run-conformance.sh @@ -50,10 +50,14 @@ if docker ps -a --format '{{.Names}}' | grep -qx "$MONGO_CONTAINER"; then echo "removing stale mongo container $MONGO_CONTAINER" docker rm -f "$MONGO_CONTAINER" >/dev/null fi -echo "starting mongo on localhost:$MONGO_PORT (container: $MONGO_CONTAINER)" +echo "starting mongo on 127.0.0.1:$MONGO_PORT (container: $MONGO_CONTAINER)" +# Bound to the loopback interface. A bare "$MONGO_PORT:27017" publishes on +# 0.0.0.0, which puts an unauthenticated Mongo on every interface of whatever +# machine runs the conformance suite -- including laptops on untrusted +# networks. Only this host needs to reach it. docker run -d --rm \ --name "$MONGO_CONTAINER" \ - -p "$MONGO_PORT:27017" \ + -p "127.0.0.1:$MONGO_PORT:27017" \ mongo:7 >/dev/null # Wait for mongo to accept connections. diff --git a/conformance/runner/package-lock.json b/conformance/runner/package-lock.json index 6fa6ca1..dd6d441 100644 --- a/conformance/runner/package-lock.json +++ b/conformance/runner/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "htmltrust-conformance-runner", "version": "0.1.0", + "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "yaml": "^2.5.0" }, diff --git a/conformance/runner/run.mjs b/conformance/runner/run.mjs index 322df49..1993c30 100755 --- a/conformance/runner/run.mjs +++ b/conformance/runner/run.mjs @@ -24,6 +24,7 @@ */ import { readFile, readdir } from "node:fs/promises"; +import { generateKeyPairSync, sign as cryptoSign } from "node:crypto"; import { fileURLToPath } from "node:url"; import { dirname, resolve, join, basename } from "node:path"; import YAML from "yaml"; @@ -464,6 +465,50 @@ async function loadFixtures(dir, only) { return fixtures; } +// ---------- Endorsement signing -------------------------------------------- + +/** + * RFC 8785 JSON Canonicalization Scheme, duplicated here on purpose: the + * runner has to be able to produce a valid endorsement signature for ANY + * target implementation, so it cannot import the implementation under test. + * Member names sort by UTF-16 code unit; strings and numbers use the + * ECMAScript JSON.stringify serialization JCS mandates. + */ +function canonicalizeJcs(value) { + if (value === null) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("JCS: non-finite number"); + return JSON.stringify(value); + } + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((v) => (v === undefined ? "null" : canonicalizeJcs(v))).join(",")}]`; + } + if (typeof value === "object") { + const names = Object.keys(value) + .filter((n) => value[n] !== undefined) + .sort((a, b) => (a === b ? 0 : a < b ? -1 : 1)); + return `{${names.map((n) => `${JSON.stringify(n)}:${canonicalizeJcs(value[n])}`).join(",")}}`; + } + throw new Error(`JCS: unsupported value of type ${typeof value}`); +} + +/** + * Sign a request body as an HTMLTrust endorsement document (draft 10.2): + * ed25519 over JCS(document with `signature` omitted), carried as canonical + * unpadded Base64. + */ +function signEndorsementBody(body, privateKey) { + const document = { ...body }; + delete document.signature; + const payload = canonicalizeJcs(document); + const signature = cryptoSign(null, Buffer.from(payload, "utf8"), privateKey) + .toString("base64") + .replace(/=+$/, ""); + return { ...document, signature }; +} + // ---------- HTTP helper ---------------------------------------------------- async function performRequest({ targetUrl, basePath }, step, vars) { @@ -473,6 +518,9 @@ async function performRequest({ targetUrl, basePath }, step, vars) { const url = `${targetUrl}${basePath}${path}`; const headers = { ...(req.headers || {}) }; + if (req.sign === "endorsement") { + req.body = signEndorsementBody(req.body, vars.__signingKey.privateKey); + } let body; if (req.body !== undefined) { if (typeof req.body === "string") { @@ -557,6 +605,30 @@ async function runStep(config, openapi, step, vars, opts, scenarioName) { if (step.capture) { for (const [varName, pathExpr] of Object.entries(step.capture)) { try { + // { header: "location", pattern: "..." } pulls a value out of a + // response header instead of the body. Servers that keep + // server-assigned identifiers out of the response body (so that a + // signed document is served back byte-for-byte) advertise them in + // Location, which is where an id has to come from. + if (pathExpr && typeof pathExpr === "object" && pathExpr.header) { + const raw = res.headers[String(pathExpr.header).toLowerCase()]; + let value = raw; + if (raw !== undefined && pathExpr.pattern) { + const m = String(raw).match(new RegExp(pathExpr.pattern)); + value = m ? (m[1] !== undefined ? m[1] : m[0]) : undefined; + } + if (value === undefined) { + return { + ok: false, + stepName, + errors: [`capture ${varName}: header "${pathExpr.header}" did not yield a value`], + response: res, + }; + } + vars[varName] = value; + if (opts.verbose) console.error(` captured ${varName}=${JSON.stringify(value)}`); + continue; + } let value = jsonPath(res.body, pathExpr); // Compatibility shim: when --accept-mongo-ids is set, fall back to // "_id" if the spec-style "id" sibling is missing. Lets fixtures be @@ -588,10 +660,22 @@ async function runScenario(config, openapi, fixture, opts) { // Auto-injected per-run variables. `run_nonce` is unique per scenario run // so fixtures can construct unique names without clashing across runs. const runNonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + // A per-scenario ed25519 key pair. The runner keeps the private half, so a + // fixture can register the public key with the target and then produce + // genuinely valid endorsement signatures against it — which is the only way + // to exercise a directory that (correctly) refuses to store an endorsement + // it cannot verify. + const signingKey = generateKeyPairSync("ed25519", { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); const vars = { generalApiKey: config.generalApiKey, adminApiKey: config.adminApiKey, run_nonce: runNonce, + baseUrl: `${config.targetUrl}${config.basePath}`, + signerPublicKeyPem: signingKey.publicKey, + __signingKey: signingKey, ...(fixture.doc.vars || {}), }; const steps = fixture.doc.steps || []; diff --git a/openapi.yaml b/openapi.yaml index 120001b..0461328 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -26,6 +26,10 @@ tags: description: Operations for managing claim types and assertions - name: Directory description: Operations for the Network of Trust directory services + - name: Keys + description: Draft HTMLTrust key document lookup + - name: Signers + description: Draft signer reputation lookup - name: Endorsements description: | Operations for storing and retrieving third-party endorsements per @@ -39,27 +43,99 @@ security: components: securitySchemes: + HttpMessageSignature: + type: http + scheme: signature + description: | + RFC 9421 HTTP Message Signature, the scheme draft §9.8 REQUIRES for + POST endpoints: "POST endpoints MUST be authenticated using HTTP + Message Signatures [RFC9421] with a key that the directory can resolve + via Section 8." + + The request carries `Signature-Input` and `Signature` header fields. + The covered components MUST include the request target (`@method` + together with `@target-uri`, or `@request-target`), `host`, `date`, + and — for any request with a body — `content-digest` (RFC 9530). The + `keyid` signature parameter is resolved per draft §8 (DID, HTTPS key + document, or a directory `/keys/{id}` reference) and the signature is + verified against that key, so the authenticated identity is the key + itself rather than a shared secret. + + Example: + + Signature-Input: sig1=("@method" "@target-uri" "host" "date" \ + "content-digest");created=1770000000;keyid="https://directory.example/api/keys/k-abc123" + Signature: sig1=:MEUCIQD...: + + Failed verification returns 401 with a + `WWW-Authenticate: Signature realm="htmltrust-directory"` challenge + and an `application/problem+json` body. + AuthorApiKey: type: apiKey in: header name: X-AUTHOR-API-KEY - description: API key linked to a specific author for author-specific operations + description: | + Supplementary scheme. API key linked to a specific author, used by the + directory's own convenience signing endpoint. It is a bearer secret, + not a resolvable identity, and is refused when NODE_ENV=production + unless HTMLTRUST_ALLOW_API_KEY_AUTH=1 is set. GeneralApiKey: type: apiKey in: header name: X-API-KEY - description: General API key for authenticated operations + description: | + Supplementary demo/admin scheme for submission endpoints. A shared + secret proves nothing about who submitted a record, so it cannot carry + the submitter identity the protocol depends on; prefer + HttpMessageSignature. Refused when NODE_ENV=production unless + HTMLTRUST_ALLOW_API_KEY_AUTH=1 is set. AdminApiKey: type: apiKey in: header name: X-ADMIN-API-KEY - description: Admin-level API key for administrative operations + description: Admin-level API key for directory-operator operations schemas: + Problem: + type: object + description: | + RFC 9457 problem details, the error format draft §9.9 requires. Served + as `application/problem+json`. + required: + - type + - title + - status + properties: + type: + type: string + description: URI identifying the problem type + title: + type: string + description: Short human-readable summary of the problem type + status: + type: integer + description: HTTP status code + detail: + type: string + description: Human-readable explanation specific to this occurrence + contentHash: + type: string + description: Present on submission errors that concern a specific content hash + example: + type: "https://htmltrust.org/errors/signature-invalid" + title: "Signature verification failed" + status: 400 + detail: "The endorsement signature did not verify against the canonical JSON payload." + Error: type: object + description: | + Legacy error envelope, still returned by endpoints outside the + authentication, endorsement, and content paths. New code should expect + `Problem`. required: - code - message @@ -139,11 +215,11 @@ components: description: ID of the author this key belongs to key: type: string - description: The public key in PEM format + description: Legacy public key in PEM format algorithm: type: string description: The cryptographic algorithm used - enum: [RSA, ECDSA, ED25519] + enum: [RSA, ECDSA, ED25519, ed25519, ecdsa-p256, ecdsa-p384, rsa-pkcs1-sha256, rsa-pss-sha256] createdAt: type: string format: date-time @@ -160,6 +236,107 @@ components: createdAt: "2023-01-01T12:00:00Z" expiresAt: "2024-01-01T12:00:00Z" + DirectoryDiscovery: + type: object + required: [directory, version, capabilities, supportedAlgorithms] + properties: + directory: + type: string + format: uri + version: + type: string + capabilities: + type: object + required: [content, endorsements, keys, reputation] + properties: + content: { type: boolean } + endorsements: { type: boolean } + keys: { type: boolean } + reputation: { type: boolean } + supportedAlgorithms: + type: object + properties: + signature: + type: array + items: { type: string } + hash: + type: array + items: { type: string } + + KeyDocument: + type: object + required: [algorithm, publicKey] + properties: + kid: + type: string + algorithm: + type: string + enum: [ed25519, ecdsa-p256, ecdsa-p384, rsa-pss-sha256, rsa-pkcs1-sha256] + publicKey: + type: string + description: Canonical unpadded standard Base64 SPKI DER public key bytes. + publicKeyEncoding: + type: string + description: Reference-server extension describing how publicKey was derived from the stored PEM. + publicKeyPem: + type: string + description: Legacy PEM compatibility field. + expires: + type: string + format: date-time + revoked: + type: boolean + + ContentRecord: + type: object + required: [contentHash, firstSeen, signers, endorsementCount] + properties: + contentHash: + type: string + description: Hash with algorithm prefix and canonical unpadded standard Base64 digest. + firstSeen: + type: string + format: date-time + signers: + type: array + items: + type: object + required: [keyid, signedAt, domain, signature] + properties: + keyid: + type: string + signedAt: + type: string + format: date-time + domain: + type: string + description: Serialized Web origin, not a bare hostname. + signature: + type: string + description: Canonical unpadded standard Base64 signature. + endorsementCount: + type: integer + + SignerReputation: + type: object + required: [keyid, score, asOf, components] + properties: + keyid: + type: string + score: + type: number + minimum: 0 + maximum: 1 + asOf: + type: string + format: date-time + components: + type: array + items: { type: string } + methodology: + type: string + format: uri + Claim: type: object required: @@ -202,24 +379,40 @@ components: type: object required: - contentHash + - claimsHash + - signedAt - domain - authorId + - keyid + - algorithm - signature - claims properties: contentHash: type: string - description: Hash of the normalized content + description: Hash of the normalized content using canonical unpadded standard Base64 + claimsHash: + type: string + description: Hash of all direct child meta claims using the same hash algorithm as contentHash + signedAt: + type: string + description: UTC RFC3339 timestamp from the direct child signed-at claim domain: type: string - description: Domain associated with the content + description: Serialized Web origin associated with the content, not a bare hostname authorId: type: string format: uuid description: ID of the author who signed the content + keyid: + type: string + description: Resolvable identifier for the signing key + algorithm: + type: string + description: Signature algorithm identifier from the registry signature: type: string - description: Cryptographic signature binding content, hash, domain, and author key + description: Canonical unpadded standard Base64 signature over contentHash:claimsHash:domain:signedAt claims: type: object description: Claims about the content @@ -229,9 +422,13 @@ components: format: date-time description: Creation timestamp example: - contentHash: "sha256:a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e" - domain: "example.com" + contentHash: "sha256:RAyBCvKTW5KNnGZSyXZYe+8V8DEEnUMRxjk5LSgCHo4" + claimsHash: "sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU" + signedAt: "2026-05-01T10:30:00Z" + domain: "https://example.com" authorId: "123e4567-e89b-12d3-a456-426614174000" + keyid: "https://directory.example/api/keys/123e4567-e89b-12d3-a456-426614174001" + algorithm: "ed25519" signature: "MEUCIQD7y5SxmQJ9f0lE9B0BwqIJKKdL5fZMNQOiPnKWUJfmrgIgEbHtPwDxM9xGbCZzW9k2R9jFxwJZQQlPfhgj+0YP7vQ=" claims: ContentType: "Article" @@ -313,58 +510,276 @@ components: Endorsement: type: object description: | - A standalone signed endorsement of a specific content hash, per - HTMLTrust spec §2.5. Verification is performed locally by the - client; the directory is a passive store. + A standalone signed endorsement document per draft §10.1. + + The directory verifies the endorser's signature over + JCS(document minus `signature`) before storing it (§9.7), and serves + the document back byte-for-byte as submitted. It adds no members of + its own: §10.1 requires unrecognised members to be preserved and + included in the signed payload, so an injected `_id`, `createdAt`, or + `contentHash` alias would invalidate the endorser's signature for + anyone who recomputes it. The server-assigned identifier is returned + in the `Location` header of the 201 response instead. + + Additional members not listed here MAY be present and are preserved. required: - endorser - - contentHash + - endorsement + - algorithm - signature - timestamp properties: - _id: - type: string - description: Server-assigned identifier endorser: type: string - description: Opaque endorser keyid (e.g. "did:web:publisher.org") - contentHash: + description: Endorser keyid, resolvable per draft §8 (e.g. "did:web:publisher.org") + endorsement: type: string - description: The targeted content hash (e.g. "sha256:...") + description: Content hash being endorsed, including algorithm prefix signature: type: string - description: Base64 signature over "{contentHash}:{timestamp}" + description: Canonical unpadded standard Base64 signature over JCS(document with signature omitted) timestamp: type: string - description: ISO-8601 timestamp at which the endorsement was issued + description: RFC 3339 UTC timestamp at which the endorsement was issued algorithm: type: string - description: Signature algorithm (default ed25519) + description: Signature algorithm identifier from draft §7.1 default: ed25519 - rawBlob: + claim: type: string - description: | - The exact bytes that were signed. Clients SHOULD use this for - byte-identical re-verification. - createdAt: + description: Free-text human-readable rationale for the endorsement + expires: type: string - format: date-time - description: When the endorsement was stored by this directory + description: RFC 3339 UTC timestamp after which the endorsement is no longer valid + revokedBy: + type: string + description: Content hash of the document that supersedes this one + contentHash: + type: string + deprecated: true + description: | + Legacy alias for `endorsement`. Only present on documents stored + before the directory began serving submissions verbatim; new + submissions are served exactly as signed. example: endorser: "did:web:publisher.org" - contentHash: "sha256:RAyBCvKTW5KNnGZSyXZYe+8V8DEEnUMRxjk5LSgCHo4" + endorsement: "sha256:RAyBCvKTW5KNnGZSyXZYe+8V8DEEnUMRxjk5LSgCHo4" signature: "BASE64_SIG" timestamp: "2025-05-01T00:00:00Z" algorithm: "ed25519" - rawBlob: "{\"endorser\":\"did:web:publisher.org\",\"endorsement\":\"sha256:RAyBCvKTW5KNnGZSyXZYe+8V8DEEnUMRxjk5LSgCHo4\",\"signature\":\"BASE64_SIG\",\"timestamp\":\"2025-05-01T00:00:00Z\"}" paths: + /.well-known/htmltrust: + get: + tags: + - Directory + summary: Discover trust directory capabilities + operationId: discoverDirectory + responses: + "200": + description: Directory discovery document + content: + application/htmltrust-directory+json: + schema: + $ref: "#/components/schemas/DirectoryDiscovery" + application/json: + schema: + $ref: "#/components/schemas/DirectoryDiscovery" + + /keys/{id}: + get: + tags: + - Keys + summary: Retrieve a draft HTMLTrust key document + operationId: getKeyDocument + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Key document + content: + application/htmltrust-key+json: + schema: + $ref: "#/components/schemas/KeyDocument" + application/json: + schema: + $ref: "#/components/schemas/KeyDocument" + "404": + description: Key not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" + + /signers/{id}/reputation: + get: + tags: + - Signers + summary: Retrieve directory-computed signer reputation + operationId: getSignerReputation + parameters: + - name: id + in: path + required: true + description: Directory key id, author id, or percent-encoded keyid. + schema: + type: string + responses: + "200": + description: Signer reputation + content: + application/json: + schema: + $ref: "#/components/schemas/SignerReputation" + "404": + description: Signer not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" + + /content: + post: + tags: + - Content + summary: Submit a signed content occurrence for indexing + description: | + Draft directory submission endpoint. The directory re-verifies the + submitted signature over `contentHash:claimsHash:domain:signedAt`. + `domain` is a serialized Web origin. `claimsHash` may be supplied by + clients that already computed the canonical direct-child meta claims + hash; otherwise the directory derives it from the submitted `claims` + array using the draft §4.6 canonical claims serialization (normalized + name/content pairs, sorted by the UTF-8 bytes of the normalized name). + operationId: submitContent + security: + - HttpMessageSignature: [] + - GeneralApiKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [contentHash, keyid, signedAt, domain, signature, sourceURL] + properties: + contentHash: + type: string + claimsHash: + type: string + keyid: + type: string + signedAt: + type: string + format: date-time + domain: + type: string + description: Serialized Web origin, not a bare hostname. + signature: + type: string + description: Canonical unpadded standard Base64 signature. + sourceURL: + type: string + format: uri + claims: + type: array + items: + type: object + required: [name, content] + properties: + name: { type: string } + content: { type: string } + responses: + "201": + description: Content record created + content: + application/htmltrust-content+json: + schema: + $ref: "#/components/schemas/ContentRecord" + application/json: + schema: + $ref: "#/components/schemas/ContentRecord" + "400": + description: Invalid submission + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" + + /content/{hash}: + get: + tags: + - Content + summary: Retrieve a draft content record by content hash + operationId: getContentRecord + parameters: + - name: hash + in: path + required: true + description: Percent-encoded content hash including algorithm prefix. + schema: + type: string + responses: + "200": + description: Content record + content: + application/htmltrust-content+json: + schema: + $ref: "#/components/schemas/ContentRecord" + application/json: + schema: + $ref: "#/components/schemas/ContentRecord" + "404": + description: Content not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" + + /content/{hash}/endorsements: + get: + tags: + - Endorsements + summary: List draft structured endorsements for a content hash + operationId: listContentEndorsements + parameters: + - name: hash + in: path + required: true + description: Percent-encoded content hash including algorithm prefix. + schema: + type: string + responses: + "200": + description: Structured endorsement documents + content: + application/htmltrust-endorsement+json: + schema: + type: array + items: + $ref: "#/components/schemas/Endorsement" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Endorsement" + /authors: post: tags: - Authors summary: Create a new author and key pair - description: Creates a new author profile and generates a key pair. The private key is stored securely and never exposed. + description: | + Creates a new author profile. If `publicKey` is supplied, the + directory registers that key and never holds the private half; the + author signs its own content and submits it to POST /content. + Otherwise the directory acts as the convenience registry of draft §9.6 + and generates the key pair itself, in which case the private key is + stored and never exposed. operationId: createAuthor security: - GeneralApiKey: [] @@ -394,9 +809,16 @@ paths: description: Type of the author key keyAlgorithm: type: string - enum: [RSA, ECDSA, ED25519] + enum: [RSA, ECDSA, ED25519, ed25519, ecdsa-p256, ecdsa-p384, rsa-pkcs1-sha256, rsa-pss-sha256] default: RSA description: The cryptographic algorithm to use for the key pair + publicKey: + type: string + description: | + Optional SPKI PEM public key the author already holds. + When present the directory stores no private key for this + author, so POST /content/sign is unavailable to it and the + author signs locally instead. responses: "201": description: Author created successfully @@ -652,7 +1074,11 @@ paths: tags: - Content summary: Sign content - description: Signs content with the author's private key + description: | + Compatibility signing helper. The client submits already-computed + `contentHash` and `claimsHash` values; the server signs the draft + payload `contentHash:claimsHash:domain:signedAt`. `claimsHash` is the + hash of all direct child `meta` claims, including `signed-at`. operationId: signContent security: - AuthorApiKey: [] @@ -664,18 +1090,27 @@ paths: type: object required: - contentHash + - claimsHash - domain + - signedAt - claims properties: contentHash: type: string - description: Hash of the normalized content + description: Hash of the normalized content using canonical unpadded standard Base64 + claimsHash: + type: string + description: Hash of all direct child meta claims using canonical unpadded standard Base64 domain: type: string - description: Domain associated with the content + description: Serialized Web origin associated with the content, not a bare hostname + signedAt: + type: string + format: date-time + description: UTC RFC3339 timestamp from the direct child signed-at claim claims: type: object - description: Claims about the content + description: Direct child meta claims about the content; every such claim participates in claimsHash. additionalProperties: true responses: "201": @@ -718,16 +1153,24 @@ paths: type: object required: - contentHash + - claimsHash - domain + - signedAt - authorId - signature properties: contentHash: type: string - description: Hash of the normalized content + description: Hash of the normalized content using canonical unpadded standard Base64 + claimsHash: + type: string + description: Hash of all direct child meta claims domain: type: string - description: Domain associated with the content + description: Serialized Web origin associated with the content, not a bare hostname + signedAt: + type: string + format: date-time authorId: type: string format: uuid @@ -1310,12 +1753,13 @@ paths: - Endorsements summary: Submit a signed endorsement for storage description: | - Stores a signed endorsement blob. The directory MAY opportunistically - verify the signature against any locally-known public key for the - endorser as a sanity check, but storage does not depend on - verification — clients verify locally per spec §2.5. + Stores a structured endorsement document. The signed payload is the + JSON canonicalization of the endorsement document with `signature` + omitted. The legacy `contentHash` field is accepted as an alias for + the draft `endorsement` field. operationId: createEndorsement security: + - HttpMessageSignature: [] - GeneralApiKey: [] requestBody: required: true @@ -1325,19 +1769,23 @@ paths: type: object required: - endorser - - contentHash + - endorsement + - algorithm - signature - timestamp properties: endorser: type: string description: Opaque endorser keyid (e.g. "did:web:publisher.org") + endorsement: + type: string + description: Draft field for the targeted content hash (e.g. "sha256:...") contentHash: type: string - description: The targeted content hash (e.g. "sha256:...") + description: Legacy alias for endorsement signature: type: string - description: Base64 signature over "{contentHash}:{timestamp}" + description: Canonical unpadded standard Base64 signature over JCS(document with signature omitted) timestamp: type: string description: ISO-8601 timestamp at which the endorsement was issued @@ -1384,7 +1832,8 @@ paths: endorsement's `endorser` keyid. operationId: deleteEndorsement security: - - GeneralApiKey: [] + - HttpMessageSignature: [] + - AdminApiKey: [] parameters: - name: id in: path diff --git a/package-lock.json b/package-lock.json index 700a50f..4ea90a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,15 @@ "": { "name": "htmltrust-server-reference", "version": "0.1.0", - "license": "MIT", + "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "github:HTMLTrust/htmltrust-canonicalization#v0.1.0", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", + "helmet": "^8.3.0", "mongoose": "^8.14.1" }, "devDependencies": { @@ -22,9 +24,10 @@ } }, "node_modules/@htmltrust/canonicalization": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-canonicalization.git#7babc9610eeb6ca19d9134ea800a7fbe19f80ab9", - "license": "MIT" + "version": "0.2.2", + "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", + "integrity": "sha512-qKOx4PipywaLx3R/Bc6S+IWSZZhD/DQ4LQaPC7bHREq40iRIjeVkkSSrHFp2cTyqdPvsPZF2ufvT5YUz9II8RA==", + "license": "LicenseRef-PolyForm-Noncommercial-1.0.0" }, "node_modules/@mongodb-js/saslprep": { "version": "1.4.6", @@ -233,21 +236,34 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -257,16 +273,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -527,9 +543,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -606,6 +622,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", @@ -840,9 +875,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -851,6 +886,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -914,6 +961,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1283,9 +1339,9 @@ } }, "node_modules/mongoose": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", - "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", + "version": "8.24.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.2.tgz", + "integrity": "sha512-5+H3MSHNJCcr9M+lVplekrZ4/Dyn3N1dOpvgn9gMwvHJhunc4G8SQcBVd/btBQoVdspVNPjx8Pw03YWBv6uTJg==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -1565,12 +1621,13 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1703,14 +1760,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1722,13 +1779,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1921,17 +1978,34 @@ "license": "0BSD" }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/undefsafe": { diff --git a/package.json b/package.json index 567a8db..7ee96d2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", + "test": "node --test \"test/**/*.test.js\"", "conformance": "node conformance/runner/with-reference-server.mjs", "conformance:docker": "bash conformance/run-conformance.sh", "conformance:runner": "node conformance/runner/run.mjs" @@ -19,11 +20,13 @@ "author": "Jason Grey ", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "github:HTMLTrust/htmltrust-canonicalization#v0.1.0", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", + "helmet": "^8.3.0", "mongoose": "^8.14.1" }, "devDependencies": { diff --git a/src/controllers/authorController.js b/src/controllers/authorController.js index 911232d..19bfe91 100644 --- a/src/controllers/authorController.js +++ b/src/controllers/authorController.js @@ -1,6 +1,9 @@ +const crypto = require("crypto"); const Author = require("../models/Author"); const Key = require("../models/Key"); const { generateKeyPair, generateApiKey } = require("../utils/crypto"); +const { detailFor, invalid, problem, safeSearchRegex } = require("../utils/htmltrustProtocol"); +const { hashApiKey } = require("../utils/apiKeys"); /** * @desc Create a new author and key pair @@ -9,12 +12,28 @@ const { generateKeyPair, generateApiKey } = require("../utils/crypto"); */ exports.createAuthor = async (req, res) => { try { - const { name, description, url, keyType, keyAlgorithm = "RSA" } = req.body; + const { name, description, url, keyType, keyAlgorithm = "ed25519" } = req.body; - // Generate key pair - const { publicKey, privateKey } = generateKeyPair(keyAlgorithm); + // An author MAY register a public key it already holds, in which case the + // directory never sees the private half. Otherwise the directory acts as + // the convenience registry described in draft §9.6 and generates the pair. + let publicKey = req.body.publicKey; + let privateKey; + if (publicKey) { + if (typeof publicKey !== "string" || !publicKey.includes("BEGIN PUBLIC KEY")) { + throw invalid("publicKey must be an SPKI PEM public key"); + } + try { + crypto.createPublicKey(publicKey); + } catch { + throw invalid("publicKey is not a readable SPKI PEM public key"); + } + } else { + ({ publicKey, privateKey } = generateKeyPair(keyAlgorithm)); + } - // Generate author API key + // Generate author API key. Only the HMAC of the key is persisted; the key + // itself is returned once here and cannot be recovered afterwards. const apiKey = generateApiKey(); // Create author @@ -23,7 +42,7 @@ exports.createAuthor = async (req, res) => { description, url, keyType, - apiKey, + apiKeyHash: hashApiKey(apiKey), }); // Create key @@ -51,7 +70,7 @@ exports.createAuthor = async (req, res) => { console.error("Create author error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error, "The author could not be created"), }); } }; @@ -65,18 +84,22 @@ exports.getAuthors = async (req, res) => { try { const { name, keyType, page = 1, limit = 20 } = req.query; - // Build query + // Build query. `name` is caller input: escaped, length-capped, and + // anchored so it cannot become regular-expression syntax evaluated inside + // the database (NoSQL injection / ReDoS). const query = {}; - if (name) query.name = { $regex: name, $options: "i" }; - if (keyType) query.keyType = keyType; + if (name) query.name = safeSearchRegex(name, "name"); + if (keyType) query.keyType = String(keyType); - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); + // Pagination, clamped so one request cannot page the whole collection. + const pageNumber = Math.max(1, parseInt(page, 10) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)); + const skip = (pageNumber - 1) * pageSize; // Execute query const authors = await Author.find(query) .skip(skip) - .limit(parseInt(limit)) + .limit(pageSize) .sort({ createdAt: -1 }); // Get total count @@ -86,16 +109,19 @@ exports.getAuthors = async (req, res) => { authors, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit), + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize, }, }); } catch (error) { + if (error.expose) { + return problem(res, 400, "Invalid query", error.message); + } console.error("Get authors error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -121,7 +147,7 @@ exports.getAuthor = async (req, res) => { console.error("Get author error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -158,7 +184,7 @@ exports.updateAuthor = async (req, res) => { console.error("Update author error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -191,7 +217,7 @@ exports.deleteAuthor = async (req, res) => { console.error("Delete author error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -225,7 +251,7 @@ exports.getAuthorPublicKey = async (req, res) => { console.error("Get public key error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; diff --git a/src/controllers/claimController.js b/src/controllers/claimController.js index 922280c..45b40ca 100644 --- a/src/controllers/claimController.js +++ b/src/controllers/claimController.js @@ -1,4 +1,5 @@ const Claim = require("../models/Claim"); +const { detailFor } = require("../utils/htmltrustProtocol"); /** * @desc Create a new claim type @@ -31,7 +32,7 @@ exports.createClaimType = async (req, res) => { console.error("Create claim type error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -70,7 +71,7 @@ exports.getClaimTypes = async (req, res) => { console.error("Get claim types error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -96,7 +97,7 @@ exports.getClaimType = async (req, res) => { console.error("Get claim type error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -132,7 +133,7 @@ exports.updateClaimType = async (req, res) => { console.error("Update claim type error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -162,7 +163,7 @@ exports.deleteClaimType = async (req, res) => { console.error("Delete claim type error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; diff --git a/src/controllers/contentController.js b/src/controllers/contentController.js index 5bf0533..fdb1af2 100644 --- a/src/controllers/contentController.js +++ b/src/controllers/contentController.js @@ -1,8 +1,24 @@ const Author = require('../models/Author'); const Key = require('../models/Key'); const ContentSignature = require('../models/ContentSignature'); +const { resolveUsableKey } = require('../utils/keyResolution'); const ContentOccurrence = require('../models/ContentOccurrence'); -const { signContent, verifySignature } = require('../utils/crypto'); +const Endorsement = require('../models/Endorsement'); +const { hashCanonical, signContent, verifySignature } = require('../utils/crypto'); +const { + assertContentHash, + assertRfc3339Utc, + decodeCanonicalBase64, + detailFor, + invalid, + normalizeClaims, + normalizeAlgorithm, + normalizeSerializedOrigin, + problem, + signedAtFromClaims, +} = require('../utils/htmltrustProtocol'); +const { canonicalizeClaims } = require('../utils/claims'); +const { directoryKeyUrl } = require('../utils/directoryUrl'); /** * Build the canonical binding string that is actually signed. @@ -17,13 +33,112 @@ const { signContent, verifySignature } = require('../utils/crypto'); */ const buildBinding = ({ contentHash, claimsHash, domain, signedAt }) => { if (!contentHash || !claimsHash || !domain || !signedAt) { - throw new Error( + throw invalid( `Missing required binding field(s): contentHash=${contentHash}, claimsHash=${claimsHash}, domain=${domain}, signedAt=${signedAt}` ); } return `${contentHash}:${claimsHash}:${domain}:${signedAt}`; }; +const claimsObject = (claims) => Object.fromEntries( + normalizeClaims(claims).map((claim) => [claim.name, claim.content]) +); + +/** + * Hash of the canonical claims serialization, draft §4.6. + * + * The canonicalization itself lives in src/utils/claims.js on top of the + * shared @htmltrust/canonicalization text normalizer, so this server produces + * the same bytes as the signers do. The previous implementation here sorted + * whole `name:content` lines with JavaScript's default UTF-16 comparison, did + * not normalize the claim text at all, and invented a `signed-at` claim when + * one was absent — each of which yields a different claims hash than a + * conforming signer computes, so a correctly signed submission failed to + * verify (and an incorrectly signed one could pass). + */ +const canonicalClaimsHash = async (claims, hashAlgorithm) => { + if (hashAlgorithm !== 'sha256') { + throw invalid('This reference server currently computes claimsHash values with sha256 only'); + } + return hashCanonical(await canonicalizeClaims(normalizeClaims(claims))); +}; + +const validateSignatureInputs = ({ contentHash, claimsHash, domain, signedAt, signature }) => { + const normalizedContentHash = assertContentHash(contentHash, 'contentHash'); + const normalizedClaimsHash = assertContentHash(claimsHash, 'claimsHash'); + const contentAlgorithm = normalizedContentHash.split(':')[0]; + const claimsAlgorithm = normalizedClaimsHash.split(':')[0]; + if (contentAlgorithm !== claimsAlgorithm) { + throw invalid('contentHash and claimsHash must use the same hash algorithm'); + } + const normalizedDomain = normalizeSerializedOrigin(domain); + assertRfc3339Utc(signedAt, 'signedAt'); + if (signature) { + decodeCanonicalBase64(signature, 'signature'); + } + return { + contentHash: normalizedContentHash, + claimsHash: normalizedClaimsHash, + domain: normalizedDomain, + signedAt, + }; +}; + +const keyidFor = (req, key) => directoryKeyUrl(req, key._id); + +/** + * Resolve a submitted keyid to a usable key, honouring revocation and expiry + * (draft §8). Falls back to the legacy "keyid is an author id" form that + * earlier clients used. + */ +const resolveSubmissionKey = async (req, keyid) => { + const resolution = await resolveUsableKey(keyid, { req }); + if (resolution.ok) return resolution; + + if (/^[0-9a-fA-F]{24}$/.test(keyid || '')) { + const key = await Key.findOne({ authorId: keyid }); + if (key && !key.revoked) { + return { + ok: true, + resolved: { + keyid, + publicKeyPem: key.publicKey, + algorithm: key.algorithm, + key, + }, + }; + } + } + + return resolution; +}; + +const contentRecord = async (req, contentHash) => { + const signatures = await ContentSignature.find({ contentHash }).sort({ createdAt: 1 }); + if (signatures.length === 0) return null; + + const signers = await Promise.all(signatures.map(async (signature) => { + const key = await Key.findById(signature.keyId); + return { + keyid: key ? keyidFor(req, key) : String(signature.keyId), + signedAt: signature.signedAt, + domain: signature.domain, + signature: signature.signature, + }; + })); + + const endorsementCount = await Endorsement.countDocuments({ + $or: [{ endorsement: contentHash }, { contentHash }], + }); + + return { + contentHash, + firstSeen: signatures[0].createdAt.toISOString(), + signers, + endorsementCount, + }; +}; + /** * @desc Sign content * @route POST /api/content/sign @@ -38,8 +153,30 @@ const buildBinding = ({ contentHash, claimsHash, domain, signedAt }) => { */ exports.signContent = async (req, res) => { try { - const { contentHash, claimsHash, domain, signedAt, claims } = req.body; + const { claims } = req.body; + const { + contentHash, + claimsHash, + domain, + signedAt, + } = validateSignatureInputs(req.body); const author = req.author; + const claimSignedAt = signedAtFromClaims(claims); + if (claimSignedAt && claimSignedAt !== signedAt) { + throw invalid('signedAt must match the direct child signed-at claim'); + } + + // Recompute the claims hash from the claims map rather than signing the + // caller's value. The binding (§5) is what the signature attests to; if + // the directory signs a claimsHash it never derived, the caller chooses + // what the key attests to and the stored claims are free to say something + // else entirely. + const recomputedClaimsHash = await canonicalClaimsHash(claims, contentHash.split(':')[0]); + if (recomputedClaimsHash !== claimsHash) { + throw invalid( + `claimsHash does not match the canonical claims serialization (computed ${recomputedClaimsHash})` + ); + } // Get author's private key const key = await Key.findOne({ authorId: author._id }).select('+privateKey'); @@ -51,6 +188,17 @@ exports.signContent = async (req, res) => { }); } + if (!key.privateKey) { + // The author registered their own public key, so the directory has no + // private half to sign with and must not pretend otherwise. + return problem( + res, + 400, + 'No directory-held signing key', + 'This author registered its own public key; content must be signed by the author and submitted to POST /content' + ); + } + // Build canonical binding per spec §2.1 const dataToSign = buildBinding({ contentHash, claimsHash, domain, signedAt }); @@ -67,7 +215,7 @@ exports.signContent = async (req, res) => { if (contentSignature) { // Update existing signature contentSignature.signature = signature; - contentSignature.claims = claims; + contentSignature.claims = claimsObject(claims); contentSignature.claimsHash = claimsHash; contentSignature.signedAt = signedAt; contentSignature.occurrences += 1; @@ -82,7 +230,7 @@ exports.signContent = async (req, res) => { authorId: author._id, keyId: key._id, signature, - claims + claims: claimsObject(claims) }); } @@ -94,14 +242,16 @@ exports.signContent = async (req, res) => { domain, authorId: author._id, signature, - claims, + keyid: keyidFor(req, key), + algorithm: normalizeAlgorithm(key.algorithm), + claims: claimsObject(claims), createdAt: contentSignature.createdAt }); } catch (error) { console.error('Sign content error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } }; @@ -137,7 +287,13 @@ exports.verifyContent = async (req, res) => { res.set('Deprecation', 'true'); res.set('Link', '; rel="deprecation"'); try { - const { contentHash, claimsHash, domain, signedAt, authorId, signature } = req.body; + const { authorId, signature } = req.body; + const { + contentHash, + claimsHash, + domain, + signedAt, + } = validateSignatureInputs(req.body); // Get author const author = await Author.findById(authorId); @@ -198,7 +354,7 @@ exports.verifyContent = async (req, res) => { console.error('Verify content error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } }; @@ -210,7 +366,14 @@ exports.verifyContent = async (req, res) => { */ exports.registerOccurrence = async (req, res) => { try { - const { contentHash, claimsHash, signedAt, url, domain, authorId, signature } = req.body; + const { url, authorId, signature } = req.body; + const domain = normalizeSerializedOrigin(req.body.domain); + const contentHash = assertContentHash(req.body.contentHash, 'contentHash'); + let claimsHash = req.body.claimsHash; + let signedAt = req.body.signedAt; + if (signature) { + ({ claimsHash, signedAt } = validateSignatureInputs(req.body)); + } // Verify the signature if provided let signatureValid = false; @@ -280,7 +443,140 @@ exports.registerOccurrence = async (req, res) => { console.error('Register occurrence error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) + }); + } +}; + +/** + * @desc Retrieve a draft-shaped content record + * @route GET /api/content/:contentHash + * @access Public + */ +exports.getContentRecord = async (req, res) => { + try { + const contentHash = assertContentHash(req.params.contentHash, 'contentHash'); + const record = await contentRecord(req, contentHash); + if (!record) { + return problem(res, 404, 'Content not found', 'No content record exists for the requested hash', { + contentHash, + }); + } + res.type('application/htmltrust-content+json').status(200).json(record); + } catch (error) { + return problem(res, 400, 'Invalid content hash', detailFor(error)); + } +}; + +/** + * @desc Submit a draft-shaped signed content occurrence + * @route POST /api/content + * @access Private (General API Key) + */ +exports.submitContent = async (req, res) => { + try { + const { keyid, sourceURL, signature, claims = [] } = req.body; + if (!keyid || !signature || !sourceURL) { + return problem(res, 400, 'Invalid content submission', 'keyid, signature, and sourceURL are required'); + } + + const contentHash = assertContentHash(req.body.contentHash, 'contentHash'); + const signedAt = assertRfc3339Utc(req.body.signedAt, 'signedAt'); + const domain = normalizeSerializedOrigin(req.body.domain); + const hashAlgorithm = contentHash.split(':')[0]; + const claimsHash = req.body.claimsHash + ? assertContentHash(req.body.claimsHash, 'claimsHash') + : await canonicalClaimsHash(claims, hashAlgorithm); + if (claimsHash.split(':')[0] !== hashAlgorithm) { + throw invalid('contentHash and claimsHash must use the same hash algorithm'); + } + + const resolution = await resolveSubmissionKey(req, keyid); + if (!resolution.ok) { + return problem(res, 400, 'Key resolution failed', `The submitted keyid could not be resolved (${resolution.reason})`, { + type: `https://htmltrust.org/errors/${resolution.reason}`, + keyid, + }); + } + const key = resolution.resolved.key; + if (!key) { + // Remote keys resolve to key material but not to a local author record, + // and every stored ContentSignature is keyed by author. Indexing + // externally-held keys needs a local registration first. + return problem(res, 400, 'Key resolution failed', 'The submitted keyid is not registered with this directory', { + type: 'https://htmltrust.org/errors/key-resolution-failed', + keyid, + }); + } + + const binding = buildBinding({ contentHash, claimsHash, domain, signedAt }); + if (!verifySignature(binding, signature, resolution.resolved.publicKeyPem, resolution.resolved.algorithm)) { + return problem(res, 400, 'Signature verification failed', 'The submitted signature did not verify against the canonical signing payload.', { + type: 'https://htmltrust.org/errors/signature-invalid', + contentHash, + }); + } + + let contentSignature = await ContentSignature.findOne({ + contentHash, + domain, + authorId: key.authorId, }); + if (contentSignature) { + contentSignature.signature = signature; + contentSignature.claimsHash = claimsHash; + contentSignature.signedAt = signedAt; + contentSignature.claims = claimsObject(claims); + contentSignature.occurrences += 1; + await contentSignature.save(); + } else { + contentSignature = await ContentSignature.create({ + contentHash, + claimsHash, + signedAt, + domain, + authorId: key.authorId, + keyId: key._id, + signature, + claims: claimsObject(claims), + }); + } + + await ContentOccurrence.findOneAndUpdate( + { signatureId: contentSignature._id, url: sourceURL }, + { + signatureId: contentSignature._id, + url: sourceURL, + domain, + signatureValid: true, + lastSeen: Date.now(), + }, + { upsert: true, setDefaultsOnInsert: true } + ); + + const record = await contentRecord(req, contentHash); + res + .status(201) + .location(`/api/content/${encodeURIComponent(contentHash)}`) + .type('application/htmltrust-content+json') + .json(record); + } catch (error) { + return problem(res, 400, 'Invalid content submission', detailFor(error)); } -}; \ No newline at end of file +}; + +exports.listContentEndorsements = async (req, res) => { + try { + const contentHash = assertContentHash(req.params.contentHash, 'contentHash'); + const endorsements = await Endorsement.find({ + $or: [{ endorsement: contentHash }, { contentHash }], + }).sort({ createdAt: -1 }); + const { toEndorsementDocument } = require('./endorsementController'); + res + .type('application/htmltrust-endorsement+json') + .status(200) + .json(endorsements.map(toEndorsementDocument)); + } catch (error) { + return problem(res, 400, 'Invalid content hash', detailFor(error)); + } +}; diff --git a/src/controllers/directoryController.js b/src/controllers/directoryController.js index 97c98f0..ff87347 100644 --- a/src/controllers/directoryController.js +++ b/src/controllers/directoryController.js @@ -2,6 +2,109 @@ const Key = require('../models/Key'); const Author = require('../models/Author'); const ContentSignature = require('../models/ContentSignature'); const ContentOccurrence = require('../models/ContentOccurrence'); +const { + detailFor, + keyDocumentFor, + normalizeSerializedOrigin, + problem, + safeSearchRegex, +} = require('../utils/htmltrustProtocol'); + +/** + * Clamp caller-supplied pagination. An unbounded `limit` turns a public read + * endpoint into a bulk-export and a memory-pressure lever. + */ +const MAX_PAGE_SIZE = 100; +const boundedLimit = (value, fallback = 20) => { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.min(parsed, MAX_PAGE_SIZE); +}; +const boundedPage = (value) => { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return 1; + return parsed; +}; + +const baseDirectoryUrl = (req) => `${req.protocol}://${req.get('host')}/api/`; + +const keyIdFromSignerId = (id) => { + if (!id || typeof id !== 'string') return null; + if (/^[0-9a-fA-F]{24}$/.test(id)) return id; + try { + const url = new URL(id); + const segments = url.pathname.split('/').filter(Boolean); + const keyIndex = segments.lastIndexOf('keys'); + if (keyIndex !== -1 && segments[keyIndex + 1]) { + return decodeURIComponent(segments[keyIndex + 1]); + } + } catch {} + return null; +}; + +exports.discovery = async (req, res) => { + res + .type('application/htmltrust-directory+json') + .status(200) + .json({ + directory: baseDirectoryUrl(req), + version: '1', + capabilities: { + content: true, + endorsements: true, + keys: true, + reputation: true + }, + supportedAlgorithms: { + signature: ['ed25519', 'rsa-pkcs1-sha256', 'ecdsa-p256'], + hash: ['sha256'] + } + }); +}; + +exports.getKeyDocument = async (req, res) => { + try { + const key = await Key.findById(req.params.id); + if (!key) { + return problem(res, 404, 'Key not found', 'No key document exists for the requested id'); + } + res + .type('application/htmltrust-key+json') + .status(200) + .json(keyDocumentFor(key)); + } catch (error) { + return problem(res, 400, 'Invalid key id', detailFor(error)); + } +}; + +exports.getSignerReputation = async (req, res) => { + try { + const signerId = decodeURIComponent(req.params.id); + const keyId = keyIdFromSignerId(signerId); + let key = null; + if (keyId) { + key = await Key.findById(keyId); + } + if (!key && /^[0-9a-fA-F]{24}$/.test(signerId)) { + key = await Key.findOne({ authorId: signerId }); + } + if (!key) { + return problem(res, 404, 'Signer not found', 'No local signer reputation exists for the requested id', { + keyid: signerId, + }); + } + + res.status(200).json({ + keyid: signerId, + score: key.trustScore, + asOf: (key.updatedAt || key.createdAt || new Date()).toISOString(), + components: ['verified-signatures', 'reports'], + methodology: `${baseDirectoryUrl(req)}methodology/reputation-v1` + }); + } catch (error) { + return problem(res, 400, 'Invalid signer id', detailFor(error)); + } +}; /** * @desc Search public keys @@ -15,17 +118,28 @@ exports.searchPublicKeys = async (req, res) => { // Build query const query = {}; - // Join with Author model to filter by author name and key type + // Join with Author model to filter by author name and key type. + // + // `authorName` is unauthenticated caller input on a public route. Passing + // it straight into $regex let the caller supply pattern syntax — both a + // NoSQL injection (the filter no longer means what the code says) and a + // denial of service (a catastrophically backtracking pattern is evaluated + // per document inside the database). safeSearchRegex escapes, caps, and + // anchors it into a literal prefix match. const authorQuery = {}; - if (authorName) authorQuery.name = { $regex: authorName, $options: 'i' }; - if (keyType) authorQuery.keyType = keyType; + if (authorName) authorQuery['author.name'] = safeSearchRegex(authorName, 'authorName'); + if (keyType) authorQuery['author.keyType'] = String(keyType); // Filter by trust score if (minTrustScore) query.trustScore = { $gte: parseFloat(minTrustScore) }; - - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); - + const normalizedDomain = domain ? normalizeSerializedOrigin(domain) : null; + + // Pagination. `limit` is caller-controlled, so it is clamped: an + // unbounded page size lets one request pull the whole collection. + const pageNumber = boundedPage(page); + const pageSize = boundedLimit(limit); + const skip = (pageNumber - 1) * pageSize; + // Execute query with aggregation to join with Author model const keys = await Key.aggregate([ { @@ -42,15 +156,14 @@ exports.searchPublicKeys = async (req, res) => { { $match: { ...query, - 'author': { $exists: true }, - ...Object.keys(authorQuery).length > 0 ? { 'author': authorQuery } : {} + ...authorQuery } }, { $skip: skip }, { - $limit: parseInt(limit) + $limit: pageSize }, { $project: { @@ -72,19 +185,22 @@ exports.searchPublicKeys = async (req, res) => { const total = await Key.countDocuments(query); res.status(200).json({ - keys, + keys: normalizedDomain ? keys.filter((key) => key.author && key.author.url && key.author.url.startsWith(normalizedDomain)) : keys, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit) + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize } }); } catch (error) { + if (error.expose) { + return problem(res, 400, 'Invalid query', error.message); + } console.error('Search public keys error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -116,7 +232,7 @@ exports.getKeyReputation = async (req, res) => { console.error('Get key reputation error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -159,7 +275,7 @@ exports.reportKey = async (req, res) => { console.error('Report key error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } }; @@ -177,14 +293,16 @@ exports.searchSignedContent = async (req, res) => { const query = {}; if (contentHash) query.contentHash = contentHash; if (authorId) query.authorId = authorId; - if (domain) query.domain = domain; + if (domain) query.domain = normalizeSerializedOrigin(domain); if (claim) { const [claimName, claimValue] = claim.split(':'); query[`claims.${claimName}`] = claimValue; } - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); + // Pagination (clamped; see boundedLimit) + const pageNumber = boundedPage(page); + const pageSize = boundedLimit(limit); + const skip = (pageNumber - 1) * pageSize; // Execute query with aggregation to join with Author model const signatures = await ContentSignature.aggregate([ @@ -206,7 +324,7 @@ exports.searchSignedContent = async (req, res) => { $skip: skip }, { - $limit: parseInt(limit) + $limit: pageSize }, { $project: { @@ -230,16 +348,16 @@ exports.searchSignedContent = async (req, res) => { signatures, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit) + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize } }); } catch (error) { console.error('Search signed content error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -268,15 +386,17 @@ exports.findContentOccurrences = async (req, res) => { // Get signature IDs const signatureIds = signatures.map(sig => sig._id); - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); + // Pagination (clamped; see boundedLimit) + const pageNumber = boundedPage(page); + const pageSize = boundedLimit(limit); + const skip = (pageNumber - 1) * pageSize; // Find occurrences const occurrences = await ContentOccurrence.find({ signatureId: { $in: signatureIds } }) .skip(skip) - .limit(parseInt(limit)) + .limit(pageSize) .sort({ firstSeen: -1 }); // Get total count @@ -288,16 +408,16 @@ exports.findContentOccurrences = async (req, res) => { occurrences, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit) + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize } }); } catch (error) { console.error('Find content occurrences error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -333,7 +453,7 @@ exports.reportContentMisuse = async (req, res) => { console.error('Report content misuse error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } -}; \ No newline at end of file +}; diff --git a/src/controllers/endorsementController.js b/src/controllers/endorsementController.js index b0a0804..85b86cf 100644 --- a/src/controllers/endorsementController.js +++ b/src/controllers/endorsementController.js @@ -1,159 +1,216 @@ +const crypto = require('crypto'); const Endorsement = require('../models/Endorsement'); -const Author = require('../models/Author'); -const Key = require('../models/Key'); const { verifySignature } = require('../utils/crypto'); +const { + assertContentHash, + assertRfc3339Utc, + assertSignatureAlgorithm, + canonicalizeEndorsement, + decodeCanonicalBase64, + detailFor, + invalid, + problem, +} = require('../utils/htmltrustProtocol'); +const { canonicalizeJcs } = require('../utils/jcs'); +const { resolveUsableKey, sameKeyMaterial } = require('../utils/keyResolution'); +const { hasAdminApiKey } = require('../middleware/auth'); /** - * Build the canonical rawBlob for an endorsement when the client did not - * supply one. The wire format follows the example in spec §2.5: + * Return the stored endorsement document exactly as it was submitted. * - * { - * "endorser": "did:web:publisher.org", - * "endorsement": "sha256-XYZ", - * "signature": "BASE64_SIG", - * "timestamp": "2025-05-01T00:00Z" - * } + * Draft §9.5: "The directory MUST NOT alter the endorsement payloads in a + * manner that invalidates the endorser's signature", and §10.1 requires + * unrecognised members to be preserved and included in the signed payload. + * Any member the directory adds — an `_id`, a `createdAt`, a `contentHash` + * alias — becomes part of JCS(document minus signature) for anyone who + * recomputes it, and the signature no longer verifies. * - * Keys are emitted in a stable order (endorser, endorsement, signature, - * timestamp, algorithm) so any verifier that reconstructs the blob from - * structured fields produces the same bytes. The optional `algorithm` field - * is omitted when it equals the default 'ed25519' to match the spec example. - * - * NOTE: clients SHOULD post their own rawBlob to avoid any ambiguity. This - * fallback exists for convenience only. + * Server-side bookkeeping is therefore kept out of the body entirely: the + * identifier of a newly stored endorsement is returned in the `Location` + * header of the 201 response. */ -const buildCanonicalBlob = ({ endorser, contentHash, signature, timestamp, algorithm }) => { - const obj = { - endorser, - endorsement: contentHash, - signature, - timestamp +const toEndorsementDocument = (endorsement) => { + if (endorsement.document && typeof endorsement.document === 'object') { + return endorsement.document; + } + // Rows written before structured documents were stored: reconstruct the + // draft shape from the indexed columns. + const document = { + endorser: endorsement.endorser, + endorsement: endorsement.endorsement || endorsement.contentHash, + algorithm: endorsement.algorithm, + timestamp: endorsement.timestamp, + signature: endorsement.signature, }; - if (algorithm && algorithm.toLowerCase() !== 'ed25519') { - obj.algorithm = algorithm; + if (endorsement.claim) document.claim = endorsement.claim; + if (endorsement.expires) document.expires = endorsement.expires; + if (endorsement.revokedBy) document.revokedBy = endorsement.revokedBy; + return document; +}; + +/** + * Validate a submitted endorsement document against draft §10.1 WITHOUT + * changing it. The returned object is the same object graph that will be + * canonicalized, verified, and stored, so nothing may be injected into it: + * every added member changes the signing payload. + */ +const validateEndorsementDocument = (body) => { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw invalid('The endorsement document must be a JSON object'); + } + const document = { ...body }; + + // `rawBlob` is a legacy compatibility field that was never part of the + // signed document; strip it before verification so old clients that still + // send it do not fail, and keep it out of storage. + delete document.rawBlob; + + for (const field of ['endorser', 'endorsement', 'signature', 'timestamp']) { + if (typeof document[field] !== 'string' || document[field].length === 0) { + throw invalid(`${field} is required`); + } } - return JSON.stringify(obj); + if (typeof document.algorithm !== 'string' || document.algorithm.length === 0) { + throw invalid('algorithm is required'); + } + + assertContentHash(document.endorsement, 'endorsement'); + assertSignatureAlgorithm(document.algorithm); + assertRfc3339Utc(document.timestamp, 'timestamp'); + decodeCanonicalBase64(document.signature, 'signature'); + if (document.expires !== undefined) assertRfc3339Utc(document.expires, 'expires'); + if (document.revokedBy !== undefined) assertContentHash(document.revokedBy, 'revokedBy'); + if (document.claim !== undefined && typeof document.claim !== 'string') { + throw invalid('claim must be a string'); + } + + return document; }; +const documentHashFor = (document) => + `sha256:${crypto.createHash('sha256').update(canonicalizeJcs(document)).digest('base64').replace(/=+$/, '')}`; + /** - * Best-effort, opportunistic verification of an endorsement's signature. + * Verify the endorser's signature over JCS(document minus `signature`), per + * draft §10.2. * - * The directory does NOT have authoritative knowledge of every endorser's - * public key — endorser keyids are opaque strings that clients resolve - * locally. As a sanity check, we attempt to find a matching Author/Key pair - * by treating the endorser string as either an Author._id or as a name. If - * no match is found, we silently store the endorsement as-is; clients verify - * locally per spec §2.5. + * Verification is mandatory. A directory that stores endorsements it cannot + * verify is a publication channel for forged attestations: anyone can claim + * any endorser's identity, and every consumer that trusts the directory's + * index (rather than re-verifying) inherits the forgery. Draft §9.7 states + * the requirement directly — the directory MUST verify the endorser's + * signature, and invalid signatures MUST be rejected with 400. * - * Returns true if verification succeeded, false if it failed, or null if no - * key was available to attempt verification. + * @returns {Promise<{ok: true} | {ok: false, status: number, title: string, detail: string, type?: string}>} */ -const tryVerify = async ({ endorser, contentHash, timestamp, signature, algorithm }) => { - let key = null; - try { - // Look for an Author whose _id or name matches the endorser string. - let author = null; - if (/^[0-9a-fA-F]{24}$/.test(endorser)) { - author = await Author.findById(endorser); - } - if (!author) { - author = await Author.findOne({ name: endorser }); - } - if (!author) return null; +const verifyEndorsementSignature = async (document, req) => { + const resolution = await resolveUsableKey(document.endorser, { req }); + if (!resolution.ok) { + return { + ok: false, + status: 400, + title: 'Key resolution failed', + detail: + resolution.reason === 'key-resolution-failed' + ? 'The endorser keyid could not be resolved to a public key this directory can verify against' + : `The endorser key is not usable (${resolution.reason})`, + type: `https://htmltrust.org/errors/${resolution.reason}`, + resolved: resolution.resolved, + }; + } - key = await Key.findOne({ authorId: author._id }); - if (!key) return null; - } catch (err) { - return null; + const { resolved } = resolution; + if (resolved.algorithm !== document.algorithm) { + return { + ok: false, + status: 400, + title: 'Algorithm mismatch', + detail: `The endorsement declares ${document.algorithm} but the resolved key is ${resolved.algorithm}`, + type: 'https://htmltrust.org/errors/algorithm-mismatch', + }; } - try { - const binding = `${contentHash}:${timestamp}`; - return verifySignature(binding, signature, key.publicKey, key.algorithm || algorithm); - } catch (err) { - return false; + const payload = canonicalizeEndorsement(document); + if (!verifySignature(payload, document.signature, resolved.publicKeyPem, resolved.algorithm)) { + return { + ok: false, + status: 400, + title: 'Signature verification failed', + detail: 'The endorsement signature did not verify against the canonical JSON payload.', + type: 'https://htmltrust.org/errors/signature-invalid', + }; } + + return { ok: true, resolved }; }; /** - * @desc Create (or upsert) an endorsement + * @desc Create an endorsement * @route POST /api/endorsements - * @access Private (General API Key) + * @access Private (RFC 9421 signature, or the demo API key scheme) * - * Request body fields (all required unless noted): - * - endorser: string (opaque keyid) - * - contentHash: string (e.g. "sha256:...") - * - signature: string (base64) - * - timestamp: string (ISO-8601) - * - algorithm: string (optional, default 'ed25519') - * - rawBlob: string (optional; the exact bytes the client signed over. - * If omitted, the server constructs a canonical blob in a - * stable key order. Clients SHOULD post their own rawBlob.) + * Request body is an endorsement document per draft §10.1: + * - endorser: string (keyid resolvable per §8) + * - endorsement: string (content hash, e.g. "sha256:...") + * - signature: string (unpadded Base64 over JCS(document minus signature)) + * - algorithm: string (§7.1 identifier) + * - timestamp: string (RFC 3339 UTC) + * - claim, expires, revokedBy and any additional members: optional, stored + * and served verbatim. */ exports.createEndorsement = async (req, res) => { + let document; try { - const { endorser, contentHash, signature, timestamp } = req.body; - const algorithm = req.body.algorithm || 'ed25519'; - let { rawBlob } = req.body; - - if (!endorser || !contentHash || !signature || !timestamp) { - return res.status(400).json({ - code: 'BAD_REQUEST', - message: 'endorser, contentHash, signature, and timestamp are required' - }); - } + document = validateEndorsementDocument(req.body); + } catch (error) { + return problem(res, 400, 'Invalid endorsement', detailFor(error, 'The endorsement document is not valid'), { + type: 'https://htmltrust.org/errors/endorsement-invalid', + }); + } - if (!rawBlob) { - rawBlob = buildCanonicalBlob({ endorser, contentHash, signature, timestamp, algorithm }); + try { + const verification = await verifyEndorsementSignature(document, req); + if (!verification.ok) { + return problem(res, verification.status, verification.title, verification.detail, { + type: verification.type, + contentHash: document.endorsement, + }); } - // Opportunistic sanity check — does NOT block storage. Clients verify - // locally per spec §2.5. - const verifyResult = await tryVerify({ endorser, contentHash, timestamp, signature, algorithm }); - if (verifyResult === false) { - console.warn( - `Endorsement signature failed opportunistic verification: endorser=${endorser} contentHash=${contentHash}` - ); - } + const documentHash = documentHashFor(document); - // Upsert: a given endorser may only have one endorsement per content - // hash. Resubmissions overwrite. - let endorsement = await Endorsement.findOne({ endorser, contentHash }); - if (endorsement) { - endorsement.signature = signature; - endorsement.timestamp = timestamp; - endorsement.algorithm = algorithm; - endorsement.rawBlob = rawBlob; - await endorsement.save(); - } else { - endorsement = await Endorsement.create({ - endorser, - contentHash, - signature, - timestamp, - algorithm, - rawBlob + // Append-only with idempotent resubmission. A second, different document + // from the same endorser for the same content hash (a revocation, an + // updated claim) is stored alongside the first: draft §10.3 requires a + // directory holding both to serve both. + let stored = await Endorsement.findOne({ documentHash }); + let created = false; + if (!stored) { + stored = await Endorsement.create({ + endorser: document.endorser, + endorsement: document.endorsement, + contentHash: document.endorsement, + signature: document.signature, + timestamp: document.timestamp, + algorithm: document.algorithm, + claim: document.claim, + expires: document.expires, + revokedBy: document.revokedBy, + document, + documentHash, }); + created = true; } - res.status(201).json({ - _id: endorsement._id, - endorser: endorsement.endorser, - contentHash: endorsement.contentHash, - signature: endorsement.signature, - timestamp: endorsement.timestamp, - algorithm: endorsement.algorithm, - rawBlob: endorsement.rawBlob, - createdAt: endorsement.createdAt, - // Expose the opportunistic verification result for diagnostics. Clients - // MUST NOT rely on this — they verify locally per spec §2.5. - opportunisticallyVerified: verifyResult - }); + return res + .status(created ? 201 : 200) + .location(`/api/endorsements/${stored._id}`) + .type('application/htmltrust-endorsement+json') + .json(toEndorsementDocument(stored)); } catch (error) { console.error('Create endorsement error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: error.message + return problem(res, 400, 'Invalid endorsement', detailFor(error, 'The endorsement could not be stored'), { + type: 'https://htmltrust.org/errors/endorsement-invalid', }); } }; @@ -166,70 +223,81 @@ exports.createEndorsement = async (req, res) => { exports.listEndorsements = async (req, res) => { try { // Accept both kebab-case (spec-style) and camelCase query parameters. - const contentHash = req.query['content-hash'] || req.query.contentHash; + const contentHash = req.query['content-hash'] || req.query.contentHash || req.query.endorsement; if (!contentHash) { - return res.status(400).json({ - code: 'BAD_REQUEST', - message: 'content-hash query parameter is required' - }); + return problem(res, 400, 'Invalid request', 'content-hash query parameter is required'); } - const endorsements = await Endorsement.find({ contentHash }).sort({ createdAt: -1 }); - - res.status(200).json( - endorsements.map((e) => ({ - _id: e._id, - endorser: e.endorser, - contentHash: e.contentHash, - signature: e.signature, - timestamp: e.timestamp, - algorithm: e.algorithm, - rawBlob: e.rawBlob, - createdAt: e.createdAt - })) - ); + const endorsementHash = assertContentHash(String(contentHash), 'content-hash'); + const endorsements = await Endorsement.find({ + $or: [{ endorsement: endorsementHash }, { contentHash: endorsementHash }] + }).sort({ createdAt: -1 }); + + return res + .type('application/htmltrust-endorsement+json') + .status(200) + .json(endorsements.map(toEndorsementDocument)); } catch (error) { console.error('List endorsements error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: error.message - }); + return problem(res, 400, 'Invalid content hash', detailFor(error, 'The content-hash parameter is not valid')); } }; +exports.toEndorsementDocument = toEndorsementDocument; + /** * @desc Delete an endorsement * @route DELETE /api/endorsements/:id - * @access Private (General API Key) + * @access The endorser (RFC 9421 signature) or the directory operator * - * MVP: gated behind the existing API key auth only. A production deployment - * MUST additionally verify that the caller's authenticated identity matches - * the endorsement's `endorser` keyid (e.g. by requiring a signed delete - * request, or by tying the API key to a specific keyid). + * Deletion is a compatibility operation; the protocol's own mechanism for + * withdrawing an endorsement is a revocation endorsement (draft §10.3), which + * is served alongside the original rather than replacing it. * - * TODO: enforce caller-keyid match against endorsement.endorser before - * permitting deletion. + * Authorization is by key, not by API key: the caller must sign the DELETE + * with the endorsement's own endorser key (compared on the resolved key + * material, so an alias keyid still matches), or present the directory admin + * key for an operator takedown. Any holder of the shared submission key being + * able to delete anyone's endorsements is a censorship primitive. */ exports.deleteEndorsement = async (req, res) => { try { const endorsement = await Endorsement.findById(req.params.id); if (!endorsement) { - return res.status(404).json({ - code: 'NOT_FOUND', - message: 'Endorsement not found' - }); + return problem(res, 404, 'Endorsement not found', 'No endorsement exists with the requested id'); + } + + if (!hasAdminApiKey(req)) { + const actor = req.htmltrustActor; + if (!actor) { + res.set('WWW-Authenticate', 'Signature realm="htmltrust-directory"'); + return problem( + res, + 401, + 'Unauthorized', + 'Deleting an endorsement requires an RFC 9421 signature from the endorser key, or the directory admin key', + { type: 'https://htmltrust.org/errors/unauthorized' }, + ); + } + + const endorserKey = await resolveUsableKey(endorsement.endorser, { req }); + const sameKeyid = actor.keyid === endorsement.endorser; + const sameMaterial = + endorserKey.ok && sameKeyMaterial(actor.resolved.publicKeyPem, endorserKey.resolved.publicKeyPem); + if (!sameKeyid && !sameMaterial) { + return problem(res, 403, 'Forbidden', 'Only the endorser may delete this endorsement', { + type: 'https://htmltrust.org/errors/forbidden', + }); + } } await Endorsement.deleteOne({ _id: endorsement._id }); - res.status(204).send(); + return res.status(204).send(); } catch (error) { console.error('Delete endorsement error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: error.message - }); + return problem(res, 400, 'Invalid request', detailFor(error, 'The endorsement could not be deleted')); } }; diff --git a/src/controllers/voteController.js b/src/controllers/voteController.js index 77fce5b..150702d 100644 --- a/src/controllers/voteController.js +++ b/src/controllers/voteController.js @@ -2,6 +2,24 @@ const Vote = require("../models/Vote"); const Author = require("../models/Author"); const Key = require("../models/Key"); const ContentSignature = require("../models/ContentSignature"); +const { detailFor, problem } = require("../utils/htmltrustProtocol"); + +/** + * The identity a vote belongs to. + * + * Votes move a key's trust score, so who cast one has to be established by + * the request rather than asserted in its body. A caller that authenticated + * with an RFC 9421 signature votes as the key it signed with. A caller using + * the shared demo API key has no distinguishable identity, so every such + * caller collapses onto a single voter id — which, combined with the unique + * { userId, targetType, targetId } index, means the shared key is worth + * exactly one vote per target instead of unlimited ballot stuffing. + */ +const voterIdentity = (req) => { + if (req.htmltrustActor) return `key:${req.htmltrustActor.keyid}`; + if (req.author) return `author:${req.author._id}`; + return "shared-api-key"; +}; /** * @desc Vote on an author or content @@ -10,7 +28,8 @@ const ContentSignature = require("../models/ContentSignature"); */ exports.createVote = async (req, res) => { try { - const { userId, targetType, targetId, voteType, reason } = req.body; + const { targetType, targetId, voteType, reason } = req.body; + const userId = voterIdentity(req); // Validate target exists let target; @@ -72,7 +91,7 @@ exports.createVote = async (req, res) => { console.error("Create vote error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -151,7 +170,7 @@ exports.getVotes = async (req, res) => { console.error("Get votes error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -172,11 +191,12 @@ exports.deleteVote = async (req, res) => { }); } - // Check if the user ID in the request matches the user ID of the vote - if (req.body.userId !== vote.userId) { - return res.status(403).json({ - code: "FORBIDDEN", - message: "Not authorized to delete this vote", + // Ownership is derived from the authenticated request, not from a + // caller-supplied body field: trusting `req.body.userId` let any caller + // delete any vote simply by naming its owner. + if (voterIdentity(req) !== vote.userId) { + return problem(res, 403, "Forbidden", "Not authorized to delete this vote", { + type: "https://htmltrust.org/errors/forbidden", }); } @@ -204,7 +224,7 @@ exports.deleteVote = async (req, res) => { console.error("Delete vote error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -262,7 +282,7 @@ exports.getVoteStats = async (req, res) => { console.error("Get vote stats error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 20bf547..b23b5f0 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,35 +1,70 @@ const Author = require("../models/Author"); +const { problem } = require("../utils/htmltrustProtocol"); +const { hashApiKey, secretsMatch } = require("../utils/apiKeys"); + +/** + * Static API-key authentication. + * + * Draft §9.8 requires POST endpoints to be authenticated with an RFC 9421 + * HTTP Message Signature bound to a resolvable key (see + * `middleware/httpSignature.js`). The shared-secret schemes below are a + * supplementary admin/demo scheme only: a shared `X-API-KEY` proves nothing + * about *who* submitted a record, so it cannot carry the identity that + * content and endorsement ingestion depend on. + * + * `apiKeyAuthEnabled()` gates them off in production. Set + * HTMLTRUST_ALLOW_API_KEY_AUTH=1 to re-enable them there (for an operator + * console, for example) with full knowledge that they are not an identity. + */ +const apiKeyAuthEnabled = () => { + if (process.env.HTMLTRUST_ALLOW_API_KEY_AUTH === "1") return true; + if (process.env.HTMLTRUST_ALLOW_API_KEY_AUTH === "0") return false; + return process.env.NODE_ENV !== "production"; +}; + +const unauthorized = (res, detail, scheme = "ApiKey") => { + // Draft §9.8: an unauthenticated request MUST get a WWW-Authenticate + // challenge. Routes that accept an RFC 9421 signature advertise it first so + // a client learns the scheme it is supposed to be using. + const schemes = Array.isArray(scheme) ? scheme : [scheme]; + res.set( + "WWW-Authenticate", + schemes.map((name) => `${name} realm="htmltrust-directory"`).join(", "), + ); + return problem(res, 401, "Unauthorized", detail, { + type: "https://htmltrust.org/errors/unauthorized", + }); +}; /** * Middleware to protect routes that require general API key authentication */ const protectWithGeneralApiKey = async (req, res, next) => { try { - const apiKey = req.header("X-API-KEY"); + if (!apiKeyAuthEnabled()) { + return unauthorized( + res, + "Static API-key authentication is disabled; submit an RFC 9421 HTTP Message Signature instead", + "Signature", + ); + } + const apiKey = req.header("X-API-KEY"); if (!apiKey) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "No API key provided", - }); + return unauthorized( + res, + "Provide an RFC 9421 HTTP Message Signature, or the demo API key in X-API-KEY", + ["Signature", "ApiKey"], + ); } - - // In a real implementation, you would validate the API key against a database - // For this example, we'll use a simple check against an environment variable - if (apiKey !== process.env.GENERAL_API_KEY) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "Invalid API key", - }); + if (!secretsMatch(apiKey, process.env.GENERAL_API_KEY || "")) { + return unauthorized(res, "Invalid API key", ["Signature", "ApiKey"]); } - next(); + return next(); } catch (error) { console.error("Auth error:", error); - res.status(401).json({ - code: "UNAUTHORIZED", - message: "Authentication failed", - }); + return unauthorized(res, "Authentication failed"); } }; @@ -38,43 +73,37 @@ const protectWithGeneralApiKey = async (req, res, next) => { */ const protectWithAuthorApiKey = async (req, res, next) => { try { - const apiKey = req.header("X-AUTHOR-API-KEY"); + if (!apiKeyAuthEnabled()) { + return unauthorized( + res, + "Static API-key authentication is disabled; submit an RFC 9421 HTTP Message Signature instead", + "Signature", + ); + } + const apiKey = req.header("X-AUTHOR-API-KEY"); if (!apiKey) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "No author API key provided", - }); + return unauthorized(res, "No author API key provided"); } - // Find the author with this API key - const author = await Author.findOne({ apiKey }).select("+apiKey"); - + // Look the author up by the hash of the presented key; the plaintext key + // is never stored, so there is nothing to compare against directly. + const author = await Author.findOne({ apiKeyHash: hashApiKey(apiKey) }); if (!author) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "Invalid author API key", - }); + return unauthorized(res, "Invalid author API key"); } - // Check if the author ID in the URL matches the authenticated author if (req.params.authorId && req.params.authorId !== author._id.toString()) { - return res.status(403).json({ - code: "FORBIDDEN", - message: "API key does not match author", + return problem(res, 403, "Forbidden", "API key does not match author", { + type: "https://htmltrust.org/errors/forbidden", }); } - // Add author to request object req.author = author; - - next(); + return next(); } catch (error) { console.error("Auth error:", error); - res.status(401).json({ - code: "UNAUTHORIZED", - message: "Authentication failed", - }); + return unauthorized(res, "Authentication failed"); } }; @@ -84,35 +113,32 @@ const protectWithAuthorApiKey = async (req, res, next) => { const protectWithAdminApiKey = async (req, res, next) => { try { const apiKey = req.header("X-ADMIN-API-KEY"); - if (!apiKey) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "No admin API key provided", - }); + return unauthorized(res, "No admin API key provided"); } - - // In a real implementation, you would validate the admin API key against a database - // For this example, we'll use a simple check against an environment variable - if (apiKey !== process.env.ADMIN_API_KEY) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "Invalid admin API key", - }); + if (!secretsMatch(apiKey, process.env.ADMIN_API_KEY || "")) { + return unauthorized(res, "Invalid admin API key"); } - next(); + req.isDirectoryAdmin = true; + return next(); } catch (error) { console.error("Auth error:", error); - res.status(401).json({ - code: "UNAUTHORIZED", - message: "Authentication failed", - }); + return unauthorized(res, "Authentication failed"); } }; +/** True when the request carries a valid directory-admin key. */ +const hasAdminApiKey = (req) => { + const apiKey = req.header("X-ADMIN-API-KEY"); + return Boolean(apiKey) && secretsMatch(apiKey, process.env.ADMIN_API_KEY || ""); +}; + module.exports = { + apiKeyAuthEnabled, + hasAdminApiKey, protectWithGeneralApiKey, protectWithAuthorApiKey, protectWithAdminApiKey, + unauthorized, }; diff --git a/src/middleware/httpSignature.js b/src/middleware/httpSignature.js new file mode 100644 index 0000000..dcbf97a --- /dev/null +++ b/src/middleware/httpSignature.js @@ -0,0 +1,400 @@ +const crypto = require("crypto"); +const { problem } = require("../utils/htmltrustProtocol"); +const { resolveUsableKey } = require("../utils/keyResolution"); + +/** + * RFC 9421 HTTP Message Signatures, as required by draft §9.8: + * + * "POST endpoints MUST be authenticated using HTTP Message Signatures + * [RFC9421] with a key that the directory can resolve via Section 8. The + * signature input MUST cover the `(request-target)`, `host`, `date`, and + * `content-digest` components at a minimum." + * + * This is a deliberately minimal verifier, not a general RFC 9421 library. + * It supports exactly what the draft requires: + * + * Covered components: `@method` plus `@target-uri` or `@path` (equivalently + * `@request-target`), `@authority` or `host`, `date`, and — whenever the + * request carries a body — `content-digest`. Requests covering a smaller + * set are rejected; covering more is allowed and the extra components are + * included in the signature base as normal. + * + * Derived components with parameters (`@query-param`, `;req`, `;sf`, `;bs`) + * are NOT supported and are rejected rather than silently ignored, because + * silently ignoring a covered component would let a signer sign something + * other than what is verified. + * + * The verified identity is the resolved key, not a shared secret: the + * `keyid` signature parameter is resolved per draft §8 and the signature is + * checked against that key. `req.htmltrustActor` is set on success. + */ + +// Accepted clock skew for the `created` parameter and the `date` header. +const MAX_SKEW_SECONDS = 300; + +// Signatures already seen inside the acceptance window, to stop a captured +// request from being replayed verbatim. Bounded so it cannot grow without +// limit; a multi-process deployment needs a shared store instead. +const REPLAY_CACHE_LIMIT = 4096; +const seenSignatures = new Map(); + +const rememberSignature = (signatureB64) => { + const now = Date.now(); + for (const [value, at] of seenSignatures) { + if (now - at > MAX_SKEW_SECONDS * 2000) seenSignatures.delete(value); + else break; + } + if (seenSignatures.has(signatureB64)) return false; + if (seenSignatures.size >= REPLAY_CACHE_LIMIT) { + const oldest = seenSignatures.keys().next().value; + seenSignatures.delete(oldest); + } + seenSignatures.set(signatureB64, now); + return true; +}; + +class SignatureError extends Error {} + +/** + * Split a structured-field dictionary on top-level commas, ignoring commas + * inside quoted strings, parenthesised inner lists, and byte sequences. + */ +const splitDictionary = (header) => { + const members = []; + let depth = 0; + let inQuotes = false; + let inBytes = false; + let start = 0; + for (let i = 0; i < header.length; i += 1) { + const ch = header[i]; + if (inQuotes) { + if (ch === "\\") i += 1; + else if (ch === '"') inQuotes = false; + continue; + } + if (inBytes) { + if (ch === ":") inBytes = false; + continue; + } + if (ch === '"') inQuotes = true; + else if (ch === ":") inBytes = true; + else if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === "," && depth === 0) { + members.push(header.slice(start, i)); + start = i + 1; + } + } + members.push(header.slice(start)); + return members.map((member) => member.trim()).filter(Boolean); +}; + +const splitLabel = (member) => { + const eq = member.indexOf("="); + if (eq === -1) throw new SignatureError("malformed structured field dictionary member"); + return [member.slice(0, eq).trim(), member.slice(eq + 1).trim()]; +}; + +/** + * Parse one `Signature-Input` value: an inner list of quoted component + * identifiers followed by `;name=value` parameters. + */ +const parseSignatureInputValue = (raw) => { + if (!raw.startsWith("(")) throw new SignatureError("signature input must start with an inner list"); + const close = raw.indexOf(")"); + if (close === -1) throw new SignatureError("unterminated signature input inner list"); + + const inner = raw.slice(1, close).trim(); + const components = []; + if (inner.length > 0) { + for (const token of inner.split(/\s+/)) { + if (!/^"[^"]*"$/.test(token)) { + throw new SignatureError(`unsupported covered component ${token}`); + } + components.push(token.slice(1, -1).toLowerCase()); + } + } + + const params = {}; + const tail = raw.slice(close + 1); + for (const part of tail.split(";")) { + const chunk = part.trim(); + if (!chunk) continue; + const eq = chunk.indexOf("="); + if (eq === -1) { + params[chunk.toLowerCase()] = true; + continue; + } + const name = chunk.slice(0, eq).trim().toLowerCase(); + let value = chunk.slice(eq + 1).trim(); + if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + else if (/^-?\d+$/.test(value)) value = Number(value); + params[name] = value; + } + + return { components, params, raw }; +}; + +const parseSignatureValue = (raw) => { + if (!raw.startsWith(":") || !raw.endsWith(":") || raw.length < 2) { + throw new SignatureError("signature value must be a byte sequence"); + } + return Buffer.from(raw.slice(1, -1), "base64"); +}; + +const headerValue = (req, name) => { + const value = req.headers[name]; + if (value === undefined) return undefined; + return Array.isArray(value) ? value.join(", ") : String(value); +}; + +/** + * Build the signature base per RFC 9421 §2.5. + */ +const buildSignatureBase = (req, components, signatureParamsRaw) => { + const lines = []; + for (const component of components) { + let value; + switch (component) { + case "@method": + value = req.method.toUpperCase(); + break; + case "@target-uri": + value = `${req.protocol}://${req.get("host")}${req.originalUrl}`; + break; + case "@request-target": + value = req.originalUrl; + break; + case "@path": + value = req.path; + break; + case "@query": + value = req.originalUrl.includes("?") ? req.originalUrl.slice(req.originalUrl.indexOf("?")) : "?"; + break; + case "@authority": + value = String(req.get("host") || "").toLowerCase(); + break; + case "@scheme": + value = req.protocol; + break; + default: + if (component.startsWith("@")) { + throw new SignatureError(`unsupported derived component "${component}"`); + } + value = headerValue(req, component); + if (value === undefined) { + throw new SignatureError(`covered header "${component}" is not present on the request`); + } + value = value.replace(/\s+/g, " ").trim(); + break; + } + lines.push(`"${component}": ${value}`); + } + lines.push(`"@signature-params": ${signatureParamsRaw}`); + return lines.join("\n"); +}; + +/** + * Verify Content-Digest (RFC 9530) against the raw request body. Without this + * the signature covers a digest header that nothing ties to the body. + */ +const verifyContentDigest = (req) => { + const header = headerValue(req, "content-digest"); + if (!header) throw new SignatureError("content-digest header is required for requests with a body"); + const body = req.rawBody || Buffer.alloc(0); + + let matched = false; + for (const member of splitDictionary(header)) { + const [algorithm, raw] = splitLabel(member); + const name = algorithm.toLowerCase(); + if (name !== "sha-256" && name !== "sha-512") continue; + const expected = crypto + .createHash(name === "sha-256" ? "sha256" : "sha512") + .update(body) + .digest(); + const provided = parseSignatureValue(raw); + if (expected.length === provided.length && crypto.timingSafeEqual(expected, provided)) { + matched = true; + } else { + throw new SignatureError("content-digest does not match the request body"); + } + } + if (!matched) throw new SignatureError("content-digest must use sha-256 or sha-512"); +}; + +const assertRequiredComponents = (components, hasBody) => { + const covered = new Set(components); + const coversTarget = + covered.has("@method") && + (covered.has("@request-target") || covered.has("@target-uri") || covered.has("@path")); + if (!coversTarget) { + throw new SignatureError('signature must cover the request target ("@method" and "@target-uri")'); + } + if (!covered.has("host") && !covered.has("@authority")) { + throw new SignatureError('signature must cover "host"'); + } + if (!covered.has("date")) { + throw new SignatureError('signature must cover "date"'); + } + if (hasBody && !covered.has("content-digest")) { + throw new SignatureError('signature must cover "content-digest" when the request has a body'); + } +}; + +const assertFreshness = (req, params) => { + const now = Math.floor(Date.now() / 1000); + if (typeof params.created === "number" && Math.abs(now - params.created) > MAX_SKEW_SECONDS) { + throw new SignatureError("signature `created` timestamp is outside the acceptance window"); + } + if (typeof params.expires === "number" && params.expires < now) { + throw new SignatureError("signature has expired"); + } + const date = headerValue(req, "date"); + const parsed = date ? Date.parse(date) : NaN; + if (!Number.isFinite(parsed)) throw new SignatureError("date header is missing or unparseable"); + if (Math.abs(now - Math.floor(parsed / 1000)) > MAX_SKEW_SECONDS) { + throw new SignatureError("date header is outside the acceptance window"); + } +}; + +/** + * Verify raw signature bytes. RFC 9421 carries ECDSA signatures in the fixed + * width (r || s) form of IEEE P1363, not the DER encoding Node defaults to, + * so the encoding is stated explicitly. + */ +const verifyBytes = (base, signature, publicKeyPem, algorithm) => { + const data = Buffer.from(base, "utf8"); + switch (algorithm) { + case "ed25519": + return crypto.verify(null, data, publicKeyPem, signature); + case "ecdsa-p256": + return crypto.verify("sha256", data, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, signature); + case "ecdsa-p384": + return crypto.verify("sha384", data, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, signature); + case "rsa-pkcs1-sha256": + return crypto.verify("sha256", data, publicKeyPem, signature); + case "rsa-pss-sha256": + return crypto.verify( + "sha256", + data, + { + key: publicKeyPem, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }, + signature, + ); + default: + throw new SignatureError(`unsupported signature algorithm ${algorithm}`); + } +}; + +/** + * Verify the request's HTTP Message Signature. + * + * `resolve` exists so tests can supply key material directly; production + * callers use the default, which resolves the keyid per draft section 8. + * + * @returns {Promise<{ok: true, actor: object} | {ok: false, status: number, title: string, detail: string}>} + */ +const verifyHttpMessageSignature = async (req, { resolve = resolveUsableKey } = {}) => { + const inputHeader = headerValue(req, "signature-input"); + const signatureHeader = headerValue(req, "signature"); + if (!inputHeader || !signatureHeader) { + return { ok: false, status: 401, title: "Unauthorized", detail: "Signature and Signature-Input headers are required" }; + } + + try { + const signatures = new Map(splitDictionary(signatureHeader).map((member) => splitLabel(member))); + const hasBody = Boolean(req.rawBody && req.rawBody.length > 0); + let lastError = null; + + for (const member of splitDictionary(inputHeader)) { + const [label, rawValue] = splitLabel(member); + const rawSignature = signatures.get(label); + if (!rawSignature) continue; + + try { + const { components, params, raw } = parseSignatureInputValue(rawValue); + if (!params.keyid || typeof params.keyid !== "string") { + throw new SignatureError("signature is missing a keyid parameter"); + } + assertRequiredComponents(components, hasBody); + assertFreshness(req, params); + if (hasBody) verifyContentDigest(req); + + const resolution = await resolve(params.keyid, { req }); + if (!resolution.ok) { + return { + ok: false, + status: 401, + title: "Key resolution failed", + detail: `The signature keyid could not be resolved to a usable key (${resolution.reason})`, + type: `https://htmltrust.org/errors/${resolution.reason}`, + }; + } + const { resolved } = resolution; + if (params.alg && params.alg !== resolved.algorithm) { + throw new SignatureError("signature `alg` does not match the resolved key algorithm"); + } + + const signature = parseSignatureValue(rawSignature); + const base = buildSignatureBase(req, components, raw); + if (!verifyBytes(base, signature, resolved.publicKeyPem, resolved.algorithm)) { + throw new SignatureError("signature did not verify against the resolved key"); + } + if (!rememberSignature(rawSignature)) { + throw new SignatureError("signature has already been used (replay)"); + } + + return { ok: true, actor: { keyid: params.keyid, resolved, label } }; + } catch (error) { + if (!(error instanceof SignatureError)) throw error; + lastError = error; + } + } + + return { + ok: false, + status: 401, + title: "Invalid signature", + detail: lastError ? lastError.message : "No usable signature was present on the request", + }; + } catch (error) { + if (error instanceof SignatureError) { + return { ok: false, status: 401, title: "Invalid signature", detail: error.message }; + } + console.error("HTTP message signature verification error:", error); + return { ok: false, status: 401, title: "Invalid signature", detail: "The request signature could not be verified" }; + } +}; + +/** + * Express middleware factory. On success `req.htmltrustActor` holds the + * verified identity. On failure the response is `401` with a + * `WWW-Authenticate` challenge, per draft §9.8. + * + * `fallback` runs when no HTTP Message Signature is present at all. It exists + * so the legacy static API-key schemes can stay available for the demo UI and + * the conformance suite; see `src/middleware/auth.js`. + */ +const requireActorSignature = ({ fallback } = {}) => async (req, res, next) => { + const hasSignature = Boolean(req.headers["signature-input"] || req.headers.signature); + if (!hasSignature && typeof fallback === "function") { + return fallback(req, res, next); + } + + const result = await verifyHttpMessageSignature(req); + if (!result.ok) { + res.set("WWW-Authenticate", 'Signature realm="htmltrust-directory"'); + return problem(res, result.status, result.title, result.detail, result.type ? { type: result.type } : {}); + } + req.htmltrustActor = result.actor; + return next(); +}; + +module.exports = { + buildSignatureBase, + requireActorSignature, + verifyHttpMessageSignature, +}; diff --git a/src/models/Author.js b/src/models/Author.js index a5b5513..9d1d9ee 100644 --- a/src/models/Author.js +++ b/src/models/Author.js @@ -22,9 +22,14 @@ const AuthorSchema = new mongoose.Schema({ enum: ['HUMAN', 'AI', 'HUMAN_AI_MIX', 'ORGANIZATION'], required: [true, 'Please specify the key type'] }, - apiKey: { + // HMAC-SHA-256 of the author's API key under the server pepper. The key + // itself is shown once at creation and never stored; see + // src/utils/apiKeys.js for the rationale and the migration from the old + // plaintext `apiKey` field. + apiKeyHash: { type: String, - select: false // Don't return API key in queries + index: true, + select: false }, createdAt: { type: Date, diff --git a/src/models/ContentSignature.js b/src/models/ContentSignature.js index dcd2eba..6c96c52 100644 --- a/src/models/ContentSignature.js +++ b/src/models/ContentSignature.js @@ -6,8 +6,8 @@ const ContentSignatureSchema = new mongoose.Schema({ required: [true, 'Content hash is required'], index: true }, - // Canonical hash of the claims map (sorted, newline-joined "name=value" - // serialization, then hashed). Part of the signature binding per spec §2.1. + // Canonical hash of the claims map (sorted "name:content\n" records, then + // hashed). Part of the signature binding per spec §2.1. claimsHash: { type: String, default: '' @@ -64,4 +64,4 @@ ContentSignatureSchema.virtual('contentOccurrences', { justOne: false }); -module.exports = mongoose.model('ContentSignature', ContentSignatureSchema); \ No newline at end of file +module.exports = mongoose.model('ContentSignature', ContentSignatureSchema); diff --git a/src/models/Endorsement.js b/src/models/Endorsement.js index 4d138fa..9ad1c76 100644 --- a/src/models/Endorsement.js +++ b/src/models/Endorsement.js @@ -13,9 +13,9 @@ const mongoose = require('mongoose'); * endorsement is performed locally by the verifier using the endorser's * public key (resolved via the same keyid mechanisms as content signatures). * - * The original signed JSON blob is stored verbatim in `rawBlob` so verifiers - * can re-verify byte-identically without re-serializing — any change to the - * key order or whitespace would invalidate the signature. + * The structured endorsement document is stored in `document`. Older clients + * may still send or read `contentHash`/`rawBlob`, but the draft field name for + * the targeted content hash is `endorsement`. */ const EndorsementSchema = new mongoose.Schema({ // Opaque endorser keyid (e.g. "did:web:publisher.org" or any other form @@ -25,13 +25,19 @@ const EndorsementSchema = new mongoose.Schema({ required: [true, 'Endorser keyid is required'], index: true }, - // The targeted content hash, e.g. "sha256:..." per spec §2.1. + // Draft field: targeted content hash, e.g. "sha256:..." per spec §6.2. + endorsement: { + type: String, + required: [true, 'Endorsement content hash is required'], + index: true + }, + // Legacy alias retained for current clients and existing data. contentHash: { type: String, required: [true, 'Content hash is required'], index: true }, - // Base64-encoded signature over the binding "{contentHash}:{timestamp}". + // Base64-encoded signature over JCS(document with signature omitted). signature: { type: String, required: [true, 'Signature is required'] @@ -43,14 +49,39 @@ const EndorsementSchema = new mongoose.Schema({ }, algorithm: { type: String, - enum: ['ed25519', 'ED25519', 'RSA', 'ECDSA'], + enum: [ + 'ed25519', + 'ED25519', + 'RSA', + 'ECDSA', + 'rsa-pkcs1-sha256', + 'rsa-pss-sha256', + 'ecdsa-p256', + 'ecdsa-p384' + ], default: 'ed25519' }, - // Original signed JSON blob the client posted, stored verbatim so verifiers - // can re-verify byte-identically without re-serializing. - rawBlob: { + claim: String, + expires: String, + revokedBy: String, + // The endorsement document exactly as it was submitted and verified. It is + // served back verbatim: draft §10.1 requires unrecognised members to be + // preserved and included in the signed payload, so adding, renaming, or + // dropping a member here would invalidate the endorser's signature. + document: { + type: mongoose.Schema.Types.Mixed, + required: [true, 'Structured endorsement document is required'] + }, + // sha256 of JCS(document), used as the identity of the stored document. + documentHash: { type: String, - required: [true, 'rawBlob is required'] + required: [true, 'Document hash is required'], + unique: true, + index: true + }, + // Legacy signed blob field retained for compatibility only. + rawBlob: { + type: String }, createdAt: { type: Date, @@ -58,8 +89,26 @@ const EndorsementSchema = new mongoose.Schema({ } }); -// Dedupe: a given endorser may only have one endorsement on file per content -// hash. Resubmissions update the existing record (see controller). -EndorsementSchema.index({ contentHash: 1, endorser: 1 }, { unique: true }); +// Endorsements are append-only. An endorser may have several documents on +// file for the same content hash — draft §10.3 requires a directory holding +// both an endorsement and its revocation to serve BOTH, so that verifiers can +// observe the revocation chain. Deduplication is by document identity +// (`documentHash`), which makes resubmitting the identical document +// idempotent while keeping distinct documents distinct. +// +// MIGRATION: databases created before this change carry unique indexes on +// { contentHash, endorser } and { endorsement, endorser }. Those indexes +// silently collapse a revocation onto the endorsement it revokes and must be +// dropped: +// db.endorsements.dropIndex("contentHash_1_endorser_1") +// db.endorsements.dropIndex("endorsement_1_endorser_1") +EndorsementSchema.index({ contentHash: 1, endorser: 1 }); +EndorsementSchema.index({ endorsement: 1, endorser: 1 }); + +EndorsementSchema.pre('validate', function(next) { + if (!this.endorsement && this.contentHash) this.endorsement = this.contentHash; + if (!this.contentHash && this.endorsement) this.contentHash = this.endorsement; + next(); +}); module.exports = mongoose.model('Endorsement', EndorsementSchema); diff --git a/src/models/Key.js b/src/models/Key.js index 86d042e..a69952f 100644 --- a/src/models/Key.js +++ b/src/models/Key.js @@ -10,15 +10,26 @@ const KeySchema = new mongoose.Schema({ type: String, required: [true, 'Public key is required'] }, + // Only populated when the directory generated the key pair on the author's + // behalf. Authors who register their own public key keep custody of the + // private half and this field stays unset. privateKey: { type: String, - required: [true, 'Private key is required'], select: false // Don't return private key in queries }, algorithm: { type: String, - enum: ['RSA', 'ECDSA', 'ED25519'], - default: 'RSA', + enum: [ + 'RSA', + 'ECDSA', + 'ED25519', + 'rsa-pkcs1-sha256', + 'rsa-pss-sha256', + 'ecdsa-p256', + 'ecdsa-p384', + 'ed25519' + ], + default: 'ed25519', required: true }, createdAt: { @@ -28,6 +39,12 @@ const KeySchema = new mongoose.Schema({ expiresAt: { type: Date }, + // Draft §8.2: a revoked key MUST NOT be used to verify a signature, so + // revocation is recorded here and enforced during key resolution. + revoked: { + type: Boolean, + default: false + }, trustScore: { type: Number, min: 0, @@ -52,4 +69,4 @@ KeySchema.virtual('signatures', { justOne: false }); -module.exports = mongoose.model('Key', KeySchema); \ No newline at end of file +module.exports = mongoose.model('Key', KeySchema); diff --git a/src/routes/content.js b/src/routes/content.js index 646d15b..a572fac 100644 --- a/src/routes/content.js +++ b/src/routes/content.js @@ -3,12 +3,16 @@ const router = express.Router(); const { signContent, verifyContent, - registerOccurrence + registerOccurrence, + getContentRecord, + submitContent, + listContentEndorsements } = require('../controllers/contentController'); const { protectWithAuthorApiKey, protectWithGeneralApiKey } = require('../middleware/auth'); +const { requireActorSignature } = require('../middleware/httpSignature'); // Routes router.route('/sign') @@ -20,4 +24,17 @@ router.route('/verify') router.route('/occurrences') .post(protectWithGeneralApiKey, registerOccurrence); -module.exports = router; \ No newline at end of file +// Draft §9.4/§9.8: submissions are authenticated with an RFC 9421 HTTP +// Message Signature from a resolvable key. The static API key remains as a +// fallback for the demo UI and the conformance suite, and is disabled in +// production. +router.route('/') + .post(requireActorSignature({ fallback: protectWithGeneralApiKey }), submitContent); + +router.route('/:contentHash/endorsements') + .get(listContentEndorsements); + +router.route('/:contentHash') + .get(getContentRecord); + +module.exports = router; diff --git a/src/routes/endorsements.js b/src/routes/endorsements.js index 89dfbe8..da06070 100644 --- a/src/routes/endorsements.js +++ b/src/routes/endorsements.js @@ -6,13 +6,21 @@ const { deleteEndorsement } = require('../controllers/endorsementController'); const { protectWithGeneralApiKey } = require('../middleware/auth'); +const { requireActorSignature } = require('../middleware/httpSignature'); // Routes +// +// Draft §9.8 requires POST endpoints to be authenticated with an RFC 9421 +// HTTP Message Signature. The static API key remains as a fallback for the +// demo UI and the conformance suite, and is disabled in production. router.route('/') .get(listEndorsements) - .post(protectWithGeneralApiKey, createEndorsement); + .post(requireActorSignature({ fallback: protectWithGeneralApiKey }), createEndorsement); +// DELETE authorizes on the endorser's own key (or the admin key) inside the +// controller, so an unsigned request is passed through to be rejected there +// with the right diagnostic rather than being accepted by a shared secret. router.route('/:id') - .delete(protectWithGeneralApiKey, deleteEndorsement); + .delete(requireActorSignature({ fallback: (req, res, next) => next() }), deleteEndorsement); module.exports = router; diff --git a/src/routes/keys.js b/src/routes/keys.js new file mode 100644 index 0000000..088a7f4 --- /dev/null +++ b/src/routes/keys.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); +const { getKeyDocument } = require('../controllers/directoryController'); + +router.route('/:id') + .get(getKeyDocument); + +module.exports = router; diff --git a/src/routes/signers.js b/src/routes/signers.js new file mode 100644 index 0000000..7816f11 --- /dev/null +++ b/src/routes/signers.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); +const { getSignerReputation } = require('../controllers/directoryController'); + +router.route('/:id/reputation') + .get(getSignerReputation); + +module.exports = router; diff --git a/src/routes/votes.js b/src/routes/votes.js index 2c19d8c..24d0438 100644 --- a/src/routes/votes.js +++ b/src/routes/votes.js @@ -7,14 +7,19 @@ const { getVoteStats, } = require("../controllers/voteController"); const { protectWithGeneralApiKey } = require("../middleware/auth"); +const { requireActorSignature } = require("../middleware/httpSignature"); + +// Votes move reputation, so the voter is the verified signer of the request +// when one is present; see voterIdentity() in the controller. +const authenticatedVoter = requireActorSignature({ fallback: protectWithGeneralApiKey }); // Routes -router.route("/").post(protectWithGeneralApiKey, createVote); +router.route("/").post(authenticatedVoter, createVote); router.route("/stats/:targetType/:targetId").get(getVoteStats); router.route("/:targetType/:targetId").get(getVotes); -router.route("/:voteId").delete(protectWithGeneralApiKey, deleteVote); +router.route("/:voteId").delete(authenticatedVoter, deleteVote); module.exports = router; diff --git a/src/server.js b/src/server.js index 9dccf17..fe4682d 100644 --- a/src/server.js +++ b/src/server.js @@ -1,11 +1,20 @@ const express = require('express'); const cors = require('cors'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); const path = require('path'); const dotenv = require('dotenv'); // Load environment variables dotenv.config(); +const { problem } = require('./utils/htmltrustProtocol'); +const { assertConfigured } = require('./utils/apiKeys'); + +// Fail fast on a misconfigured production deployment rather than at the first +// request that happens to need the missing secret. +assertConfigured(); + // Database connection const connectDB = require('./config/db'); connectDB(); @@ -13,32 +22,136 @@ connectDB(); // Initialize Express app const app = express(); +// Security headers. The demo UI at / pulls Bootstrap and crypto-js from +// jsdelivr and carries one inline