From 31f13ab20456d97766c89781c2d2eea2f0d84579 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 05:26:00 -0500 Subject: [PATCH 1/3] feat(server): complete v1 directory surface --- README.md | 8 +- conformance/README.md | 28 ++--- conformance/runner/v1-smoke.mjs | 94 ++++++++++++++--- openapi.yaml | 141 +++---------------------- src/controllers/authorController.js | 4 +- src/controllers/contentController.js | 4 +- src/controllers/directoryController.js | 57 ++++++++-- src/middleware/httpSignature.js | 4 +- src/models/Key.js | 19 ++++ src/server.js | 12 --- src/utils/directoryUrl.js | 7 +- src/utils/keyResolution.js | 16 +-- test/canonicalRoutes.test.js | 6 +- test/keyDocument.test.js | 24 +++++ 14 files changed, 232 insertions(+), 192 deletions(-) diff --git a/README.md b/README.md index a7d9fc7..76c52bb 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ This is the lowest-dependency test path: it requires Docker and a shell. Set `HT npm ci --omit=dev ``` -3. Set `NODE_ENV=production`, `AUTHOR_API_KEY_PEPPER`, and `DIRECTORY_BASE_URL`. Use a random, long-lived pepper and keep it outside the repository. `DIRECTORY_BASE_URL` must be the public origin clients use to resolve directory key URLs. +3. Set `NODE_ENV=production`, `AUTHOR_API_KEY_PEPPER`, and `DIRECTORY_BASE_URL`. Use a random, long-lived pepper and keep it outside the repository. `DIRECTORY_BASE_URL` must be the public HTTPS origin clients use to resolve directory key URLs. When the variable is unset, the server falls back to the request origin for local development, including `http://localhost`; that fallback is not a production deployment setting. 4. Set `GENERAL_API_KEY` and `ADMIN_API_KEY` when compatibility or operator routes need them. Static general and author API-key authentication is disabled in production unless `HTMLTRUST_ALLOW_API_KEY_AUTH=1` is set. 5. Start the service with `npm start` behind a TLS-terminating reverse proxy. Set `TRUST_PROXY` to the number of trusted proxy hops when the proxy forwards client addresses. @@ -101,11 +101,9 @@ The server exposes two HTTP surfaces. The root routes are the canonical HTMLTrus | `POST /content` | Submit and re-verify a signed content record | RFC 9421 HTTP Message Signature | | `GET /content/:hash` | Retrieve a content record by percent-encoded hash | Public | | `GET /content/:hash/endorsements` | List endorsements for a content hash | Public | -| `GET /endorsements?content-hash=...` | List endorsements for a content hash | Public | | `POST /endorsements` | Store a signed endorsement | RFC 9421 HTTP Message Signature | -| `DELETE /endorsements/:id` | Delete an endorsement with the endorser key or directory admin key | Endorser signature or admin key | -Canonical writes require a resolvable key in an RFC 9421 signature. The covered components include `@method`, `@target-uri`, `host`, `date`, and `content-digest` for requests with a body. The `keyid` identifies the key that signed the request. +Canonical writes require a resolvable key in an RFC 9421 signature. The `sig1` input MUST cover exactly `@method`, `@target-uri`, `host`, `date`, and `content-digest`, in that order. The `keyid` identifies the key that signed the request. Canonical reads return an HTMLTrust media type by default and accept `application/json`. Responses include `Vary: Accept`; public reads include `Cache-Control` and `ETag`. A matching `If-None-Match` request receives `304 Not Modified`. Canonical JSON submissions accept `application/json` and `application/*+json`; another request media type receives `415`. @@ -154,7 +152,7 @@ Author API keys are returned once by `POST /api/authors`. The server stores an H ### Key custody -`POST /api/authors` accepts an optional SPKI PEM `publicKey`. Supplying one registers a key held by the caller and leaves its private key outside the directory. Omitting it asks the server to generate and hold a key pair for the convenience registry flow. Content signed by a caller-held key is submitted through `POST /content` or the compatibility `POST /api/content` route. +`POST /api/authors` accepts an optional SPKI PEM `publicKey`. Supplying one registers a key held by the caller and leaves its private key outside the directory. Omitting it asks the server to generate and hold a key pair for the convenience registry flow. The public key document uses an opaque `id` in `/keys/{id}` URLs; existing pre-v1 rows remain readable through their legacy ObjectId URL until migrated. Content signed by a caller-held key is submitted through `POST /content` or the compatibility `POST /api/content` route. ## Project structure diff --git a/conformance/README.md b/conformance/README.md index 8e24f62..8fdce03 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -162,19 +162,19 @@ If you need a richer validator, swap in `ajv` — `validate()` in ## Known deviations of the Node reference -For transparency — these were observed while building the suite. They are -**not** fixed in this task (out of scope), but the suite is configured to -work around them when run against the Node reference. - -1. **`id` fields are 24-char MongoDB ObjectIds, not RFC-4122 UUIDs.** - `openapi.yaml` specifies `format: uuid` for every id field. The Node - reference simply emits the underlying Mongoose `_id`. The - `--accept-mongo-ids` flag relaxes UUID checks to also accept ObjectIds. -2. **Mongoose `_id` is exposed alongside the spec's `id` in many responses.** - `openapi.yaml` doesn't forbid extra properties (no `additionalProperties: - false`), so this is technically tolerable; but it leaks implementation - detail and a strict OpenAPI validator could complain. The same flag - permits `_id` as a synonym for `id`. +The following compatibility notes describe behavior retained on the legacy +`/api` surface. The suite is configured to work around them when run against +the Node reference. + +1. **Compatibility author ids remain 24-char MongoDB ObjectIds.** + Canonical key-document ids are opaque public ids, while the older author + and compatibility-resource envelopes still expose their historical + ObjectId values. The `--accept-mongo-ids` flag relaxes UUID checks for those + compatibility fields. +2. **Mongoose `_id` is exposed alongside the spec's `id` in some compatibility + responses.** `openapi.yaml` does not forbid extra properties, so this is + technically tolerable; the `--accept-mongo-ids` flag also accepts `_id` as + a capture fallback. 3. **API is mounted under `/api/…` instead of `/v1/…`** as the spec's `servers` block implies. `--base-path /api` accommodates this; other implementations should use `/v1` or `""` as appropriate. @@ -185,5 +185,5 @@ work around them when run against the Node reference. Running the suite in **strict** mode against the reference (drop `--accept-mongo-ids`, use `--base-path ""`, and target a spec-conformant -server) will surface deviations 1 and 2 immediately; that is the intended +server) will surface the compatibility deviations immediately; that is the intended behaviour for verifying other implementations. diff --git a/conformance/runner/v1-smoke.mjs b/conformance/runner/v1-smoke.mjs index d2d4edb..66eb1e4 100644 --- a/conformance/runner/v1-smoke.mjs +++ b/conformance/runner/v1-smoke.mjs @@ -41,31 +41,48 @@ const canonicalize = (value) => { const unpadded = (buffer) => buffer.toString("base64").replace(/=+$/, ""); const prefixedSha256 = (text) => `sha256:${unpadded(createHash("sha256").update(text).digest())}`; -const signHttpRequest = ({ url, body, keyid, privateKey, nonce }) => { +const signHttpRequest = ({ + url, + body, + keyid, + privateKey, + nonce, + components = ["@method", "@target-uri", "host", "date", "content-digest"], + label = "sig1", + includeAlg = true, + padded = false, +}) => { const parsed = new URL(url); const date = new Date().toUTCString(); const created = Math.floor(Date.now() / 1000); const digest = createHash("sha256").update(body).digest("base64"); const contentDigest = `sha-256=:${digest}:`; const parameters = - `("@method" "@target-uri" "host" "date" "content-digest")` + - `;created=${created};keyid="${keyid}";alg="ed25519";nonce="${nonce}"`; + `(${components.map((component) => `"${component}"`).join(" ")})` + + `;created=${created};keyid="${keyid}"` + + (includeAlg ? `;alg="ed25519"` : "") + + `;nonce="${nonce}"`; + const values = { + "@method": "POST", + "@target-uri": url, + host: parsed.host, + date, + "content-digest": contentDigest, + "@request-target": parsed.pathname + parsed.search, + }; const base = [ - '"@method": POST', - `"@target-uri": ${url}`, - `"host": ${parsed.host}`, - `"date": ${date}`, - `"content-digest": ${contentDigest}`, + ...components.map((component) => `"${component}": ${values[component]}`), `"@signature-params": ${parameters}`, ].join("\n"); - const signature = unpadded(cryptoSign(null, Buffer.from(base, "utf8"), privateKey)); + let signature = cryptoSign(null, Buffer.from(base, "utf8"), privateKey).toString("base64"); + if (!padded) signature = unpadded(Buffer.from(signature, "base64")); return { "content-type": "application/json", host: parsed.host, date, "content-digest": contentDigest, - "signature-input": `sig1=${parameters}`, - signature: `sig1=:${signature}:`, + "signature-input": `${label}=${parameters}`, + signature: `${label}=:${signature}${padded ? "=" : ""}:`, }; }; @@ -110,11 +127,19 @@ const main = async () => { if (discovery.body.directory !== `${target}/`) { fail("discovery directory does not name the canonical root", discovery.body); } + await requestJson(`${target}/endorsements?content-hash=sha256:removed-root-list-route`, {}, 404); + await requestJson(`${target}/endorsements/000000000000000000000000`, { method: "DELETE" }, 404); const keyDocument = await requestJson(keyid); if (keyDocument.body.kid !== keyid || keyDocument.body.publicKeyPem !== undefined) { fail("root key document has the wrong kid or exposes the PEM compatibility field", keyDocument.body); } + const reputation = await requestJson( + `${target}/signers/${encodeURIComponent(keyId)}/reputation`, + ); + if (reputation.body.keyid !== keyId || typeof reputation.body.score !== "number") { + fail("root signer reputation has the wrong key identifier or score", reputation.body); + } const signedAt = "2026-01-15T12:00:00Z"; const claims = [ @@ -206,6 +231,51 @@ const main = async () => { fail("canonical POST /content accepted API-key fallback or omitted its signature challenge"); } + const submissionBody = JSON.stringify(submission); + const signatureProfileCases = [ + { + name: "legacy request-target component", + components: ["@method", "@request-target", "host", "date", "content-digest"], + nonce: "content-legacy-target", + }, + { + name: "reordered components", + components: ["@target-uri", "@method", "host", "date", "content-digest"], + nonce: "content-reordered-components", + }, + { + name: "wrong signature label", + label: "other", + nonce: "content-wrong-label", + }, + { + name: "missing alg parameter", + includeAlg: false, + nonce: "content-missing-alg", + }, + { + name: "padded signature bytes", + padded: true, + nonce: "content-padded-signature", + }, + ]; + for (const profileCase of signatureProfileCases) { + const rejected = await requestJson(`${target}/content`, { + method: "POST", + headers: signHttpRequest({ + url: `${target}/content`, + body: submissionBody, + keyid, + privateKey: signingKey.privateKey, + ...profileCase, + }), + body: submissionBody, + }, 401); + if (!rejected.response.headers.get("www-authenticate")) { + fail(`canonical POST /content accepted ${profileCase.name}`); + } + } + const unsignedEndorsement = { endorser: keyid, endorsement: contentHash, @@ -242,7 +312,7 @@ const main = async () => { fail("root content endorsement listing did not return the stored document", endorsements.body); } - console.log("HTMLTrust v1 directory smoke: 12 checks passed"); + console.log("HTMLTrust v1 directory smoke: canonical operations and signature profile checks passed"); }; main().catch((error) => { diff --git a/openapi.yaml b/openapi.yaml index fd0617d..95b2670 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -65,9 +65,9 @@ components: 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 + For the canonical v1 POST endpoints, `sig1` MUST cover exactly these + components in this order: `@method`, `@target-uri`, `host`, `date`, + and `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 @@ -77,7 +77,7 @@ components: Signature-Input: sig1=("@method" "@target-uri" "host" "date" \ "content-digest");created=1770000000;keyid="https://directory.example/keys/k-abc123";alg="ed25519" - Signature: sig1=:MEUCIQD...: + Signature: sig1=:BASE64_ED25519_SIGNATURE: Failed verification returns 401 with a `WWW-Authenticate: Signature realm="htmltrust-directory"` challenge @@ -219,8 +219,7 @@ components: properties: id: type: string - format: uuid - description: Unique identifier for the public key + description: Opaque public identifier for the key document authorId: type: string format: uuid @@ -241,7 +240,7 @@ components: format: date-time description: Expiration timestamp (if applicable) example: - id: "123e4567-e89b-12d3-a456-426614174001" + id: "k_2Y7mB8xQ4nP6rT9vW3zA5cD7eF" authorId: "123e4567-e89b-12d3-a456-426614174000" key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----" algorithm: "RSA" @@ -357,6 +356,7 @@ components: signedAt: type: string format: date-time + pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" scope: type: string enum: [url, origin] @@ -369,11 +369,13 @@ components: sourceURL: type: string format: uri + pattern: "^[hH][tT][tT][pP][sS]://" claims: type: array items: type: object required: [name, content] + additionalProperties: false properties: name: { type: string } content: { type: string } @@ -479,6 +481,7 @@ components: signedAt: type: string description: Exact UTC timestamp in YYYY-MM-DDTHH:MM:SSZ form + pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" scope: type: string enum: [url, origin] @@ -490,6 +493,7 @@ components: type: string format: uri description: Final HTTPS occurrence URL + pattern: "^[hH][tT][tT][pP][sS]://" keyid: type: string description: Exact directory key identifier bound into the JCS payload @@ -571,7 +575,7 @@ components: signedAt: "2026-05-01T10:30:00Z" domain: "https://example.com" authorId: "123e4567-e89b-12d3-a456-426614174000" - keyid: "https://directory.example/keys/123e4567-e89b-12d3-a456-426614174001" + keyid: "https://directory.example/keys/k_2Y7mB8xQ4nP6rT9vW3zA5cD7eF" algorithm: "ed25519" signature: "MEUCIQD7y5SxmQJ9f0lE9B0BwqIJKKdL5fZMNQOiPnKWUJfmrgIgEbHtPwDxM9xGbCZzW9k2R9jFxwJZQQlPfhgj+0YP7vQ=" claims: @@ -589,8 +593,7 @@ components: properties: keyId: type: string - format: uuid - description: ID of the public key + description: Opaque public key id, or a legacy ObjectId accepted by the compatibility route trustScore: type: number format: float @@ -608,7 +611,7 @@ components: format: date-time description: Last update timestamp example: - keyId: "123e4567-e89b-12d3-a456-426614174001" + keyId: "k_2Y7mB8xQ4nP6rT9vW3zA5cD7eF" trustScore: 0.95 verifiedSignatures: 1250 reports: 2 @@ -1700,10 +1703,9 @@ paths: - name: keyId in: path required: true - description: ID of the public key + description: Opaque public key id, or a legacy ObjectId accepted by the compatibility route. schema: type: string - format: uuid responses: "200": description: Key reputation @@ -1731,10 +1733,9 @@ paths: - name: keyId in: path required: true - description: ID of the public key + description: Opaque public key id, or a legacy ObjectId accepted by the compatibility route. schema: type: string - format: uuid requestBody: required: true content: @@ -2006,63 +2007,6 @@ paths: $ref: "#/components/schemas/Error" /endorsements: - get: - tags: - - Endorsements - summary: List endorsements for a content hash - description: | - Returns all endorsements on file for a given content hash. The - directory is a passive store; clients MUST verify each endorsement's - signature locally using the endorser's public key (per spec §2.5) - before treating it as trustworthy. Unverified endorsements MUST NOT - contribute to any trust decision. - operationId: listEndorsements - parameters: - - name: content-hash - in: query - required: true - description: The content hash to look up endorsements for (e.g. "sha256:...") - schema: - type: string - responses: - "200": - description: Array of endorsements - headers: - Cache-Control: - schema: { type: string } - ETag: - schema: { type: string } - Vary: - schema: { type: string } - content: - application/htmltrust-endorsement+json: - schema: - type: array - items: - $ref: "#/components/schemas/Endorsement" - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Endorsement" - "400": - description: Invalid input - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - "406": - description: Requested representation is not supported - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - "500": - description: Directory could not read endorsements - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" post: tags: - Endorsements @@ -2131,56 +2075,3 @@ paths: application/problem+json: schema: $ref: "#/components/schemas/Problem" - - /endorsements/{id}: - delete: - tags: - - Endorsements - summary: Delete an endorsement - description: | - Removes an endorsement from the directory. The caller must authenticate - with the endorsement's key or with the directory administrator key. - operationId: deleteEndorsement - security: - - HttpSignatureInput: [] - HttpMessageSignature: [] - - AdminApiKey: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "204": - description: Endorsement deleted - "400": - description: Invalid endorsement identifier or request - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - "401": - description: Unauthorized - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - "403": - description: The authenticated key is not the endorser - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - "404": - description: Endorsement not found - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - "500": - description: Directory could not delete the endorsement - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" diff --git a/src/controllers/authorController.js b/src/controllers/authorController.js index 19bfe91..57a3970 100644 --- a/src/controllers/authorController.js +++ b/src/controllers/authorController.js @@ -240,7 +240,9 @@ exports.getAuthorPublicKey = async (req, res) => { } res.status(200).json({ - id: key._id, + // Expose the opaque protocol identifier. Keep the Mongo ObjectId in the + // compatibility storage model, but never make it the new key URL id. + id: key.publicId || key._id, authorId: key.authorId, key: key.publicKey, algorithm: key.algorithm, diff --git a/src/controllers/contentController.js b/src/controllers/contentController.js index bc4ab18..383b93c 100644 --- a/src/controllers/contentController.js +++ b/src/controllers/contentController.js @@ -17,7 +17,7 @@ const { problem, } = require('../utils/htmltrustProtocol'); const { canonicalizeClaims } = require('../utils/claims'); -const { directoryKeyUrl } = require('../utils/directoryUrl'); +const { directoryKeyUrl, publicKeyId } = require('../utils/directoryUrl'); const { buildV1ContentSigningPayload, deriveLocation, @@ -111,7 +111,7 @@ const validateSignatureInputs = ({ contentHash, claimsHash, domain, signedAt, si }; }; -const keyidFor = (req, key) => directoryKeyUrl(req, key._id); +const keyidFor = (req, key) => directoryKeyUrl(req, publicKeyId(key)); /** * Resolve a submitted keyid to a usable key, honouring revocation and expiry diff --git a/src/controllers/directoryController.js b/src/controllers/directoryController.js index f4e671c..4891388 100644 --- a/src/controllers/directoryController.js +++ b/src/controllers/directoryController.js @@ -9,7 +9,7 @@ const { problem, safeSearchRegex, } = require('../utils/htmltrustProtocol'); -const { directoryBaseUrl, directoryKeyUrl } = require('../utils/directoryUrl'); +const { directoryBaseUrl, directoryKeyUrl, publicKeyId } = require('../utils/directoryUrl'); const { negotiatedType } = require('../middleware/contentNegotiation'); /** @@ -44,6 +44,32 @@ const keyIdFromSignerId = (id) => { return null; }; +const OPAQUE_KEY_ID = /^k_[A-Za-z0-9_-]{20,64}$/; +const MONGO_KEY_ID = /^[0-9a-fA-F]{24}$/; + +const decodedKeyId = (value) => { + if (typeof value !== 'string') return null; + let decoded; + try { + decoded = decodeURIComponent(value); + } catch { + return null; + } + return MONGO_KEY_ID.test(decoded) || OPAQUE_KEY_ID.test(decoded) ? decoded : null; +}; + +// New keys are addressed by their opaque publicId. Existing rows remain +// readable through their historical Mongo ObjectId until they are migrated. +const findDirectoryKey = async (value) => { + const id = decodedKeyId(value); + if (!id) return null; + if (MONGO_KEY_ID.test(id)) { + const legacy = await Key.findById(id); + if (legacy) return legacy; + } + return Key.findOne({ publicId: id }); +}; + exports.discovery = async (req, res) => { res .type(negotiatedType(req, 'application/htmltrust-directory+json')) @@ -72,19 +98,20 @@ exports.discovery = async (req, res) => { }; exports.getKeyDocument = async (req, res) => { - if (!/^[0-9a-fA-F]{24}$/.test(req.params.id)) { - return problem(res, 400, 'Invalid key id', 'The key id must be a 24-character hexadecimal identifier'); + const id = decodedKeyId(req.params.id); + if (!id) { + return problem(res, 400, 'Invalid key id', 'The key id must be a valid opaque path identifier'); } try { - const key = await Key.findById(req.params.id); + const key = await findDirectoryKey(id); if (!key) { return problem(res, 404, 'Key not found', 'No key document exists for the requested id'); } res .type(negotiatedType(req, 'application/htmltrust-key+json')) .status(200) - .json(keyDocumentFor(key, directoryKeyUrl(req, key._id))); + .json(keyDocumentFor(key, directoryKeyUrl(req, publicKeyId(key)))); } catch (error) { console.error('Get key document error:', error); return problem(res, 500, 'Directory read failure', 'The directory could not read the key document', { @@ -105,7 +132,10 @@ exports.getSignerReputation = async (req, res) => { const keyId = keyIdFromSignerId(signerId); let key = null; if (keyId) { - key = await Key.findById(keyId); + key = await findDirectoryKey(keyId); + } + if (!key && OPAQUE_KEY_ID.test(signerId)) { + key = await findDirectoryKey(signerId); } if (!key && /^[0-9a-fA-F]{24}$/.test(signerId)) { key = await Key.findOne({ authorId: signerId }); @@ -193,6 +223,7 @@ exports.searchPublicKeys = async (req, res) => { { $project: { _id: 1, + publicId: 1, authorId: 1, publicKey: 1, algorithm: 1, @@ -208,9 +239,15 @@ exports.searchPublicKeys = async (req, res) => { // Get total count const total = await Key.countDocuments(query); + const publicKeys = keys.map((key) => ({ + ...key, + id: key.publicId || String(key._id), + })); res.status(200).json({ - keys: normalizedDomain ? keys.filter((key) => key.author && key.author.url && key.author.url.startsWith(normalizedDomain)) : keys, + keys: normalizedDomain + ? publicKeys.filter((key) => key.author && key.author.url && key.author.url.startsWith(normalizedDomain)) + : publicKeys, pagination: { total, pages: Math.ceil(total / pageSize), @@ -237,7 +274,7 @@ exports.searchPublicKeys = async (req, res) => { */ exports.getKeyReputation = async (req, res) => { try { - const key = await Key.findById(req.params.keyId); + const key = await findDirectoryKey(req.params.keyId); if (!key) { return res.status(404).json({ @@ -247,7 +284,7 @@ exports.getKeyReputation = async (req, res) => { } res.status(200).json({ - keyId: key._id, + keyId: publicKeyId(key), trustScore: key.trustScore, verifiedSignatures: key.verifiedSignatures, reports: key.reports, @@ -272,7 +309,7 @@ exports.reportKey = async (req, res) => { const { reason, details, evidence } = req.body; // Find key - const key = await Key.findById(req.params.keyId); + const key = await findDirectoryKey(req.params.keyId); if (!key) { return res.status(404).json({ diff --git a/src/middleware/httpSignature.js b/src/middleware/httpSignature.js index 5bb089d..c9ed102 100644 --- a/src/middleware/httpSignature.js +++ b/src/middleware/httpSignature.js @@ -7,8 +7,8 @@ const { resolveUsableKey } = require("../utils/keyResolution"); * * "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." + * canonical v1 signature input covers exactly `@method`, `@target-uri`, + * `host`, `date`, and `content-digest`, in that order." * * This is a deliberately small verifier, not a general RFC 9421 library. * Strict mode implements the HTMLTrust v1 request profile. Compatibility diff --git a/src/models/Key.js b/src/models/Key.js index 2a5c294..76e4c33 100644 --- a/src/models/Key.js +++ b/src/models/Key.js @@ -1,4 +1,5 @@ const mongoose = require('mongoose'); +const crypto = require('crypto'); const KeySchema = new mongoose.Schema({ authorId: { @@ -10,6 +11,24 @@ const KeySchema = new mongoose.Schema({ type: String, required: [true, 'Public key is required'] }, + // Opaque public identifier used in directory key URLs. The Mongo `_id` is + // storage metadata and must not be the protocol-facing key identifier. + // `sparse` keeps existing pre-v1 rows resolvable during migration; legacy + // rows without publicId continue to use their ObjectId as a compatibility + // alias until they are rewritten. + publicId: { + type: String, + required: true, + unique: true, + sparse: true, + index: true, + // Mongoose hydrates defaults onto legacy rows too. Only generate this + // value for new documents, so an old row without publicId remains + // addressable through its historical ObjectId compatibility alias. + default: function generatePublicId() { + return this.isNew ? `k_${crypto.randomBytes(18).toString('base64url')}` : undefined; + }, + }, // 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. diff --git a/src/server.js b/src/server.js index 0ae4f16..ba5fbbc 100644 --- a/src/server.js +++ b/src/server.js @@ -128,8 +128,6 @@ const { } = require('./controllers/contentController'); const { createEndorsement, - listEndorsements, - deleteEndorsement, } = require('./controllers/endorsementController'); const { getKeyDocument, @@ -153,11 +151,6 @@ app.get( negotiate('application/htmltrust-content+json'), getContentRecordV1, ); -app.get( - '/endorsements', - negotiate('application/htmltrust-endorsement+json'), - listEndorsements, -); app.post( '/endorsements', writeLimiter, @@ -165,11 +158,6 @@ app.post( requireActorSignature({ strictV1: true }), createEndorsement, ); -app.delete( - '/endorsements/:id', - requireActorSignature({ fallback: (req, res, next) => next() }), - deleteEndorsement, -); app.get('/keys/:id', negotiate('application/htmltrust-key+json'), getKeyDocument); app.get( '/signers/:id/reputation', diff --git a/src/utils/directoryUrl.js b/src/utils/directoryUrl.js index fa6d9eb..f199e0e 100644 --- a/src/utils/directoryUrl.js +++ b/src/utils/directoryUrl.js @@ -8,10 +8,15 @@ const directoryBaseUrl = (req, env = process.env) => { return url.href.replace(/\/$/, ''); } + // The request-origin fallback keeps local development and the conformance + // runner on HTTP. Deployments serving canonical key URLs must set an + // explicit HTTPS DIRECTORY_BASE_URL. return `${req.protocol}://${req.get('host')}`; }; const directoryKeyUrl = (req, keyId, env = process.env) => `${directoryBaseUrl(req, env)}/keys/${encodeURIComponent(String(keyId))}`; -module.exports = { directoryBaseUrl, directoryKeyUrl }; +const publicKeyId = (key) => String(key.publicId || key._id); + +module.exports = { directoryBaseUrl, directoryKeyUrl, publicKeyId }; diff --git a/src/utils/keyResolution.js b/src/utils/keyResolution.js index f39b875..0aa3783 100644 --- a/src/utils/keyResolution.js +++ b/src/utils/keyResolution.js @@ -34,14 +34,16 @@ const REMOTE_ENABLED = () => process.env.HTMLTRUST_REMOTE_KEY_RESOLUTION === "1" const REMOTE_TIMEOUT_MS = 5000; const REMOTE_MAX_BYTES = 64 * 1024; const OBJECT_ID = /^[0-9a-fA-F]{24}$/; +const OPAQUE_KEY_ID = /^k_[A-Za-z0-9_-]{20,64}$/; /** - * Pull a directory key id out of a keyid string. Accepts a bare ObjectId or - * any URL whose path contains a `/keys/{id}` segment (draft §8.3). + * Pull a directory key id out of a keyid string. Accepts a bare legacy + * ObjectId or an opaque public id in a URL whose path contains a + * `/keys/{id}` segment (draft §8.3). */ const directoryKeyIdFrom = (keyid) => { if (typeof keyid !== "string" || keyid.length === 0) return null; - if (OBJECT_ID.test(keyid)) return keyid; + if (OBJECT_ID.test(keyid) || OPAQUE_KEY_ID.test(keyid)) return keyid; let url; try { url = new URL(keyid); @@ -52,7 +54,7 @@ const directoryKeyIdFrom = (keyid) => { const keysIndex = segments.lastIndexOf("keys"); if (keysIndex === -1 || !segments[keysIndex + 1]) return null; const candidate = decodeURIComponent(segments[keysIndex + 1]); - return OBJECT_ID.test(candidate) ? candidate : null; + return OBJECT_ID.test(candidate) || OPAQUE_KEY_ID.test(candidate) ? candidate : null; }; /** @@ -63,7 +65,7 @@ const directoryKeyIdFrom = (keyid) => { */ const isSelfHosted = (keyid, selfOrigins) => { if (typeof keyid !== "string") return false; - if (OBJECT_ID.test(keyid)) return true; + if (OBJECT_ID.test(keyid) || OPAQUE_KEY_ID.test(keyid)) return true; try { const url = new URL(keyid); return selfOrigins.includes(`${url.protocol}//${url.host}`.toLowerCase()); @@ -183,7 +185,9 @@ const resolveLocal = async (keyid, selfOrigins, requestedAlgorithm) => { if (!isSelfHosted(keyid, selfOrigins)) return null; const id = directoryKeyIdFrom(keyid); if (!id) return null; - const key = await Key.findById(id); + const key = OBJECT_ID.test(id) + ? await Key.findById(id) + : await Key.findOne({ publicId: id }); if (!key) return null; const algorithm = normalizeAlgorithm(key.algorithm); if (requestedAlgorithm && algorithm !== requestedAlgorithm) return null; diff --git a/test/canonicalRoutes.test.js b/test/canonicalRoutes.test.js index 64d7cda..1adc10d 100644 --- a/test/canonicalRoutes.test.js +++ b/test/canonicalRoutes.test.js @@ -10,6 +10,8 @@ test('canonical endorsement creation returns a canonical resource Location', () assert.doesNotMatch(endorsementSource, /\.location\(`\/api\/endorsements\/\$\{stored\._id\}`\)/); }); -test('canonical endorsement deletion is registered', () => { - assert.match(serverSource, /app\.delete\(\s*['"]\/endorsements\/:id['"]/s); +test('canonical root exposes only the normative endorsement write', () => { + assert.match(serverSource, /app\.post\(\s*['"]\/endorsements['"]/s); + assert.doesNotMatch(serverSource, /app\.get\(\s*['"]\/endorsements['"]/s); + assert.doesNotMatch(serverSource, /app\.delete\(\s*['"]\/endorsements\/:id['"]/s); }); diff --git a/test/keyDocument.test.js b/test/keyDocument.test.js index 1ee14cb..f1f9f3c 100644 --- a/test/keyDocument.test.js +++ b/test/keyDocument.test.js @@ -2,6 +2,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const { keyDocumentFor } = require("../src/utils/htmltrustProtocol"); +const { publicKeyId } = require("../src/utils/directoryUrl"); +const Key = require("../src/models/Key"); test("directory key documents expose canonical SPKI DER and lifecycle metadata", () => { const { publicKey } = crypto.generateKeyPairSync("ed25519", { @@ -39,3 +41,25 @@ test("directory key documents expose canonical SPKI DER and lifecycle metadata", publicKey, ); }); + +test("new directory keys receive an opaque public identifier", () => { + const key = new Key({ + authorId: "507f1f77bcf86cd799439011", + publicKey: "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----", + algorithm: "ed25519", + }); + assert.match(key.publicId, /^k_[A-Za-z0-9_-]{20,}$/); + assert.notEqual(key.publicId, String(key._id)); +}); + +test("legacy hydrated keys keep their ObjectId URL compatibility alias", () => { + const legacyId = "507f1f77bcf86cd799439011"; + const key = Key.hydrate({ + _id: legacyId, + authorId: "507f1f77bcf86cd799439012", + publicKey: "legacy", + algorithm: "ed25519", + }); + assert.equal(key.publicId, undefined); + assert.equal(publicKeyId(key), legacyId); +}); From 2369b4bf6a845b294431a196e8dbe03790da8be1 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 05:33:56 -0500 Subject: [PATCH 2/3] fix(server): close v1 directory review gaps --- conformance/README.md | 22 +++++++++++--------- conformance/runner/v1-smoke.mjs | 24 ++++++++++++++++++++-- openapi.yaml | 36 ++++++++++++++++++++------------- package-lock.json | 6 +++--- package.json | 2 +- scripts/migrate-v1-indexes.js | 26 +++++++++++++++++++++++- src/models/Key.js | 7 ++++++- src/server.js | 2 ++ src/utils/directoryUrl.js | 14 ++++++++++++- test/canonicalRoutes.test.js | 29 ++++++++++++++++++++++++++ test/directoryUrl.test.js | 25 ++++++++++++++++++++++- test/keyDocument.test.js | 2 ++ 12 files changed, 162 insertions(+), 33 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 8fdce03..ee43165 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -48,7 +48,7 @@ cd conformance/runner npm install # one-time node run.mjs \ --target-url http://your-server.example \ - --base-path /v1 \ + --base-path /api \ --general-api-key YOUR_GENERAL_KEY \ --admin-api-key YOUR_ADMIN_KEY ``` @@ -58,7 +58,7 @@ All flags: | Flag | Default | Description | |---|---|---| | `--target-url URL` | `http://localhost:3000` | Base URL of the server | -| `--base-path PATH` | `/api` | Prefix prepended to spec paths. Use `/v1` for spec-conformant servers, `/api` for the Node reference, or `""` for none. | +| `--base-path PATH` | `/api` | Prefix prepended to the fixture's logical compatibility paths. Use `/api` for the Node reference or `""` when the target mounts those compatibility paths at the origin. Canonical v1 root paths are exercised separately by `runner/v1-smoke.mjs` and are never prefixed. | | `--general-api-key KEY` | env `GENERAL_API_KEY` | Value for `X-API-KEY` | | `--admin-api-key KEY` | env `ADMIN_API_KEY` | Value for `X-ADMIN-API-KEY` | | `--fixtures-dir DIR` | `../fixtures` | Where YAML fixtures live | @@ -175,15 +175,19 @@ the Node reference. responses.** `openapi.yaml` does not forbid extra properties, so this is technically tolerable; the `--accept-mongo-ids` flag also accepts `_id` as a capture fallback. -3. **API is mounted under `/api/…` instead of `/v1/…`** as the spec's - `servers` block implies. `--base-path /api` accommodates this; other - implementations should use `/v1` or `""` as appropriate. +3. **The fixture files use logical compatibility paths such as `/authors` and + `/directory/keys`.** The published OpenAPI contract names their runtime + paths with the `/api` prefix. The runner adds `--base-path` to fixture URLs + and uses named schemas rather than looking up paths in OpenAPI, so this + keeps the fixtures reusable without hiding the deployed route prefix. 4. **`/votes` endpoints are implemented but not documented in `openapi.yaml`.** Fixtures 05, 06, and 09 exercise them anyway since the spec text references endorsement/trust voting. A future spec revision is expected to formalize them. -Running the suite in **strict** mode against the reference (drop -`--accept-mongo-ids`, use `--base-path ""`, and target a spec-conformant -server) will surface the compatibility deviations immediately; that is the intended -behaviour for verifying other implementations. +For a strict check against another implementation, use a target and +`--base-path` that match its published compatibility routes, omit +`--accept-mongo-ids`, and run the canonical v1 smoke against the unprefixed +root paths. The Node reference intentionally keeps its `/api` routes for +backward compatibility while its canonical v1 directory surface is rooted at +`/`. diff --git a/conformance/runner/v1-smoke.mjs b/conformance/runner/v1-smoke.mjs index 66eb1e4..0647c90 100644 --- a/conformance/runner/v1-smoke.mjs +++ b/conformance/runner/v1-smoke.mjs @@ -29,6 +29,13 @@ const requestJson = async (url, init = {}, expectedStatus = 200) => { return { response, body }; }; +const assertMediaType = ({ response }, expected, label) => { + const contentType = response.headers.get("content-type") || ""; + if (!contentType.startsWith(expected)) { + fail(`${label} returned ${contentType}, expected ${expected}`); + } +}; + const canonicalize = (value) => { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; @@ -68,6 +75,7 @@ const signHttpRequest = ({ host: parsed.host, date, "content-digest": contentDigest, + "content-type": "application/json", "@request-target": parsed.pathname + parsed.search, }; const base = [ @@ -121,6 +129,7 @@ const main = async () => { const keyid = `${target}/keys/${keyId}`; const discovery = await requestJson(`${target}/.well-known/htmltrust`); + assertMediaType(discovery, "application/htmltrust-directory+json", "discovery"); if (!discovery.body.supportedProfiles?.includes("htmltrust-signature-v1")) { fail("discovery does not advertise htmltrust-signature-v1", discovery.body); } @@ -131,12 +140,14 @@ const main = async () => { await requestJson(`${target}/endorsements/000000000000000000000000`, { method: "DELETE" }, 404); const keyDocument = await requestJson(keyid); + assertMediaType(keyDocument, "application/htmltrust-key+json", "key document"); if (keyDocument.body.kid !== keyid || keyDocument.body.publicKeyPem !== undefined) { fail("root key document has the wrong kid or exposes the PEM compatibility field", keyDocument.body); } const reputation = await requestJson( `${target}/signers/${encodeURIComponent(keyId)}/reputation`, ); + assertMediaType(reputation, "application/json", "signer reputation"); if (reputation.body.keyid !== keyId || typeof reputation.body.score !== "number") { fail("root signer reputation has the wrong key identifier or score", reputation.body); } @@ -191,6 +202,7 @@ const main = async () => { privateKey: signingKey.privateKey, nonce: "content-valid", }); + assertMediaType(submitted, "application/htmltrust-content+json", "content submission"); if (submitted.response.headers.get("location") !== `/content/${encodeURIComponent(contentHash)}`) { fail("POST /content returned the wrong Location header", submitted.response.headers.get("location")); } @@ -204,7 +216,8 @@ const main = async () => { fail("POST /content returned an incomplete v1 signer record", submitted.body); } - await requestJson(`${target}/content/${encodeURIComponent(contentHash)}`); + const content = await requestJson(`${target}/content/${encodeURIComponent(contentHash)}`); + assertMediaType(content, "application/htmltrust-content+json", "content record"); const badLocation = { ...submission, location: "https://example.com/research/other" }; const rejectedLocation = await signedPost({ @@ -253,6 +266,11 @@ const main = async () => { includeAlg: false, nonce: "content-missing-alg", }, + { + name: "additional covered component", + components: ["@method", "@target-uri", "host", "date", "content-digest", "content-type"], + nonce: "content-additional-component", + }, { name: "padded signature bytes", padded: true, @@ -291,13 +309,14 @@ const main = async () => { signingKey.privateKey, )), }; - await signedPost({ + const submittedEndorsement = await signedPost({ path: "/endorsements", document: endorsement, keyid, privateKey: signingKey.privateKey, nonce: "endorsement-valid", }); + assertMediaType(submittedEndorsement, "application/htmltrust-endorsement+json", "endorsement submission"); await signedPost({ path: "/endorsements", document: endorsement, @@ -308,6 +327,7 @@ const main = async () => { const endorsements = await requestJson( `${target}/content/${encodeURIComponent(contentHash)}/endorsements`, ); + assertMediaType(endorsements, "application/htmltrust-endorsement+json", "endorsement listing"); if (!Array.isArray(endorsements.body) || endorsements.body.length !== 1) { fail("root content endorsement listing did not return the stored document", endorsements.body); } diff --git a/openapi.yaml b/openapi.yaml index 95b2670..2d1a9b8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -220,6 +220,7 @@ components: id: type: string description: Opaque public identifier for the key document + pattern: "^(k_[A-Za-z0-9_-]{20,64}|[0-9A-Fa-f]{24})$" authorId: type: string format: uuid @@ -757,6 +758,7 @@ paths: required: true schema: type: string + pattern: "^(k_[A-Za-z0-9_-]{20,64}|[0-9A-Fa-f]{24})$" responses: "200": description: Key document @@ -1029,7 +1031,7 @@ paths: schema: $ref: "#/components/schemas/Problem" - /authors: + /api/authors: post: tags: - Authors @@ -1169,7 +1171,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /authors/{authorId}: + /api/authors/{authorId}: get: tags: - Authors @@ -1301,7 +1303,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /authors/{authorId}/public-key: + /api/authors/{authorId}/public-key: get: tags: - Authors @@ -1330,7 +1332,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /content/sign: + /api/content/sign: post: tags: - Content @@ -1408,7 +1410,7 @@ paths: schema: $ref: "#/components/schemas/Problem" - /content/verify: + /api/content/verify: post: tags: - Content @@ -1480,7 +1482,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /claims: + /api/claims: post: tags: - Claims @@ -1580,7 +1582,7 @@ paths: schema: $ref: "#/components/schemas/Problem" - /claims/{claimId}: + /api/claims/{claimId}: get: tags: - Claims @@ -1609,7 +1611,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /directory/keys: + /api/directory/keys: get: tags: - Directory @@ -1692,7 +1694,7 @@ paths: schema: $ref: "#/components/schemas/Problem" - /directory/keys/{keyId}/reputation: + /api/directory/keys/{keyId}/reputation: get: tags: - Directory @@ -1706,6 +1708,7 @@ paths: description: Opaque public key id, or a legacy ObjectId accepted by the compatibility route. schema: type: string + pattern: "^(k_[A-Za-z0-9_-]{20,64}|[0-9A-Fa-f]{24})$" responses: "200": description: Key reputation @@ -1720,7 +1723,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /directory/keys/{keyId}/report: + /api/directory/keys/{keyId}/report: post: tags: - Directory @@ -1736,6 +1739,7 @@ paths: description: Opaque public key id, or a legacy ObjectId accepted by the compatibility route. schema: type: string + pattern: "^(k_[A-Za-z0-9_-]{20,64}|[0-9A-Fa-f]{24})$" requestBody: required: true content: @@ -1790,7 +1794,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /directory/content: + /api/directory/content: get: tags: - Directory @@ -1870,7 +1874,7 @@ paths: schema: $ref: "#/components/schemas/Problem" - /directory/content/{contentHash}/occurrences: + /api/directory/content/{contentHash}/occurrences: get: tags: - Directory @@ -1927,7 +1931,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /directory/content/report: + /api/directory/content/report: post: tags: - Directory @@ -2006,7 +2010,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /endorsements: + /api/endorsements: post: tags: - Endorsements @@ -2075,3 +2079,7 @@ paths: application/problem+json: schema: $ref: "#/components/schemas/Problem" + + /endorsements: + post: + $ref: "#/paths/~1api~1endorsements/post" diff --git a/package-lock.json b/package-lock.json index 18847b8..74c4bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", @@ -26,8 +26,8 @@ }, "node_modules/@htmltrust/canonicalization": { "version": "0.3.0", - "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", - "integrity": "sha512-oeZyQepl+Xub2j0Q+i84jxL21L6z7l/6CHtavTAarqHivCAbA0OZGnIwdt265GMMqtN7co9l1dK6yGtNtBLZ8g==", + "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", + "integrity": "sha512-omTofsbv/S5XJBXzhOwirxpLD2uSkpVeAyINgQ+eQTi7qmjvWUoA6zTeHFbyQ5d7iXScmQO8/f66hDvfaeYdbA==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "parse5": "7.3.0" diff --git a/package.json b/package.json index b7625ce..95d82bb 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "author": "Jason Grey ", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", diff --git a/scripts/migrate-v1-indexes.js b/scripts/migrate-v1-indexes.js index 92bd93e..640fbed 100644 --- a/scripts/migrate-v1-indexes.js +++ b/scripts/migrate-v1-indexes.js @@ -14,8 +14,10 @@ * npm run migrate:v1 */ const mongoose = require('mongoose'); +const crypto = require('crypto'); const ContentSignature = require('../src/models/ContentSignature'); const Endorsement = require('../src/models/Endorsement'); +const Key = require('../src/models/Key'); const LEGACY_INDEXES = [ [ContentSignature, 'contentHash_1_domain_1_authorId_1'], @@ -23,6 +25,26 @@ const LEGACY_INDEXES = [ [Endorsement, 'endorsement_1_endorser_1'], ]; +const missingPublicId = { + $or: [{ publicId: { $exists: false } }, { publicId: null }], +}; + +const newPublicId = () => `k_${crypto.randomBytes(18).toString('base64url')}`; + +const backfillPublicIds = async () => { + let updated = 0; + const cursor = Key.collection.find(missingPublicId, { projection: { _id: 1 } }); + for await (const key of cursor) { + const result = await Key.collection.updateOne( + { _id: key._id, ...missingPublicId }, + { $set: { publicId: newPublicId() } }, + ); + updated += result.modifiedCount; + } + if (updated > 0) console.log(`backfilled ${updated} key public id(s)`); + return updated; +}; + const dropIfPresent = async (model, name) => { let indexes; try { @@ -46,11 +68,13 @@ const migrate = async () => { // different options, which is the reason this migration exists. await mongoose.connect(mongoUri, { autoIndex: false }); try { + await backfillPublicIds(); for (const [model, indexName] of LEGACY_INDEXES) { await dropIfPresent(model, indexName); } // Recreate the current partial/non-unique definitions without touching // unrelated indexes owned by an operator or another application. + await Key.createIndexes(); await ContentSignature.createIndexes(); await Endorsement.createIndexes(); console.log('v1 index migration complete'); @@ -66,4 +90,4 @@ if (require.main === module) { }); } -module.exports = { LEGACY_INDEXES, dropIfPresent, migrate }; +module.exports = { LEGACY_INDEXES, backfillPublicIds, dropIfPresent, migrate }; diff --git a/src/models/Key.js b/src/models/Key.js index 76e4c33..9695812 100644 --- a/src/models/Key.js +++ b/src/models/Key.js @@ -18,7 +18,12 @@ const KeySchema = new mongoose.Schema({ // alias until they are rewritten. publicId: { type: String, - required: true, + // New keys always receive the default below. Legacy hydrated keys may + // still omit this field until migrate:v1 backfills them, so updates to + // those rows must remain valid during the migration window. + required: function publicIdRequired() { + return this.isNew || this.publicId !== undefined; + }, unique: true, sparse: true, index: true, diff --git a/src/server.js b/src/server.js index ba5fbbc..17b6c04 100644 --- a/src/server.js +++ b/src/server.js @@ -10,10 +10,12 @@ dotenv.config(); const { problem } = require('./utils/htmltrustProtocol'); const { assertConfigured } = require('./utils/apiKeys'); +const { assertDirectoryBaseUrl } = require('./utils/directoryUrl'); // Fail fast on a misconfigured production deployment rather than at the first // request that happens to need the missing secret. assertConfigured(); +assertDirectoryBaseUrl(); // Database connection const connectDB = require('./config/db'); diff --git a/src/utils/directoryUrl.js b/src/utils/directoryUrl.js index f199e0e..9e302bc 100644 --- a/src/utils/directoryUrl.js +++ b/src/utils/directoryUrl.js @@ -5,9 +5,16 @@ const directoryBaseUrl = (req, env = process.env) => { if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error('DIRECTORY_BASE_URL must use http or https'); } + if (env.NODE_ENV === 'production' && url.protocol !== 'https:') { + throw new Error('DIRECTORY_BASE_URL must use https in production'); + } return url.href.replace(/\/$/, ''); } + if (env.NODE_ENV === 'production') { + throw new Error('DIRECTORY_BASE_URL must be set in production'); + } + // The request-origin fallback keeps local development and the conformance // runner on HTTP. Deployments serving canonical key URLs must set an // explicit HTTPS DIRECTORY_BASE_URL. @@ -19,4 +26,9 @@ const directoryKeyUrl = (req, keyId, env = process.env) => const publicKeyId = (key) => String(key.publicId || key._id); -module.exports = { directoryBaseUrl, directoryKeyUrl, publicKeyId }; +const assertDirectoryBaseUrl = (env = process.env) => { + if (env.NODE_ENV !== 'production') return; + directoryBaseUrl(null, env); +}; + +module.exports = { assertDirectoryBaseUrl, directoryBaseUrl, directoryKeyUrl, publicKeyId }; diff --git a/test/canonicalRoutes.test.js b/test/canonicalRoutes.test.js index 1adc10d..7d80b21 100644 --- a/test/canonicalRoutes.test.js +++ b/test/canonicalRoutes.test.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const serverSource = fs.readFileSync(require.resolve('../src/server'), 'utf8'); const endorsementSource = fs.readFileSync(require.resolve('../src/controllers/endorsementController'), 'utf8'); +const openapiSource = fs.readFileSync(require.resolve('../openapi.yaml'), 'utf8'); test('canonical endorsement creation returns a canonical resource Location', () => { assert.match(endorsementSource, /\.location\(`\/endorsements\/\$\{stored\._id\}`\)/); @@ -15,3 +16,31 @@ test('canonical root exposes only the normative endorsement write', () => { assert.doesNotMatch(serverSource, /app\.get\(\s*['"]\/endorsements['"]/s); assert.doesNotMatch(serverSource, /app\.delete\(\s*['"]\/endorsements\/:id['"]/s); }); + +test('canonical root route and OpenAPI path sets match the seven normative operations', () => { + for (const pattern of [ + /app\.get\(\s*['"]\/.well-known\/htmltrust['"]/s, + /app\.get\(\s*['"]\/content\/:contentHash['"]/s, + /app\.post\(\s*['"]\/content['"]/s, + /app\.get\(\s*['"]\/content\/:contentHash\/endorsements['"]/s, + /app\.post\(\s*['"]\/endorsements['"]/s, + /app\.get\(\s*['"]\/keys\/:id['"]/s, + /app\.get\(\s*['"]\/signers\/:id\/reputation['"]/s, + ]) { + assert.match(serverSource, pattern); + } + + const paths = [...openapiSource.matchAll(/^ (\/[^:]+):$/gm)].map((match) => match[1]); + assert.deepEqual( + paths.filter((path) => !path.startsWith('/api/')), + [ + '/.well-known/htmltrust', + '/keys/{id}', + '/signers/{id}/reputation', + '/content', + '/content/{hash}', + '/content/{hash}/endorsements', + '/endorsements', + ], + ); +}); diff --git a/test/directoryUrl.test.js b/test/directoryUrl.test.js index d3551cc..4b9a49f 100644 --- a/test/directoryUrl.test.js +++ b/test/directoryUrl.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { directoryBaseUrl, directoryKeyUrl } = require('../src/utils/directoryUrl'); +const { assertDirectoryBaseUrl, directoryBaseUrl, directoryKeyUrl } = require('../src/utils/directoryUrl'); const request = { protocol: 'http', @@ -27,3 +27,26 @@ test('directory URLs reject unsupported configured schemes', () => { /must use http or https/, ); }); + +test('production directory URLs require an explicit HTTPS base URL', () => { + assert.throws( + () => assertDirectoryBaseUrl({ NODE_ENV: 'production' }), + /must be set in production/, + ); + assert.throws( + () => assertDirectoryBaseUrl({ NODE_ENV: 'production', DIRECTORY_BASE_URL: 'http://directory.example' }), + /must use https in production/, + ); + assert.doesNotThrow(() => assertDirectoryBaseUrl({ + NODE_ENV: 'production', + DIRECTORY_BASE_URL: 'https://directory.example', + })); +}); + +test('development keeps the HTTP request-origin fallback', () => { + assert.equal(directoryBaseUrl(request, { NODE_ENV: 'development' }), 'http://localhost:3000'); + assert.equal(directoryBaseUrl(request, { + NODE_ENV: 'development', + DIRECTORY_BASE_URL: 'http://directory.example', + }), 'http://directory.example'); +}); diff --git a/test/keyDocument.test.js b/test/keyDocument.test.js index f1f9f3c..e2b3a3b 100644 --- a/test/keyDocument.test.js +++ b/test/keyDocument.test.js @@ -61,5 +61,7 @@ test("legacy hydrated keys keep their ObjectId URL compatibility alias", () => { algorithm: "ed25519", }); assert.equal(key.publicId, undefined); + key.trustScore = 0.75; + assert.equal(key.validateSync(), undefined, "legacy key updates must remain valid before migration"); assert.equal(publicKeyId(key), legacyId); }); From db095171cd9c01cc4f710e87c4fea209052db642 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 05:38:14 -0500 Subject: [PATCH 3/3] fix(server): document compatibility endorsements --- openapi.yaml | 183 ++++++++++++++++++++++++++++++++++++++- src/models/Key.js | 13 ++- test/keyDocument.test.js | 25 +++--- 3 files changed, 203 insertions(+), 18 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 2d1a9b8..b879ac7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2011,6 +2011,61 @@ paths: $ref: "#/components/schemas/Error" /api/endorsements: + get: + tags: + - Endorsements + summary: List endorsements for a content hash (compatibility) + description: | + Returns all endorsements on file for a given content hash. This + legacy `/api` route is public and remains available for pre-v1 + clients. Clients MUST verify each endorsement locally. + operationId: listEndorsementsCompatibility + parameters: + - name: content-hash + in: query + required: true + description: The content hash to look up endorsements for (for example, `sha256:...`). + schema: + type: string + responses: + "200": + description: Array of endorsements + headers: + Cache-Control: + schema: { type: string } + ETag: + schema: { type: string } + Vary: + schema: { type: string } + content: + application/htmltrust-endorsement+json: + schema: + type: array + items: + $ref: "#/components/schemas/Endorsement" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Endorsement" + "400": + description: Invalid input + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "406": + description: Requested representation is not supported + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "500": + description: Directory could not read endorsements + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" post: tags: - Endorsements @@ -2018,13 +2073,13 @@ paths: description: | Stores a structured endorsement document. The signed payload is the JSON canonicalization of the endorsement document with `signature` - omitted. The canonical endpoint requires RFC 9421 authentication; - the `/api/endorsements` compatibility route also accepts the legacy - API-key scheme. + omitted. The compatibility route accepts either the RFC 9421 + signature or the legacy general API key scheme. operationId: createEndorsement security: - HttpSignatureInput: [] HttpMessageSignature: [] + - GeneralApiKey: [] requestBody: required: true content: @@ -2080,6 +2135,126 @@ paths: schema: $ref: "#/components/schemas/Problem" + /api/endorsements/{id}: + delete: + tags: + - Endorsements + summary: Delete an endorsement (compatibility) + description: | + Removes an endorsement from the legacy `/api` surface. The route + accepts an RFC 9421 signature from the endorser or an administrator + API key. The route's controller also checks that a signed caller is + the endorser before deleting the record. + operationId: deleteEndorsementCompatibility + security: + - HttpSignatureInput: [] + HttpMessageSignature: [] + - AdminApiKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Endorsement deleted + "400": + description: Invalid endorsement identifier or request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "401": + description: Unauthorized + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "403": + description: The authenticated key is not the endorser + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "404": + description: Endorsement not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "500": + description: Directory could not delete the endorsement + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + /endorsements: post: - $ref: "#/paths/~1api~1endorsements/post" + tags: + - Endorsements + summary: Submit a signed endorsement for storage + description: | + Stores a structured endorsement document. The signed payload is the + JSON canonicalization of the endorsement document with `signature` + omitted. The canonical endpoint requires the exact RFC 9421 HTMLTrust + v1 signature profile and has no API-key fallback. + operationId: createCanonicalEndorsement + security: + - HttpSignatureInput: [] + HttpMessageSignature: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Endorsement" + application/htmltrust-endorsement+json: + schema: + $ref: "#/components/schemas/Endorsement" + responses: + "201": + description: Endorsement stored + headers: + Cache-Control: + schema: { type: string } + Location: + schema: { type: string } + content: + application/htmltrust-endorsement+json: + schema: + $ref: "#/components/schemas/Endorsement" + application/json: + schema: + $ref: "#/components/schemas/Endorsement" + "400": + description: Invalid input + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "401": + description: Unauthorized + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "406": + description: Requested representation is not supported + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "415": + description: Request body media type is not supported + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "500": + description: Verified endorsement could not be stored + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" diff --git a/src/models/Key.js b/src/models/Key.js index 9695812..2e43a2e 100644 --- a/src/models/Key.js +++ b/src/models/Key.js @@ -19,10 +19,17 @@ const KeySchema = new mongoose.Schema({ publicId: { type: String, // New keys always receive the default below. Legacy hydrated keys may - // still omit this field until migrate:v1 backfills them, so updates to - // those rows must remain valid during the migration window. + // still omit or contain null here until migrate:v1 backfills them, so + // updates to those rows must remain valid during the migration window. required: function publicIdRequired() { - return this.isNew || this.publicId !== undefined; + return this.isNew; + }, + immutable: true, + validate: { + validator(value) { + return !this.isNew || /^k_[A-Za-z0-9_-]{20,64}$/.test(value); + }, + message: 'publicId must be an opaque k_ identifier', }, unique: true, sparse: true, diff --git a/test/keyDocument.test.js b/test/keyDocument.test.js index e2b3a3b..e89c177 100644 --- a/test/keyDocument.test.js +++ b/test/keyDocument.test.js @@ -52,16 +52,19 @@ test("new directory keys receive an opaque public identifier", () => { assert.notEqual(key.publicId, String(key._id)); }); -test("legacy hydrated keys keep their ObjectId URL compatibility alias", () => { +test("legacy hydrated keys keep their ObjectId URL compatibility alias and update safely", () => { const legacyId = "507f1f77bcf86cd799439011"; - const key = Key.hydrate({ - _id: legacyId, - authorId: "507f1f77bcf86cd799439012", - publicKey: "legacy", - algorithm: "ed25519", - }); - assert.equal(key.publicId, undefined); - key.trustScore = 0.75; - assert.equal(key.validateSync(), undefined, "legacy key updates must remain valid before migration"); - assert.equal(publicKeyId(key), legacyId); + for (const publicId of [undefined, null]) { + const key = Key.hydrate({ + _id: legacyId, + authorId: "507f1f77bcf86cd799439012", + publicKey: "legacy", + algorithm: "ed25519", + ...(publicId === null ? { publicId } : {}), + }); + assert.equal(key.publicId, publicId); + key.trustScore = 0.75; + assert.equal(key.validateSync(), undefined, "legacy key updates must remain valid before migration"); + assert.equal(publicKeyId(key), legacyId); + } });