From adb0c76634c053349987d7b2c62c3586438c7c16 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 13:43:23 -0500 Subject: [PATCH 1/3] feat(directory): federate signer opinions --- README.md | 32 +++- openapi.yaml | 179 +++++++++++++++++++-- scripts/migrate-v1-indexes.js | 12 +- src/controllers/directoryController.js | 183 ++++++++++++++------- src/controllers/signerVoteController.js | 90 +++++++++++ src/models/SignerReport.js | 29 ++++ src/models/SignerReputation.js | 21 +++ src/models/SignerVote.js | 14 ++ src/routes/directory.js | 20 ++- src/services/signerOpinion.js | 36 +++++ test/indexes.test.js | 11 +- test/signerOpinionIntegration.test.js | 68 ++++++++ test/signerReport.test.js | 180 +++++++++++++++++++++ test/signerReputation.test.js | 18 +++ test/signerVote.test.js | 201 ++++++++++++++++++++++++ 15 files changed, 1020 insertions(+), 74 deletions(-) create mode 100644 src/controllers/signerVoteController.js create mode 100644 src/models/SignerReport.js create mode 100644 src/models/SignerReputation.js create mode 100644 src/models/SignerVote.js create mode 100644 src/services/signerOpinion.js create mode 100644 test/signerOpinionIntegration.test.js create mode 100644 test/signerReport.test.js create mode 100644 test/signerReputation.test.js create mode 100644 test/signerVote.test.js diff --git a/README.md b/README.md index 76c52bb..091b6ca 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,24 @@ Set `MONGO_URI` in `.env` to the database used by the server. The default develo `npm run dev` uses nodemon. Use `npm start` for a regular Node process. +To start the documented MongoDB 7 development dependency with Docker and wait +until it accepts connections: + +```sh +docker rm -f htmltrust-mongo >/dev/null 2>&1 || true +docker run -d --name htmltrust-mongo -p 127.0.0.1:27017:27017 mongo:7 +until docker exec htmltrust-mongo mongosh --quiet --eval "db.adminCommand('ping').ok" >/dev/null 2>&1; do sleep 1; done +MONGO_URI=mongodb://localhost:27017/content-signing npm run dev +``` + +Remove that container when finished with `docker rm -f htmltrust-mongo`. The +repository's `npm run conformance:docker` command performs the same MongoDB 7 +startup and readiness check for a disposable conformance run. + ### Run tests -Unit tests use Node's built-in test runner and require no database: +Most unit tests use Node's built-in test runner without a database. The signer +opinion integration tests use `mongodb-memory-server`: ```sh npm test @@ -52,7 +67,7 @@ npm --prefix conformance/runner ci npm run conformance ``` -The conformance command runs every fixture and the canonical v1 smoke checks. Set `SERVER_PORT` or `MONGO_PORT` when the defaults are occupied. The first run can download a MongoDB binary for `mongodb-memory-server`. +The conformance command runs every fixture and the canonical v1 smoke checks. Set `SERVER_PORT` or `MONGO_PORT` when the defaults are occupied. The first test or conformance run can download a MongoDB binary for `mongodb-memory-server`; that requires network access. Its cache can be relocated with `MONGOMS_DOWNLOAD_DIR` when a persistent cache is preferred. ## Test in Docker @@ -64,6 +79,10 @@ The repository script runs unit and conformance tests inside a disposable Node 2 This is the lowest-dependency test path: it requires Docker and a shell. Set `HTMLTRUST_TEST_IMAGE` to use another compatible Node image. The older `npm run conformance:docker` command remains available for developers who want to run the conformance runner with a host Node process and a Docker MongoDB container. +The end-to-end repository uses Docker Compose, not this server test script. Check +that Compose v2 is installed with `docker compose version` before running that +workflow. + ## Deployment 1. Provision MongoDB 7, create a database for this service, and set `MONGO_URI` with credentials appropriate for the deployment. @@ -116,7 +135,7 @@ The following routes retain the original `/api` prefix and response shapes: | `/api/authors` | Create and list authors; read, update, or delete an author; read its public key | | `/api/content` | Sign, verify, submit, and retrieve content; register occurrences; list content endorsements | | `/api/claims` | Create, list, read, update, and delete claim types | -| `/api/directory` | Search keys and content; read key reputation and occurrences; report keys or content | +| `/api/directory` | Search keys and content; read key reputation and occurrences; report keys, signers, or content; submit signer votes | | `/api/endorsements` | List, submit, and delete endorsements | | `/api/votes` | Submit votes; list votes; read vote statistics; delete a vote | | `/api/keys` and `/api/signers` | Read the compatibility key and signer-reputation documents | @@ -124,6 +143,10 @@ The following routes retain the original `/api` prefix and response shapes: The compatibility routes use the API-key headers described below for their original protected operations. Content and endorsement submission also accept the RFC 9421 flow, while canonical root submissions require that flow. The OpenAPI file defines the long-term v1 resource shapes and media types; this implementation currently keeps author, claim-management, directory-search, reporting, and voting operations under `/api`. +`POST /api/directory/signer-votes` accepts `{ "signerId": "", "voteType": "TRUST|DISTRUST", "reason": "" }`. The signer ID may name a key hosted by another directory. An RFC 9421 signature keys the vote to its resolved `keyid`; the development `X-API-KEY` fallback uses one shared voter identity. A voter can submit one current vote per signer. The first submission returns `201`, and a repeat or vote change returns `200`. Reputation reads derive the current vote contribution from these records. + +`POST /api/directory/signer-reports` records a report against an exact local or foreign keyid. Send an `Idempotency-Key` header when a caller may retry the request. A repeat with the same authenticated reporter, signer ID, and idempotency key returns the original report with `200`; a new report returns `201`. Reputation reads derive report counts and score adjustments from stored reports. The original local author/content vote API remains available at `POST /api/votes`. + ### Deprecated route `POST /api/content/verify` is deprecated and returns `Deprecation: true` as specified by RFC 9745. Signature verification belongs in the client, using a public key retrieved from the directory and a local cryptographic API such as `SubtleCrypto`. The route remains for legacy clients and will be removed in a future major version. There is no canonical root `/content/verify` route. @@ -176,7 +199,10 @@ openapi.yaml API contract and response schemas ## Related repositories - [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec) +- [Canonicalization library](https://github.com/HTMLTrust/htmltrust-canonicalization), pinned to `5e51040dcaaf50935e245702bdefbc18a1d542ce` by this package - [Browser reference](https://github.com/HTMLTrust/htmltrust-browser-reference) +- [Browser client](https://github.com/HTMLTrust/htmltrust-browser-client) +- [End-to-end harness](https://github.com/HTMLTrust/htmltrust-e2e) - [CMS reference](https://github.com/HTMLTrust/htmltrust-cms-reference) - [Project website](https://github.com/HTMLTrust/htmltrust-website) diff --git a/openapi.yaml b/openapi.yaml index b879ac7..9621330 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -391,6 +391,14 @@ components: type: number minimum: 0 maximum: 1 + reports: + type: integer + minimum: 0 + description: Number of reports held by this directory for the signer. + verifiedSignatures: + type: integer + minimum: 0 + description: Number of signatures this directory has verified for the signer. asOf: type: string format: date-time @@ -401,6 +409,34 @@ components: type: string format: uri + SignerVoteResult: + type: object + required: [voteId, voterId, signerId, voteType] + properties: + voteId: { type: string } + voterId: { type: string } + signerId: { type: string, maxLength: 2048 } + voteType: + type: string + enum: [TRUST, DISTRUST] + previousVoteType: + type: [string, "null"] + enum: [TRUST, DISTRUST, null] + updatedAt: + type: string + format: date-time + + SignerReportResult: + type: object + required: [reportId, status] + properties: + reportId: + type: string + format: uuid + status: + type: string + enum: [PENDING, UNDER_REVIEW, ACCEPTED, REJECTED] + Claim: type: object required: @@ -1740,12 +1776,21 @@ paths: schema: type: string pattern: "^(k_[A-Za-z0-9_-]{20,64}|[0-9A-Fa-f]{24})$" + - name: Idempotency-Key + in: header + required: false + description: Reuse the same visible ASCII value when retrying one report. + schema: + type: string + minLength: 1 + maxLength: 128 requestBody: required: true content: application/json: schema: type: object + additionalProperties: false required: - reason properties: @@ -1755,10 +1800,12 @@ paths: description: Reason for reporting details: type: string + maxLength: 4096 description: Additional details about the report evidence: type: string format: uri + maxLength: 2048 description: URL to evidence supporting the report responses: "201": @@ -1766,15 +1813,13 @@ paths: content: application/json: schema: - type: object - properties: - reportId: - type: string - format: uuid - status: - type: string - enum: [PENDING, UNDER_REVIEW, ACCEPTED, REJECTED] - default: PENDING + $ref: "#/components/schemas/SignerReportResult" + "200": + description: A retry with the same idempotency key returned the existing report + content: + application/json: + schema: + $ref: "#/components/schemas/SignerReportResult" "400": description: Invalid input content: @@ -1794,6 +1839,122 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/directory/signer-reports: + post: + tags: + - Directory + summary: Report a signer by exact keyid + description: Records this directory's opinion about a signer that may be published by another directory. + operationId: reportSigner + security: + - GeneralApiKey: [] + parameters: + - name: Idempotency-Key + in: header + required: false + description: Reuse the same visible ASCII value when retrying one report. + schema: + type: string + minLength: 1 + maxLength: 128 + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [signerId, reason] + properties: + signerId: + type: string + maxLength: 2048 + description: Exact signer keyid, including a foreign directory URL when applicable. + reason: + type: string + enum: [IMPERSONATION, MISINFORMATION, SPAM, OTHER] + details: + type: string + maxLength: 4096 + evidence: + type: string + format: uri + maxLength: 2048 + responses: + "201": + description: Report submitted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/SignerReportResult" + "200": + description: A retry with the same idempotency key returned the existing report + content: + application/json: + schema: + $ref: "#/components/schemas/SignerReportResult" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Unauthorized + + /api/directory/signer-votes: + post: + tags: + - Directory + summary: Record an authenticated opinion about a signer + description: Stores one current vote per authenticated voter and exact signer keyid. + operationId: submitSignerVote + security: + - HttpSignatureInput: [] + HttpMessageSignature: [] + - GeneralApiKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [signerId, voteType] + properties: + signerId: + type: string + minLength: 1 + maxLength: 2048 + description: Exact signer keyid, including a foreign directory URL when applicable. + voteType: + type: string + enum: [TRUST, DISTRUST] + reason: + type: string + maxLength: 4096 + responses: + "201": + description: First vote recorded + content: + application/json: + schema: + $ref: "#/components/schemas/SignerVoteResult" + "200": + description: Existing vote updated or repeated idempotently + content: + application/json: + schema: + $ref: "#/components/schemas/SignerVoteResult" + "400": + description: Invalid signer identifier, vote type, or reason + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "401": + description: Authentication required + /api/directory/content: get: tags: diff --git a/scripts/migrate-v1-indexes.js b/scripts/migrate-v1-indexes.js index 640fbed..9d69cec 100644 --- a/scripts/migrate-v1-indexes.js +++ b/scripts/migrate-v1-indexes.js @@ -18,6 +18,10 @@ const crypto = require('crypto'); const ContentSignature = require('../src/models/ContentSignature'); const Endorsement = require('../src/models/Endorsement'); const Key = require('../src/models/Key'); +const SignerReport = require('../src/models/SignerReport'); +const SignerVote = require('../src/models/SignerVote'); + +const CURRENT_INDEX_MODELS = [Key, ContentSignature, Endorsement, SignerVote, SignerReport]; const LEGACY_INDEXES = [ [ContentSignature, 'contentHash_1_domain_1_authorId_1'], @@ -74,9 +78,9 @@ const migrate = async () => { } // 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(); + for (const model of CURRENT_INDEX_MODELS) { + await model.createIndexes(); + } console.log('v1 index migration complete'); } finally { await mongoose.disconnect(); @@ -90,4 +94,4 @@ if (require.main === module) { }); } -module.exports = { LEGACY_INDEXES, backfillPublicIds, dropIfPresent, migrate }; +module.exports = { CURRENT_INDEX_MODELS, LEGACY_INDEXES, backfillPublicIds, dropIfPresent, migrate }; diff --git a/src/controllers/directoryController.js b/src/controllers/directoryController.js index 4891388..4841f46 100644 --- a/src/controllers/directoryController.js +++ b/src/controllers/directoryController.js @@ -1,7 +1,11 @@ +const crypto = require('crypto'); const Key = require('../models/Key'); const Author = require('../models/Author'); const ContentSignature = require('../models/ContentSignature'); const ContentOccurrence = require('../models/ContentOccurrence'); +const SignerReputation = require('../models/SignerReputation'); +const SignerReport = require('../models/SignerReport'); +const { applySignerOpinion, signerOpinion } = require('../services/signerOpinion'); const { detailFor, keyDocumentFor, @@ -70,6 +74,28 @@ const findDirectoryKey = async (value) => { return Key.findOne({ publicId: id }); }; +/** Resolve only a signer identifier that belongs to this directory. */ +const findLocalSignerKey = async (req, signerId) => { + if (OPAQUE_KEY_ID.test(signerId) || MONGO_KEY_ID.test(signerId)) { + const direct = await findDirectoryKey(signerId); + if (direct) return direct; + if (MONGO_KEY_ID.test(signerId)) return Key.findOne({ authorId: signerId }); + return null; + } + + const keyId = keyIdFromSignerId(signerId); + if (!keyId) return null; + const key = await findDirectoryKey(keyId); + if (!key) return null; + return directoryKeyUrl(req, publicKeyId(key)) === signerId ? key : null; +}; + +const reportActorIdentity = (req) => { + if (req.htmltrustActor?.keyid) return `key:${req.htmltrustActor.keyid}`; + if (req.author?._id) return `author:${req.author._id}`; + return 'shared-api-key'; +}; + exports.discovery = async (req, res) => { res .type(negotiatedType(req, 'application/htmltrust-directory+json')) @@ -121,36 +147,33 @@ exports.getKeyDocument = async (req, res) => { }; exports.getSignerReputation = async (req, res) => { - let signerId; - try { - signerId = decodeURIComponent(req.params.id); - } catch (error) { - return problem(res, 400, 'Invalid signer id', 'The signer id is not valid percent-encoding'); - } + // Express has already decoded the route parameter exactly once. Decoding + // it again would change a legitimate `%2F` sequence inside the keyid. + const signerId = req.params.id; try { - const keyId = keyIdFromSignerId(signerId); - let key = null; - if (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 }); - } - if (!key) { - return problem(res, 404, 'Signer not found', 'No local signer reputation exists for the requested id', { + const [key, external] = await Promise.all([ + findLocalSignerKey(req, signerId), + SignerReputation.findOne({ signerId }).lean(), + ]); + const opinionSignerId = key ? directoryKeyUrl(req, publicKeyId(key)) : signerId; + const opinion = await signerOpinion(opinionSignerId); + if (!key && !external && opinion.voteCount === 0 && opinion.reportCount === 0) { + return problem(res, 404, 'Signer not found', 'No signer reputation exists for the requested id', { keyid: signerId, }); } + const reputation = key || external || { trustScore: 0.5, reports: 0, verifiedSignatures: 0 }; + const effective = applySignerOpinion(reputation, opinion); + res.status(200).json({ keyid: signerId, - score: key.trustScore, - asOf: (key.updatedAt || key.createdAt || new Date()).toISOString(), - components: ['verified-signatures', 'reports'], + score: effective.score, + reports: effective.reports, + verifiedSignatures: effective.verifiedSignatures, + asOf: (reputation.updatedAt || reputation.createdAt || new Date()).toISOString(), + components: ['verified-signatures', 'reports', 'votes'], methodology: `${baseDirectoryUrl(req)}methodology/reputation-v1` }); } catch (error) { @@ -283,11 +306,13 @@ exports.getKeyReputation = async (req, res) => { }); } + const signerId = directoryKeyUrl(req, publicKeyId(key)); + const effective = applySignerOpinion(key, await signerOpinion(signerId)); res.status(200).json({ keyId: publicKeyId(key), - trustScore: key.trustScore, - verifiedSignatures: key.verifiedSignatures, - reports: key.reports, + trustScore: effective.score, + verifiedSignatures: effective.verifiedSignatures, + reports: effective.reports, lastUpdated: key.updatedAt || key.createdAt }); } catch (error) { @@ -300,48 +325,96 @@ exports.getKeyReputation = async (req, res) => { }; /** - * @desc Report a key + * @desc Report a signer, including a signer published by another directory * @route POST /api/directory/keys/:keyId/report + * @route POST /api/directory/signer-reports with { signerId } * @access Private (General API Key) */ -exports.reportKey = async (req, res) => { +exports.reportSigner = async (req, res) => { try { - const { reason, details, evidence } = req.body; - - // Find key - const key = await findDirectoryKey(req.params.keyId); - - if (!key) { - return res.status(404).json({ - code: 'NOT_FOUND', - message: 'Key not found' - }); + const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {}; + const unknownFields = Object.keys(body).filter( + (field) => !['signerId', 'reason', 'details', 'evidence'].includes(field), + ); + if (unknownFields.length > 0) { + return problem(res, 400, 'Invalid request body', 'Only signerId, reason, details, and evidence are accepted'); } - - // Increment reports count - key.reports += 1; - - // Adjust trust score based on reports - // This is a simple implementation - in a real system, you would have a more sophisticated algorithm - key.trustScore = Math.max(0, key.trustScore - 0.05); - - await key.save(); - - // In a real implementation, you would store the report details in a separate collection - - res.status(201).json({ - reportId: Date.now().toString(), // Placeholder for a real report ID - status: 'PENDING' + const { reason, details, evidence } = body; + if (req.params.keyId && Object.hasOwn(body, 'signerId')) { + return problem(res, 400, 'Invalid request body', 'The key report route takes its signer id from the path'); + } + const requestedSignerId = req.params.keyId || body.signerId; + if (typeof requestedSignerId !== 'string' || !requestedSignerId || requestedSignerId.trim() !== requestedSignerId) { + return problem(res, 400, 'Invalid signer id', 'signerId is required'); + } + if (requestedSignerId.length > 2048 || /[\u0000-\u001f\u007f]/.test(requestedSignerId)) { + return problem(res, 400, 'Invalid signer id', 'The signer id must be between 1 and 2048 characters without controls'); + } + if (!['IMPERSONATION', 'MISINFORMATION', 'SPAM', 'OTHER'].includes(reason)) { + return problem(res, 400, 'Invalid report reason', 'reason must be IMPERSONATION, MISINFORMATION, SPAM, or OTHER'); + } + if (details !== undefined && (typeof details !== 'string' || details.length > 4096)) { + return problem(res, 400, 'Invalid report details', 'details must be a string of at most 4096 characters'); + } + if (evidence !== undefined) { + if (typeof evidence !== 'string' || evidence.length > 2048) { + return problem(res, 400, 'Invalid report evidence', 'evidence must be an HTTP or HTTPS URL of at most 2048 characters'); + } + try { + const evidenceUrl = new URL(evidence); + if (!['http:', 'https:'].includes(evidenceUrl.protocol)) throw new Error('protocol'); + } catch { + return problem(res, 400, 'Invalid report evidence', 'evidence must be an HTTP or HTTPS URL of at most 2048 characters'); + } + } + + const localKey = await findLocalSignerKey(req, requestedSignerId); + if (req.params.keyId && !localKey) { + return problem(res, 404, 'Key not found', 'No local key exists for the requested id'); + } + const signerId = localKey ? directoryKeyUrl(req, publicKeyId(localKey)) : requestedSignerId; + const suppliedRequestKey = req.get('Idempotency-Key'); + if (suppliedRequestKey !== undefined && !/^[\x21-\x7e]{1,128}$/.test(suppliedRequestKey)) { + return problem(res, 400, 'Invalid idempotency key', 'Idempotency-Key must contain 1 to 128 visible ASCII characters'); + } + const reporterId = reportActorIdentity(req); + const requestKey = suppliedRequestKey || crypto.randomUUID(); + const filter = { reporterId, signerId, requestKey }; + let write; + try { + write = await SignerReport.updateOne(filter, { + $setOnInsert: { + reportId: crypto.randomUUID(), + reporterId, + signerId, + requestKey, + reason, + details, + evidence, + status: 'PENDING', + }, + }, { upsert: true }); + } catch (error) { + if (error?.code !== 11000) throw error; + write = { upsertedCount: 0 }; + } + const report = await SignerReport.findOne(filter).lean(); + if (!report) throw new Error('report could not be stored'); + + res.status(write.upsertedCount === 1 ? 201 : 200).json({ + reportId: report.reportId, + status: report.status, }); } catch (error) { console.error('Report key error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: detailFor(error) + return problem(res, 500, 'Directory write failure', 'The directory could not store the signer report', { + type: 'https://htmltrust.org/errors/storage-failure', }); } }; +exports.reportKey = exports.reportSigner; + /** * @desc Search signed content * @route GET /api/directory/content diff --git a/src/controllers/signerVoteController.js b/src/controllers/signerVoteController.js new file mode 100644 index 0000000..8511e48 --- /dev/null +++ b/src/controllers/signerVoteController.js @@ -0,0 +1,90 @@ +const SignerVote = require('../models/SignerVote'); +const { problem } = require('../utils/htmltrustProtocol'); + +const MAX_SIGNER_ID_LENGTH = 2048; +const MAX_REASON_LENGTH = 4096; + +/** + * Use the verified HTTP-signature key as the voter identity. The shared API + * key fallback remains available for the demo deployment and intentionally + * collapses those submissions to one voter, as the legacy vote API does. + */ +function voterIdentity(req) { + if (req.htmltrustActor?.keyid) return `key:${req.htmltrustActor.keyid}`; + if (req.author?._id) return `author:${req.author._id}`; + return 'shared-api-key'; +} + +function validateSignerId(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SIGNER_ID_LENGTH) { + return 'signerId must be between 1 and 2048 characters'; + } + if (value.trim() !== value) return 'signerId must not have surrounding whitespace'; + if (/[\u0000-\u001f\u007f]/.test(value)) { + return 'signerId contains a control character'; + } + return null; +} + +/** POST /api/directory/signer-votes */ +exports.submitSignerVote = async (req, res) => { + try { + if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) { + return problem(res, 400, 'Invalid request body', 'The request body must be a JSON object'); + } + const unknownFields = Object.keys(req.body).filter( + (field) => !['signerId', 'voteType', 'reason'].includes(field), + ); + if (unknownFields.length > 0) { + return problem(res, 400, 'Invalid request body', 'Only signerId, voteType, and reason are accepted'); + } + const { signerId, voteType, reason } = req.body || {}; + const signerError = validateSignerId(signerId); + if (signerError) return problem(res, 400, 'Invalid signer id', signerError); + if (voteType !== 'TRUST' && voteType !== 'DISTRUST') { + return problem(res, 400, 'Invalid vote type', 'voteType must be TRUST or DISTRUST'); + } + if (reason !== undefined && (typeof reason !== 'string' || reason.length > MAX_REASON_LENGTH)) { + return problem(res, 400, 'Invalid reason', 'reason must be a string of at most 4096 characters'); + } + + const voterId = voterIdentity(req); + const filter = { voterId, signerId }; + const update = { + $set: { voteType, ...(reason === undefined ? {} : { reason }) }, + $setOnInsert: { voterId, signerId }, + }; + let previous; + try { + previous = await SignerVote.findOneAndUpdate(filter, update, { + new: false, + upsert: true, + setDefaultsOnInsert: true, + }); + } catch (error) { + // Concurrent first votes can race at the unique index. Retry as a plain + // update so both callers complete and the last write becomes current. + if (error?.code !== 11000) throw error; + previous = await SignerVote.findOneAndUpdate(filter, update, { new: false }); + } + const vote = await SignerVote.findOne(filter); + if (!vote) throw new Error('signer vote could not be stored'); + const previousVoteType = previous?.voteType || null; + + return res.status(previousVoteType ? 200 : 201).json({ + voteId: String(vote._id), + voterId, + signerId, + voteType, + previousVoteType, + updatedAt: vote.updatedAt, + }); + } catch (error) { + console.error('Submit signer vote error:', error); + return problem(res, 500, 'Directory write failure', 'The directory could not store the signer vote', { + type: 'https://htmltrust.org/errors/storage-failure', + }); + } +}; + +exports.voterIdentity = voterIdentity; diff --git a/src/models/SignerReport.js b/src/models/SignerReport.js new file mode 100644 index 0000000..30f6f1d --- /dev/null +++ b/src/models/SignerReport.js @@ -0,0 +1,29 @@ +const mongoose = require('mongoose'); + +/** One authenticated report about an exact signer keyid. */ +const SignerReportSchema = new mongoose.Schema({ + reportId: { type: String, required: true, unique: true, immutable: true }, + reporterId: { type: String, required: true, maxlength: 2048 }, + signerId: { type: String, required: true, maxlength: 2048 }, + requestKey: { type: String, required: true, maxlength: 128 }, + reason: { + type: String, + enum: ['IMPERSONATION', 'MISINFORMATION', 'SPAM', 'OTHER'], + required: true, + }, + details: { type: String, maxlength: 4096 }, + evidence: { type: String, maxlength: 2048 }, + status: { + type: String, + enum: ['PENDING', 'UNDER_REVIEW', 'ACCEPTED', 'REJECTED'], + default: 'PENDING', + }, +}, { timestamps: true }); + +SignerReportSchema.index( + { reporterId: 1, signerId: 1, requestKey: 1 }, + { unique: true }, +); +SignerReportSchema.index({ signerId: 1 }); + +module.exports = mongoose.model('SignerReport', SignerReportSchema); diff --git a/src/models/SignerReputation.js b/src/models/SignerReputation.js new file mode 100644 index 0000000..62aa552 --- /dev/null +++ b/src/models/SignerReputation.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose'); + +/** + * A directory may evaluate a signer that is published by another directory. + * Keep that opinion separate from Key, which represents locally resolvable key + * material and therefore requires a local author relationship. + */ +const SignerReputationSchema = new mongoose.Schema({ + signerId: { + type: String, + required: true, + unique: true, + trim: true, + maxlength: 2048, + }, + trustScore: { type: Number, min: 0, max: 1, default: 0.5 }, + verifiedSignatures: { type: Number, min: 0, default: 0 }, + reports: { type: Number, min: 0, default: 0 }, +}, { timestamps: true }); + +module.exports = mongoose.model('SignerReputation', SignerReputationSchema); diff --git a/src/models/SignerVote.js b/src/models/SignerVote.js new file mode 100644 index 0000000..219c763 --- /dev/null +++ b/src/models/SignerVote.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); + +/** One authenticated voter's current opinion about one signer keyid. */ +const SignerVoteSchema = new mongoose.Schema({ + voterId: { type: String, required: true, trim: true, maxlength: 2048 }, + signerId: { type: String, required: true, trim: true, maxlength: 2048 }, + voteType: { type: String, enum: ['TRUST', 'DISTRUST'], required: true }, + reason: { type: String, maxlength: 4096 }, +}, { timestamps: true }); + +SignerVoteSchema.index({ voterId: 1, signerId: 1 }, { unique: true }); +SignerVoteSchema.index({ signerId: 1 }); + +module.exports = mongoose.model('SignerVote', SignerVoteSchema); diff --git a/src/routes/directory.js b/src/routes/directory.js index 5bb671e..0945921 100644 --- a/src/routes/directory.js +++ b/src/routes/directory.js @@ -4,13 +4,18 @@ const { searchPublicKeys, getKeyReputation, reportKey, + reportSigner, searchSignedContent, findContentOccurrences, reportContentMisuse } = require('../controllers/directoryController'); +const { submitSignerVote } = require('../controllers/signerVoteController'); const { - protectWithGeneralApiKey + protectWithGeneralApiKey, } = require('../middleware/auth'); +const { requireActorSignature } = require('../middleware/httpSignature'); + +const authenticatedVoter = requireActorSignature({ fallback: protectWithGeneralApiKey }); // Key routes router.route('/keys') @@ -22,6 +27,17 @@ router.route('/keys/:keyId/reputation') router.route('/keys/:keyId/report') .post(protectWithGeneralApiKey, reportKey); +// A directory can record an opinion about a keyid published by another +// directory. The body keeps the exact foreign signer identifier and avoids +// relying on encoded slashes in an Express path parameter. +router.route('/signer-reports') + .post(protectWithGeneralApiKey, reportSigner); + +// A signer vote is keyed by the authenticated HTTP-signature actor when one +// is present. The API-key fallback is retained for the local demo deployment. +router.route('/signer-votes') + .post(authenticatedVoter, submitSignerVote); + // Content routes router.route('/content') .get(searchSignedContent); @@ -32,4 +48,4 @@ router.route('/content/:contentHash/occurrences') router.route('/content/report') .post(protectWithGeneralApiKey, reportContentMisuse); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/services/signerOpinion.js b/src/services/signerOpinion.js new file mode 100644 index 0000000..a0559b5 --- /dev/null +++ b/src/services/signerOpinion.js @@ -0,0 +1,36 @@ +const SignerVote = require('../models/SignerVote'); +const SignerReport = require('../models/SignerReport'); + +/** Derive current opinion totals from immutable reports and current votes. */ +async function signerOpinion(signerId) { + const [voteTotals, reportCount] = await Promise.all([ + SignerVote.aggregate([ + { $match: { signerId } }, + { + $group: { + _id: null, + count: { $sum: 1 }, + balance: { + $sum: { $cond: [{ $eq: ['$voteType', 'TRUST'] }, 1, -1] }, + }, + }, + }, + ]), + SignerReport.countDocuments({ signerId }), + ]); + return { + voteCount: voteTotals[0]?.count || 0, + voteDelta: (voteTotals[0]?.balance || 0) * 0.01, + reportCount, + }; +} + +function applySignerOpinion(base, opinion) { + return { + score: Math.max(0, Math.min(1, base.trustScore + opinion.voteDelta - opinion.reportCount * 0.05)), + reports: (base.reports || 0) + opinion.reportCount, + verifiedSignatures: base.verifiedSignatures || 0, + }; +} + +module.exports = { applySignerOpinion, signerOpinion }; diff --git a/test/indexes.test.js b/test/indexes.test.js index b99986f..28fb6dd 100644 --- a/test/indexes.test.js +++ b/test/indexes.test.js @@ -2,7 +2,9 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const ContentSignature = require('../src/models/ContentSignature'); const Endorsement = require('../src/models/Endorsement'); -const { LEGACY_INDEXES } = require('../scripts/migrate-v1-indexes'); +const SignerReport = require('../src/models/SignerReport'); +const SignerVote = require('../src/models/SignerVote'); +const { CURRENT_INDEX_MODELS, LEGACY_INDEXES } = require('../scripts/migrate-v1-indexes'); test('v1 content identity uses a partial unique index', () => { const index = ContentSignature.schema.indexes().find(([keys]) => @@ -15,6 +17,13 @@ test('v1 content identity uses a partial unique index', () => { assert.deepEqual(legacy[1].partialFilterExpression.profile, { $in: [null] }); }); +test('signer opinion indexes support public reads and the production migration', () => { + for (const model of [SignerVote, SignerReport]) { + assert.ok(model.schema.indexes().some(([keys]) => keys.signerId === 1 && Object.keys(keys).length === 1)); + assert.ok(CURRENT_INDEX_MODELS.includes(model)); + } +}); + test('pre-v1 index names are covered by the explicit migration', () => { const names = LEGACY_INDEXES.map(([, name]) => name); assert.deepEqual(names, [ diff --git a/test/signerOpinionIntegration.test.js b/test/signerOpinionIntegration.test.js new file mode 100644 index 0000000..e717e0d --- /dev/null +++ b/test/signerOpinionIntegration.test.js @@ -0,0 +1,68 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const SignerReport = require('../src/models/SignerReport'); +const SignerVote = require('../src/models/SignerVote'); +const { getSignerReputation, reportSigner } = require('../src/controllers/directoryController'); +const { submitSignerVote } = require('../src/controllers/signerVoteController'); + +function response() { + return { + code: 200, + body: null, + status(code) { this.code = code; return this; }, + json(body) { this.body = body; return this; }, + set() { return this; }, + type() { return this; }, + }; +} + +test('concurrent vote and report retries leave one source-of-truth record', async (t) => { + const mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri(), { dbName: 'signer-opinion-test' }); + await Promise.all([SignerVote.syncIndexes(), SignerReport.syncIndexes()]); + t.after(async () => { + await mongoose.disconnect(); + await mongod.stop(); + }); + + const signerId = 'https://foreign.example/keys/k_concurrent_1234567890'; + const voteResponses = Array.from({ length: 20 }, response); + await Promise.all(voteResponses.map((res) => submitSignerVote({ + body: { signerId, voteType: 'TRUST' }, + htmltrustActor: { keyid: 'https://voter.example/keys/one' }, + }, res))); + assert.equal(await SignerVote.countDocuments({ signerId }), 1); + assert.equal(voteResponses.filter((res) => res.code === 201).length, 1); + assert.equal(voteResponses.every((res) => res.code === 200 || res.code === 201), true); + + const reportResponses = Array.from({ length: 20 }, response); + await Promise.all(reportResponses.map((res) => reportSigner({ + body: { + signerId, + reason: 'OTHER', + evidence: 'https://evidence.example/item', + }, + params: {}, + get(name) { + if (name === 'Idempotency-Key') return 'same-logical-report'; + if (name.toLowerCase() === 'host') return 'directory.example'; + return undefined; + }, + protocol: 'https', + }, res))); + assert.equal(await SignerReport.countDocuments({ signerId }), 1); + assert.equal(reportResponses.filter((res) => res.code === 201).length, 1); + assert.equal(reportResponses.every((res) => res.code === 200 || res.code === 201), true); + + const read = response(); + await getSignerReputation({ + params: { id: signerId }, + protocol: 'https', + get: () => 'directory.example', + }, read); + assert.equal(read.code, 200); + assert.equal(read.body.reports, 1); + assert.equal(read.body.score, 0.46); +}); diff --git a/test/signerReport.test.js b/test/signerReport.test.js new file mode 100644 index 0000000..021a691 --- /dev/null +++ b/test/signerReport.test.js @@ -0,0 +1,180 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Key = require('../src/models/Key'); +const SignerReputation = require('../src/models/SignerReputation'); +const SignerReport = require('../src/models/SignerReport'); +const SignerVote = require('../src/models/SignerVote'); +const { getSignerReputation, reportSigner } = require('../src/controllers/directoryController'); + +const original = { + directoryBaseUrl: process.env.DIRECTORY_BASE_URL, + keyFindById: Key.findById, + keyFindOne: Key.findOne, + reputationFindOne: SignerReputation.findOne, + reportCount: SignerReport.countDocuments, + reportFindOne: SignerReport.findOne, + reportUpdateOne: SignerReport.updateOne, + voteAggregate: SignerVote.aggregate, +}; + +const reports = new Map(); +const localKey = { + _id: '507f1f77bcf86cd799439011', + publicId: 'k_local_12345678901234567890', + trustScore: 0.5, + reports: 0, + verifiedSignatures: 0, + updatedAt: new Date('2026-08-28T12:00:00Z'), +}; + +function reportKey(filter) { + return `${filter.reporterId}\u0000${filter.signerId}\u0000${filter.requestKey}`; +} + +function response() { + return { + code: 200, + body: null, + status(code) { this.code = code; return this; }, + json(body) { this.body = body; return this; }, + set() { return this; }, + type() { return this; }, + }; +} + +function request({ signerId, keyId, idempotencyKey = 'request-1', reason = 'OTHER', evidence = 'https://evidence.example/item' }) { + return { + body: { ...(signerId ? { signerId } : {}), reason, evidence }, + params: keyId ? { keyId } : {}, + protocol: 'https', + get(name) { + if (name === 'Idempotency-Key') return idempotencyKey; + if (name.toLowerCase() === 'host') return 'local.example'; + return undefined; + }, + }; +} + +function installFakes() { + process.env.DIRECTORY_BASE_URL = 'https://local.example'; + reports.clear(); + Key.findById = async () => null; + Key.findOne = async (filter) => filter.publicId === localKey.publicId ? localKey : null; + SignerReputation.findOne = () => { + const query = Promise.resolve(null); + query.lean = async () => null; + return query; + }; + SignerVote.aggregate = async () => []; + SignerReport.updateOne = async (filter, update) => { + const key = reportKey(filter); + if (reports.has(key)) return { upsertedCount: 0 }; + reports.set(key, { ...update.$setOnInsert }); + return { upsertedCount: 1 }; + }; + SignerReport.findOne = (filter) => ({ + lean: async () => reports.get(reportKey(filter)) || null, + }); + SignerReport.countDocuments = async ({ signerId }) => + [...reports.values()].filter((report) => report.signerId === signerId).length; +} + +function restoreFakes() { + if (original.directoryBaseUrl === undefined) delete process.env.DIRECTORY_BASE_URL; + else process.env.DIRECTORY_BASE_URL = original.directoryBaseUrl; + Key.findById = original.keyFindById; + Key.findOne = original.keyFindOne; + SignerReputation.findOne = original.reputationFindOne; + SignerReport.countDocuments = original.reportCount; + SignerReport.findOne = original.reportFindOne; + SignerReport.updateOne = original.reportUpdateOne; + SignerVote.aggregate = original.voteAggregate; +} + +test('local canonical reports are idempotent and do not create a shadow reputation', async (t) => { + installFakes(); + t.after(restoreFakes); + const signerId = `https://local.example/keys/${localKey.publicId}`; + + const first = response(); + await reportSigner(request({ signerId }), first); + assert.equal(first.code, 201); + assert.match(first.body.reportId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + + const retry = response(); + await reportSigner(request({ signerId }), retry); + assert.equal(retry.code, 200); + assert.equal(retry.body.reportId, first.body.reportId); + assert.equal(reports.size, 1); + assert.equal(localKey.reports, 0, 'derived records replace mutable report counters'); + + const read = response(); + await getSignerReputation({ + params: { id: signerId }, + protocol: 'https', + get: () => 'local.example', + }, read); + assert.equal(read.code, 200); + assert.equal(read.body.keyid, signerId); + assert.equal(read.body.reports, 1); + assert.equal(read.body.score, 0.45); + + const compatibilityRead = response(); + await getSignerReputation({ + params: { id: localKey.publicId }, + protocol: 'https', + get: () => 'local.example', + }, compatibilityRead); + assert.equal(compatibilityRead.code, 200); + assert.equal(compatibilityRead.body.reports, 1); + assert.equal(compatibilityRead.body.score, 0.45); +}); + +test('foreign reports preserve exact percent escapes and validate the documented schema', async (t) => { + installFakes(); + t.after(restoreFakes); + const signerId = 'https://foreign.example/keys/name%2Fescaped'; + + const accepted = response(); + await reportSigner(request({ signerId }), accepted); + assert.equal(accepted.code, 201); + assert.equal([...reports.values()][0].signerId, signerId); + + const badReason = response(); + await reportSigner(request({ signerId, idempotencyKey: 'request-2', reason: 'WHATEVER' }), badReason); + assert.equal(badReason.code, 400); + assert.equal(reports.size, 1); +}); + +test('the compatibility key report route returns 404 for an unknown local key', async (t) => { + installFakes(); + t.after(restoreFakes); + + const result = response(); + await reportSigner(request({ keyId: 'k_unknown_12345678901234567890' }), result); + assert.equal(result.code, 404); + assert.equal(reports.size, 0); +}); + +test('the compatibility key route rejects a misleading body signer id', async (t) => { + installFakes(); + t.after(restoreFakes); + + const req = request({ keyId: localKey.publicId }); + req.body.signerId = 'https://foreign.example/keys/other'; + const result = response(); + await reportSigner(req, result); + assert.equal(result.code, 400); + assert.equal(reports.size, 0); +}); + +test('signer report storage failures return a server error', async (t) => { + installFakes(); + t.after(restoreFakes); + SignerReport.updateOne = async () => { throw new Error('storage unavailable'); }; + + const result = response(); + await reportSigner(request({ signerId: 'https://foreign.example/keys/failure' }), result); + assert.equal(result.code, 500); + assert.equal(result.body.type, 'https://htmltrust.org/errors/storage-failure'); +}); diff --git a/test/signerReputation.test.js b/test/signerReputation.test.js new file mode 100644 index 0000000..434f241 --- /dev/null +++ b/test/signerReputation.test.js @@ -0,0 +1,18 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const SignerReputation = require('../src/models/SignerReputation'); + +test('external signer reputation stores an exact foreign keyid without local key custody', () => { + const signerId = 'https://directory-a.example/keys/k_external_1234567890'; + const reputation = new SignerReputation({ signerId, reports: 2, trustScore: 0.35 }); + + assert.equal(reputation.validateSync(), undefined); + assert.equal(reputation.signerId, signerId); + assert.equal(reputation.publicKey, undefined); + assert.equal(reputation.authorId, undefined); +}); + +test('external signer reputation rejects an empty or oversized identifier', () => { + assert.notEqual(new SignerReputation({ signerId: '' }).validateSync(), undefined); + assert.notEqual(new SignerReputation({ signerId: 'x'.repeat(2049) }).validateSync(), undefined); +}); diff --git a/test/signerVote.test.js b/test/signerVote.test.js new file mode 100644 index 0000000..ca5c0f6 --- /dev/null +++ b/test/signerVote.test.js @@ -0,0 +1,201 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const SignerVote = require('../src/models/SignerVote'); +const SignerReputation = require('../src/models/SignerReputation'); +const SignerReport = require('../src/models/SignerReport'); +const Key = require('../src/models/Key'); +const { submitSignerVote } = require('../src/controllers/signerVoteController'); +const { getSignerReputation } = require('../src/controllers/directoryController'); + +const original = { + voteFindOne: SignerVote.findOne, + voteFindOneAndUpdate: SignerVote.findOneAndUpdate, + voteAggregate: SignerVote.aggregate, + repFindOne: SignerReputation.findOne, + reportCount: SignerReport.countDocuments, + keyFindOne: Key.findOne, +}; + +const votes = new Map(); +const reputations = new Map(); +let voteNumber = 0; + +function voteKey(voterId, signerId) { + return `${voterId}\u0000${signerId}`; +} + +function fakeResponse() { + return { + code: 200, + body: null, + status(code) { this.code = code; return this; }, + json(body) { this.body = body; return this; }, + set() { return this; }, + type() { return this; }, + }; +} + +function installFakes() { + votes.clear(); + reputations.clear(); + voteNumber = 0; + SignerVote.findOne = async ({ voterId, signerId }) => votes.get(voteKey(voterId, signerId)) || null; + SignerVote.findOneAndUpdate = async ({ voterId, signerId }, update, options) => { + const key = voteKey(voterId, signerId); + const existing = votes.get(key); + const previous = existing ? { ...existing } : null; + if (existing) { + existing.voteType = update.$set.voteType; + if (update.$set.reason !== undefined) existing.reason = update.$set.reason; + existing.updatedAt = new Date(); + } else if (options?.upsert) { + votes.set(key, { + _id: `vote-${++voteNumber}`, + voterId, + signerId, + voteType: update.$set.voteType, + reason: update.$set.reason, + updatedAt: new Date(), + }); + } + return previous; + }; + SignerVote.aggregate = async (pipeline) => { + const signerId = pipeline[0].$match.signerId; + const matching = [...votes.values()].filter((vote) => vote.signerId === signerId); + if (matching.length === 0) return []; + return [{ + count: matching.length, + balance: matching.reduce((sum, vote) => sum + (vote.voteType === 'TRUST' ? 1 : -1), 0), + }]; + }; + SignerReputation.findOne = ({ signerId }) => { + const query = Promise.resolve(reputations.get(signerId) || null); + query.lean = async () => reputations.get(signerId) || null; + return query; + }; + SignerReport.countDocuments = async () => 0; + Key.findOne = async () => null; +} + +function restoreFakes() { + SignerVote.findOne = original.voteFindOne; + SignerVote.findOneAndUpdate = original.voteFindOneAndUpdate; + SignerVote.aggregate = original.voteAggregate; + SignerReputation.findOne = original.repFindOne; + SignerReport.countDocuments = original.reportCount; + Key.findOne = original.keyFindOne; +} + +function request(signerId, voteType, keyid, reason) { + return { + body: { signerId, voteType, ...(reason === undefined ? {} : { reason }) }, + htmltrustActor: { keyid }, + }; +} + +async function reputationScore(signerId) { + const response = fakeResponse(); + await getSignerReputation({ + params: { id: signerId }, + protocol: 'https', + get: () => 'directory-b.example', + }, response); + assert.equal(response.code, 200); + return response.body.score; +} + +test('external signer votes are idempotent, reversible, and independently keyed', async (t) => { + installFakes(); + t.after(restoreFakes); + + const signerId = 'https://directory-a.example/keys/k_foreign_1234567890'; + const first = fakeResponse(); + await submitSignerVote(request(signerId, 'TRUST', 'https://voter-a.example/key'), first); + assert.equal(first.code, 201); + assert.equal(first.body.signerId, signerId); + assert.equal(await reputationScore(signerId), 0.51); + + const repeat = fakeResponse(); + await submitSignerVote(request(signerId, 'TRUST', 'https://voter-a.example/key', 'same vote'), repeat); + assert.equal(repeat.code, 200); + assert.equal(repeat.body.previousVoteType, 'TRUST'); + assert.equal(await reputationScore(signerId), 0.51); + assert.equal(votes.size, 1); + + const changed = fakeResponse(); + await submitSignerVote(request(signerId, 'DISTRUST', 'https://voter-a.example/key'), changed); + assert.equal(changed.code, 200); + assert.equal(changed.body.previousVoteType, 'TRUST'); + assert.equal(await reputationScore(signerId), 0.49); + + const independent = fakeResponse(); + await submitSignerVote(request(signerId, 'TRUST', 'https://voter-b.example/key'), independent); + assert.equal(independent.code, 201); + assert.equal(await reputationScore(signerId), 0.5); + assert.equal(votes.size, 2); + + const getResponse = fakeResponse(); + await getSignerReputation({ + params: { id: signerId }, + protocol: 'https', + get: () => 'directory-b.example', + }, getResponse); + assert.equal(getResponse.code, 200); + assert.equal(getResponse.body.keyid, signerId); + assert.equal(getResponse.body.score, 0.5); +}); + +test('canonical reputation lookup preserves percent escapes in an exact signer id', async (t) => { + installFakes(); + t.after(restoreFakes); + + const signerId = 'https://directory-a.example/keys/name%2Fwith-escape'; + reputations.set(signerId, { + signerId, + trustScore: 0.6, + verifiedSignatures: 0, + reports: 0, + updatedAt: new Date('2026-08-28T12:00:00Z'), + }); + const response = fakeResponse(); + await getSignerReputation({ + // Express route parameters arrive decoded once. The literal percent + // sequence belongs to the signer identifier and must remain untouched. + params: { id: signerId }, + protocol: 'https', + get: () => 'directory-b.example', + }, response); + + assert.equal(response.code, 200); + assert.equal(response.body.keyid, signerId); + assert.equal(response.body.score, 0.6); +}); + +test('external signer votes reject invalid identifiers and vote types', async (t) => { + installFakes(); + t.after(restoreFakes); + + for (const body of [ + { signerId: '', voteType: 'TRUST' }, + { signerId: ' https://directory.example/key', voteType: 'TRUST' }, + { signerId: 'https://directory.example/key\nsecond-line', voteType: 'TRUST' }, + { signerId: 'https://directory.example/key', voteType: 'MAYBE' }, + ]) { + const response = fakeResponse(); + await submitSignerVote({ body }, response); + assert.equal(response.code, 400); + } + assert.equal(votes.size, 0, 'invalid requests must not create vote records'); +}); + +test('signer vote storage failures return a server error', async (t) => { + installFakes(); + t.after(restoreFakes); + SignerVote.findOneAndUpdate = async () => { throw new Error('storage unavailable'); }; + + const result = fakeResponse(); + await submitSignerVote(request('https://directory.example/key', 'TRUST', 'https://voter.example/key'), result); + assert.equal(result.code, 500); + assert.equal(result.body.type, 'https://htmltrust.org/errors/storage-failure'); +}); From cdbe8b167531218170e15b8f2818ab59abb71cc5 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 14:53:56 -0500 Subject: [PATCH 2/3] chore(deps): pin portable canonicalization build --- README.md | 2 +- package-lock.json | 9 ++++++--- package.json | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 091b6ca..1db86fa 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ openapi.yaml API contract and response schemas ## Related repositories - [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec) -- [Canonicalization library](https://github.com/HTMLTrust/htmltrust-canonicalization), pinned to `5e51040dcaaf50935e245702bdefbc18a1d542ce` by this package +- [Canonicalization library](https://github.com/HTMLTrust/htmltrust-canonicalization), pinned to `760593d4a02e9fffa56dc4d002eb52ab2ade1b49` by this package - [Browser reference](https://github.com/HTMLTrust/htmltrust-browser-reference) - [Browser client](https://github.com/HTMLTrust/htmltrust-browser-client) - [End-to-end harness](https://github.com/HTMLTrust/htmltrust-e2e) diff --git a/package-lock.json b/package-lock.json index 74c4bab..ac35446 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/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/760593d4a02e9fffa56dc4d002eb52ab2ade1b49.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", @@ -26,12 +26,15 @@ }, "node_modules/@htmltrust/canonicalization": { "version": "0.3.0", - "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", - "integrity": "sha512-omTofsbv/S5XJBXzhOwirxpLD2uSkpVeAyINgQ+eQTi7qmjvWUoA6zTeHFbyQ5d7iXScmQO8/f66hDvfaeYdbA==", + "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/760593d4a02e9fffa56dc4d002eb52ab2ade1b49.tar.gz", + "integrity": "sha512-KL/G0LIVaexheok7kKeVu6X+kV3d/mm+BMYX9kfTwJIFG0v52AboNYPkTy8FuGXpwbQFj3wflnd66jZtB5kNEw==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "parse5": "7.3.0" }, + "bin": { + "htmltrust-portable-preflight": "javascript/bin/portable-authoring.js" + }, "engines": { "node": ">=22" } diff --git a/package.json b/package.json index 95d82bb..037cccf 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/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/760593d4a02e9fffa56dc4d002eb52ab2ade1b49.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", From 07a286dfd0a219e75286e983315d5a886e9e1a2d Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 14:56:37 -0500 Subject: [PATCH 3/3] fix(directory): preserve legacy report errors --- src/controllers/directoryController.js | 30 ++++++++++++++++---------- test/signerReport.test.js | 6 ++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/controllers/directoryController.js b/src/controllers/directoryController.js index 4841f46..9e1c05c 100644 --- a/src/controllers/directoryController.js +++ b/src/controllers/directoryController.js @@ -96,6 +96,14 @@ const reportActorIdentity = (req) => { return 'shared-api-key'; }; +const signerReportError = (req, res, status, title, detail, options) => { + if (req.params.keyId) { + const code = status === 404 ? 'NOT_FOUND' : status === 500 ? 'SERVER_ERROR' : 'BAD_REQUEST'; + return res.status(status).json({ code, message: detail }); + } + return problem(res, status, title, detail, options); +}; + exports.discovery = async (req, res) => { res .type(negotiatedType(req, 'application/htmltrust-directory+json')) @@ -337,45 +345,45 @@ exports.reportSigner = async (req, res) => { (field) => !['signerId', 'reason', 'details', 'evidence'].includes(field), ); if (unknownFields.length > 0) { - return problem(res, 400, 'Invalid request body', 'Only signerId, reason, details, and evidence are accepted'); + return signerReportError(req, res, 400, 'Invalid request body', 'Only signerId, reason, details, and evidence are accepted'); } const { reason, details, evidence } = body; if (req.params.keyId && Object.hasOwn(body, 'signerId')) { - return problem(res, 400, 'Invalid request body', 'The key report route takes its signer id from the path'); + return signerReportError(req, res, 400, 'Invalid request body', 'The key report route takes its signer id from the path'); } const requestedSignerId = req.params.keyId || body.signerId; if (typeof requestedSignerId !== 'string' || !requestedSignerId || requestedSignerId.trim() !== requestedSignerId) { - return problem(res, 400, 'Invalid signer id', 'signerId is required'); + return signerReportError(req, res, 400, 'Invalid signer id', 'signerId is required'); } if (requestedSignerId.length > 2048 || /[\u0000-\u001f\u007f]/.test(requestedSignerId)) { - return problem(res, 400, 'Invalid signer id', 'The signer id must be between 1 and 2048 characters without controls'); + return signerReportError(req, res, 400, 'Invalid signer id', 'The signer id must be between 1 and 2048 characters without controls'); } if (!['IMPERSONATION', 'MISINFORMATION', 'SPAM', 'OTHER'].includes(reason)) { - return problem(res, 400, 'Invalid report reason', 'reason must be IMPERSONATION, MISINFORMATION, SPAM, or OTHER'); + return signerReportError(req, res, 400, 'Invalid report reason', 'reason must be IMPERSONATION, MISINFORMATION, SPAM, or OTHER'); } if (details !== undefined && (typeof details !== 'string' || details.length > 4096)) { - return problem(res, 400, 'Invalid report details', 'details must be a string of at most 4096 characters'); + return signerReportError(req, res, 400, 'Invalid report details', 'details must be a string of at most 4096 characters'); } if (evidence !== undefined) { if (typeof evidence !== 'string' || evidence.length > 2048) { - return problem(res, 400, 'Invalid report evidence', 'evidence must be an HTTP or HTTPS URL of at most 2048 characters'); + return signerReportError(req, res, 400, 'Invalid report evidence', 'evidence must be an HTTP or HTTPS URL of at most 2048 characters'); } try { const evidenceUrl = new URL(evidence); if (!['http:', 'https:'].includes(evidenceUrl.protocol)) throw new Error('protocol'); } catch { - return problem(res, 400, 'Invalid report evidence', 'evidence must be an HTTP or HTTPS URL of at most 2048 characters'); + return signerReportError(req, res, 400, 'Invalid report evidence', 'evidence must be an HTTP or HTTPS URL of at most 2048 characters'); } } const localKey = await findLocalSignerKey(req, requestedSignerId); if (req.params.keyId && !localKey) { - return problem(res, 404, 'Key not found', 'No local key exists for the requested id'); + return signerReportError(req, res, 404, 'Key not found', 'No local key exists for the requested id'); } const signerId = localKey ? directoryKeyUrl(req, publicKeyId(localKey)) : requestedSignerId; const suppliedRequestKey = req.get('Idempotency-Key'); if (suppliedRequestKey !== undefined && !/^[\x21-\x7e]{1,128}$/.test(suppliedRequestKey)) { - return problem(res, 400, 'Invalid idempotency key', 'Idempotency-Key must contain 1 to 128 visible ASCII characters'); + return signerReportError(req, res, 400, 'Invalid idempotency key', 'Idempotency-Key must contain 1 to 128 visible ASCII characters'); } const reporterId = reportActorIdentity(req); const requestKey = suppliedRequestKey || crypto.randomUUID(); @@ -407,7 +415,7 @@ exports.reportSigner = async (req, res) => { }); } catch (error) { console.error('Report key error:', error); - return problem(res, 500, 'Directory write failure', 'The directory could not store the signer report', { + return signerReportError(req, res, 500, 'Directory write failure', 'The directory could not store the signer report', { type: 'https://htmltrust.org/errors/storage-failure', }); } diff --git a/test/signerReport.test.js b/test/signerReport.test.js index 021a691..045a3a2 100644 --- a/test/signerReport.test.js +++ b/test/signerReport.test.js @@ -153,6 +153,10 @@ test('the compatibility key report route returns 404 for an unknown local key', const result = response(); await reportSigner(request({ keyId: 'k_unknown_12345678901234567890' }), result); assert.equal(result.code, 404); + assert.deepEqual(result.body, { + code: 'NOT_FOUND', + message: 'No local key exists for the requested id', + }); assert.equal(reports.size, 0); }); @@ -165,6 +169,8 @@ test('the compatibility key route rejects a misleading body signer id', async (t const result = response(); await reportSigner(req, result); assert.equal(result.code, 400); + assert.equal(result.body.code, 'BAD_REQUEST'); + assert.equal(typeof result.body.message, 'string'); assert.equal(reports.size, 0); });