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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# HTMLTrust Server Reference

- Maintainer: Jason Grey
- Updated: 2026-08-28
- Version: 0.1.0, draft v1 API
- Status: Runnable reference server
- For: directory operators and integration developers
- Reading time: 10 minutes

This repository contains the runnable Node.js reference server for the HTMLTrust trust directory API. It stores author profiles and public keys, accepts signed content and endorsements, and exposes directory search and reputation data.

The wire contract is documented in [`openapi.yaml`](openapi.yaml). The server is the implementation used by the local development workflow and the end-to-end simulation.
Expand Down Expand Up @@ -130,16 +137,18 @@ Use RFC 9421 signatures for canonical writes. The compatibility surface supports
| Header | Use |
|---|---|
| `X-API-KEY` | General compatibility operations, including author creation, occurrence registration, reporting, and demo submissions |
| `X-AUTHOR-API-KEY` | Author-specific compatibility operations and the compatibility signing helper |
| `X-AUTHOR-API-KEY` | Author-specific operations and the v1 convenience signing helper |
| `X-ADMIN-API-KEY` | Claim-type administration and endorsement takedown |

Author API keys are returned once by `POST /api/authors`. The server stores an HMAC-SHA-256 digest under `AUTHOR_API_KEY_PEPPER`. A deployment that still has plaintext keys must migrate them using the procedure in [`src/utils/apiKeys.js`](src/utils/apiKeys.js), then remove the plaintext field.

### Wire-format details

- Hashes, signatures, and key bytes use unpadded standard Base64.
- `domain` values are serialized web origins such as `https://publisher.example:8443`.
- Content signatures bind `contentHash`, `claimsHash`, `domain`, and `signedAt`. `claimsHash` covers the canonical serialization of direct `meta` claims in the signed section.
- `sourceURL` values sent to `POST /api/content/sign` must be final HTTPS URLs. The `scope` value (`url` or `origin`) determines the derived `location` bound into the v1 signing payload.
- `POST /api/content/sign` computes `claimsHash` from the strict v1 claims array and signs the RFC 8785 JCS object containing the frozen profile, algorithm, keyid, content hash, claims hash, timestamp, scope, and location. It returns the complete attribute set needed for a `<signed-section>`.
- The convenience signer uses the newest active directory-held key. It returns the stored artifact for an identical retry. A changed payload for the same content, location, and key returns `409 Conflict` because signed artifacts are immutable.
- The convenience route rejects the legacy `domain`, `authorId`, and caller-supplied `claimsHash` fields. Use canonical `POST /content` when the author holds the private key and submits an RFC 9421-authenticated signature.
- Endorsement signatures cover the RFC 8785 JCS serialization of the endorsement document with `signature` omitted. New endorsements are served as signed, and the 201 response supplies the stored identifier in `Location`.
- Endorsements are append-only. An identical retry on canonical `POST /endorsements` returns `201` with the existing resource in `Location`; the `/api/endorsements` compatibility route returns `200`. A different document from the same endorser and content hash is stored as another record.

Expand Down
51 changes: 19 additions & 32 deletions conformance/fixtures/03-signed-content-submission.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
#
# Prerequisite: create an author so we have an author-scoped API key.
# Then sign a content hash. The signature itself is server-generated;
# we only assert response shape (the spec requires contentHash, domain,
# authorId, signature, and claims to be present).
# the convenience endpoint emits the complete frozen v1 signed-section
# attribute set.
name: Sign content with author key
description: |
POST /content/sign produces a ContentSignature bound to the author's key.
POST /content/sign produces a v1 signed-section record bound to the
author's directory-held key and the final HTTPS source URL.
steps:
- name: Create author (signing identity)
request:
Expand All @@ -32,40 +33,26 @@ steps:
X-AUTHOR-API-KEY: $authorApiKey
body:
contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc"
claimsHash: "sha256:4LOflWusiW26FjvAHhwAZpPqZrLblKkYZ1QYKPORKDo"
domain: "https://conformance.example.com"
signedAt: "2026-05-12T12:00:00.000Z"
sourceURL: "https://conformance.example.com/articles/03#summary"
scope: "url"
signedAt: "2026-05-12T12:00:00Z"
claims:
signed-at: "2026-05-12T12:00:00.000Z"
ContentType: "Article"
License: "CC-BY-4.0"
- name: "signed-at"
content: "2026-05-12T12:00:00Z"
- name: "claim:ContentType"
content: "Article"
- name: "claim:License"
content: "CC-BY-4.0"
expect:
status: 201
schema: ContentSignature
schema: V1ContentSignature
body:
contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc"
domain: "https://conformance.example.com"
profile: "htmltrust-signature-v1"
scope: "url"
location: "https://conformance.example.com/articles/03"
sourceURL: "https://conformance.example.com/articles/03#summary"
signature: $nonempty-string
algorithm: "ed25519"
keyid: $nonempty-string
claims:
ContentType: "Article"
License: "CC-BY-4.0"
capture:
signature: $.signature

