From 478ae4d859bb43c644f7e5ac039dc680af56c95f Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 10 Apr 2026 22:36:06 -0500 Subject: [PATCH 1/2] docs: add 'permissive community directory' personality framing Explicitly positions this Node.js reference as one of three federated reference directory implementations, each with a distinct curatorial personality: - Node (this) -- permissive community baseline, full-featured, neutral - Python -- curated journalism with admin approval + punitive scoring - Rust -- rapid-flag public-safety with time-decay + researcher whitelist All three conform to the same OpenAPI spec, demonstrating that federation is real: clients subscribe to one or more directories and weight the returned scores per their own trust policy. --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7875a3..2cde2f9 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,20 @@ -# HTMLTrust Server Reference +# HTMLTrust Server Reference (Node.js) Reference implementation of the HTMLTrust trust directory API — a server that manages author identities, cryptographic key pairs, content signing/verification, and a federated trust directory with reputation tracking. This is a companion to the [HTMLTrust specification](https://github.com/ArcadeLabsInc/htmltrust-spec). +## Personality: the "permissive community directory" + +The HTMLTrust protocol is federated, meaning multiple trust directories MAY coexist with different curatorial philosophies. This Node.js implementation is the baseline reference: full-featured, permissive, and neutral -- suitable for general-purpose deployment and as a canonical implementation of every endpoint in the OpenAPI spec. + +The sibling reference implementations demonstrate alternative curatorial philosophies using the same protocol: + +- **[`htmltrust-server-reference-python`](../htmltrust-server-reference-python/)** -- curated journalism directory. Admin-approval queue, Article/News scope, punitive reputation formula. Simulates EFF/ProPublica/Poynter-style deployments. +- **[`htmltrust-server-reference-rust`](../htmltrust-server-reference-rust/)** -- rapid-flag public-safety directory. Time-decayed reputation, whitelisted-researcher fatal flagging, PostgreSQL backend. Simulates Internet Archive / security research collective deployments. + +All three conform to the same OpenAPI spec. Clients don't need per-directory logic -- they simply subscribe to one or more directories and weight the returned scores according to their own trust policy. + ## What It Does This server implements the **Trust Directory** component of the HTMLTrust system: From cbda56c4203fd836b780e0b8bcf4d809808a4508 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Tue, 28 Apr 2026 19:19:40 -0500 Subject: [PATCH 2/2] feat: add endorsement endpoints and deprecate /api/content/verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements spec §2.5 endorsement storage and serving, and aligns the trust server's role with spec §3.1 by deprecating the server-side verification endpoint. Cryptographic verification is a local operation in clients; the trust directory's job is to store and serve signed artifacts (signatures, endorsements, reports), not to act as an oracle. Endorsements (spec §2.5): - POST /api/endorsements stores a signed endorsement blob, optionally re-verifying server-side as a sanity check (clients MUST NOT rely on this; verification is local per spec) - GET /api/endorsements?content-hash=... returns stored blobs verbatim for clients to verify locally - DELETE /api/endorsements/:id (MVP-gated; TODO for keyid-match auth) - Endorsement model with unique compound index on {contentHash, endorser} Deprecation of /api/content/verify: - Sets Deprecation: true and Link rel="deprecation" headers on response - deprecated: true in OpenAPI spec - README documents the deprecation and points to local verification Behavior of existing sign and verify endpoints is unchanged; only the metadata signaling that verify is no longer canonical. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 +- openapi.yaml | 201 ++++++++++++++++++- src/controllers/contentController.js | 121 +++++++++--- src/controllers/endorsementController.js | 235 +++++++++++++++++++++++ src/models/ContentSignature.js | 12 ++ src/models/Endorsement.js | 65 +++++++ src/routes/endorsements.js | 18 ++ src/server.js | 1 + src/utils/crypto.js | 39 +++- 9 files changed, 670 insertions(+), 34 deletions(-) create mode 100644 src/controllers/endorsementController.js create mode 100644 src/models/Endorsement.js create mode 100644 src/routes/endorsements.js diff --git a/README.md b/README.md index 2cde2f9..2e918b2 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,18 @@ Full API documentation is in [`openapi.yaml`](openapi.yaml). Key endpoint groups | `POST /api/authors` | Create author + key pair | General API key | | `GET /api/authors/:id/public-key` | Get author's public key | Public | | `POST /api/content/sign` | Sign a content hash | Author API key | -| `POST /api/content/verify` | Verify a signature | Public | +| `POST /api/content/verify` | Verify a signature (deprecated, see below) | Public | | `GET /api/directory/keys` | Search public keys | Public | | `GET /api/directory/content` | Search signed content | Public | +| `GET /api/endorsements?content-hash=...` | List endorsements for a content hash | Public | +| `POST /api/endorsements` | Submit a signed endorsement | General API key | +| `DELETE /api/endorsements/:id` | Delete an endorsement | General API key | | `POST /api/votes` | Vote trust/distrust | General API key | +### Deprecated endpoints + +`POST /api/content/verify` is deprecated. Per [HTMLTrust spec §3.1](https://htmltrust.dev/spec#section-3-1), cryptographic verification is a local operation: clients MUST verify signatures themselves (e.g. via `SubtleCrypto`) using public keys resolved through the directory's key endpoints. A remote yes/no answer from the directory is by definition not a cryptographic guarantee since the directory is not part of the trust root. The endpoint remains as a low-trust convenience for legacy clients, returns the `Deprecation: true` header (RFC 9745), and will be removed in a future major version. The directory's role is to serve public keys, endorsements, and reputation data — not to act as an oracle for signature validity. + ### Authentication Three tiers of API key auth via headers: @@ -97,6 +104,7 @@ src/ │ ├── claimController.js │ ├── contentController.js │ ├── directoryController.js +│ ├── endorsementController.js │ └── voteController.js ├── middleware/ │ └── auth.js # API key authentication @@ -105,6 +113,7 @@ src/ │ ├── Claim.js │ ├── ContentOccurrence.js │ ├── ContentSignature.js +│ ├── Endorsement.js │ ├── Key.js │ └── Vote.js ├── public/ # Demo web UI @@ -115,6 +124,7 @@ src/ │ ├── claims.js │ ├── content.js │ ├── directory.js +│ ├── endorsements.js │ └── votes.js └── utils/ └── crypto.js # Key generation, signing, verification diff --git a/openapi.yaml b/openapi.yaml index ce5d294..53850fe 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -26,6 +26,13 @@ tags: description: Operations for managing claim types and assertions - name: Directory description: Operations for the Network of Trust directory services + - name: Endorsements + description: | + Operations for storing and retrieving third-party endorsements per + HTMLTrust spec §2.5. Endorsements are standalone signed JSON blobs + that attest a third party's opinion about a specific content hash. + The directory is a passive store; verification is performed locally + by the client. security: - {} # No default security @@ -303,6 +310,54 @@ components: authorId: "123e4567-e89b-12d3-a456-426614174000" signatureValid: true + Endorsement: + type: object + description: | + A standalone signed endorsement of a specific content hash, per + HTMLTrust spec §2.5. Verification is performed locally by the + client; the directory is a passive store. + required: + - endorser + - contentHash + - signature + - timestamp + properties: + _id: + type: string + description: Server-assigned identifier + endorser: + type: string + description: Opaque endorser keyid (e.g. "did:web:publisher.org") + contentHash: + type: string + description: The targeted content hash (e.g. "sha256:...") + signature: + type: string + description: Base64 signature over "{contentHash}:{timestamp}" + timestamp: + type: string + description: ISO-8601 timestamp at which the endorsement was issued + algorithm: + type: string + description: Signature algorithm (default ed25519) + default: ed25519 + rawBlob: + type: string + description: | + The exact bytes that were signed. Clients SHOULD use this for + byte-identical re-verification. + createdAt: + type: string + format: date-time + description: When the endorsement was stored by this directory + example: + endorser: "did:web:publisher.org" + contentHash: "sha256:RAyBCvKTW5KNnGZSyXZYe+8V8DEEnUMRxjk5LSgCHo4" + signature: "BASE64_SIG" + timestamp: "2025-05-01T00:00:00Z" + algorithm: "ed25519" + rawBlob: "{\"endorser\":\"did:web:publisher.org\",\"endorsement\":\"sha256:RAyBCvKTW5KNnGZSyXZYe+8V8DEEnUMRxjk5LSgCHo4\",\"signature\":\"BASE64_SIG\",\"timestamp\":\"2025-05-01T00:00:00Z\"}" + paths: /authors: post: @@ -646,8 +701,14 @@ paths: post: tags: - Content - summary: Verify content signature - description: Verifies the signature of content + summary: Verify content signature (DEPRECATED) + description: | + DEPRECATED: cryptographic verification is a local operation per + HTMLTrust spec §3.1. This endpoint is retained as a low-trust + convenience and will be removed in a future major version. Clients + MUST verify signatures locally (e.g. via SubtleCrypto). Responses + include the `Deprecation: true` header per RFC 9745. + deprecated: true operationId: verifyContent requestBody: required: true @@ -1209,3 +1270,139 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + + /endorsements: + get: + tags: + - Endorsements + summary: List endorsements for a content hash + description: | + Returns all endorsements on file for a given content hash. The + directory is a passive store; clients MUST verify each endorsement's + signature locally using the endorser's public key (per spec §2.5) + before treating it as trustworthy. Unverified endorsements MUST NOT + contribute to any trust decision. + operationId: listEndorsements + parameters: + - name: content-hash + in: query + required: true + description: The content hash to look up endorsements for (e.g. "sha256:...") + schema: + type: string + responses: + "200": + description: Array of endorsements + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Endorsement" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + tags: + - Endorsements + summary: Submit a signed endorsement for storage + description: | + Stores a signed endorsement blob. The directory MAY opportunistically + verify the signature against any locally-known public key for the + endorser as a sanity check, but storage does not depend on + verification — clients verify locally per spec §2.5. + operationId: createEndorsement + security: + - GeneralApiKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - endorser + - contentHash + - signature + - timestamp + properties: + endorser: + type: string + description: Opaque endorser keyid (e.g. "did:web:publisher.org") + contentHash: + type: string + description: The targeted content hash (e.g. "sha256:...") + signature: + type: string + description: Base64 signature over "{contentHash}:{timestamp}" + timestamp: + type: string + description: ISO-8601 timestamp at which the endorsement was issued + algorithm: + type: string + description: Signature algorithm (default ed25519) + default: ed25519 + rawBlob: + type: string + description: | + The exact bytes the client signed over. Clients SHOULD + supply this so verifiers can re-verify byte-identically. + If omitted, the server constructs a canonical blob in a + stable key order. + responses: + "201": + description: Endorsement stored + content: + application/json: + schema: + $ref: "#/components/schemas/Endorsement" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /endorsements/{id}: + delete: + tags: + - Endorsements + summary: Delete an endorsement + description: | + Removes an endorsement from the directory. MVP: gated behind the + general API key only. Production deployments MUST additionally + require that the caller's authenticated identity match the + endorsement's `endorser` keyid. + operationId: deleteEndorsement + security: + - GeneralApiKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Endorsement deleted + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Endorsement not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/src/controllers/contentController.js b/src/controllers/contentController.js index cfa7b16..5bf0533 100644 --- a/src/controllers/contentController.js +++ b/src/controllers/contentController.js @@ -4,19 +4,46 @@ const ContentSignature = require('../models/ContentSignature'); const ContentOccurrence = require('../models/ContentOccurrence'); const { signContent, verifySignature } = require('../utils/crypto'); +/** + * Build the canonical binding string that is actually signed. + * + * Per HTMLTrust spec §2.1, the signature binds four values with colon + * separators: {content-hash}:{claims-hash}:{domain}:{signed-at} + * + * The signer's identity is intentionally NOT included in the binding + * because it is implicit in keyid resolution: any attempt to claim a + * signature under a different identity would resolve to a different + * public key and fail verification. + */ +const buildBinding = ({ contentHash, claimsHash, domain, signedAt }) => { + if (!contentHash || !claimsHash || !domain || !signedAt) { + throw new Error( + `Missing required binding field(s): contentHash=${contentHash}, claimsHash=${claimsHash}, domain=${domain}, signedAt=${signedAt}` + ); + } + return `${contentHash}:${claimsHash}:${domain}:${signedAt}`; +}; + /** * @desc Sign content * @route POST /api/content/sign * @access Private (Author API Key) + * + * Request body: + * - contentHash: string, already-hashed canonical content (e.g. "sha256:...") + * - claimsHash: string, already-hashed canonical claims serialization + * - domain: string, publication origin + * - signedAt: string, ISO-8601 timestamp + * - claims: object, full claims map (stored for serving back to verifiers) */ exports.signContent = async (req, res) => { try { - const { contentHash, domain, claims } = req.body; + const { contentHash, claimsHash, domain, signedAt, claims } = req.body; const author = req.author; // Get author's private key const key = await Key.findOne({ authorId: author._id }).select('+privateKey'); - + if (!key) { return res.status(404).json({ code: 'NOT_FOUND', @@ -24,10 +51,10 @@ exports.signContent = async (req, res) => { }); } - // Create data string to sign (contentHash + domain + authorId) - const dataToSign = `${contentHash}:${domain}:${author._id}`; - - // Sign the data + // Build canonical binding per spec §2.1 + const dataToSign = buildBinding({ contentHash, claimsHash, domain, signedAt }); + + // Sign the binding string const signature = signContent(dataToSign, key.privateKey, key.algorithm); // Check if a signature already exists for this content, domain, and author @@ -41,12 +68,16 @@ exports.signContent = async (req, res) => { // Update existing signature contentSignature.signature = signature; contentSignature.claims = claims; + contentSignature.claimsHash = claimsHash; + contentSignature.signedAt = signedAt; contentSignature.occurrences += 1; await contentSignature.save(); } else { // Create new signature contentSignature = await ContentSignature.create({ contentHash, + claimsHash, + signedAt, domain, authorId: author._id, keyId: key._id, @@ -58,6 +89,8 @@ exports.signContent = async (req, res) => { // Return the signature res.status(201).json({ contentHash, + claimsHash, + signedAt, domain, authorId: author._id, signature, @@ -77,14 +110,38 @@ exports.signContent = async (req, res) => { * @desc Verify content signature * @route POST /api/content/verify * @access Public + * + * DEPRECATED: cryptographic verification is a local operation per HTMLTrust + * spec §3.1; this endpoint is retained as a low-trust convenience and will + * be removed in a future major version. Clients MUST verify signatures + * locally (e.g. via SubtleCrypto) — a remote yes/no answer from a directory + * is by definition not a cryptographic guarantee, since the directory is + * not part of the trust root. The directory's role is to serve public keys, + * endorsements, and reputation data; it is not an oracle for signature + * validity. + * + * Responses include the HTTP `Deprecation: true` header (RFC 9745) and a + * `Link` header pointing at the relevant spec section to advertise the + * deprecation to clients. + * + * Request body: + * - contentHash: string + * - claimsHash: string + * - domain: string + * - signedAt: string (ISO-8601) + * - authorId: string + * - signature: string (base64) */ exports.verifyContent = async (req, res) => { + // Advertise deprecation per RFC 9745. + res.set('Deprecation', 'true'); + res.set('Link', '; rel="deprecation"'); try { - const { contentHash, domain, authorId, signature } = req.body; + const { contentHash, claimsHash, domain, signedAt, authorId, signature } = req.body; // Get author const author = await Author.findById(authorId); - + if (!author) { return res.status(404).json({ code: 'NOT_FOUND', @@ -94,7 +151,7 @@ exports.verifyContent = async (req, res) => { // Get author's public key const key = await Key.findOne({ authorId }); - + if (!key) { return res.status(404).json({ code: 'NOT_FOUND', @@ -102,11 +159,15 @@ exports.verifyContent = async (req, res) => { }); } - // Create data string that was signed - const dataToVerify = `${contentHash}:${domain}:${authorId}`; - - // Verify the signature - const valid = verifySignature(dataToVerify, signature, key.publicKey, key.algorithm); + // Build canonical binding per spec §2.1 + let valid = false; + try { + const dataToVerify = buildBinding({ contentHash, claimsHash, domain, signedAt }); + valid = verifySignature(dataToVerify, signature, key.publicKey, key.algorithm); + } catch (err) { + // Missing binding fields -> invalid signature + valid = false; + } // Get claims if signature is valid let claims = {}; @@ -117,10 +178,10 @@ exports.verifyContent = async (req, res) => { domain, authorId }); - + if (contentSignature) { claims = contentSignature.claims; - + // Increment verified signatures count key.verifiedSignatures += 1; await key.save(); @@ -149,23 +210,25 @@ exports.verifyContent = async (req, res) => { */ exports.registerOccurrence = async (req, res) => { try { - const { contentHash, url, domain, authorId, signature } = req.body; + const { contentHash, claimsHash, signedAt, url, domain, authorId, signature } = req.body; // Verify the signature if provided let signatureValid = false; let signatureId = null; - - if (signature && authorId) { + + if (signature && authorId && claimsHash && signedAt) { // Get author's public key const key = await Key.findOne({ authorId }); - + if (key) { - // Create data string that was signed - const dataToVerify = `${contentHash}:${domain}:${authorId}`; - - // Verify the signature - signatureValid = verifySignature(dataToVerify, signature, key.publicKey, key.algorithm); - + // Build canonical binding per spec §2.1 + try { + const dataToVerify = buildBinding({ contentHash, claimsHash, domain, signedAt }); + signatureValid = verifySignature(dataToVerify, signature, key.publicKey, key.algorithm); + } catch (err) { + signatureValid = false; + } + if (signatureValid) { // Find or create the content signature let contentSignature = await ContentSignature.findOne({ @@ -173,17 +236,19 @@ exports.registerOccurrence = async (req, res) => { domain, authorId }); - + if (!contentSignature) { contentSignature = await ContentSignature.create({ contentHash, + claimsHash, + signedAt, domain, authorId, keyId: key._id, signature }); } - + signatureId = contentSignature._id; } } diff --git a/src/controllers/endorsementController.js b/src/controllers/endorsementController.js new file mode 100644 index 0000000..b0a0804 --- /dev/null +++ b/src/controllers/endorsementController.js @@ -0,0 +1,235 @@ +const Endorsement = require('../models/Endorsement'); +const Author = require('../models/Author'); +const Key = require('../models/Key'); +const { verifySignature } = require('../utils/crypto'); + +/** + * Build the canonical rawBlob for an endorsement when the client did not + * supply one. The wire format follows the example in spec §2.5: + * + * { + * "endorser": "did:web:publisher.org", + * "endorsement": "sha256-XYZ", + * "signature": "BASE64_SIG", + * "timestamp": "2025-05-01T00:00Z" + * } + * + * 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. + */ +const buildCanonicalBlob = ({ endorser, contentHash, signature, timestamp, algorithm }) => { + const obj = { + endorser, + endorsement: contentHash, + signature, + timestamp + }; + if (algorithm && algorithm.toLowerCase() !== 'ed25519') { + obj.algorithm = algorithm; + } + return JSON.stringify(obj); +}; + +/** + * Best-effort, opportunistic verification of an endorsement's signature. + * + * 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. + * + * Returns true if verification succeeded, false if it failed, or null if no + * key was available to attempt verification. + */ +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; + + key = await Key.findOne({ authorId: author._id }); + if (!key) return null; + } catch (err) { + return null; + } + + try { + const binding = `${contentHash}:${timestamp}`; + return verifySignature(binding, signature, key.publicKey, key.algorithm || algorithm); + } catch (err) { + return false; + } +}; + +/** + * @desc Create (or upsert) an endorsement + * @route POST /api/endorsements + * @access Private (General API Key) + * + * 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.) + */ +exports.createEndorsement = async (req, res) => { + 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' + }); + } + + if (!rawBlob) { + rawBlob = buildCanonicalBlob({ endorser, contentHash, signature, timestamp, algorithm }); + } + + // 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}` + ); + } + + // 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 + }); + } + + 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 + }); + } catch (error) { + console.error('Create endorsement error:', error); + res.status(400).json({ + code: 'BAD_REQUEST', + message: error.message + }); + } +}; + +/** + * @desc List endorsements for a content hash + * @route GET /api/endorsements?content-hash=sha256:... + * @access Public + */ +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; + + if (!contentHash) { + return res.status(400).json({ + code: 'BAD_REQUEST', + message: '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 + })) + ); + } catch (error) { + console.error('List endorsements error:', error); + res.status(400).json({ + code: 'BAD_REQUEST', + message: error.message + }); + } +}; + +/** + * @desc Delete an endorsement + * @route DELETE /api/endorsements/:id + * @access Private (General API Key) + * + * 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). + * + * TODO: enforce caller-keyid match against endorsement.endorser before + * permitting deletion. + */ +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' + }); + } + + await Endorsement.deleteOne({ _id: endorsement._id }); + + res.status(204).send(); + } catch (error) { + console.error('Delete endorsement error:', error); + res.status(400).json({ + code: 'BAD_REQUEST', + message: error.message + }); + } +}; diff --git a/src/models/ContentSignature.js b/src/models/ContentSignature.js index 51c6dc0..dcd2eba 100644 --- a/src/models/ContentSignature.js +++ b/src/models/ContentSignature.js @@ -6,6 +6,18 @@ 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. + claimsHash: { + type: String, + default: '' + }, + // ISO-8601 timestamp from the element in the + // signed-section. Part of the signature binding per spec §2.1. + signedAt: { + type: String, + default: '' + }, domain: { type: String, required: [true, 'Domain is required'], diff --git a/src/models/Endorsement.js b/src/models/Endorsement.js new file mode 100644 index 0000000..4d138fa --- /dev/null +++ b/src/models/Endorsement.js @@ -0,0 +1,65 @@ +const mongoose = require('mongoose'); + +/** + * Endorsement model. + * + * Per HTMLTrust spec §2.5, an endorsement is a standalone signed JSON blob + * issued by a third party (publisher, expert, other user) that attests an + * opinion about a specific piece of signed content at a specific moment in + * time. Endorsements target specific content hashes, not signers. + * + * The directory acts as a passive store: it indexes endorsements by content + * hash and serves them on request. Cryptographic verification of an + * 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. + */ +const EndorsementSchema = new mongoose.Schema({ + // Opaque endorser keyid (e.g. "did:web:publisher.org" or any other form + // resolvable to a public key per spec §2.3). + endorser: { + type: String, + required: [true, 'Endorser keyid is required'], + index: true + }, + // The targeted content hash, e.g. "sha256:..." per spec §2.1. + contentHash: { + type: String, + required: [true, 'Content hash is required'], + index: true + }, + // Base64-encoded signature over the binding "{contentHash}:{timestamp}". + signature: { + type: String, + required: [true, 'Signature is required'] + }, + // ISO-8601 timestamp at which the endorsement was issued. + timestamp: { + type: String, + required: [true, 'Timestamp is required'] + }, + algorithm: { + type: String, + enum: ['ed25519', 'ED25519', 'RSA', 'ECDSA'], + default: 'ed25519' + }, + // Original signed JSON blob the client posted, stored verbatim so verifiers + // can re-verify byte-identically without re-serializing. + rawBlob: { + type: String, + required: [true, 'rawBlob is required'] + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +// 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 }); + +module.exports = mongoose.model('Endorsement', EndorsementSchema); diff --git a/src/routes/endorsements.js b/src/routes/endorsements.js new file mode 100644 index 0000000..89dfbe8 --- /dev/null +++ b/src/routes/endorsements.js @@ -0,0 +1,18 @@ +const express = require('express'); +const router = express.Router(); +const { + createEndorsement, + listEndorsements, + deleteEndorsement +} = require('../controllers/endorsementController'); +const { protectWithGeneralApiKey } = require('../middleware/auth'); + +// Routes +router.route('/') + .get(listEndorsements) + .post(protectWithGeneralApiKey, createEndorsement); + +router.route('/:id') + .delete(protectWithGeneralApiKey, deleteEndorsement); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 1583865..9dccf17 100644 --- a/src/server.js +++ b/src/server.js @@ -24,6 +24,7 @@ app.use('/api/content', require('./routes/content')); app.use('/api/claims', require('./routes/claims')); app.use('/api/directory', require('./routes/directory')); app.use('/api/votes', require('./routes/votes')); +app.use('/api/endorsements', require('./routes/endorsements')); // Default route app.get('/', (req, res) => { diff --git a/src/utils/crypto.js b/src/utils/crypto.js index bf0eba3..64b659b 100644 --- a/src/utils/crypto.js +++ b/src/utils/crypto.js @@ -139,14 +139,46 @@ const getNormalizeText = async () => { }; /** - * Hash content using SHA-256, applying canonical normalization first + * Hash content using SHA-256, applying canonical normalization first. + * + * Returns an unpadded Base64-encoded digest prefixed with the algorithm name, + * per HTMLTrust spec §2.1 "Hash and signature encoding". Example output: + * + * "sha256:RAyBCvKTW5KNnGZSyXZYe+8V8DEEnUMRxjk5LSgCHo4" + * + * Callers MUST use the full prefixed form (including "sha256:") when + * storing or transmitting the hash. + * * @param {string} content - The content to hash - * @returns {Promise} - The hash + * @returns {Promise} - The prefixed hash string */ const hashContent = async (content) => { const normalizeText = await getNormalizeText(); const normalized = normalizeText(content); - return crypto.createHash("sha256").update(normalized).digest("hex"); + const digest = crypto + .createHash("sha256") + .update(normalized) + .digest("base64") + .replace(/=+$/, ""); // unpadded + return `sha256:${digest}`; +}; + +/** + * Hash an already-canonicalized string using SHA-256 with base64 encoding. + * Use this when the input is already the canonical serialization (e.g., a + * claims canonical string from canonicalizeClaims()); it skips the text + * normalization step. + * + * @param {string} canonical - Already-canonical string + * @returns {string} - Prefixed hash (e.g. "sha256:...") + */ +const hashCanonical = (canonical) => { + const digest = crypto + .createHash("sha256") + .update(canonical) + .digest("base64") + .replace(/=+$/, ""); + return `sha256:${digest}`; }; /** @@ -162,5 +194,6 @@ module.exports = { signContent, verifySignature, hashContent, + hashCanonical, generateApiKey, };