From ea402ae4ff33bd8b0a9a9c71721cdd24e2238a1d Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 03:51:21 -0500 Subject: [PATCH] feat: add immutable signature v1 signer --- README.md | 15 +- .../03-signed-content-submission.yaml | 51 +-- .../04-content-retrieval-by-hash.yaml | 12 +- openapi.yaml | 130 ++++++- package-lock.json | 6 +- package.json | 2 +- src/controllers/contentController.js | 243 ++++++++---- src/public/index.html | 17 +- src/public/js/main.js | 19 +- src/utils/signingProfile.js | 22 +- test/contentSigning.test.js | 368 ++++++++++++++++++ 11 files changed, 735 insertions(+), 150 deletions(-) create mode 100644 test/contentSigning.test.js diff --git a/README.md b/README.md index 533f8ce..a7d9fc7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # HTMLTrust Server Reference +- Maintainer: Jason Grey +- Updated: 2026-08-28 +- Version: 0.1.0, draft v1 API +- Status: Runnable reference server +- For: directory operators and integration developers +- Reading time: 10 minutes + This repository contains the runnable Node.js reference server for the HTMLTrust trust directory API. It stores author profiles and public keys, accepts signed content and endorsements, and exposes directory search and reputation data. The wire contract is documented in [`openapi.yaml`](openapi.yaml). The server is the implementation used by the local development workflow and the end-to-end simulation. @@ -130,7 +137,7 @@ Use RFC 9421 signatures for canonical writes. The compatibility surface supports | Header | Use | |---|---| | `X-API-KEY` | General compatibility operations, including author creation, occurrence registration, reporting, and demo submissions | -| `X-AUTHOR-API-KEY` | Author-specific compatibility operations and the compatibility signing helper | +| `X-AUTHOR-API-KEY` | Author-specific operations and the v1 convenience signing helper | | `X-ADMIN-API-KEY` | Claim-type administration and endorsement takedown | Author API keys are returned once by `POST /api/authors`. The server stores an HMAC-SHA-256 digest under `AUTHOR_API_KEY_PEPPER`. A deployment that still has plaintext keys must migrate them using the procedure in [`src/utils/apiKeys.js`](src/utils/apiKeys.js), then remove the plaintext field. @@ -138,8 +145,10 @@ Author API keys are returned once by `POST /api/authors`. The server stores an H ### Wire-format details - Hashes, signatures, and key bytes use unpadded standard Base64. -- `domain` values are serialized web origins such as `https://publisher.example:8443`. -- Content signatures bind `contentHash`, `claimsHash`, `domain`, and `signedAt`. `claimsHash` covers the canonical serialization of direct `meta` claims in the signed section. +- `sourceURL` values sent to `POST /api/content/sign` must be final HTTPS URLs. The `scope` value (`url` or `origin`) determines the derived `location` bound into the v1 signing payload. +- `POST /api/content/sign` computes `claimsHash` from the strict v1 claims array and signs the RFC 8785 JCS object containing the frozen profile, algorithm, keyid, content hash, claims hash, timestamp, scope, and location. It returns the complete attribute set needed for a ``. +- The convenience signer uses the newest active directory-held key. It returns the stored artifact for an identical retry. A changed payload for the same content, location, and key returns `409 Conflict` because signed artifacts are immutable. +- The convenience route rejects the legacy `domain`, `authorId`, and caller-supplied `claimsHash` fields. Use canonical `POST /content` when the author holds the private key and submits an RFC 9421-authenticated signature. - Endorsement signatures cover the RFC 8785 JCS serialization of the endorsement document with `signature` omitted. New endorsements are served as signed, and the 201 response supplies the stored identifier in `Location`. - Endorsements are append-only. An identical retry on canonical `POST /endorsements` returns `201` with the existing resource in `Location`; the `/api/endorsements` compatibility route returns `200`. A different document from the same endorser and content hash is stored as another record. diff --git a/conformance/fixtures/03-signed-content-submission.yaml b/conformance/fixtures/03-signed-content-submission.yaml index 2a6e663..236bdb9 100644 --- a/conformance/fixtures/03-signed-content-submission.yaml +++ b/conformance/fixtures/03-signed-content-submission.yaml @@ -2,11 +2,12 @@ # # Prerequisite: create an author so we have an author-scoped API key. # Then sign a content hash. The signature itself is server-generated; -# we only assert response shape (the spec requires contentHash, domain, -# authorId, signature, and claims to be present). +# the convenience endpoint emits the complete frozen v1 signed-section +# attribute set. name: Sign content with author key description: | - POST /content/sign produces a ContentSignature bound to the author's key. + POST /content/sign produces a v1 signed-section record bound to the + author's directory-held key and the final HTTPS source URL. steps: - name: Create author (signing identity) request: @@ -32,40 +33,26 @@ steps: X-AUTHOR-API-KEY: $authorApiKey body: contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc" - claimsHash: "sha256:4LOflWusiW26FjvAHhwAZpPqZrLblKkYZ1QYKPORKDo" - domain: "https://conformance.example.com" - signedAt: "2026-05-12T12:00:00.000Z" + sourceURL: "https://conformance.example.com/articles/03#summary" + scope: "url" + signedAt: "2026-05-12T12:00:00Z" claims: - signed-at: "2026-05-12T12:00:00.000Z" - ContentType: "Article" - License: "CC-BY-4.0" + - name: "signed-at" + content: "2026-05-12T12:00:00Z" + - name: "claim:ContentType" + content: "Article" + - name: "claim:License" + content: "CC-BY-4.0" expect: status: 201 - schema: ContentSignature + schema: V1ContentSignature body: contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc" - domain: "https://conformance.example.com" + profile: "htmltrust-signature-v1" + scope: "url" + location: "https://conformance.example.com/articles/03" + sourceURL: "https://conformance.example.com/articles/03#summary" signature: $nonempty-string algorithm: "ed25519" keyid: $nonempty-string - claims: - ContentType: "Article" - License: "CC-BY-4.0" - capture: - signature: $.signature - - - name: Verify the freshly signed content - request: - method: POST - path: /content/verify - body: - contentHash: "sha256:xJTJYuXl1MuP1EjRLhKtgMUZvvc6qexrTMHyVnVL+Yc" - claimsHash: "sha256:4LOflWusiW26FjvAHhwAZpPqZrLblKkYZ1QYKPORKDo" - domain: "https://conformance.example.com" - signedAt: "2026-05-12T12:00:00.000Z" - authorId: $authorId - signature: $signature - expect: - status: 200 - body: - valid: true + claims: $any diff --git a/conformance/fixtures/04-content-retrieval-by-hash.yaml b/conformance/fixtures/04-content-retrieval-by-hash.yaml index 31c2c6f..1c56f1e 100644 --- a/conformance/fixtures/04-content-retrieval-by-hash.yaml +++ b/conformance/fixtures/04-content-retrieval-by-hash.yaml @@ -33,12 +33,14 @@ steps: X-AUTHOR-API-KEY: $authorApiKey body: contentHash: "sha256:82rHSQ/ThLduI0bbHYOVbxn5mEXR0FxMzn6YsVHSwSs" - claimsHash: "sha256:M9aSt+kY8XVWfoAaE4jJEbxdU2sOw01Kr8BU/vhyoUo" - domain: "https://retrieval.example.com" - signedAt: "2026-05-12T12:00:00.000Z" + sourceURL: "https://retrieval.example.com/articles/04" + scope: "url" + signedAt: "2026-05-12T12:00:00Z" claims: - signed-at: "2026-05-12T12:00:00.000Z" - ContentType: "Article" + - name: "signed-at" + content: "2026-05-12T12:00:00Z" + - name: "claim:ContentType" + content: "Article" expect: status: 201 diff --git a/openapi.yaml b/openapi.yaml index c0a9153..fd0617d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -436,6 +436,89 @@ components: createdAt: "2023-01-01T12:00:00Z" updatedAt: "2023-01-01T12:00:00Z" + V1ContentSignature: + type: object + required: + - profile + - context + - canonicalizationProfile + - attributeProfile + - urlProfile + - contentHash + - claimsHash + - signedAt + - scope + - location + - sourceURL + - keyid + - algorithm + - signature + - claims + properties: + profile: + type: string + enum: [htmltrust-signature-v1] + context: + type: string + format: uri + canonicalizationProfile: + type: string + enum: [htmltrust-c14n-v1] + attributeProfile: + type: string + enum: [htmltrust-attrs-v1] + urlProfile: + type: string + enum: [htmltrust-safe-url-v1] + contentHash: + type: string + description: Hash of the canonical signed content + claimsHash: + type: string + description: Directory-computed hash of the canonical v1 claims serialization + signedAt: + type: string + description: Exact UTC timestamp in YYYY-MM-DDTHH:MM:SSZ form + scope: + type: string + enum: [url, origin] + location: + type: string + format: uri + description: URL or origin derived from sourceURL under scope + sourceURL: + type: string + format: uri + description: Final HTTPS occurrence URL + keyid: + type: string + description: Exact directory key identifier bound into the JCS payload + algorithm: + type: string + description: Canonical signature algorithm identifier + signature: + type: string + description: Signature over the RFC 8785 JCS v1 signing payload + claims: + type: array + items: + type: object + required: [name, content] + additionalProperties: false + properties: + name: { type: string } + content: { type: string } + authorId: + type: string + description: Directory author that owns the signing key + domain: + type: string + format: uri + description: Occurrence origin retained for compatibility and indexing + createdAt: + type: string + format: date-time + ContentSignature: type: object required: @@ -1250,10 +1333,12 @@ paths: - Content summary: Sign content description: | - Compatibility signing helper. The client submits already-computed - `contentHash` and `claimsHash` values; the server signs the draft - payload `contentHash:claimsHash:domain:signedAt`. `claimsHash` is the - hash of all direct child `meta` claims, including `signed-at`. + Author-authenticated convenience signer for the frozen + `htmltrust-signature-v1` profile. The directory selects the + authenticated author's directory-held key, derives `location` from + the final HTTPS `sourceURL` and `scope`, computes `claimsHash` from + the strict v1 claims array, and signs the RFC 8785 JSON payload. The + caller must not submit `claimsHash` or the legacy `domain` field. operationId: signContent security: - AuthorApiKey: [] @@ -1265,35 +1350,42 @@ paths: type: object required: - contentHash - - claimsHash - - domain + - sourceURL + - scope - signedAt - claims properties: contentHash: type: string description: Hash of the normalized content using canonical unpadded standard Base64 - claimsHash: + sourceURL: type: string - description: Hash of all direct child meta claims using canonical unpadded standard Base64 - domain: + format: uri + description: Final HTTPS response URL where the signed section will be published + scope: type: string - description: Serialized Web origin associated with the content; bare hostnames are invalid + enum: [url, origin] + description: Location binding scope used to derive the signed location signedAt: type: string - format: date-time - description: UTC RFC3339 timestamp from the direct child signed-at claim + description: Exact UTC timestamp in YYYY-MM-DDTHH:MM:SSZ form, also present in claims claims: - type: object - description: Direct child meta claims about the content; every such claim participates in claimsHash. - additionalProperties: true + type: array + description: Strict v1 claim records. Include exactly one signed-at record. + items: + type: object + required: [name, content] + additionalProperties: false + properties: + name: { type: string } + content: { type: string } responses: "201": description: Content signed successfully content: application/json: schema: - $ref: "#/components/schemas/ContentSignature" + $ref: "#/components/schemas/V1ContentSignature" "400": description: Invalid input content: @@ -1306,6 +1398,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "404": + description: No active directory-held signing key exists for the author + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" /content/verify: post: diff --git a/package-lock.json b/package-lock.json index 2e39ee0..18847b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/37359dd8a872f8d09fa0e7f7dd75567d92e5bec4.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", @@ -26,8 +26,8 @@ }, "node_modules/@htmltrust/canonicalization": { "version": "0.3.0", - "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/37359dd8a872f8d09fa0e7f7dd75567d92e5bec4.tar.gz", - "integrity": "sha512-IXCQNBj3M5CtOsk8zNncN9LhmDPrOUul3O13hqmgBbGdUDRZ9V2JOigdklAFtxTy3QAoE4SuODW/48svVZwwow==", + "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "integrity": "sha512-oeZyQepl+Xub2j0Q+i84jxL21L6z7l/6CHtavTAarqHivCAbA0OZGnIwdt265GMMqtN7co9l1dK6yGtNtBLZ8g==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "parse5": "7.3.0" diff --git a/package.json b/package.json index 379d9fc..b7625ce 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "author": "Jason Grey ", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/37359dd8a872f8d09fa0e7f7dd75567d92e5bec4.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", diff --git a/src/controllers/contentController.js b/src/controllers/contentController.js index 44efe22..bc4ab18 100644 --- a/src/controllers/contentController.js +++ b/src/controllers/contentController.js @@ -15,11 +15,15 @@ const { normalizeAlgorithm, normalizeSerializedOrigin, problem, - signedAtFromClaims, } = require('../utils/htmltrustProtocol'); const { canonicalizeClaims } = require('../utils/claims'); const { directoryKeyUrl } = require('../utils/directoryUrl'); -const { validateV1ContentSubmission } = require('../utils/signingProfile'); +const { + buildV1ContentSigningPayload, + deriveLocation, + PROFILE, + validateV1ContentSubmission, +} = require('../utils/signingProfile'); const { negotiatedType } = require('../middleware/contentNegotiation'); /** @@ -46,6 +50,27 @@ const claimsObject = (claims) => Object.fromEntries( normalizeClaims(claims).map((claim) => [claim.name, claim.content]) ); +const contentSignatureConflict = (message) => { + const error = invalid(message); + error.statusCode = 409; + error.problemType = 'https://htmltrust.org/errors/content-signature-conflict'; + return error; +}; + +const isDuplicateKeyError = (error) => error?.code === 11000 || error?.codeName === 'DuplicateKey'; + +// A v1 ContentSignature row is an immutable signed artifact. Its identity is +// content + profile + derived location + keyid, while sourceURL is occurrence +// metadata and may differ for an origin-scoped signature. +const matchesV1Artifact = (record, prepared, signature) => ( + record.profile === PROFILE.signature && + record.algorithm === prepared.algorithm && + record.claimsHash === prepared.claimsHash && + record.signedAt === prepared.signedAt && + record.scope === prepared.scope && + record.signature === signature +); + /** * Hash of the canonical claims serialization, draft §4.6. * @@ -149,51 +174,62 @@ const contentRecord = async (req, contentHash, { v1Only = false } = {}) => { }; /** - * @desc Sign content + * @desc Sign content with the directory-held v1 convenience key * @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) + * - sourceURL: string, final HTTPS response URL + * - scope: string, either "url" or "origin" + * - signedAt: string, exact v1 UTC timestamp + * - claims: array of {name, content} records, including signed-at + * + * The directory derives every other signing field from the authenticated + * author and the submitted source URL. This route intentionally has no + * legacy colon-binding or caller-supplied claims-hash compatibility path. */ exports.signContent = async (req, res) => { try { - 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'); + if (!author || !author._id) { + return problem(res, 401, 'Unauthorized', 'An authenticated author is required to sign content', { + type: 'https://htmltrust.org/errors/unauthorized', + }); } - // 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})` - ); + const body = req.body || {}; + // These fields belonged to the removed colon-binding endpoint. Rejecting + // them catches stale clients instead of silently signing a different + // interpretation of their request. + for (const field of ['domain', 'authorId', 'claimsHash']) { + if (Object.hasOwn(body, field)) { + throw invalid(`${field} is not accepted by the v1 signing endpoint`); + } } - // Get author's private key - const key = await Key.findOne({ authorId: author._id }).select('+privateKey'); + // The authenticated author selects the key and all key/profile fields. + // A client may echo them for diagnostics, but cannot choose a different + // key or algorithm for the directory-held private key. + const keyQuery = Key.findOne({ + authorId: author._id, + revoked: { $ne: true }, + $or: [ + { expiresAt: { $exists: false } }, + { expiresAt: null }, + { expiresAt: { $gt: new Date() } }, + ], + }); + // Rotation creates a new key while the old key remains resolvable for + // historical signatures. The newest usable key is the current convenience + // signing key under the existing Key schema, with _id as a stable tie + // breaker for equal timestamps. + const key = await keyQuery.sort({ createdAt: -1, _id: -1 }).select('+privateKey'); if (!key) { - return res.status(404).json({ - code: 'NOT_FOUND', - message: 'Key not found for this author' + return problem(res, 404, 'Signing key not found', 'No active directory-held signing key exists for this author', { + type: 'https://htmltrust.org/errors/signing-key-not-found', + authorId: String(author._id), }); } @@ -208,59 +244,120 @@ exports.signContent = async (req, res) => { ); } - // 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); + const keyid = keyidFor(req, key); + const algorithm = normalizeAlgorithm(key.algorithm); + const location = deriveLocation(body.sourceURL, body.scope); + const expectedFields = { + profile: PROFILE.signature, + context: PROFILE.context, + canonicalizationProfile: PROFILE.canonicalization, + attributeProfile: PROFILE.attributes, + urlProfile: PROFILE.url, + keyid, + algorithm, + location, + }; + for (const [field, expected] of Object.entries(expectedFields)) { + if (Object.hasOwn(body, field) && body[field] !== expected) { + throw invalid(`${field} must equal the directory-selected v1 value`); + } + } - // Check if a signature already exists for this content, domain, and author - let contentSignature = await ContentSignature.findOne({ - contentHash, - domain, - authorId: author._id + const prepared = await buildV1ContentSigningPayload({ + ...body, + profile: PROFILE.signature, + keyid, + algorithm, + location, }); + const signature = signContent(prepared.payload, key.privateKey, algorithm); + const sourceOrigin = new URL(prepared.sourceURL).origin; - if (contentSignature) { - // Update existing signature - contentSignature.signature = signature; - contentSignature.claims = claimsObject(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, - signature, - claims: claimsObject(claims) - }); + // v1 identity is content + derived location + exact key identifier. A + // signature can occur at more than one URL when its scope is origin. + const identity = { + contentHash: prepared.contentHash, + profile: PROFILE.signature, + location: prepared.location, + keyid, + }; + let contentSignature = await ContentSignature.findOne(identity); + + if (contentSignature && !matchesV1Artifact(contentSignature, prepared, signature)) { + throw contentSignatureConflict( + 'A different signed payload already exists for this content, location, and key', + ); + } + + if (!contentSignature) { + try { + contentSignature = await ContentSignature.create({ + ...identity, + claimsHash: prepared.claimsHash, + signedAt: prepared.signedAt, + domain: sourceOrigin, + algorithm, + scope: prepared.scope, + sourceURL: prepared.sourceURL, + authorId: author._id, + keyId: key._id, + signature, + claims: claimsObject(prepared.claims), + }); + } catch (error) { + // Two identical first submissions can race the unique v1 identity + // index. Re-read the winner and treat it as an idempotent retry. A + // different payload still receives the explicit conflict response. + if (!isDuplicateKeyError(error)) throw error; + contentSignature = await ContentSignature.findOne(identity); + if (!contentSignature) throw error; + if (!matchesV1Artifact(contentSignature, prepared, signature)) { + throw contentSignatureConflict( + 'A different signed payload already exists for this content, location, and key', + ); + } + } } - // Return the signature + await ContentOccurrence.findOneAndUpdate( + { signatureId: contentSignature._id, url: prepared.sourceURL }, + { + signatureId: contentSignature._id, + url: prepared.sourceURL, + domain: sourceOrigin, + signatureValid: true, + lastSeen: Date.now(), + }, + { upsert: true, setDefaultsOnInsert: true }, + ); + + // Return the complete v1 attribute set, so the caller can publish a + // signed-section without reconstructing any cryptographic field. res.status(201).json({ - contentHash, - claimsHash, - signedAt, - domain, + profile: PROFILE.signature, + context: PROFILE.context, + canonicalizationProfile: PROFILE.canonicalization, + attributeProfile: PROFILE.attributes, + urlProfile: PROFILE.url, + contentHash: prepared.contentHash, + claimsHash: prepared.claimsHash, + signedAt: prepared.signedAt, + scope: prepared.scope, + location: prepared.location, + sourceURL: prepared.sourceURL, + domain: sourceOrigin, authorId: author._id, signature, - keyid: keyidFor(req, key), - algorithm: normalizeAlgorithm(key.algorithm), - claims: claimsObject(claims), + keyid, + algorithm, + claims: prepared.claims, createdAt: contentSignature.createdAt }); } catch (error) { console.error('Sign content error:', error); - res.status(400).json({ - code: 'BAD_REQUEST', - message: detailFor(error) + const status = error.statusCode || 400; + return problem(res, status, status === 409 ? 'Content signature conflict' : 'Invalid v1 signing request', detailFor(error), { + type: error.problemType || 'https://htmltrust.org/errors/content-signing-invalid', }); } }; diff --git a/src/public/index.html b/src/public/index.html index f15bbcd..434bfd2 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -131,16 +131,19 @@

