Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
49 changes: 39 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,41 +22,66 @@ 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:
MONGO_URI: mongodb://localhost:27017/htmltrust-test
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 &
sleep 3
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: |
Expand Down
62 changes: 52 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,46 +51,88 @@ 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:

| 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

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

Expand Down
19 changes: 11 additions & 8 deletions conformance/fixtures/03-signed-content-submission.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
20 changes: 16 additions & 4 deletions conformance/fixtures/04-content-retrieval-by-hash.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,20 @@ 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

- 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:
Expand All @@ -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
Expand Down
Loading
Loading