- name: Verify the freshly signed content
request:
method: POST
path: /content/verify
body:
contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc"
claimsHash: "sha256:4LOflWusiW26FjvAHhwAZpPqZrLblKkYZ1QYKPORKDo"
domain: "https://conformance.example.com"
signedAt: "2026-05-12T12:00:00.000Z"
authorId: $authorId
signature: $signature
expect:
status: 200
body:
valid: true
claims: $any
12 changes: 7 additions & 5 deletions conformance/fixtures/04-content-retrieval-by-hash.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ steps:
X-AUTHOR-API-KEY: $authorApiKey
body:
contentHash: "sha256:82rHSQ/ThLduI0bbHYOVbxn5mEXR0FxMzn6YsVHSwSs"
claimsHash: "sha256:M9aSt+kY8XVWfoAaE4jJEbxdU2sOw01Kr8BU/vhyoUo"
domain: "https://retrieval.example.com"
signedAt: "2026-05-12T12:00:00.000Z"
sourceURL: "https://retrieval.example.com/articles/04"
scope: "url"
signedAt: "2026-05-12T12:00:00Z"
claims:
signed-at: "2026-05-12T12:00:00.000Z"
ContentType: "Article"
- name: "signed-at"
content: "2026-05-12T12:00:00Z"
- name: "claim:ContentType"
content: "Article"
expect:
status: 201

Expand Down
130 changes: 114 additions & 16 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,89 @@ components:
createdAt: "2023-01-01T12:00:00Z"
updatedAt: "2023-01-01T12:00:00Z"

V1ContentSignature:
type: object
required:
- profile
- context
- canonicalizationProfile
- attributeProfile
- urlProfile
- contentHash
- claimsHash
- signedAt
- scope
- location
- sourceURL
- keyid
- algorithm
- signature
- claims
properties:
profile:
type: string
enum: [htmltrust-signature-v1]
context:
type: string
format: uri
canonicalizationProfile:
type: string
enum: [htmltrust-c14n-v1]
attributeProfile:
type: string
enum: [htmltrust-attrs-v1]
urlProfile:
type: string
enum: [htmltrust-safe-url-v1]
contentHash:
type: string
description: Hash of the canonical signed content
claimsHash:
type: string
description: Directory-computed hash of the canonical v1 claims serialization
signedAt:
type: string
description: Exact UTC timestamp in YYYY-MM-DDTHH:MM:SSZ form
scope:
type: string
enum: [url, origin]
location:
type: string
format: uri
description: URL or origin derived from sourceURL under scope
sourceURL:
type: string
format: uri
description: Final HTTPS occurrence URL
keyid:
type: string
description: Exact directory key identifier bound into the JCS payload
algorithm:
type: string
description: Canonical signature algorithm identifier
signature:
type: string
description: Signature over the RFC 8785 JCS v1 signing payload
claims:
type: array
items:
type: object
required: [name, content]
additionalProperties: false
properties:
name: { type: string }
content: { type: string }
authorId:
type: string
description: Directory author that owns the signing key
domain:
type: string
format: uri
description: Occurrence origin retained for compatibility and indexing
createdAt:
type: string
format: date-time

ContentSignature:
type: object
required:
Expand Down Expand Up @@ -1250,10 +1333,12 @@ paths:
- Content
summary: Sign content
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`.
Author-authenticated convenience signer for the frozen
`htmltrust-signature-v1` profile. The directory selects the
authenticated author's directory-held key, derives `location` from
the final HTTPS `sourceURL` and `scope`, computes `claimsHash` from
the strict v1 claims array, and signs the RFC 8785 JSON payload. The
caller must not submit `claimsHash` or the legacy `domain` field.
operationId: signContent
security:
- AuthorApiKey: []
Expand All @@ -1265,35 +1350,42 @@ paths:
type: object
required:
- contentHash
- claimsHash
- domain
- sourceURL
- scope
- signedAt
- claims
properties:
contentHash:
type: string
description: Hash of the normalized content using canonical unpadded standard Base64
claimsHash:
sourceURL:
type: string
description: Hash of all direct child meta claims using canonical unpadded standard Base64
domain:
format: uri
description: Final HTTPS response URL where the signed section will be published
scope:
type: string
description: Serialized Web origin associated with the content; bare hostnames are invalid
enum: [url, origin]
description: Location binding scope used to derive the signed location
signedAt:
type: string
format: date-time
description: UTC RFC3339 timestamp from the direct child signed-at claim
description: Exact UTC timestamp in YYYY-MM-DDTHH:MM:SSZ form, also present in claims
claims:
type: object
description: Direct child meta claims about the content; every such claim participates in claimsHash.
additionalProperties: true
type: array
description: Strict v1 claim records. Include exactly one signed-at record.
items:
type: object
required: [name, content]
additionalProperties: false
properties:
name: { type: string }
content: { type: string }
responses:
"201":
description: Content signed successfully
content:
application/json:
schema:
$ref: "#/components/schemas/ContentSignature"
$ref: "#/components/schemas/V1ContentSignature"
"400":
description: Invalid input
content:
Expand All @@ -1306,6 +1398,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: No active directory-held signing key exists for the author
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"

/content/verify:
post:
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"author": "Jason Grey <jason@jason-grey.com>",
"license": "LicenseRef-PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/37359dd8a872f8d09fa0e7f7dd75567d92e5bec4.tar.gz",
"@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
Expand Down
Loading
Loading