Sign Content

- - + +
- - + +
- - + +
@@ -291,4 +294,4 @@

Get Vote Statistics

- \ No newline at end of file + diff --git a/src/public/js/main.js b/src/public/js/main.js index bb960ea..fec0fef 100644 --- a/src/public/js/main.js +++ b/src/public/js/main.js @@ -49,7 +49,7 @@ async function apiRequest(endpoint, method, data = null, headers = {}) { // Hash content using SHA-256 function hashContent(content) { - return 'sha256:' + CryptoJS.SHA256(content).toString(CryptoJS.enc.Hex); + return 'sha256:' + CryptoJS.SHA256(content).toString(CryptoJS.enc.Base64).replace(/=+$/, ''); } // Create Author Form @@ -111,9 +111,9 @@ document.getElementById('sign-content-form').addEventListener('submit', async fu try { const content = document.getElementById('content-text').value; - const domain = document.getElementById('content-domain').value; + const sourceURL = document.getElementById('content-source-url').value; + const scope = document.getElementById('content-scope').value; const claimsJson = document.getElementById('content-claims').value; - const authorId = document.getElementById('author-id').value; const authorApiKey = document.getElementById('author-api-key').value; // Hash the content @@ -127,9 +127,18 @@ document.getElementById('sign-content-form').addEventListener('submit', async fu throw new Error('Invalid JSON for claims'); } + if (!Array.isArray(claims)) { + claims = Object.entries(claims).map(([name, content]) => ({ name, content: String(content) })); + } + const signedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + claims = claims.filter(({ name }) => name !== 'signed-at'); + claims.push({ name: 'signed-at', content: signedAt }); + const data = { contentHash, - domain, + sourceURL, + scope, + signedAt, claims }; @@ -258,4 +267,4 @@ document.getElementById('vote-stats-form').addEventListener('submit', async func } catch (error) { displayResult('vote-stats-result', error.message, true); } -}); \ No newline at end of file +}); diff --git a/src/utils/signingProfile.js b/src/utils/signingProfile.js index f5b3693..f25b2eb 100644 --- a/src/utils/signingProfile.js +++ b/src/utils/signingProfile.js @@ -144,9 +144,12 @@ const assertSignatureShape = (signature, algorithm) => { }; /** - * Validate a POST /content body and construct the exact v1 signing payload. + * Validate the unsigned fields of a v1 content submission and construct the + * exact payload that the signature covers. Directory convenience signers use + * this before creating the signature; POST /content uses the same helper and + * then verifies the caller-supplied signature. */ -const validateV1ContentSubmission = async (body) => { +const buildV1ContentSigningPayload = async (body) => { if (!body || typeof body !== "object" || Array.isArray(body)) { throw invalid("the content submission must be a JSON object"); } @@ -170,8 +173,6 @@ const validateV1ContentSubmission = async (body) => { if (body.location !== location) { throw invalid(`location must equal the ${scope} location derived from sourceURL`); } - const signature = assertSignatureShape(body.signature, algorithm); - const normalizedClaims = await normalizeClaims(body.claims, { strictArray: true }); const signedAtClaims = normalizedClaims.filter((claim) => claim.name === "signed-at"); if (signedAtClaims.length !== 1) { @@ -204,14 +205,25 @@ const validateV1ContentSubmission = async (body) => { payload: canonicalizeJcs(signingObject), profile: PROFILE.signature, scope, - signature, signedAt, sourceURL: body.sourceURL, }; }; +/** + * Validate a POST /content body and construct the exact v1 signing payload. + */ +const validateV1ContentSubmission = async (body) => { + const prepared = await buildV1ContentSigningPayload(body); + return { + ...prepared, + signature: assertSignatureShape(body.signature, prepared.algorithm), + }; +}; + module.exports = { assertV1Timestamp, + buildV1ContentSigningPayload, deriveLocation, hashCanonicalClaims, PROFILE, diff --git a/test/contentSigning.test.js b/test/contentSigning.test.js new file mode 100644 index 0000000..7c0244d --- /dev/null +++ b/test/contentSigning.test.js @@ -0,0 +1,368 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("crypto"); + +const Key = require("../src/models/Key"); +const ContentSignature = require("../src/models/ContentSignature"); +const ContentOccurrence = require("../src/models/ContentOccurrence"); +const Author = require("../src/models/Author"); +const contentController = require("../src/controllers/contentController"); +const { protectWithAuthorApiKey } = require("../src/middleware/auth"); +const { buildV1ContentSigningPayload, PROFILE } = require("../src/utils/signingProfile"); +const { verifySignature } = require("../src/utils/crypto"); + +const requestBody = () => ({ + contentHash: "sha256:IVAwpRTDujszmYf76W497alVTtxGCgtJtQlasiFSCM8", + sourceURL: "HTTPS://EXAMPLE.COM:443/essays/engines#analysis", + scope: "url", + signedAt: "2026-01-15T12:00:00Z", + claims: [ + { name: "author", content: "Ada Lovelace" }, + { name: "signed-at", content: "2026-01-15T12:00:00Z" }, + ], +}); + +const response = () => { + const result = {}; + return { + result, + status(code) { result.status = code; return this; }, + type() { return this; }, + json(body) { result.body = body; return this; }, + set() { return this; }, + }; +}; + +test("author convenience signing returns and stores a complete v1 section", async () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const authorId = "507f1f77bcf86cd799439011"; + const keyId = "507f1f77bcf86cd799439012"; + const key = { + _id: keyId, + authorId, + algorithm: "ed25519", + publicKey, + privateKey, + }; + const stored = []; + const occurrences = []; + let keySort; + const original = { + findOne: Key.findOne, + signatureFindOne: ContentSignature.findOne, + signatureCreate: ContentSignature.create, + occurrenceUpsert: ContentOccurrence.findOneAndUpdate, + }; + try { + Key.findOne = () => ({ + sort(value) { keySort = value; return this; }, + select: async () => key, + }); + ContentSignature.findOne = async () => null; + ContentSignature.create = async (document) => { + const record = { ...document, _id: "507f1f77bcf86cd799439013", createdAt: new Date("2026-01-15T12:00:00Z") }; + stored.push(record); + return record; + }; + ContentOccurrence.findOneAndUpdate = async (identity, update) => { + occurrences.push({ identity, update }); + return update; + }; + + const req = { + protocol: "https", + get(name) { return name.toLowerCase() === "host" ? "directory.example" : undefined; }, + body: requestBody(), + author: { _id: authorId }, + }; + const res = response(); + await contentController.signContent(req, res); + + assert.equal(res.result.status, 201); + const output = res.result.body; + assert.equal(output.profile, PROFILE.signature); + assert.equal(output.scope, "url"); + assert.equal(output.location, "https://example.com/essays/engines"); + assert.equal(output.keyid, "https://directory.example/keys/507f1f77bcf86cd799439012"); + assert.equal(output.algorithm, "ed25519"); + assert.equal(output.claimsHash, "sha256:Y1i6VMTgz6hx2A3DEuoqOD4N2otfZwlKj/tJQQglHIQ"); + assert.deepEqual(keySort, { createdAt: -1, _id: -1 }); + assert.equal(stored.length, 1); + assert.equal(stored[0].profile, PROFILE.signature); + assert.equal(stored[0].location, output.location); + assert.equal(occurrences.length, 1); + assert.equal(occurrences[0].identity.url, requestBody().sourceURL); + + const prepared = await buildV1ContentSigningPayload({ + ...requestBody(), + profile: PROFILE.signature, + keyid: output.keyid, + algorithm: output.algorithm, + location: output.location, + }); + assert.equal(verifySignature(prepared.payload, output.signature, publicKey, "ed25519"), true); + } finally { + Key.findOne = original.findOne; + ContentSignature.findOne = original.signatureFindOne; + ContentSignature.create = original.signatureCreate; + ContentOccurrence.findOneAndUpdate = original.occurrenceUpsert; + } +}); + +test("author convenience signing rejects the removed colon-binding fields", async () => { + const res = response(); + await contentController.signContent({ + body: { ...requestBody(), domain: "https://example.com" }, + author: { _id: "507f1f77bcf86cd799439011" }, + }, res); + assert.equal(res.result.status, 400); + assert.match(res.result.body.detail, /domain is not accepted/); +}); + +test("author convenience signing returns an RFC 9457 problem when no active key exists", async () => { + const originalFindOne = Key.findOne; + try { + Key.findOne = () => ({ + sort() { return this; }, + select: async () => null, + }); + const res = response(); + await contentController.signContent({ + body: requestBody(), + author: { _id: "507f1f77bcf86cd799439011" }, + }, res); + assert.equal(res.result.status, 404); + assert.equal(res.result.body.type, "https://htmltrust.org/errors/signing-key-not-found"); + assert.equal(res.result.body.status, 404); + } finally { + Key.findOne = originalFindOne; + } +}); + +test("author convenience signing treats an identical retry as immutable and idempotent", async () => { + const { privateKey } = crypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const authorId = "507f1f77bcf86cd799439021"; + const key = { + _id: "507f1f77bcf86cd799439022", + authorId, + algorithm: "ed25519", + privateKey, + }; + const body = requestBody(); + const keyid = "https://directory.example/keys/507f1f77bcf86cd799439022"; + const location = "https://example.com/essays/engines"; + const prepared = await buildV1ContentSigningPayload({ + ...body, + profile: PROFILE.signature, + keyid, + algorithm: "ed25519", + location, + }); + const existing = { + _id: "507f1f77bcf86cd799439023", + profile: PROFILE.signature, + algorithm: "ed25519", + claimsHash: prepared.claimsHash, + signedAt: prepared.signedAt, + scope: prepared.scope, + signature: require("../src/utils/crypto").signContent(prepared.payload, privateKey, "ed25519"), + createdAt: new Date("2026-01-15T12:00:00Z"), + occurrences: 4, + }; + const original = { + keyFindOne: Key.findOne, + signatureFindOne: ContentSignature.findOne, + signatureCreate: ContentSignature.create, + occurrenceUpsert: ContentOccurrence.findOneAndUpdate, + }; + let createCalls = 0; + try { + Key.findOne = () => ({ sort() { return this; }, select: async () => key }); + ContentSignature.findOne = async () => existing; + ContentSignature.create = async () => { createCalls += 1; throw new Error("must not create on retry"); }; + ContentOccurrence.findOneAndUpdate = async () => existing; + const res = response(); + await contentController.signContent({ + protocol: "https", + get(name) { return name.toLowerCase() === "host" ? "directory.example" : undefined; }, + body, + author: { _id: authorId }, + }, res); + assert.equal(res.result.status, 201); + assert.equal(createCalls, 0); + assert.equal(existing.occurrences, 4); + assert.equal(res.result.body.signature, existing.signature); + } finally { + Key.findOne = original.keyFindOne; + ContentSignature.findOne = original.signatureFindOne; + ContentSignature.create = original.signatureCreate; + ContentOccurrence.findOneAndUpdate = original.occurrenceUpsert; + } +}); + +test("author convenience signing rejects a changed payload for an existing v1 identity", async () => { + const { privateKey } = crypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const authorId = "507f1f77bcf86cd799439031"; + const key = { + _id: "507f1f77bcf86cd799439032", + authorId, + algorithm: "ed25519", + privateKey, + }; + const body = requestBody(); + const keyid = "https://directory.example/keys/507f1f77bcf86cd799439032"; + const prepared = await buildV1ContentSigningPayload({ + ...body, + profile: PROFILE.signature, + keyid, + algorithm: "ed25519", + location: "https://example.com/essays/engines", + }); + const original = { + keyFindOne: Key.findOne, + signatureFindOne: ContentSignature.findOne, + occurrenceUpsert: ContentOccurrence.findOneAndUpdate, + }; + try { + Key.findOne = () => ({ sort() { return this; }, select: async () => key }); + ContentSignature.findOne = async () => ({ + profile: PROFILE.signature, + algorithm: "ed25519", + claimsHash: "sha256:changed", + signedAt: prepared.signedAt, + scope: prepared.scope, + signature: "old-signature", + }); + ContentOccurrence.findOneAndUpdate = async () => null; + const res = response(); + await contentController.signContent({ + protocol: "https", + get(name) { return name.toLowerCase() === "host" ? "directory.example" : undefined; }, + body, + author: { _id: authorId }, + }, res); + assert.equal(res.result.status, 409); + assert.equal(res.result.body.type, "https://htmltrust.org/errors/content-signature-conflict"); + } finally { + Key.findOne = original.keyFindOne; + ContentSignature.findOne = original.signatureFindOne; + ContentOccurrence.findOneAndUpdate = original.occurrenceUpsert; + } +}); + +test("author convenience signing recovers an identical first-submit race", async () => { + const { privateKey } = crypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const authorId = "507f1f77bcf86cd799439061"; + const key = { + _id: "507f1f77bcf86cd799439062", + authorId, + algorithm: "ed25519", + privateKey, + }; + const body = requestBody(); + const keyid = "https://directory.example/keys/507f1f77bcf86cd799439062"; + const prepared = await buildV1ContentSigningPayload({ + ...body, + profile: PROFILE.signature, + keyid, + algorithm: "ed25519", + location: "https://example.com/essays/engines", + }); + const signature = require("../src/utils/crypto").signContent(prepared.payload, privateKey, "ed25519"); + const winner = { + _id: "507f1f77bcf86cd799439063", + profile: PROFILE.signature, + algorithm: "ed25519", + claimsHash: prepared.claimsHash, + signedAt: prepared.signedAt, + scope: prepared.scope, + signature, + createdAt: new Date("2026-01-15T12:00:00Z"), + }; + const original = { + keyFindOne: Key.findOne, + signatureFindOne: ContentSignature.findOne, + signatureCreate: ContentSignature.create, + occurrenceUpsert: ContentOccurrence.findOneAndUpdate, + }; + let reads = 0; + try { + Key.findOne = () => ({ sort() { return this; }, select: async () => key }); + ContentSignature.findOne = async () => { + reads += 1; + return reads === 1 ? null : winner; + }; + ContentSignature.create = async () => { + const error = new Error("duplicate v1 identity"); + error.code = 11000; + throw error; + }; + ContentOccurrence.findOneAndUpdate = async () => winner; + const res = response(); + await contentController.signContent({ + protocol: "https", + get(name) { return name.toLowerCase() === "host" ? "directory.example" : undefined; }, + body, + author: { _id: authorId }, + }, res); + assert.equal(res.result.status, 201); + assert.equal(reads, 2); + assert.equal(res.result.body.signature, signature); + } finally { + Key.findOne = original.keyFindOne; + ContentSignature.findOne = original.signatureFindOne; + ContentSignature.create = original.signatureCreate; + ContentOccurrence.findOneAndUpdate = original.occurrenceUpsert; + } +}); + +test("author API middleware attaches the author identity used by the signer route", async () => { + const originalFindOne = Author.findOne; + let nextCalls = 0; + try { + Author.findOne = async () => ({ _id: "507f1f77bcf86cd799439041" }); + const req = { + params: {}, + header(name) { return name === "X-AUTHOR-API-KEY" ? "author-secret" : undefined; }, + }; + const result = {}; + const res = { + set() { return this; }, + status(code) { result.status = code; return this; }, + type() { return this; }, + json(body) { result.body = body; return this; }, + }; + await protectWithAuthorApiKey(req, res, () => { nextCalls += 1; }); + assert.equal(nextCalls, 1); + assert.equal(req.author._id, "507f1f77bcf86cd799439041"); + assert.equal(result.status, undefined); + } finally { + Author.findOne = originalFindOne; + } +}); + +test("v1 content signature claims hydrate as a Mongoose Map", () => { + const record = ContentSignature.hydrate({ + contentHash: "sha256:IVAwpRTDujszmYf76W497alVTtxGCgtJtQlasiFSCM8", + profile: PROFILE.signature, + location: "https://example.com/essays/engines", + keyid: "https://directory.example/keys/507f1f77bcf86cd799439052", + claimsHash: "sha256:Y1i6VMTgz6hx2A3DEuoqOD4N2otfZwlKj/tJQQglHIQ", + signedAt: "2026-01-15T12:00:00Z", + signature: "signature", + claims: { author: "Ada Lovelace" }, + }); + assert.equal(record.claims.get("author"), "Ada Lovelace"); +});