From 015692d5c2555bacc3d0bd8a7f3acffc28dd11b7 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Thu, 27 Aug 2026 15:18:50 -0500 Subject: [PATCH 1/5] feat: enforce signed directory writes --- package-lock.json | 155 ++++++--- package.json | 3 + src/controllers/authorController.js | 66 ++-- src/controllers/claimController.js | 11 +- src/controllers/contentController.js | 317 +++++++++++++++++- src/controllers/directoryController.js | 188 +++++++++-- src/controllers/endorsementController.js | 386 +++++++++++++--------- src/controllers/voteController.js | 40 ++- src/middleware/auth.js | 144 ++++---- src/middleware/httpSignature.js | 400 +++++++++++++++++++++++ src/models/Author.js | 9 +- src/models/ContentSignature.js | 6 +- src/models/Endorsement.js | 75 ++++- src/models/Key.js | 25 +- src/routes/content.js | 21 +- src/routes/endorsements.js | 12 +- src/routes/keys.js | 8 + src/routes/signers.js | 8 + src/routes/votes.js | 9 +- src/server.js | 143 +++++++- src/utils/apiKeys.js | 84 +++++ src/utils/claims.js | 94 ++++++ src/utils/crypto.js | 95 ++++-- src/utils/htmltrustProtocol.js | 268 +++++++++++++++ src/utils/jcs.js | 122 +++++++ src/utils/keyResolution.js | 321 ++++++++++++++++++ test/crypto.test.js | 29 ++ test/httpSignature.test.js | 171 ++++++++++ test/jcs.test.js | 77 +++++ test/keyResolution.test.js | 35 ++ test/protocol.test.js | 83 +++++ 31 files changed, 2998 insertions(+), 407 deletions(-) create mode 100644 src/middleware/httpSignature.js create mode 100644 src/routes/keys.js create mode 100644 src/routes/signers.js create mode 100644 src/utils/apiKeys.js create mode 100644 src/utils/claims.js create mode 100644 src/utils/htmltrustProtocol.js create mode 100644 src/utils/jcs.js create mode 100644 src/utils/keyResolution.js create mode 100644 test/crypto.test.js create mode 100644 test/httpSignature.test.js create mode 100644 test/jcs.test.js create mode 100644 test/keyResolution.test.js create mode 100644 test/protocol.test.js diff --git a/package-lock.json b/package-lock.json index 700a50f..2b619e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,15 @@ "": { "name": "htmltrust-server-reference", "version": "0.1.0", - "license": "MIT", + "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "@htmltrust/canonicalization": "github:HTMLTrust/htmltrust-canonicalization#v0.1.0", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", + "helmet": "^8.3.0", "mongoose": "^8.14.1" }, "devDependencies": { @@ -233,20 +235,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -256,17 +258,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -527,9 +542,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -606,6 +621,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", @@ -840,9 +874,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -851,6 +885,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -914,6 +960,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1283,9 +1338,9 @@ } }, "node_modules/mongoose": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", - "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", + "version": "8.24.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.2.tgz", + "integrity": "sha512-5+H3MSHNJCcr9M+lVplekrZ4/Dyn3N1dOpvgn9gMwvHJhunc4G8SQcBVd/btBQoVdspVNPjx8Pw03YWBv6uTJg==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -1565,12 +1620,13 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1703,14 +1759,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1722,13 +1778,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1921,17 +1977,34 @@ "license": "0BSD" }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/undefsafe": { diff --git a/package.json b/package.json index 567a8db..18768a0 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", + "test": "node --test \"test/**/*.test.js\"", "conformance": "node conformance/runner/with-reference-server.mjs", "conformance:docker": "bash conformance/run-conformance.sh", "conformance:runner": "node conformance/runner/run.mjs" @@ -23,7 +24,9 @@ "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", + "helmet": "^8.3.0", "mongoose": "^8.14.1" }, "devDependencies": { diff --git a/src/controllers/authorController.js b/src/controllers/authorController.js index 911232d..19bfe91 100644 --- a/src/controllers/authorController.js +++ b/src/controllers/authorController.js @@ -1,6 +1,9 @@ +const crypto = require("crypto"); const Author = require("../models/Author"); const Key = require("../models/Key"); const { generateKeyPair, generateApiKey } = require("../utils/crypto"); +const { detailFor, invalid, problem, safeSearchRegex } = require("../utils/htmltrustProtocol"); +const { hashApiKey } = require("../utils/apiKeys"); /** * @desc Create a new author and key pair @@ -9,12 +12,28 @@ const { generateKeyPair, generateApiKey } = require("../utils/crypto"); */ exports.createAuthor = async (req, res) => { try { - const { name, description, url, keyType, keyAlgorithm = "RSA" } = req.body; + const { name, description, url, keyType, keyAlgorithm = "ed25519" } = req.body; - // Generate key pair - const { publicKey, privateKey } = generateKeyPair(keyAlgorithm); + // An author MAY register a public key it already holds, in which case the + // directory never sees the private half. Otherwise the directory acts as + // the convenience registry described in draft §9.6 and generates the pair. + let publicKey = req.body.publicKey; + let privateKey; + if (publicKey) { + if (typeof publicKey !== "string" || !publicKey.includes("BEGIN PUBLIC KEY")) { + throw invalid("publicKey must be an SPKI PEM public key"); + } + try { + crypto.createPublicKey(publicKey); + } catch { + throw invalid("publicKey is not a readable SPKI PEM public key"); + } + } else { + ({ publicKey, privateKey } = generateKeyPair(keyAlgorithm)); + } - // Generate author API key + // Generate author API key. Only the HMAC of the key is persisted; the key + // itself is returned once here and cannot be recovered afterwards. const apiKey = generateApiKey(); // Create author @@ -23,7 +42,7 @@ exports.createAuthor = async (req, res) => { description, url, keyType, - apiKey, + apiKeyHash: hashApiKey(apiKey), }); // Create key @@ -51,7 +70,7 @@ exports.createAuthor = async (req, res) => { console.error("Create author error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error, "The author could not be created"), }); } }; @@ -65,18 +84,22 @@ exports.getAuthors = async (req, res) => { try { const { name, keyType, page = 1, limit = 20 } = req.query; - // Build query + // Build query. `name` is caller input: escaped, length-capped, and + // anchored so it cannot become regular-expression syntax evaluated inside + // the database (NoSQL injection / ReDoS). const query = {}; - if (name) query.name = { $regex: name, $options: "i" }; - if (keyType) query.keyType = keyType; + if (name) query.name = safeSearchRegex(name, "name"); + if (keyType) query.keyType = String(keyType); - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); + // Pagination, clamped so one request cannot page the whole collection. + const pageNumber = Math.max(1, parseInt(page, 10) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)); + const skip = (pageNumber - 1) * pageSize; // Execute query const authors = await Author.find(query) .skip(skip) - .limit(parseInt(limit)) + .limit(pageSize) .sort({ createdAt: -1 }); // Get total count @@ -86,16 +109,19 @@ exports.getAuthors = async (req, res) => { authors, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit), + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize, }, }); } catch (error) { + if (error.expose) { + return problem(res, 400, "Invalid query", error.message); + } console.error("Get authors error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -121,7 +147,7 @@ exports.getAuthor = async (req, res) => { console.error("Get author error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -158,7 +184,7 @@ exports.updateAuthor = async (req, res) => { console.error("Update author error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -191,7 +217,7 @@ exports.deleteAuthor = async (req, res) => { console.error("Delete author error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -225,7 +251,7 @@ exports.getAuthorPublicKey = async (req, res) => { console.error("Get public key error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; diff --git a/src/controllers/claimController.js b/src/controllers/claimController.js index 922280c..45b40ca 100644 --- a/src/controllers/claimController.js +++ b/src/controllers/claimController.js @@ -1,4 +1,5 @@ const Claim = require("../models/Claim"); +const { detailFor } = require("../utils/htmltrustProtocol"); /** * @desc Create a new claim type @@ -31,7 +32,7 @@ exports.createClaimType = async (req, res) => { console.error("Create claim type error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -70,7 +71,7 @@ exports.getClaimTypes = async (req, res) => { console.error("Get claim types error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -96,7 +97,7 @@ exports.getClaimType = async (req, res) => { console.error("Get claim type error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -132,7 +133,7 @@ exports.updateClaimType = async (req, res) => { console.error("Update claim type error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -162,7 +163,7 @@ exports.deleteClaimType = async (req, res) => { console.error("Delete claim type error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; diff --git a/src/controllers/contentController.js b/src/controllers/contentController.js index 5bf0533..9e0683f 100644 --- a/src/controllers/contentController.js +++ b/src/controllers/contentController.js @@ -1,8 +1,22 @@ const Author = require('../models/Author'); const Key = require('../models/Key'); const ContentSignature = require('../models/ContentSignature'); +const { resolveUsableKey } = require('../utils/keyResolution'); const ContentOccurrence = require('../models/ContentOccurrence'); -const { signContent, verifySignature } = require('../utils/crypto'); +const Endorsement = require('../models/Endorsement'); +const { hashCanonical, signContent, verifySignature } = require('../utils/crypto'); +const { + assertContentHash, + assertRfc3339Utc, + decodeCanonicalBase64, + detailFor, + invalid, + normalizeClaims, + normalizeSerializedOrigin, + problem, + signedAtFromClaims, +} = require('../utils/htmltrustProtocol'); +const { canonicalizeClaims } = require('../utils/claims'); /** * Build the canonical binding string that is actually signed. @@ -17,13 +31,112 @@ const { signContent, verifySignature } = require('../utils/crypto'); */ const buildBinding = ({ contentHash, claimsHash, domain, signedAt }) => { if (!contentHash || !claimsHash || !domain || !signedAt) { - throw new Error( + throw invalid( `Missing required binding field(s): contentHash=${contentHash}, claimsHash=${claimsHash}, domain=${domain}, signedAt=${signedAt}` ); } return `${contentHash}:${claimsHash}:${domain}:${signedAt}`; }; +const claimsObject = (claims) => Object.fromEntries( + normalizeClaims(claims).map((claim) => [claim.name, claim.content]) +); + +/** + * Hash of the canonical claims serialization, draft §4.6. + * + * The canonicalization itself lives in src/utils/claims.js on top of the + * shared @htmltrust/canonicalization text normalizer, so this server produces + * the same bytes as the signers do. The previous implementation here sorted + * whole `name:content` lines with JavaScript's default UTF-16 comparison, did + * not normalize the claim text at all, and invented a `signed-at` claim when + * one was absent — each of which yields a different claims hash than a + * conforming signer computes, so a correctly signed submission failed to + * verify (and an incorrectly signed one could pass). + */ +const canonicalClaimsHash = async (claims, hashAlgorithm) => { + if (hashAlgorithm !== 'sha256') { + throw invalid('This reference server currently computes claimsHash values with sha256 only'); + } + return hashCanonical(await canonicalizeClaims(normalizeClaims(claims))); +}; + +const validateSignatureInputs = ({ contentHash, claimsHash, domain, signedAt, signature }) => { + const normalizedContentHash = assertContentHash(contentHash, 'contentHash'); + const normalizedClaimsHash = assertContentHash(claimsHash, 'claimsHash'); + const contentAlgorithm = normalizedContentHash.split(':')[0]; + const claimsAlgorithm = normalizedClaimsHash.split(':')[0]; + if (contentAlgorithm !== claimsAlgorithm) { + throw invalid('contentHash and claimsHash must use the same hash algorithm'); + } + const normalizedDomain = normalizeSerializedOrigin(domain); + assertRfc3339Utc(signedAt, 'signedAt'); + if (signature) { + decodeCanonicalBase64(signature, 'signature'); + } + return { + contentHash: normalizedContentHash, + claimsHash: normalizedClaimsHash, + domain: normalizedDomain, + signedAt, + }; +}; + +const keyidFor = (req, key) => `${req.protocol}://${req.get('host')}/api/keys/${key._id}`; + +/** + * Resolve a submitted keyid to a usable key, honouring revocation and expiry + * (draft §8). Falls back to the legacy "keyid is an author id" form that + * earlier clients used. + */ +const resolveSubmissionKey = async (req, keyid) => { + const resolution = await resolveUsableKey(keyid, { req }); + if (resolution.ok) return resolution; + + if (/^[0-9a-fA-F]{24}$/.test(keyid || '')) { + const key = await Key.findOne({ authorId: keyid }); + if (key && !key.revoked) { + return { + ok: true, + resolved: { + keyid, + publicKeyPem: key.publicKey, + algorithm: key.algorithm, + key, + }, + }; + } + } + + return resolution; +}; + +const contentRecord = async (req, contentHash) => { + const signatures = await ContentSignature.find({ contentHash }).sort({ createdAt: 1 }); + if (signatures.length === 0) return null; + + const signers = await Promise.all(signatures.map(async (signature) => { + const key = await Key.findById(signature.keyId); + return { + keyid: key ? keyidFor(req, key) : String(signature.keyId), + signedAt: signature.signedAt, + domain: signature.domain, + signature: signature.signature, + }; + })); + + const endorsementCount = await Endorsement.countDocuments({ + $or: [{ endorsement: contentHash }, { contentHash }], + }); + + return { + contentHash, + firstSeen: signatures[0].createdAt.toISOString(), + signers, + endorsementCount, + }; +}; + /** * @desc Sign content * @route POST /api/content/sign @@ -38,8 +151,30 @@ const buildBinding = ({ contentHash, claimsHash, domain, signedAt }) => { */ exports.signContent = async (req, res) => { try { - const { contentHash, claimsHash, domain, signedAt, claims } = req.body; + const { claims } = req.body; + const { + contentHash, + claimsHash, + domain, + signedAt, + } = validateSignatureInputs(req.body); const author = req.author; + const claimSignedAt = signedAtFromClaims(claims); + if (claimSignedAt && claimSignedAt !== signedAt) { + throw invalid('signedAt must match the direct child signed-at claim'); + } + + // Recompute the claims hash from the claims map rather than signing the + // caller's value. The binding (§5) is what the signature attests to; if + // the directory signs a claimsHash it never derived, the caller chooses + // what the key attests to and the stored claims are free to say something + // else entirely. + const recomputedClaimsHash = await canonicalClaimsHash(claims, contentHash.split(':')[0]); + if (recomputedClaimsHash !== claimsHash) { + throw invalid( + `claimsHash does not match the canonical claims serialization (computed ${recomputedClaimsHash})` + ); + } // Get author's private key const key = await Key.findOne({ authorId: author._id }).select('+privateKey'); @@ -51,6 +186,17 @@ exports.signContent = async (req, res) => { }); } + if (!key.privateKey) { + // The author registered their own public key, so the directory has no + // private half to sign with and must not pretend otherwise. + return problem( + res, + 400, + 'No directory-held signing key', + 'This author registered its own public key; content must be signed by the author and submitted to POST /content' + ); + } + // Build canonical binding per spec §2.1 const dataToSign = buildBinding({ contentHash, claimsHash, domain, signedAt }); @@ -67,7 +213,7 @@ exports.signContent = async (req, res) => { if (contentSignature) { // Update existing signature contentSignature.signature = signature; - contentSignature.claims = claims; + contentSignature.claims = claimsObject(claims); contentSignature.claimsHash = claimsHash; contentSignature.signedAt = signedAt; contentSignature.occurrences += 1; @@ -82,7 +228,7 @@ exports.signContent = async (req, res) => { authorId: author._id, keyId: key._id, signature, - claims + claims: claimsObject(claims) }); } @@ -94,14 +240,15 @@ exports.signContent = async (req, res) => { domain, authorId: author._id, signature, - claims, + keyid: keyidFor(req, key), + claims: claimsObject(claims), createdAt: contentSignature.createdAt }); } catch (error) { console.error('Sign content error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } }; @@ -137,7 +284,13 @@ exports.verifyContent = async (req, res) => { res.set('Deprecation', 'true'); res.set('Link', '; rel="deprecation"'); try { - const { contentHash, claimsHash, domain, signedAt, authorId, signature } = req.body; + const { authorId, signature } = req.body; + const { + contentHash, + claimsHash, + domain, + signedAt, + } = validateSignatureInputs(req.body); // Get author const author = await Author.findById(authorId); @@ -198,7 +351,7 @@ exports.verifyContent = async (req, res) => { console.error('Verify content error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } }; @@ -210,7 +363,14 @@ exports.verifyContent = async (req, res) => { */ exports.registerOccurrence = async (req, res) => { try { - const { contentHash, claimsHash, signedAt, url, domain, authorId, signature } = req.body; + const { url, authorId, signature } = req.body; + const domain = normalizeSerializedOrigin(req.body.domain); + const contentHash = assertContentHash(req.body.contentHash, 'contentHash'); + let claimsHash = req.body.claimsHash; + let signedAt = req.body.signedAt; + if (signature) { + ({ claimsHash, signedAt } = validateSignatureInputs(req.body)); + } // Verify the signature if provided let signatureValid = false; @@ -280,7 +440,140 @@ exports.registerOccurrence = async (req, res) => { console.error('Register occurrence error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) + }); + } +}; + +/** + * @desc Retrieve a draft-shaped content record + * @route GET /api/content/:contentHash + * @access Public + */ +exports.getContentRecord = async (req, res) => { + try { + const contentHash = assertContentHash(req.params.contentHash, 'contentHash'); + const record = await contentRecord(req, contentHash); + if (!record) { + return problem(res, 404, 'Content not found', 'No content record exists for the requested hash', { + contentHash, + }); + } + res.type('application/htmltrust-content+json').status(200).json(record); + } catch (error) { + return problem(res, 400, 'Invalid content hash', detailFor(error)); + } +}; + +/** + * @desc Submit a draft-shaped signed content occurrence + * @route POST /api/content + * @access Private (General API Key) + */ +exports.submitContent = async (req, res) => { + try { + const { keyid, sourceURL, signature, claims = [] } = req.body; + if (!keyid || !signature || !sourceURL) { + return problem(res, 400, 'Invalid content submission', 'keyid, signature, and sourceURL are required'); + } + + const contentHash = assertContentHash(req.body.contentHash, 'contentHash'); + const signedAt = assertRfc3339Utc(req.body.signedAt, 'signedAt'); + const domain = normalizeSerializedOrigin(req.body.domain); + const hashAlgorithm = contentHash.split(':')[0]; + const claimsHash = req.body.claimsHash + ? assertContentHash(req.body.claimsHash, 'claimsHash') + : await canonicalClaimsHash(claims, hashAlgorithm); + if (claimsHash.split(':')[0] !== hashAlgorithm) { + throw invalid('contentHash and claimsHash must use the same hash algorithm'); + } + + const resolution = await resolveSubmissionKey(req, keyid); + if (!resolution.ok) { + return problem(res, 400, 'Key resolution failed', `The submitted keyid could not be resolved (${resolution.reason})`, { + type: `https://htmltrust.org/errors/${resolution.reason}`, + keyid, + }); + } + const key = resolution.resolved.key; + if (!key) { + // Remote keys resolve to key material but not to a local author record, + // and every stored ContentSignature is keyed by author. Indexing + // externally-held keys needs a local registration first. + return problem(res, 400, 'Key resolution failed', 'The submitted keyid is not registered with this directory', { + type: 'https://htmltrust.org/errors/key-resolution-failed', + keyid, + }); + } + + const binding = buildBinding({ contentHash, claimsHash, domain, signedAt }); + if (!verifySignature(binding, signature, resolution.resolved.publicKeyPem, resolution.resolved.algorithm)) { + return problem(res, 400, 'Signature verification failed', 'The submitted signature did not verify against the canonical signing payload.', { + type: 'https://htmltrust.org/errors/signature-invalid', + contentHash, + }); + } + + let contentSignature = await ContentSignature.findOne({ + contentHash, + domain, + authorId: key.authorId, }); + if (contentSignature) { + contentSignature.signature = signature; + contentSignature.claimsHash = claimsHash; + contentSignature.signedAt = signedAt; + contentSignature.claims = claimsObject(claims); + contentSignature.occurrences += 1; + await contentSignature.save(); + } else { + contentSignature = await ContentSignature.create({ + contentHash, + claimsHash, + signedAt, + domain, + authorId: key.authorId, + keyId: key._id, + signature, + claims: claimsObject(claims), + }); + } + + await ContentOccurrence.findOneAndUpdate( + { signatureId: contentSignature._id, url: sourceURL }, + { + signatureId: contentSignature._id, + url: sourceURL, + domain, + signatureValid: true, + lastSeen: Date.now(), + }, + { upsert: true, setDefaultsOnInsert: true } + ); + + const record = await contentRecord(req, contentHash); + res + .status(201) + .location(`/api/content/${encodeURIComponent(contentHash)}`) + .type('application/htmltrust-content+json') + .json(record); + } catch (error) { + return problem(res, 400, 'Invalid content submission', detailFor(error)); } -}; \ No newline at end of file +}; + +exports.listContentEndorsements = async (req, res) => { + try { + const contentHash = assertContentHash(req.params.contentHash, 'contentHash'); + const endorsements = await Endorsement.find({ + $or: [{ endorsement: contentHash }, { contentHash }], + }).sort({ createdAt: -1 }); + const { toEndorsementDocument } = require('./endorsementController'); + res + .type('application/htmltrust-endorsement+json') + .status(200) + .json(endorsements.map(toEndorsementDocument)); + } catch (error) { + return problem(res, 400, 'Invalid content hash', detailFor(error)); + } +}; diff --git a/src/controllers/directoryController.js b/src/controllers/directoryController.js index 97c98f0..ff87347 100644 --- a/src/controllers/directoryController.js +++ b/src/controllers/directoryController.js @@ -2,6 +2,109 @@ const Key = require('../models/Key'); const Author = require('../models/Author'); const ContentSignature = require('../models/ContentSignature'); const ContentOccurrence = require('../models/ContentOccurrence'); +const { + detailFor, + keyDocumentFor, + normalizeSerializedOrigin, + problem, + safeSearchRegex, +} = require('../utils/htmltrustProtocol'); + +/** + * Clamp caller-supplied pagination. An unbounded `limit` turns a public read + * endpoint into a bulk-export and a memory-pressure lever. + */ +const MAX_PAGE_SIZE = 100; +const boundedLimit = (value, fallback = 20) => { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.min(parsed, MAX_PAGE_SIZE); +}; +const boundedPage = (value) => { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return 1; + return parsed; +}; + +const baseDirectoryUrl = (req) => `${req.protocol}://${req.get('host')}/api/`; + +const keyIdFromSignerId = (id) => { + if (!id || typeof id !== 'string') return null; + if (/^[0-9a-fA-F]{24}$/.test(id)) return id; + try { + const url = new URL(id); + const segments = url.pathname.split('/').filter(Boolean); + const keyIndex = segments.lastIndexOf('keys'); + if (keyIndex !== -1 && segments[keyIndex + 1]) { + return decodeURIComponent(segments[keyIndex + 1]); + } + } catch {} + return null; +}; + +exports.discovery = async (req, res) => { + res + .type('application/htmltrust-directory+json') + .status(200) + .json({ + directory: baseDirectoryUrl(req), + version: '1', + capabilities: { + content: true, + endorsements: true, + keys: true, + reputation: true + }, + supportedAlgorithms: { + signature: ['ed25519', 'rsa-pkcs1-sha256', 'ecdsa-p256'], + hash: ['sha256'] + } + }); +}; + +exports.getKeyDocument = async (req, res) => { + try { + const key = await Key.findById(req.params.id); + if (!key) { + return problem(res, 404, 'Key not found', 'No key document exists for the requested id'); + } + res + .type('application/htmltrust-key+json') + .status(200) + .json(keyDocumentFor(key)); + } catch (error) { + return problem(res, 400, 'Invalid key id', detailFor(error)); + } +}; + +exports.getSignerReputation = async (req, res) => { + try { + const signerId = decodeURIComponent(req.params.id); + const keyId = keyIdFromSignerId(signerId); + let key = null; + if (keyId) { + key = await Key.findById(keyId); + } + if (!key && /^[0-9a-fA-F]{24}$/.test(signerId)) { + key = await Key.findOne({ authorId: signerId }); + } + if (!key) { + return problem(res, 404, 'Signer not found', 'No local signer reputation exists for the requested id', { + keyid: signerId, + }); + } + + res.status(200).json({ + keyid: signerId, + score: key.trustScore, + asOf: (key.updatedAt || key.createdAt || new Date()).toISOString(), + components: ['verified-signatures', 'reports'], + methodology: `${baseDirectoryUrl(req)}methodology/reputation-v1` + }); + } catch (error) { + return problem(res, 400, 'Invalid signer id', detailFor(error)); + } +}; /** * @desc Search public keys @@ -15,17 +118,28 @@ exports.searchPublicKeys = async (req, res) => { // Build query const query = {}; - // Join with Author model to filter by author name and key type + // Join with Author model to filter by author name and key type. + // + // `authorName` is unauthenticated caller input on a public route. Passing + // it straight into $regex let the caller supply pattern syntax — both a + // NoSQL injection (the filter no longer means what the code says) and a + // denial of service (a catastrophically backtracking pattern is evaluated + // per document inside the database). safeSearchRegex escapes, caps, and + // anchors it into a literal prefix match. const authorQuery = {}; - if (authorName) authorQuery.name = { $regex: authorName, $options: 'i' }; - if (keyType) authorQuery.keyType = keyType; + if (authorName) authorQuery['author.name'] = safeSearchRegex(authorName, 'authorName'); + if (keyType) authorQuery['author.keyType'] = String(keyType); // Filter by trust score if (minTrustScore) query.trustScore = { $gte: parseFloat(minTrustScore) }; - - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); - + const normalizedDomain = domain ? normalizeSerializedOrigin(domain) : null; + + // Pagination. `limit` is caller-controlled, so it is clamped: an + // unbounded page size lets one request pull the whole collection. + const pageNumber = boundedPage(page); + const pageSize = boundedLimit(limit); + const skip = (pageNumber - 1) * pageSize; + // Execute query with aggregation to join with Author model const keys = await Key.aggregate([ { @@ -42,15 +156,14 @@ exports.searchPublicKeys = async (req, res) => { { $match: { ...query, - 'author': { $exists: true }, - ...Object.keys(authorQuery).length > 0 ? { 'author': authorQuery } : {} + ...authorQuery } }, { $skip: skip }, { - $limit: parseInt(limit) + $limit: pageSize }, { $project: { @@ -72,19 +185,22 @@ exports.searchPublicKeys = async (req, res) => { const total = await Key.countDocuments(query); res.status(200).json({ - keys, + keys: normalizedDomain ? keys.filter((key) => key.author && key.author.url && key.author.url.startsWith(normalizedDomain)) : keys, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit) + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize } }); } catch (error) { + if (error.expose) { + return problem(res, 400, 'Invalid query', error.message); + } console.error('Search public keys error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -116,7 +232,7 @@ exports.getKeyReputation = async (req, res) => { console.error('Get key reputation error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -159,7 +275,7 @@ exports.reportKey = async (req, res) => { console.error('Report key error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } }; @@ -177,14 +293,16 @@ exports.searchSignedContent = async (req, res) => { const query = {}; if (contentHash) query.contentHash = contentHash; if (authorId) query.authorId = authorId; - if (domain) query.domain = domain; + if (domain) query.domain = normalizeSerializedOrigin(domain); if (claim) { const [claimName, claimValue] = claim.split(':'); query[`claims.${claimName}`] = claimValue; } - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); + // Pagination (clamped; see boundedLimit) + const pageNumber = boundedPage(page); + const pageSize = boundedLimit(limit); + const skip = (pageNumber - 1) * pageSize; // Execute query with aggregation to join with Author model const signatures = await ContentSignature.aggregate([ @@ -206,7 +324,7 @@ exports.searchSignedContent = async (req, res) => { $skip: skip }, { - $limit: parseInt(limit) + $limit: pageSize }, { $project: { @@ -230,16 +348,16 @@ exports.searchSignedContent = async (req, res) => { signatures, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit) + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize } }); } catch (error) { console.error('Search signed content error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -268,15 +386,17 @@ exports.findContentOccurrences = async (req, res) => { // Get signature IDs const signatureIds = signatures.map(sig => sig._id); - // Pagination - const skip = (parseInt(page) - 1) * parseInt(limit); + // Pagination (clamped; see boundedLimit) + const pageNumber = boundedPage(page); + const pageSize = boundedLimit(limit); + const skip = (pageNumber - 1) * pageSize; // Find occurrences const occurrences = await ContentOccurrence.find({ signatureId: { $in: signatureIds } }) .skip(skip) - .limit(parseInt(limit)) + .limit(pageSize) .sort({ firstSeen: -1 }); // Get total count @@ -288,16 +408,16 @@ exports.findContentOccurrences = async (req, res) => { occurrences, pagination: { total, - pages: Math.ceil(total / parseInt(limit)), - page: parseInt(page), - limit: parseInt(limit) + pages: Math.ceil(total / pageSize), + page: pageNumber, + limit: pageSize } }); } catch (error) { console.error('Find content occurrences error:', error); res.status(500).json({ code: 'SERVER_ERROR', - message: error.message + message: detailFor(error) }); } }; @@ -333,7 +453,7 @@ exports.reportContentMisuse = async (req, res) => { console.error('Report content misuse error:', error); res.status(400).json({ code: 'BAD_REQUEST', - message: error.message + message: detailFor(error) }); } -}; \ No newline at end of file +}; diff --git a/src/controllers/endorsementController.js b/src/controllers/endorsementController.js index b0a0804..85b86cf 100644 --- a/src/controllers/endorsementController.js +++ b/src/controllers/endorsementController.js @@ -1,159 +1,216 @@ +const crypto = require('crypto'); const Endorsement = require('../models/Endorsement'); -const Author = require('../models/Author'); -const Key = require('../models/Key'); const { verifySignature } = require('../utils/crypto'); +const { + assertContentHash, + assertRfc3339Utc, + assertSignatureAlgorithm, + canonicalizeEndorsement, + decodeCanonicalBase64, + detailFor, + invalid, + problem, +} = require('../utils/htmltrustProtocol'); +const { canonicalizeJcs } = require('../utils/jcs'); +const { resolveUsableKey, sameKeyMaterial } = require('../utils/keyResolution'); +const { hasAdminApiKey } = require('../middleware/auth'); /** - * Build the canonical rawBlob for an endorsement when the client did not - * supply one. The wire format follows the example in spec §2.5: + * Return the stored endorsement document exactly as it was submitted. * - * { - * "endorser": "did:web:publisher.org", - * "endorsement": "sha256-XYZ", - * "signature": "BASE64_SIG", - * "timestamp": "2025-05-01T00:00Z" - * } + * Draft §9.5: "The directory MUST NOT alter the endorsement payloads in a + * manner that invalidates the endorser's signature", and §10.1 requires + * unrecognised members to be preserved and included in the signed payload. + * Any member the directory adds — an `_id`, a `createdAt`, a `contentHash` + * alias — becomes part of JCS(document minus signature) for anyone who + * recomputes it, and the signature no longer verifies. * - * Keys are emitted in a stable order (endorser, endorsement, signature, - * timestamp, algorithm) so any verifier that reconstructs the blob from - * structured fields produces the same bytes. The optional `algorithm` field - * is omitted when it equals the default 'ed25519' to match the spec example. - * - * NOTE: clients SHOULD post their own rawBlob to avoid any ambiguity. This - * fallback exists for convenience only. + * Server-side bookkeeping is therefore kept out of the body entirely: the + * identifier of a newly stored endorsement is returned in the `Location` + * header of the 201 response. */ -const buildCanonicalBlob = ({ endorser, contentHash, signature, timestamp, algorithm }) => { - const obj = { - endorser, - endorsement: contentHash, - signature, - timestamp +const toEndorsementDocument = (endorsement) => { + if (endorsement.document && typeof endorsement.document === 'object') { + return endorsement.document; + } + // Rows written before structured documents were stored: reconstruct the + // draft shape from the indexed columns. + const document = { + endorser: endorsement.endorser, + endorsement: endorsement.endorsement || endorsement.contentHash, + algorithm: endorsement.algorithm, + timestamp: endorsement.timestamp, + signature: endorsement.signature, }; - if (algorithm && algorithm.toLowerCase() !== 'ed25519') { - obj.algorithm = algorithm; + if (endorsement.claim) document.claim = endorsement.claim; + if (endorsement.expires) document.expires = endorsement.expires; + if (endorsement.revokedBy) document.revokedBy = endorsement.revokedBy; + return document; +}; + +/** + * Validate a submitted endorsement document against draft §10.1 WITHOUT + * changing it. The returned object is the same object graph that will be + * canonicalized, verified, and stored, so nothing may be injected into it: + * every added member changes the signing payload. + */ +const validateEndorsementDocument = (body) => { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw invalid('The endorsement document must be a JSON object'); + } + const document = { ...body }; + + // `rawBlob` is a legacy compatibility field that was never part of the + // signed document; strip it before verification so old clients that still + // send it do not fail, and keep it out of storage. + delete document.rawBlob; + + for (const field of ['endorser', 'endorsement', 'signature', 'timestamp']) { + if (typeof document[field] !== 'string' || document[field].length === 0) { + throw invalid(`${field} is required`); + } } - return JSON.stringify(obj); + if (typeof document.algorithm !== 'string' || document.algorithm.length === 0) { + throw invalid('algorithm is required'); + } + + assertContentHash(document.endorsement, 'endorsement'); + assertSignatureAlgorithm(document.algorithm); + assertRfc3339Utc(document.timestamp, 'timestamp'); + decodeCanonicalBase64(document.signature, 'signature'); + if (document.expires !== undefined) assertRfc3339Utc(document.expires, 'expires'); + if (document.revokedBy !== undefined) assertContentHash(document.revokedBy, 'revokedBy'); + if (document.claim !== undefined && typeof document.claim !== 'string') { + throw invalid('claim must be a string'); + } + + return document; }; +const documentHashFor = (document) => + `sha256:${crypto.createHash('sha256').update(canonicalizeJcs(document)).digest('base64').replace(/=+$/, '')}`; + /** - * Best-effort, opportunistic verification of an endorsement's signature. + * Verify the endorser's signature over JCS(document minus `signature`), per + * draft §10.2. * - * The directory does NOT have authoritative knowledge of every endorser's - * public key — endorser keyids are opaque strings that clients resolve - * locally. As a sanity check, we attempt to find a matching Author/Key pair - * by treating the endorser string as either an Author._id or as a name. If - * no match is found, we silently store the endorsement as-is; clients verify - * locally per spec §2.5. + * Verification is mandatory. A directory that stores endorsements it cannot + * verify is a publication channel for forged attestations: anyone can claim + * any endorser's identity, and every consumer that trusts the directory's + * index (rather than re-verifying) inherits the forgery. Draft §9.7 states + * the requirement directly — the directory MUST verify the endorser's + * signature, and invalid signatures MUST be rejected with 400. * - * Returns true if verification succeeded, false if it failed, or null if no - * key was available to attempt verification. + * @returns {Promise<{ok: true} | {ok: false, status: number, title: string, detail: string, type?: string}>} */ -const tryVerify = async ({ endorser, contentHash, timestamp, signature, algorithm }) => { - let key = null; - try { - // Look for an Author whose _id or name matches the endorser string. - let author = null; - if (/^[0-9a-fA-F]{24}$/.test(endorser)) { - author = await Author.findById(endorser); - } - if (!author) { - author = await Author.findOne({ name: endorser }); - } - if (!author) return null; +const verifyEndorsementSignature = async (document, req) => { + const resolution = await resolveUsableKey(document.endorser, { req }); + if (!resolution.ok) { + return { + ok: false, + status: 400, + title: 'Key resolution failed', + detail: + resolution.reason === 'key-resolution-failed' + ? 'The endorser keyid could not be resolved to a public key this directory can verify against' + : `The endorser key is not usable (${resolution.reason})`, + type: `https://htmltrust.org/errors/${resolution.reason}`, + resolved: resolution.resolved, + }; + } - key = await Key.findOne({ authorId: author._id }); - if (!key) return null; - } catch (err) { - return null; + const { resolved } = resolution; + if (resolved.algorithm !== document.algorithm) { + return { + ok: false, + status: 400, + title: 'Algorithm mismatch', + detail: `The endorsement declares ${document.algorithm} but the resolved key is ${resolved.algorithm}`, + type: 'https://htmltrust.org/errors/algorithm-mismatch', + }; } - try { - const binding = `${contentHash}:${timestamp}`; - return verifySignature(binding, signature, key.publicKey, key.algorithm || algorithm); - } catch (err) { - return false; + const payload = canonicalizeEndorsement(document); + if (!verifySignature(payload, document.signature, resolved.publicKeyPem, resolved.algorithm)) { + return { + ok: false, + status: 400, + title: 'Signature verification failed', + detail: 'The endorsement signature did not verify against the canonical JSON payload.', + type: 'https://htmltrust.org/errors/signature-invalid', + }; } + + return { ok: true, resolved }; }; /** - * @desc Create (or upsert) an endorsement + * @desc Create an endorsement * @route POST /api/endorsements - * @access Private (General API Key) + * @access Private (RFC 9421 signature, or the demo API key scheme) * - * Request body fields (all required unless noted): - * - endorser: string (opaque keyid) - * - contentHash: string (e.g. "sha256:...") - * - signature: string (base64) - * - timestamp: string (ISO-8601) - * - algorithm: string (optional, default 'ed25519') - * - rawBlob: string (optional; the exact bytes the client signed over. - * If omitted, the server constructs a canonical blob in a - * stable key order. Clients SHOULD post their own rawBlob.) + * Request body is an endorsement document per draft §10.1: + * - endorser: string (keyid resolvable per §8) + * - endorsement: string (content hash, e.g. "sha256:...") + * - signature: string (unpadded Base64 over JCS(document minus signature)) + * - algorithm: string (§7.1 identifier) + * - timestamp: string (RFC 3339 UTC) + * - claim, expires, revokedBy and any additional members: optional, stored + * and served verbatim. */ exports.createEndorsement = async (req, res) => { + let document; try { - const { endorser, contentHash, signature, timestamp } = req.body; - const algorithm = req.body.algorithm || 'ed25519'; - let { rawBlob } = req.body; - - if (!endorser || !contentHash || !signature || !timestamp) { - return res.status(400).json({ - code: 'BAD_REQUEST', - message: 'endorser, contentHash, signature, and timestamp are required' - }); - } + document = validateEndorsementDocument(req.body); + } catch (error) { + return problem(res, 400, 'Invalid endorsement', detailFor(error, 'The endorsement document is not valid'), { + type: 'https://htmltrust.org/errors/endorsement-invalid', + }); + } - if (!rawBlob) { - rawBlob = buildCanonicalBlob({ endorser, contentHash, signature, timestamp, algorithm }); + try { + const verification = await verifyEndorsementSignature(document, req); + if (!verification.ok) { + return problem(res, verification.status, verification.title, verification.detail, { + type: verification.type, + contentHash: document.endorsement, + }); } - // Opportunistic sanity check — does NOT block storage. Clients verify - // locally per spec §2.5. - const verifyResult = await tryVerify({ endorser, contentHash, timestamp, signature, algorithm }); - if (verifyResult === false) { - console.warn( - `Endorsement signature failed opportunistic verification: endorser=${endorser} contentHash=${contentHash}` - ); - } + const documentHash = documentHashFor(document); - // Upsert: a given endorser may only have one endorsement per content - // hash. Resubmissions overwrite. - let endorsement = await Endorsement.findOne({ endorser, contentHash }); - if (endorsement) { - endorsement.signature = signature; - endorsement.timestamp = timestamp; - endorsement.algorithm = algorithm; - endorsement.rawBlob = rawBlob; - await endorsement.save(); - } else { - endorsement = await Endorsement.create({ - endorser, - contentHash, - signature, - timestamp, - algorithm, - rawBlob + // Append-only with idempotent resubmission. A second, different document + // from the same endorser for the same content hash (a revocation, an + // updated claim) is stored alongside the first: draft §10.3 requires a + // directory holding both to serve both. + let stored = await Endorsement.findOne({ documentHash }); + let created = false; + if (!stored) { + stored = await Endorsement.create({ + endorser: document.endorser, + endorsement: document.endorsement, + contentHash: document.endorsement, + signature: document.signature, + timestamp: document.timestamp, + algorithm: document.algorithm, + claim: document.claim, + expires: document.expires, + revokedBy: document.revokedBy, + document, + documentHash, }); + created = true; } - res.status(201).json({ - _id: endorsement._id, - endorser: endorsement.endorser, - contentHash: endorsement.contentHash, - signature: endorsement.signature, - timestamp: endorsement.timestamp, - algorithm: endorsement.algorithm, - rawBlob: endorsement.rawBlob, - createdAt: endorsement.createdAt, - // Expose the opportunistic verification result for diagnostics. Clients - // MUST NOT rely on this — they verify locally per spec §2.5. - opportunisticallyVerified: verifyResult - }); + return res + .status(created ? 201 : 200) + .location(`/api/endorsements/${stored._id}`) + .type('application/htmltrust-endorsement+json') + .json(toEndorsementDocument(stored)); } catch (error) { console.error('Create endorsement error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: error.message + return problem(res, 400, 'Invalid endorsement', detailFor(error, 'The endorsement could not be stored'), { + type: 'https://htmltrust.org/errors/endorsement-invalid', }); } }; @@ -166,70 +223,81 @@ exports.createEndorsement = async (req, res) => { exports.listEndorsements = async (req, res) => { try { // Accept both kebab-case (spec-style) and camelCase query parameters. - const contentHash = req.query['content-hash'] || req.query.contentHash; + const contentHash = req.query['content-hash'] || req.query.contentHash || req.query.endorsement; if (!contentHash) { - return res.status(400).json({ - code: 'BAD_REQUEST', - message: 'content-hash query parameter is required' - }); + return problem(res, 400, 'Invalid request', 'content-hash query parameter is required'); } - const endorsements = await Endorsement.find({ contentHash }).sort({ createdAt: -1 }); - - res.status(200).json( - endorsements.map((e) => ({ - _id: e._id, - endorser: e.endorser, - contentHash: e.contentHash, - signature: e.signature, - timestamp: e.timestamp, - algorithm: e.algorithm, - rawBlob: e.rawBlob, - createdAt: e.createdAt - })) - ); + const endorsementHash = assertContentHash(String(contentHash), 'content-hash'); + const endorsements = await Endorsement.find({ + $or: [{ endorsement: endorsementHash }, { contentHash: endorsementHash }] + }).sort({ createdAt: -1 }); + + return res + .type('application/htmltrust-endorsement+json') + .status(200) + .json(endorsements.map(toEndorsementDocument)); } catch (error) { console.error('List endorsements error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: error.message - }); + return problem(res, 400, 'Invalid content hash', detailFor(error, 'The content-hash parameter is not valid')); } }; +exports.toEndorsementDocument = toEndorsementDocument; + /** * @desc Delete an endorsement * @route DELETE /api/endorsements/:id - * @access Private (General API Key) + * @access The endorser (RFC 9421 signature) or the directory operator * - * MVP: gated behind the existing API key auth only. A production deployment - * MUST additionally verify that the caller's authenticated identity matches - * the endorsement's `endorser` keyid (e.g. by requiring a signed delete - * request, or by tying the API key to a specific keyid). + * Deletion is a compatibility operation; the protocol's own mechanism for + * withdrawing an endorsement is a revocation endorsement (draft §10.3), which + * is served alongside the original rather than replacing it. * - * TODO: enforce caller-keyid match against endorsement.endorser before - * permitting deletion. + * Authorization is by key, not by API key: the caller must sign the DELETE + * with the endorsement's own endorser key (compared on the resolved key + * material, so an alias keyid still matches), or present the directory admin + * key for an operator takedown. Any holder of the shared submission key being + * able to delete anyone's endorsements is a censorship primitive. */ exports.deleteEndorsement = async (req, res) => { try { const endorsement = await Endorsement.findById(req.params.id); if (!endorsement) { - return res.status(404).json({ - code: 'NOT_FOUND', - message: 'Endorsement not found' - }); + return problem(res, 404, 'Endorsement not found', 'No endorsement exists with the requested id'); + } + + if (!hasAdminApiKey(req)) { + const actor = req.htmltrustActor; + if (!actor) { + res.set('WWW-Authenticate', 'Signature realm="htmltrust-directory"'); + return problem( + res, + 401, + 'Unauthorized', + 'Deleting an endorsement requires an RFC 9421 signature from the endorser key, or the directory admin key', + { type: 'https://htmltrust.org/errors/unauthorized' }, + ); + } + + const endorserKey = await resolveUsableKey(endorsement.endorser, { req }); + const sameKeyid = actor.keyid === endorsement.endorser; + const sameMaterial = + endorserKey.ok && sameKeyMaterial(actor.resolved.publicKeyPem, endorserKey.resolved.publicKeyPem); + if (!sameKeyid && !sameMaterial) { + return problem(res, 403, 'Forbidden', 'Only the endorser may delete this endorsement', { + type: 'https://htmltrust.org/errors/forbidden', + }); + } } await Endorsement.deleteOne({ _id: endorsement._id }); - res.status(204).send(); + return res.status(204).send(); } catch (error) { console.error('Delete endorsement error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: error.message - }); + return problem(res, 400, 'Invalid request', detailFor(error, 'The endorsement could not be deleted')); } }; diff --git a/src/controllers/voteController.js b/src/controllers/voteController.js index 77fce5b..150702d 100644 --- a/src/controllers/voteController.js +++ b/src/controllers/voteController.js @@ -2,6 +2,24 @@ const Vote = require("../models/Vote"); const Author = require("../models/Author"); const Key = require("../models/Key"); const ContentSignature = require("../models/ContentSignature"); +const { detailFor, problem } = require("../utils/htmltrustProtocol"); + +/** + * The identity a vote belongs to. + * + * Votes move a key's trust score, so who cast one has to be established by + * the request rather than asserted in its body. A caller that authenticated + * with an RFC 9421 signature votes as the key it signed with. A caller using + * the shared demo API key has no distinguishable identity, so every such + * caller collapses onto a single voter id — which, combined with the unique + * { userId, targetType, targetId } index, means the shared key is worth + * exactly one vote per target instead of unlimited ballot stuffing. + */ +const voterIdentity = (req) => { + if (req.htmltrustActor) return `key:${req.htmltrustActor.keyid}`; + if (req.author) return `author:${req.author._id}`; + return "shared-api-key"; +}; /** * @desc Vote on an author or content @@ -10,7 +28,8 @@ const ContentSignature = require("../models/ContentSignature"); */ exports.createVote = async (req, res) => { try { - const { userId, targetType, targetId, voteType, reason } = req.body; + const { targetType, targetId, voteType, reason } = req.body; + const userId = voterIdentity(req); // Validate target exists let target; @@ -72,7 +91,7 @@ exports.createVote = async (req, res) => { console.error("Create vote error:", error); res.status(400).json({ code: "BAD_REQUEST", - message: error.message, + message: detailFor(error), }); } }; @@ -151,7 +170,7 @@ exports.getVotes = async (req, res) => { console.error("Get votes error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -172,11 +191,12 @@ exports.deleteVote = async (req, res) => { }); } - // Check if the user ID in the request matches the user ID of the vote - if (req.body.userId !== vote.userId) { - return res.status(403).json({ - code: "FORBIDDEN", - message: "Not authorized to delete this vote", + // Ownership is derived from the authenticated request, not from a + // caller-supplied body field: trusting `req.body.userId` let any caller + // delete any vote simply by naming its owner. + if (voterIdentity(req) !== vote.userId) { + return problem(res, 403, "Forbidden", "Not authorized to delete this vote", { + type: "https://htmltrust.org/errors/forbidden", }); } @@ -204,7 +224,7 @@ exports.deleteVote = async (req, res) => { console.error("Delete vote error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; @@ -262,7 +282,7 @@ exports.getVoteStats = async (req, res) => { console.error("Get vote stats error:", error); res.status(500).json({ code: "SERVER_ERROR", - message: error.message, + message: detailFor(error), }); } }; diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 20bf547..b23b5f0 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,35 +1,70 @@ const Author = require("../models/Author"); +const { problem } = require("../utils/htmltrustProtocol"); +const { hashApiKey, secretsMatch } = require("../utils/apiKeys"); + +/** + * Static API-key authentication. + * + * Draft §9.8 requires POST endpoints to be authenticated with an RFC 9421 + * HTTP Message Signature bound to a resolvable key (see + * `middleware/httpSignature.js`). The shared-secret schemes below are a + * supplementary admin/demo scheme only: a shared `X-API-KEY` proves nothing + * about *who* submitted a record, so it cannot carry the identity that + * content and endorsement ingestion depend on. + * + * `apiKeyAuthEnabled()` gates them off in production. Set + * HTMLTRUST_ALLOW_API_KEY_AUTH=1 to re-enable them there (for an operator + * console, for example) with full knowledge that they are not an identity. + */ +const apiKeyAuthEnabled = () => { + if (process.env.HTMLTRUST_ALLOW_API_KEY_AUTH === "1") return true; + if (process.env.HTMLTRUST_ALLOW_API_KEY_AUTH === "0") return false; + return process.env.NODE_ENV !== "production"; +}; + +const unauthorized = (res, detail, scheme = "ApiKey") => { + // Draft §9.8: an unauthenticated request MUST get a WWW-Authenticate + // challenge. Routes that accept an RFC 9421 signature advertise it first so + // a client learns the scheme it is supposed to be using. + const schemes = Array.isArray(scheme) ? scheme : [scheme]; + res.set( + "WWW-Authenticate", + schemes.map((name) => `${name} realm="htmltrust-directory"`).join(", "), + ); + return problem(res, 401, "Unauthorized", detail, { + type: "https://htmltrust.org/errors/unauthorized", + }); +}; /** * Middleware to protect routes that require general API key authentication */ const protectWithGeneralApiKey = async (req, res, next) => { try { - const apiKey = req.header("X-API-KEY"); + if (!apiKeyAuthEnabled()) { + return unauthorized( + res, + "Static API-key authentication is disabled; submit an RFC 9421 HTTP Message Signature instead", + "Signature", + ); + } + const apiKey = req.header("X-API-KEY"); if (!apiKey) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "No API key provided", - }); + return unauthorized( + res, + "Provide an RFC 9421 HTTP Message Signature, or the demo API key in X-API-KEY", + ["Signature", "ApiKey"], + ); } - - // In a real implementation, you would validate the API key against a database - // For this example, we'll use a simple check against an environment variable - if (apiKey !== process.env.GENERAL_API_KEY) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "Invalid API key", - }); + if (!secretsMatch(apiKey, process.env.GENERAL_API_KEY || "")) { + return unauthorized(res, "Invalid API key", ["Signature", "ApiKey"]); } - next(); + return next(); } catch (error) { console.error("Auth error:", error); - res.status(401).json({ - code: "UNAUTHORIZED", - message: "Authentication failed", - }); + return unauthorized(res, "Authentication failed"); } }; @@ -38,43 +73,37 @@ const protectWithGeneralApiKey = async (req, res, next) => { */ const protectWithAuthorApiKey = async (req, res, next) => { try { - const apiKey = req.header("X-AUTHOR-API-KEY"); + if (!apiKeyAuthEnabled()) { + return unauthorized( + res, + "Static API-key authentication is disabled; submit an RFC 9421 HTTP Message Signature instead", + "Signature", + ); + } + const apiKey = req.header("X-AUTHOR-API-KEY"); if (!apiKey) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "No author API key provided", - }); + return unauthorized(res, "No author API key provided"); } - // Find the author with this API key - const author = await Author.findOne({ apiKey }).select("+apiKey"); - + // Look the author up by the hash of the presented key; the plaintext key + // is never stored, so there is nothing to compare against directly. + const author = await Author.findOne({ apiKeyHash: hashApiKey(apiKey) }); if (!author) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "Invalid author API key", - }); + return unauthorized(res, "Invalid author API key"); } - // Check if the author ID in the URL matches the authenticated author if (req.params.authorId && req.params.authorId !== author._id.toString()) { - return res.status(403).json({ - code: "FORBIDDEN", - message: "API key does not match author", + return problem(res, 403, "Forbidden", "API key does not match author", { + type: "https://htmltrust.org/errors/forbidden", }); } - // Add author to request object req.author = author; - - next(); + return next(); } catch (error) { console.error("Auth error:", error); - res.status(401).json({ - code: "UNAUTHORIZED", - message: "Authentication failed", - }); + return unauthorized(res, "Authentication failed"); } }; @@ -84,35 +113,32 @@ const protectWithAuthorApiKey = async (req, res, next) => { const protectWithAdminApiKey = async (req, res, next) => { try { const apiKey = req.header("X-ADMIN-API-KEY"); - if (!apiKey) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "No admin API key provided", - }); + return unauthorized(res, "No admin API key provided"); } - - // In a real implementation, you would validate the admin API key against a database - // For this example, we'll use a simple check against an environment variable - if (apiKey !== process.env.ADMIN_API_KEY) { - return res.status(401).json({ - code: "UNAUTHORIZED", - message: "Invalid admin API key", - }); + if (!secretsMatch(apiKey, process.env.ADMIN_API_KEY || "")) { + return unauthorized(res, "Invalid admin API key"); } - next(); + req.isDirectoryAdmin = true; + return next(); } catch (error) { console.error("Auth error:", error); - res.status(401).json({ - code: "UNAUTHORIZED", - message: "Authentication failed", - }); + return unauthorized(res, "Authentication failed"); } }; +/** True when the request carries a valid directory-admin key. */ +const hasAdminApiKey = (req) => { + const apiKey = req.header("X-ADMIN-API-KEY"); + return Boolean(apiKey) && secretsMatch(apiKey, process.env.ADMIN_API_KEY || ""); +}; + module.exports = { + apiKeyAuthEnabled, + hasAdminApiKey, protectWithGeneralApiKey, protectWithAuthorApiKey, protectWithAdminApiKey, + unauthorized, }; diff --git a/src/middleware/httpSignature.js b/src/middleware/httpSignature.js new file mode 100644 index 0000000..dcbf97a --- /dev/null +++ b/src/middleware/httpSignature.js @@ -0,0 +1,400 @@ +const crypto = require("crypto"); +const { problem } = require("../utils/htmltrustProtocol"); +const { resolveUsableKey } = require("../utils/keyResolution"); + +/** + * RFC 9421 HTTP Message Signatures, as required by draft §9.8: + * + * "POST endpoints MUST be authenticated using HTTP Message Signatures + * [RFC9421] with a key that the directory can resolve via Section 8. The + * signature input MUST cover the `(request-target)`, `host`, `date`, and + * `content-digest` components at a minimum." + * + * This is a deliberately minimal verifier, not a general RFC 9421 library. + * It supports exactly what the draft requires: + * + * Covered components: `@method` plus `@target-uri` or `@path` (equivalently + * `@request-target`), `@authority` or `host`, `date`, and — whenever the + * request carries a body — `content-digest`. Requests covering a smaller + * set are rejected; covering more is allowed and the extra components are + * included in the signature base as normal. + * + * Derived components with parameters (`@query-param`, `;req`, `;sf`, `;bs`) + * are NOT supported and are rejected rather than silently ignored, because + * silently ignoring a covered component would let a signer sign something + * other than what is verified. + * + * The verified identity is the resolved key, not a shared secret: the + * `keyid` signature parameter is resolved per draft §8 and the signature is + * checked against that key. `req.htmltrustActor` is set on success. + */ + +// Accepted clock skew for the `created` parameter and the `date` header. +const MAX_SKEW_SECONDS = 300; + +// Signatures already seen inside the acceptance window, to stop a captured +// request from being replayed verbatim. Bounded so it cannot grow without +// limit; a multi-process deployment needs a shared store instead. +const REPLAY_CACHE_LIMIT = 4096; +const seenSignatures = new Map(); + +const rememberSignature = (signatureB64) => { + const now = Date.now(); + for (const [value, at] of seenSignatures) { + if (now - at > MAX_SKEW_SECONDS * 2000) seenSignatures.delete(value); + else break; + } + if (seenSignatures.has(signatureB64)) return false; + if (seenSignatures.size >= REPLAY_CACHE_LIMIT) { + const oldest = seenSignatures.keys().next().value; + seenSignatures.delete(oldest); + } + seenSignatures.set(signatureB64, now); + return true; +}; + +class SignatureError extends Error {} + +/** + * Split a structured-field dictionary on top-level commas, ignoring commas + * inside quoted strings, parenthesised inner lists, and byte sequences. + */ +const splitDictionary = (header) => { + const members = []; + let depth = 0; + let inQuotes = false; + let inBytes = false; + let start = 0; + for (let i = 0; i < header.length; i += 1) { + const ch = header[i]; + if (inQuotes) { + if (ch === "\\") i += 1; + else if (ch === '"') inQuotes = false; + continue; + } + if (inBytes) { + if (ch === ":") inBytes = false; + continue; + } + if (ch === '"') inQuotes = true; + else if (ch === ":") inBytes = true; + else if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === "," && depth === 0) { + members.push(header.slice(start, i)); + start = i + 1; + } + } + members.push(header.slice(start)); + return members.map((member) => member.trim()).filter(Boolean); +}; + +const splitLabel = (member) => { + const eq = member.indexOf("="); + if (eq === -1) throw new SignatureError("malformed structured field dictionary member"); + return [member.slice(0, eq).trim(), member.slice(eq + 1).trim()]; +}; + +/** + * Parse one `Signature-Input` value: an inner list of quoted component + * identifiers followed by `;name=value` parameters. + */ +const parseSignatureInputValue = (raw) => { + if (!raw.startsWith("(")) throw new SignatureError("signature input must start with an inner list"); + const close = raw.indexOf(")"); + if (close === -1) throw new SignatureError("unterminated signature input inner list"); + + const inner = raw.slice(1, close).trim(); + const components = []; + if (inner.length > 0) { + for (const token of inner.split(/\s+/)) { + if (!/^"[^"]*"$/.test(token)) { + throw new SignatureError(`unsupported covered component ${token}`); + } + components.push(token.slice(1, -1).toLowerCase()); + } + } + + const params = {}; + const tail = raw.slice(close + 1); + for (const part of tail.split(";")) { + const chunk = part.trim(); + if (!chunk) continue; + const eq = chunk.indexOf("="); + if (eq === -1) { + params[chunk.toLowerCase()] = true; + continue; + } + const name = chunk.slice(0, eq).trim().toLowerCase(); + let value = chunk.slice(eq + 1).trim(); + if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + else if (/^-?\d+$/.test(value)) value = Number(value); + params[name] = value; + } + + return { components, params, raw }; +}; + +const parseSignatureValue = (raw) => { + if (!raw.startsWith(":") || !raw.endsWith(":") || raw.length < 2) { + throw new SignatureError("signature value must be a byte sequence"); + } + return Buffer.from(raw.slice(1, -1), "base64"); +}; + +const headerValue = (req, name) => { + const value = req.headers[name]; + if (value === undefined) return undefined; + return Array.isArray(value) ? value.join(", ") : String(value); +}; + +/** + * Build the signature base per RFC 9421 §2.5. + */ +const buildSignatureBase = (req, components, signatureParamsRaw) => { + const lines = []; + for (const component of components) { + let value; + switch (component) { + case "@method": + value = req.method.toUpperCase(); + break; + case "@target-uri": + value = `${req.protocol}://${req.get("host")}${req.originalUrl}`; + break; + case "@request-target": + value = req.originalUrl; + break; + case "@path": + value = req.path; + break; + case "@query": + value = req.originalUrl.includes("?") ? req.originalUrl.slice(req.originalUrl.indexOf("?")) : "?"; + break; + case "@authority": + value = String(req.get("host") || "").toLowerCase(); + break; + case "@scheme": + value = req.protocol; + break; + default: + if (component.startsWith("@")) { + throw new SignatureError(`unsupported derived component "${component}"`); + } + value = headerValue(req, component); + if (value === undefined) { + throw new SignatureError(`covered header "${component}" is not present on the request`); + } + value = value.replace(/\s+/g, " ").trim(); + break; + } + lines.push(`"${component}": ${value}`); + } + lines.push(`"@signature-params": ${signatureParamsRaw}`); + return lines.join("\n"); +}; + +/** + * Verify Content-Digest (RFC 9530) against the raw request body. Without this + * the signature covers a digest header that nothing ties to the body. + */ +const verifyContentDigest = (req) => { + const header = headerValue(req, "content-digest"); + if (!header) throw new SignatureError("content-digest header is required for requests with a body"); + const body = req.rawBody || Buffer.alloc(0); + + let matched = false; + for (const member of splitDictionary(header)) { + const [algorithm, raw] = splitLabel(member); + const name = algorithm.toLowerCase(); + if (name !== "sha-256" && name !== "sha-512") continue; + const expected = crypto + .createHash(name === "sha-256" ? "sha256" : "sha512") + .update(body) + .digest(); + const provided = parseSignatureValue(raw); + if (expected.length === provided.length && crypto.timingSafeEqual(expected, provided)) { + matched = true; + } else { + throw new SignatureError("content-digest does not match the request body"); + } + } + if (!matched) throw new SignatureError("content-digest must use sha-256 or sha-512"); +}; + +const assertRequiredComponents = (components, hasBody) => { + const covered = new Set(components); + const coversTarget = + covered.has("@method") && + (covered.has("@request-target") || covered.has("@target-uri") || covered.has("@path")); + if (!coversTarget) { + throw new SignatureError('signature must cover the request target ("@method" and "@target-uri")'); + } + if (!covered.has("host") && !covered.has("@authority")) { + throw new SignatureError('signature must cover "host"'); + } + if (!covered.has("date")) { + throw new SignatureError('signature must cover "date"'); + } + if (hasBody && !covered.has("content-digest")) { + throw new SignatureError('signature must cover "content-digest" when the request has a body'); + } +}; + +const assertFreshness = (req, params) => { + const now = Math.floor(Date.now() / 1000); + if (typeof params.created === "number" && Math.abs(now - params.created) > MAX_SKEW_SECONDS) { + throw new SignatureError("signature `created` timestamp is outside the acceptance window"); + } + if (typeof params.expires === "number" && params.expires < now) { + throw new SignatureError("signature has expired"); + } + const date = headerValue(req, "date"); + const parsed = date ? Date.parse(date) : NaN; + if (!Number.isFinite(parsed)) throw new SignatureError("date header is missing or unparseable"); + if (Math.abs(now - Math.floor(parsed / 1000)) > MAX_SKEW_SECONDS) { + throw new SignatureError("date header is outside the acceptance window"); + } +}; + +/** + * Verify raw signature bytes. RFC 9421 carries ECDSA signatures in the fixed + * width (r || s) form of IEEE P1363, not the DER encoding Node defaults to, + * so the encoding is stated explicitly. + */ +const verifyBytes = (base, signature, publicKeyPem, algorithm) => { + const data = Buffer.from(base, "utf8"); + switch (algorithm) { + case "ed25519": + return crypto.verify(null, data, publicKeyPem, signature); + case "ecdsa-p256": + return crypto.verify("sha256", data, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, signature); + case "ecdsa-p384": + return crypto.verify("sha384", data, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, signature); + case "rsa-pkcs1-sha256": + return crypto.verify("sha256", data, publicKeyPem, signature); + case "rsa-pss-sha256": + return crypto.verify( + "sha256", + data, + { + key: publicKeyPem, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }, + signature, + ); + default: + throw new SignatureError(`unsupported signature algorithm ${algorithm}`); + } +}; + +/** + * Verify the request's HTTP Message Signature. + * + * `resolve` exists so tests can supply key material directly; production + * callers use the default, which resolves the keyid per draft section 8. + * + * @returns {Promise<{ok: true, actor: object} | {ok: false, status: number, title: string, detail: string}>} + */ +const verifyHttpMessageSignature = async (req, { resolve = resolveUsableKey } = {}) => { + const inputHeader = headerValue(req, "signature-input"); + const signatureHeader = headerValue(req, "signature"); + if (!inputHeader || !signatureHeader) { + return { ok: false, status: 401, title: "Unauthorized", detail: "Signature and Signature-Input headers are required" }; + } + + try { + const signatures = new Map(splitDictionary(signatureHeader).map((member) => splitLabel(member))); + const hasBody = Boolean(req.rawBody && req.rawBody.length > 0); + let lastError = null; + + for (const member of splitDictionary(inputHeader)) { + const [label, rawValue] = splitLabel(member); + const rawSignature = signatures.get(label); + if (!rawSignature) continue; + + try { + const { components, params, raw } = parseSignatureInputValue(rawValue); + if (!params.keyid || typeof params.keyid !== "string") { + throw new SignatureError("signature is missing a keyid parameter"); + } + assertRequiredComponents(components, hasBody); + assertFreshness(req, params); + if (hasBody) verifyContentDigest(req); + + const resolution = await resolve(params.keyid, { req }); + if (!resolution.ok) { + return { + ok: false, + status: 401, + title: "Key resolution failed", + detail: `The signature keyid could not be resolved to a usable key (${resolution.reason})`, + type: `https://htmltrust.org/errors/${resolution.reason}`, + }; + } + const { resolved } = resolution; + if (params.alg && params.alg !== resolved.algorithm) { + throw new SignatureError("signature `alg` does not match the resolved key algorithm"); + } + + const signature = parseSignatureValue(rawSignature); + const base = buildSignatureBase(req, components, raw); + if (!verifyBytes(base, signature, resolved.publicKeyPem, resolved.algorithm)) { + throw new SignatureError("signature did not verify against the resolved key"); + } + if (!rememberSignature(rawSignature)) { + throw new SignatureError("signature has already been used (replay)"); + } + + return { ok: true, actor: { keyid: params.keyid, resolved, label } }; + } catch (error) { + if (!(error instanceof SignatureError)) throw error; + lastError = error; + } + } + + return { + ok: false, + status: 401, + title: "Invalid signature", + detail: lastError ? lastError.message : "No usable signature was present on the request", + }; + } catch (error) { + if (error instanceof SignatureError) { + return { ok: false, status: 401, title: "Invalid signature", detail: error.message }; + } + console.error("HTTP message signature verification error:", error); + return { ok: false, status: 401, title: "Invalid signature", detail: "The request signature could not be verified" }; + } +}; + +/** + * Express middleware factory. On success `req.htmltrustActor` holds the + * verified identity. On failure the response is `401` with a + * `WWW-Authenticate` challenge, per draft §9.8. + * + * `fallback` runs when no HTTP Message Signature is present at all. It exists + * so the legacy static API-key schemes can stay available for the demo UI and + * the conformance suite; see `src/middleware/auth.js`. + */ +const requireActorSignature = ({ fallback } = {}) => async (req, res, next) => { + const hasSignature = Boolean(req.headers["signature-input"] || req.headers.signature); + if (!hasSignature && typeof fallback === "function") { + return fallback(req, res, next); + } + + const result = await verifyHttpMessageSignature(req); + if (!result.ok) { + res.set("WWW-Authenticate", 'Signature realm="htmltrust-directory"'); + return problem(res, result.status, result.title, result.detail, result.type ? { type: result.type } : {}); + } + req.htmltrustActor = result.actor; + return next(); +}; + +module.exports = { + buildSignatureBase, + requireActorSignature, + verifyHttpMessageSignature, +}; diff --git a/src/models/Author.js b/src/models/Author.js index a5b5513..9d1d9ee 100644 --- a/src/models/Author.js +++ b/src/models/Author.js @@ -22,9 +22,14 @@ const AuthorSchema = new mongoose.Schema({ enum: ['HUMAN', 'AI', 'HUMAN_AI_MIX', 'ORGANIZATION'], required: [true, 'Please specify the key type'] }, - apiKey: { + // HMAC-SHA-256 of the author's API key under the server pepper. The key + // itself is shown once at creation and never stored; see + // src/utils/apiKeys.js for the rationale and the migration from the old + // plaintext `apiKey` field. + apiKeyHash: { type: String, - select: false // Don't return API key in queries + index: true, + select: false }, createdAt: { type: Date, diff --git a/src/models/ContentSignature.js b/src/models/ContentSignature.js index dcd2eba..6c96c52 100644 --- a/src/models/ContentSignature.js +++ b/src/models/ContentSignature.js @@ -6,8 +6,8 @@ const ContentSignatureSchema = new mongoose.Schema({ required: [true, 'Content hash is required'], index: true }, - // Canonical hash of the claims map (sorted, newline-joined "name=value" - // serialization, then hashed). Part of the signature binding per spec §2.1. + // Canonical hash of the claims map (sorted "name:content\n" records, then + // hashed). Part of the signature binding per spec §2.1. claimsHash: { type: String, default: '' @@ -64,4 +64,4 @@ ContentSignatureSchema.virtual('contentOccurrences', { justOne: false }); -module.exports = mongoose.model('ContentSignature', ContentSignatureSchema); \ No newline at end of file +module.exports = mongoose.model('ContentSignature', ContentSignatureSchema); diff --git a/src/models/Endorsement.js b/src/models/Endorsement.js index 4d138fa..9ad1c76 100644 --- a/src/models/Endorsement.js +++ b/src/models/Endorsement.js @@ -13,9 +13,9 @@ const mongoose = require('mongoose'); * endorsement is performed locally by the verifier using the endorser's * public key (resolved via the same keyid mechanisms as content signatures). * - * The original signed JSON blob is stored verbatim in `rawBlob` so verifiers - * can re-verify byte-identically without re-serializing — any change to the - * key order or whitespace would invalidate the signature. + * The structured endorsement document is stored in `document`. Older clients + * may still send or read `contentHash`/`rawBlob`, but the draft field name for + * the targeted content hash is `endorsement`. */ const EndorsementSchema = new mongoose.Schema({ // Opaque endorser keyid (e.g. "did:web:publisher.org" or any other form @@ -25,13 +25,19 @@ const EndorsementSchema = new mongoose.Schema({ required: [true, 'Endorser keyid is required'], index: true }, - // The targeted content hash, e.g. "sha256:..." per spec §2.1. + // Draft field: targeted content hash, e.g. "sha256:..." per spec §6.2. + endorsement: { + type: String, + required: [true, 'Endorsement content hash is required'], + index: true + }, + // Legacy alias retained for current clients and existing data. contentHash: { type: String, required: [true, 'Content hash is required'], index: true }, - // Base64-encoded signature over the binding "{contentHash}:{timestamp}". + // Base64-encoded signature over JCS(document with signature omitted). signature: { type: String, required: [true, 'Signature is required'] @@ -43,14 +49,39 @@ const EndorsementSchema = new mongoose.Schema({ }, algorithm: { type: String, - enum: ['ed25519', 'ED25519', 'RSA', 'ECDSA'], + enum: [ + 'ed25519', + 'ED25519', + 'RSA', + 'ECDSA', + 'rsa-pkcs1-sha256', + 'rsa-pss-sha256', + 'ecdsa-p256', + 'ecdsa-p384' + ], default: 'ed25519' }, - // Original signed JSON blob the client posted, stored verbatim so verifiers - // can re-verify byte-identically without re-serializing. - rawBlob: { + claim: String, + expires: String, + revokedBy: String, + // The endorsement document exactly as it was submitted and verified. It is + // served back verbatim: draft §10.1 requires unrecognised members to be + // preserved and included in the signed payload, so adding, renaming, or + // dropping a member here would invalidate the endorser's signature. + document: { + type: mongoose.Schema.Types.Mixed, + required: [true, 'Structured endorsement document is required'] + }, + // sha256 of JCS(document), used as the identity of the stored document. + documentHash: { type: String, - required: [true, 'rawBlob is required'] + required: [true, 'Document hash is required'], + unique: true, + index: true + }, + // Legacy signed blob field retained for compatibility only. + rawBlob: { + type: String }, createdAt: { type: Date, @@ -58,8 +89,26 @@ const EndorsementSchema = new mongoose.Schema({ } }); -// Dedupe: a given endorser may only have one endorsement on file per content -// hash. Resubmissions update the existing record (see controller). -EndorsementSchema.index({ contentHash: 1, endorser: 1 }, { unique: true }); +// Endorsements are append-only. An endorser may have several documents on +// file for the same content hash — draft §10.3 requires a directory holding +// both an endorsement and its revocation to serve BOTH, so that verifiers can +// observe the revocation chain. Deduplication is by document identity +// (`documentHash`), which makes resubmitting the identical document +// idempotent while keeping distinct documents distinct. +// +// MIGRATION: databases created before this change carry unique indexes on +// { contentHash, endorser } and { endorsement, endorser }. Those indexes +// silently collapse a revocation onto the endorsement it revokes and must be +// dropped: +// db.endorsements.dropIndex("contentHash_1_endorser_1") +// db.endorsements.dropIndex("endorsement_1_endorser_1") +EndorsementSchema.index({ contentHash: 1, endorser: 1 }); +EndorsementSchema.index({ endorsement: 1, endorser: 1 }); + +EndorsementSchema.pre('validate', function(next) { + if (!this.endorsement && this.contentHash) this.endorsement = this.contentHash; + if (!this.contentHash && this.endorsement) this.contentHash = this.endorsement; + next(); +}); module.exports = mongoose.model('Endorsement', EndorsementSchema); diff --git a/src/models/Key.js b/src/models/Key.js index 86d042e..a69952f 100644 --- a/src/models/Key.js +++ b/src/models/Key.js @@ -10,15 +10,26 @@ const KeySchema = new mongoose.Schema({ type: String, required: [true, 'Public key is required'] }, + // Only populated when the directory generated the key pair on the author's + // behalf. Authors who register their own public key keep custody of the + // private half and this field stays unset. privateKey: { type: String, - required: [true, 'Private key is required'], select: false // Don't return private key in queries }, algorithm: { type: String, - enum: ['RSA', 'ECDSA', 'ED25519'], - default: 'RSA', + enum: [ + 'RSA', + 'ECDSA', + 'ED25519', + 'rsa-pkcs1-sha256', + 'rsa-pss-sha256', + 'ecdsa-p256', + 'ecdsa-p384', + 'ed25519' + ], + default: 'ed25519', required: true }, createdAt: { @@ -28,6 +39,12 @@ const KeySchema = new mongoose.Schema({ expiresAt: { type: Date }, + // Draft §8.2: a revoked key MUST NOT be used to verify a signature, so + // revocation is recorded here and enforced during key resolution. + revoked: { + type: Boolean, + default: false + }, trustScore: { type: Number, min: 0, @@ -52,4 +69,4 @@ KeySchema.virtual('signatures', { justOne: false }); -module.exports = mongoose.model('Key', KeySchema); \ No newline at end of file +module.exports = mongoose.model('Key', KeySchema); diff --git a/src/routes/content.js b/src/routes/content.js index 646d15b..a572fac 100644 --- a/src/routes/content.js +++ b/src/routes/content.js @@ -3,12 +3,16 @@ const router = express.Router(); const { signContent, verifyContent, - registerOccurrence + registerOccurrence, + getContentRecord, + submitContent, + listContentEndorsements } = require('../controllers/contentController'); const { protectWithAuthorApiKey, protectWithGeneralApiKey } = require('../middleware/auth'); +const { requireActorSignature } = require('../middleware/httpSignature'); // Routes router.route('/sign') @@ -20,4 +24,17 @@ router.route('/verify') router.route('/occurrences') .post(protectWithGeneralApiKey, registerOccurrence); -module.exports = router; \ No newline at end of file +// Draft §9.4/§9.8: submissions are authenticated with an RFC 9421 HTTP +// Message Signature from a resolvable key. The static API key remains as a +// fallback for the demo UI and the conformance suite, and is disabled in +// production. +router.route('/') + .post(requireActorSignature({ fallback: protectWithGeneralApiKey }), submitContent); + +router.route('/:contentHash/endorsements') + .get(listContentEndorsements); + +router.route('/:contentHash') + .get(getContentRecord); + +module.exports = router; diff --git a/src/routes/endorsements.js b/src/routes/endorsements.js index 89dfbe8..da06070 100644 --- a/src/routes/endorsements.js +++ b/src/routes/endorsements.js @@ -6,13 +6,21 @@ const { deleteEndorsement } = require('../controllers/endorsementController'); const { protectWithGeneralApiKey } = require('../middleware/auth'); +const { requireActorSignature } = require('../middleware/httpSignature'); // Routes +// +// Draft §9.8 requires POST endpoints to be authenticated with an RFC 9421 +// HTTP Message Signature. The static API key remains as a fallback for the +// demo UI and the conformance suite, and is disabled in production. router.route('/') .get(listEndorsements) - .post(protectWithGeneralApiKey, createEndorsement); + .post(requireActorSignature({ fallback: protectWithGeneralApiKey }), createEndorsement); +// DELETE authorizes on the endorser's own key (or the admin key) inside the +// controller, so an unsigned request is passed through to be rejected there +// with the right diagnostic rather than being accepted by a shared secret. router.route('/:id') - .delete(protectWithGeneralApiKey, deleteEndorsement); + .delete(requireActorSignature({ fallback: (req, res, next) => next() }), deleteEndorsement); module.exports = router; diff --git a/src/routes/keys.js b/src/routes/keys.js new file mode 100644 index 0000000..088a7f4 --- /dev/null +++ b/src/routes/keys.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); +const { getKeyDocument } = require('../controllers/directoryController'); + +router.route('/:id') + .get(getKeyDocument); + +module.exports = router; diff --git a/src/routes/signers.js b/src/routes/signers.js new file mode 100644 index 0000000..7816f11 --- /dev/null +++ b/src/routes/signers.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); +const { getSignerReputation } = require('../controllers/directoryController'); + +router.route('/:id/reputation') + .get(getSignerReputation); + +module.exports = router; diff --git a/src/routes/votes.js b/src/routes/votes.js index 2c19d8c..24d0438 100644 --- a/src/routes/votes.js +++ b/src/routes/votes.js @@ -7,14 +7,19 @@ const { getVoteStats, } = require("../controllers/voteController"); const { protectWithGeneralApiKey } = require("../middleware/auth"); +const { requireActorSignature } = require("../middleware/httpSignature"); + +// Votes move reputation, so the voter is the verified signer of the request +// when one is present; see voterIdentity() in the controller. +const authenticatedVoter = requireActorSignature({ fallback: protectWithGeneralApiKey }); // Routes -router.route("/").post(protectWithGeneralApiKey, createVote); +router.route("/").post(authenticatedVoter, createVote); router.route("/stats/:targetType/:targetId").get(getVoteStats); router.route("/:targetType/:targetId").get(getVotes); -router.route("/:voteId").delete(protectWithGeneralApiKey, deleteVote); +router.route("/:voteId").delete(authenticatedVoter, deleteVote); module.exports = router; diff --git a/src/server.js b/src/server.js index 9dccf17..fe4682d 100644 --- a/src/server.js +++ b/src/server.js @@ -1,11 +1,20 @@ const express = require('express'); const cors = require('cors'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); const path = require('path'); const dotenv = require('dotenv'); // Load environment variables dotenv.config(); +const { problem } = require('./utils/htmltrustProtocol'); +const { assertConfigured } = require('./utils/apiKeys'); + +// Fail fast on a misconfigured production deployment rather than at the first +// request that happens to need the missing secret. +assertConfigured(); + // Database connection const connectDB = require('./config/db'); connectDB(); @@ -13,32 +22,136 @@ connectDB(); // Initialize Express app const app = express(); +// Security headers. The demo UI at / pulls Bootstrap and crypto-js from +// jsdelivr and carries one inline