diff --git a/.env.example b/.env.example index 87dc6d6..56e4d80 100644 --- a/.env.example +++ b/.env.example @@ -117,10 +117,16 @@ WEBHOOK_TIMEOUT_MS=10000 WEBHOOK_MAX_ATTEMPTS=3 # ── Request signing ───────────────────────────────────────────────────────── -# [optional] When true, publisher mutations need X-Timestamp + X-Signature -# (HMAC of the canonical request, keyed by the API key). -REQUIRE_REQUEST_SIGNATURE=false +# [optional] When true, publisher mutations need X-Timestamp + X-Signature. +# The HMAC secret is the per-publisher signing secret (NOT the API key) — see +# POST /publishers/me/signing-secret/rotate (#42). +# Defaults to true in production; false in development/test (#44). +# REQUIRE_REQUEST_SIGNATURE=true SIGNATURE_MAX_SKEW_MS=300000 +# [required when REQUIRE_REQUEST_SIGNATURE=true] AES-256 key used to encrypt +# per-publisher signing secrets at rest. Must be at least 16 characters. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# SIGNING_SECRET_KEY= # ── Caching / idempotency ─────────────────────────────────────────────────── # [optional] Cache and idempotency tuning diff --git a/.gitignore b/.gitignore index 451f348..156273a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,20 @@ dist/ .DS_Store coverage/ last-processed-ledger.json + +# Test snapshots and vitest cache +**/__snapshots__/ +.vitest-cache/ +vitest.d.ts + +# TypeScript build info +*.tsbuildinfo + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS artefacts +Thumbs.db diff --git a/README.md b/README.md index d6d55a9..bee1b91 100644 --- a/README.md +++ b/README.md @@ -141,15 +141,102 @@ Publishers configure one endpoint with `PATCH /publishers/me/webhooks` (`webhookUrl`, `webhookSecret`, `webhookEvents`, `webhookEnabled`). Events: `resource.purchased`, `payment.received`, `resource.listed`, `resource.delisted`. Deliveries are `POST { event, timestamp, data }`. When a secret is set, -`X-SynapsVault-Signature` holds the hex HMAC-SHA256 of the raw body: +`X-SynapsVault-Signature` holds the hex HMAC-SHA256 of `${timestamp}.${body}`, +where `timestamp` is the ISO-8601 value in `X-SynapsVault-Timestamp` and +`body` is the raw JSON body (#45). Each delivery also carries a unique +`X-SynapsVault-Id` header for deduplication. + +Verification example (Node.js): ```js -const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); +const crypto = require("crypto"); + +function verifyWebhook(req, secret) { + const timestamp = req.headers["x-synapsvault-timestamp"]; + const signature = req.headers["x-synapsvault-signature"]; + + // Reject deliveries older than 5 minutes to prevent replays. + const age = Date.now() - new Date(timestamp).getTime(); + if (age > 5 * 60 * 1000) throw new Error("Webhook too old"); + + const expected = crypto + .createHmac("sha256", secret) + .update(`${timestamp}.${req.rawBody}`) // rawBody = Buffer / string before JSON.parse + .digest("hex"); + + if (!crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"))) { + throw new Error("Invalid webhook signature"); + } +} ``` Failures are retried with exponential backoff (`WEBHOOK_MAX_ATTEMPTS`). Private and loopback URLs are rejected. +### Request signatures (#42, #43, #44) + +Publisher mutation endpoints (`POST /resources`, `DELETE /resources/:id`, etc.) +support — and in production **require** — HMAC-SHA256 request signatures. + +**How it works** + +Every publisher receives a per-publisher **signing secret** (separate from the +API key) on registration. The signing secret is the HMAC key, not the API key +itself. This means an attacker who intercepts the API key cannot forge signed +requests. + +**Signing a request (client side)** + +```js +const crypto = require("crypto"); + +function signRequest({ signingSecret, method, path, timestamp, bodyHash, idempotencyKey }) { + const parts = [method.toUpperCase(), path, timestamp, bodyHash]; + if (idempotencyKey) parts.push(idempotencyKey); + const canonical = parts.join("\n"); + return crypto.createHmac("sha256", signingSecret).update(canonical, "utf8").digest("hex"); +} + +const timestamp = Math.floor(Date.now() / 1000).toString(); +const bodyHash = crypto.createHash("sha256").update(rawBody, "utf8").digest("hex"); + +const signature = signRequest({ + signingSecret: "", + method: "POST", + path: "/resources/res-1/register", + timestamp, + bodyHash, +}); + +// Add to request headers: +// x-api-key: +// X-Timestamp: +// X-Signature: +``` + +**Rotating the signing secret** + +``` +POST /publishers/me/signing-secret/rotate +x-api-key: +``` + +Returns `{ signingSecret, rotatedAt }`. The new secret is shown once — store it +securely. The old secret is immediately invalid. + +**Environment variables** + +| Variable | Default | Description | +|---|---|---| +| `REQUIRE_REQUEST_SIGNATURE` | `true` (prod) / `false` (dev) | Enforce HMAC signatures on publisher mutations (#44) | +| `SIGNING_SECRET_KEY` | — | AES-256 encryption key for signing secrets at rest. Required when `REQUIRE_REQUEST_SIGNATURE=true` (#42) | +| `SIGNATURE_MAX_SKEW_MS` | `300000` | Max clock skew (ms) for `X-Timestamp` | + +**Production behaviour (#44):** `REQUIRE_REQUEST_SIGNATURE` defaults to `true` +in production. If it is explicitly set to `false`, the server logs a security +warning at boot. `SIGNING_SECRET_KEY` must be provided when signatures are +required. + ## Rate limiting All rate limits use a sliding-window algorithm. Responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers. Blocked requests return `429` with `{ "error": "Too many requests", "code": "RATE_LIMITED", "retryAfterSeconds": N }` and a `Retry-After` header. diff --git a/drizzle/0014_publisher_signing_secret.sql b/drizzle/0014_publisher_signing_secret.sql new file mode 100644 index 0000000..3bf01ce --- /dev/null +++ b/drizzle/0014_publisher_signing_secret.sql @@ -0,0 +1,5 @@ +-- #42 Per-publisher HMAC signing secret stored encrypted (AES-256-GCM via +-- SIGNING_SECRET_KEY). NULL means the publisher pre-dates the feature; they +-- must call POST /publishers/me/signing-secret/rotate to get a secret before +-- signed requests will be accepted. +ALTER TABLE "publishers" ADD COLUMN "signing_secret" text; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 40d6cba..b62fab9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1780400000000, "tag": "0011_idempotency_records", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1761004800000, + "tag": "0014_publisher_signing_secret", + "breakpoints": true } ] } diff --git a/src/config.ts b/src/config.ts index c2c013f..f440403 100644 --- a/src/config.ts +++ b/src/config.ts @@ -262,8 +262,17 @@ const envSchema = z.object({ // Advisory lock key used to elect the single worker leader across replicas. WORKER_LEADER_LOCK_ID: z.coerce.number().int().default(4_021_001), - // Optional HMAC-SHA256 request signatures for publisher mutations (off by default). - REQUIRE_REQUEST_SIGNATURE: envFlag(false), + // AES-256 key used to encrypt per-publisher signing secrets at rest (#42). + // Required when REQUIRE_REQUEST_SIGNATURE is true. + SIGNING_SECRET_KEY: z.string().min(16).optional(), + + // HMAC-SHA256 request signatures for publisher mutations. + // Defaults to true in production; false elsewhere. Override with env var. + REQUIRE_REQUEST_SIGNATURE: envFlag( + typeof process !== "undefined" && (process.env.NODE_ENV ?? "development") === "production" + ? true + : false + ), // Legacy register flow where the server signs with the platform registry key // on the creator's behalf (#80). Off by default; clients must send signedXdr. ALLOW_LEGACY_REGISTER: envFlag(false), @@ -336,4 +345,31 @@ if (networkIssues.length > 0) { process.exit(1); } -export const config = parsed.data; \ No newline at end of file +export const config = parsed.data; + +// ── Boot-time security warnings (#44) ──────────────────────────────────────── +if (parsed.data.NODE_ENV === "production" && !parsed.data.REQUIRE_REQUEST_SIGNATURE) { + rootLogger.warn( + { event: "config_signature_disabled" }, + "[security] REQUIRE_REQUEST_SIGNATURE is off in production — publisher mutation " + + "endpoints accept a bare API key without a signed request. Set " + + "REQUIRE_REQUEST_SIGNATURE=true and SIGNING_SECRET_KEY to enforce HMAC-signed " + + "requests (#44).", + ); + console.warn( + "[config] WARNING: REQUIRE_REQUEST_SIGNATURE=false in production. " + + "Publisher mutations are not protected by HMAC signatures. " + + "Set REQUIRE_REQUEST_SIGNATURE=true and SIGNING_SECRET_KEY to fix this (#44).", + ); +} + +if (parsed.data.REQUIRE_REQUEST_SIGNATURE && !parsed.data.SIGNING_SECRET_KEY) { + rootLogger.error( + { event: "config_signing_key_missing" }, + "SIGNING_SECRET_KEY is required when REQUIRE_REQUEST_SIGNATURE=true (#42).", + ); + console.error( + "[config] SIGNING_SECRET_KEY: required when REQUIRE_REQUEST_SIGNATURE=true (#42)", + ); + process.exit(1); +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index c08f6db..0baf5c5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -61,6 +61,10 @@ export const publishers = pgTable( // Subscribed WebhookEvent values; null/empty means all events. webhookEvents: text("webhook_events").array(), webhookEnabled: boolean("webhook_enabled").notNull().default(false), + // Per-publisher HMAC signing secret (shown once on creation/rotation, stored + // encrypted). Used by requestSignatureAuth (#42) instead of the raw API key + // so possession of the key alone is insufficient to forge a valid signature. + signingSecret: text("signing_secret"), // Set when the publisher rotates their API key. Any key whose hash matches // a row with a non-null api_key_revoked_at is rejected by apiKeyAuth (#40). apiKeyRevokedAt: timestamp("api_key_revoked_at"), diff --git a/src/middleware/requestSignatureAuth.test.ts b/src/middleware/requestSignatureAuth.test.ts index 796def3..5e15e79 100644 --- a/src/middleware/requestSignatureAuth.test.ts +++ b/src/middleware/requestSignatureAuth.test.ts @@ -1,13 +1,41 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Request, Response, NextFunction } from "express"; +const TEST_SIGNING_SECRET = "svs_test_signing_secret_value_for_hmac"; + vi.mock("../config.js", () => ({ config: { REQUIRE_REQUEST_SIGNATURE: true, SIGNATURE_MAX_SKEW_MS: 300_000, + SIGNING_SECRET_KEY: undefined, // no encryption in tests — plain storage + REDIS_URL: undefined, + }, +})); + +// Mock the DB so the middleware can look up the publisher's signing secret +// without a real database connection. +vi.mock("../db/client.js", () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ signingSecret: TEST_SIGNING_SECRET }]), + }), + }), + }), }, })); +vi.mock("../db/schema.js", () => ({ + publishers: { apiKeyHash: "api_key_hash", signingSecret: "signing_secret" }, +})); + +vi.mock("../utils/crypto.js", async (importOriginal) => ({ + ...(await importOriginal()), + hashApiKey: (key: string) => `hashed_${key}`, + decryptSigningSecret: (val: string) => val, +})); + import { __resetSignatureReplayCache, requestSignatureAuth } from "./requestSignatureAuth.js"; import { EMPTY_BODY_HASH, @@ -40,7 +68,8 @@ function mockResponse() { } describe("requestSignatureAuth", () => { - const secret = "mv_test_secret"; + // Use the signing secret (not the API key) for HMAC — #42 + const secret = TEST_SIGNING_SECRET; const timestamp = Math.floor(Date.now() / 1000).toString(); const path = "/resources/res-1/register"; const rawBody = JSON.stringify({ signedXdr: "AAAA" }); @@ -63,12 +92,12 @@ describe("requestSignatureAuth", () => { vi.useRealTimers(); }); - it("accepts a valid signed request", () => { + it("accepts a valid signed request (uses signing secret, not API key)", async () => { const req = { method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", // API key just identifies the publisher; secret is separate "x-timestamp": timestamp, "x-signature": signature, }, @@ -77,17 +106,17 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expect(next).toHaveBeenCalledWith(); }); - it("rejects a stale timestamp", () => { + it("rejects a stale timestamp", async () => { vi.setSystemTime(new Date(Number(timestamp) * 1000 + 600_000)); const req = { method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-timestamp": timestamp, "x-signature": signature, }, @@ -96,16 +125,16 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expectRejected(next, "Request timestamp outside allowed window"); }); - it("rejects a tampered body", () => { + it("rejects a tampered body", async () => { const req = { method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-timestamp": timestamp, "x-signature": signature, }, @@ -114,11 +143,11 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expectRejected(next, "Invalid request signature"); }); - it("accepts DELETE with empty body hash", () => { + it("accepts DELETE with empty body hash", async () => { const deletePath = "/resources/res-1"; const deleteSignature = signPublisherRequest({ secret, @@ -131,7 +160,7 @@ describe("requestSignatureAuth", () => { method: "DELETE", originalUrl: deletePath, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-timestamp": timestamp, "x-signature": deleteSignature, }, @@ -139,17 +168,17 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expect(next).toHaveBeenCalledWith(); }); - it("rejects a replayed request with the same signature", () => { + it("rejects a replayed request with the same signature", async () => { const makeReq = () => ({ method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-timestamp": timestamp, "x-signature": signature, }, @@ -158,40 +187,40 @@ describe("requestSignatureAuth", () => { const firstRes = mockResponse(); const firstNext = vi.fn() as NextFunction; - requestSignatureAuth(makeReq(), firstRes, firstNext); + await requestSignatureAuth(makeReq(), firstRes, firstNext); expect(firstNext).toHaveBeenCalledWith(); const secondRes = mockResponse(); const secondNext = vi.fn() as NextFunction; - requestSignatureAuth(makeReq(), secondRes, secondNext); + await requestSignatureAuth(makeReq(), secondRes, secondNext); expectRejected(secondNext, "Request signature already used"); }); - it("does not record replays for invalid signatures", () => { + it("does not record replays for invalid signatures", async () => { const makeReq = (sig: string) => ({ method: "POST", originalUrl: path, - headers: { "x-api-key": secret, "x-timestamp": timestamp, "x-signature": sig }, + headers: { "x-api-key": "any_api_key", "x-timestamp": timestamp, "x-signature": sig }, rawBody: Buffer.from(JSON.stringify({ signedXdr: "CCCC" }), "utf8"), }) as unknown as Request; const forged = "ab".repeat(32); const first = vi.fn() as NextFunction; - requestSignatureAuth(makeReq(forged), mockResponse(), first); + await requestSignatureAuth(makeReq(forged), mockResponse(), first); expectRejected(first, "Invalid request signature"); const second = vi.fn() as NextFunction; - requestSignatureAuth(makeReq(forged), mockResponse(), second); + await requestSignatureAuth(makeReq(forged), mockResponse(), second); expectRejected(second, "Invalid request signature"); }); - it("rejects a request with a missing signature header", () => { + it("rejects a request with a missing signature header", async () => { const req = { method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-timestamp": timestamp, }, rawBody: Buffer.from(rawBody, "utf8"), @@ -199,16 +228,16 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expectRejected(next, "Missing request signature headers"); }); - it("rejects a request with a missing timestamp header", () => { + it("rejects a request with a missing timestamp header", async () => { const req = { method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-signature": signature, }, rawBody: Buffer.from(rawBody, "utf8"), @@ -216,16 +245,16 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expectRejected(next, "Missing request signature headers"); }); - it("rejects a request with a non-numeric timestamp", () => { + it("rejects a request with a non-numeric timestamp", async () => { const req = { method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-timestamp": "not-a-number", "x-signature": signature, }, @@ -234,13 +263,14 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expectRejected(next, "Invalid request timestamp"); }); - it("rejects a request signed with the wrong secret", () => { + it("rejects a signature made with the API key instead of the signing secret (#42)", async () => { + // Old behavior: sign with the API key directly. Must be rejected. const badSignature = signPublisherRequest({ - secret: "mv_wrong_secret", + secret: "any_api_key", // using the API key as the HMAC secret — must fail method: "POST", path, timestamp, @@ -250,7 +280,7 @@ describe("requestSignatureAuth", () => { method: "POST", originalUrl: path, headers: { - "x-api-key": secret, + "x-api-key": "any_api_key", "x-timestamp": timestamp, "x-signature": badSignature, }, @@ -259,7 +289,7 @@ describe("requestSignatureAuth", () => { const res = mockResponse(); const next = vi.fn() as NextFunction; - requestSignatureAuth(req, res, next); + await requestSignatureAuth(req, res, next); expectRejected(next, "Invalid request signature"); }); }); @@ -274,14 +304,18 @@ describe("requestSignatureAuth disabled", () => { config: { REQUIRE_REQUEST_SIGNATURE: false, SIGNATURE_MAX_SKEW_MS: 300_000, + SIGNING_SECRET_KEY: undefined, + REDIS_URL: undefined, }, })); + vi.doMock("../db/client.js", () => ({ db: {} })); + vi.doMock("../db/schema.js", () => ({ publishers: {} })); const { requestSignatureAuth: disabledAuth } = await import("./requestSignatureAuth.js"); const req = { method: "POST", originalUrl: "/resources", headers: {} } as unknown as Request; const res = mockResponse(); const next = vi.fn() as NextFunction; - disabledAuth(req, res, next); + await disabledAuth(req, res, next); expect(next).toHaveBeenCalledWith(); }); }); diff --git a/src/middleware/requestSignatureAuth.ts b/src/middleware/requestSignatureAuth.ts index 8fe2501..8dd3030 100644 --- a/src/middleware/requestSignatureAuth.ts +++ b/src/middleware/requestSignatureAuth.ts @@ -1,5 +1,10 @@ import type { Request, Response, NextFunction } from "express"; +import { eq } from "drizzle-orm"; import { config } from "../config.js"; +import { db } from "../db/client.js"; +import { publishers } from "../db/schema.js"; +import { hashApiKey } from "../utils/crypto.js"; +import { decryptSigningSecret } from "../utils/crypto.js"; import { AppError } from "../lib/errors.js"; import { EMPTY_BODY_HASH, @@ -9,13 +14,7 @@ import { isTimestampWithinSkew, verifyRequestSignature, } from "../utils/requestSignature.js"; - -/** - * Signatures accepted within the skew window, used for replay protection. - * In-memory, so it only protects a single instance; only *valid* signatures - * are recorded, so the set can't be flooded with garbage. - */ -const seenSignatures = new Map(); +import { rootLogger } from "../lib/logger.js"; declare global { namespace Express { @@ -26,6 +25,87 @@ declare global { } } +// ── Replay cache ───────────────────────────────────────────────────────────── +// +// #43: use Redis SET NX + TTL when REDIS_URL is set so replay protection works +// across replicas. Fall back to an in-process Map when Redis is not configured, +// but prune on a timer instead of on every request. + +/** In-process fallback: signature → expiry timestamp (ms). */ +const inMemoryCache = new Map(); + +/** Prune expired entries on a timer (every 60 s) — not on every request (#43). */ +const pruneTimer = setInterval(() => { + const now = Date.now(); + for (const [sig, expiresAt] of inMemoryCache) { + if (expiresAt <= now) inMemoryCache.delete(sig); + } +}, 60_000); +pruneTimer.unref(); // don't keep the process alive + +let redisClient: import("redis").RedisClientType | null = null; +let redisConnectPromise: Promise | null = null; + +function getRedisClient(): import("redis").RedisClientType | null { + if (!config.REDIS_URL) return null; + if (redisClient?.isOpen) return redisClient; + if (!redisConnectPromise) { + redisConnectPromise = (async () => { + try { + const { createClient } = await import("redis"); + const client = createClient({ + url: config.REDIS_URL, + socket: { + connectTimeout: 5_000, + reconnectStrategy: (retries: number) => + retries >= 3 ? new Error("Redis unreachable") : 200 * (retries + 1), + }, + }); + client.on("error", () => {}); + client.on("end", () => { + redisClient = null; + redisConnectPromise = null; + }); + await client.connect(); + redisClient = client as import("redis").RedisClientType; + } catch (err) { + rootLogger.warn({ event: "replay_cache_redis_unavailable", err }, "Redis replay cache unavailable — falling back to in-process cache"); + redisClient = null; + redisConnectPromise = null; + } + })(); + } + return null; // will be non-null after connect resolves +} + +/** + * Check for a replayed signature, and mark it as seen. + * Returns true when the signature has already been used (replay). + */ +async function checkAndMarkReplay(signature: string, ttlMs: number): Promise { + const redis = getRedisClient(); + if (redis) { + // SET NX with TTL — atomic; works across replicas (#43). + const key = `replay:${signature}`; + const ttlSeconds = Math.ceil(ttlMs / 1000); + try { + const set = await redis.set(key, "1", { NX: true, EX: ttlSeconds }); + // set === null means key already existed → replay + return set === null; + } catch (err) { + rootLogger.warn({ event: "replay_cache_redis_error", err }, "Redis replay check failed — falling back to in-process cache"); + } + } + + // In-process fallback. + const now = Date.now(); + if (inMemoryCache.has(signature)) return true; + inMemoryCache.set(signature, now + ttlMs); + return false; +} + +// ── Body hashing ───────────────────────────────────────────────────────────── + function resolveBodyHash(req: Request): string { const contentType = req.headers["content-type"] ?? ""; @@ -48,14 +128,23 @@ function resolveBodyHash(req: Request): string { return EMPTY_BODY_HASH; } +// ── Middleware ──────────────────────────────────────────────────────────────── + /** - * Optional HMAC-SHA256 request signature verification for publisher mutations. - * When REQUIRE_REQUEST_SIGNATURE is false (default), this middleware is a no-op. + * HMAC-SHA256 request signature verification for publisher mutations (#42, #43). * - * Expects X-Timestamp (unix seconds) and X-Signature (hex HMAC) headers. - * The API key (x-api-key) is the HMAC secret. + * When REQUIRE_REQUEST_SIGNATURE is false (default outside production), this + * middleware is a no-op. + * + * Expects: + * x-api-key — publisher API key (identifies the publisher to look up their signing secret) + * X-Timestamp — unix seconds + * X-Signature — hex HMAC-SHA256 of the canonical string, keyed by the publisher's signing secret + * + * The HMAC secret is the publisher's per-record signing secret (#42), NOT the + * raw API key. Signatures made with the API key alone are rejected. */ -export function requestSignatureAuth(req: Request, res: Response, next: NextFunction) { +export async function requestSignatureAuth(req: Request, res: Response, next: NextFunction) { if (!config.REQUIRE_REQUEST_SIGNATURE) { next(); return; @@ -90,9 +179,40 @@ export function requestSignatureAuth(req: Request, res: Response, next: NextFunc return; } + // Look up the publisher by API key hash to retrieve their signing secret (#42). + let signingSecret: string; + try { + const apiKeyHash = hashApiKey(apiKey); + const [row] = await db + .select({ signingSecret: publishers.signingSecret }) + .from(publishers) + .where(eq(publishers.apiKeyHash, apiKeyHash)) + .limit(1); + + if (!row || !row.signingSecret) { + fail("No signing secret found — rotate your signing secret first"); + return; + } + + // Decrypt if SIGNING_SECRET_KEY is configured (#42). + if (config.SIGNING_SECRET_KEY) { + const plain = decryptSigningSecret(row.signingSecret, config.SIGNING_SECRET_KEY); + if (!plain) { + fail("Invalid signing secret configuration"); + return; + } + signingSecret = plain; + } else { + signingSecret = row.signingSecret; + } + } catch { + fail("Failed to load signing secret"); + return; + } + const idempotencyHeader = req.headers["idempotency-key"]; const valid = verifyRequestSignature({ - secret: apiKey, + secret: signingSecret, method: req.method, path: getRequestPath(req.originalUrl), timestamp: timestampHeader, @@ -105,23 +225,22 @@ export function requestSignatureAuth(req: Request, res: Response, next: NextFunc return; } - pruneExpired(seenSignatures, now); - if (seenSignatures.has(signatureHeader)) { - fail("Request signature already used"); + // Replay protection (#43). + try { + const isReplay = await checkAndMarkReplay(signatureHeader, config.SIGNATURE_MAX_SKEW_MS * 2); + if (isReplay) { + fail("Request signature already used"); + return; + } + } catch { + fail("Replay check failed"); return; } - seenSignatures.set(signatureHeader, now + config.SIGNATURE_MAX_SKEW_MS); next(); } -/** Test helper — forget previously accepted signatures. */ +/** Test helper — forget previously accepted signatures (in-process cache only). */ export function __resetSignatureReplayCache(): void { - seenSignatures.clear(); -} - -function pruneExpired(entries: Map, now: number): void { - for (const [key, expiresAt] of entries) { - if (expiresAt <= now) entries.delete(key); - } + inMemoryCache.clear(); } diff --git a/src/routes/publishers.ts b/src/routes/publishers.ts index d0a689f..82367ed 100644 --- a/src/routes/publishers.ts +++ b/src/routes/publishers.ts @@ -9,7 +9,7 @@ import { updateRateLimitSchema, updateWebhookConfigSchema, } from "../schemas/requests.js"; -import { registerPublisher, getPublisherResources, rotateApiKey } from "../services/publisherService.js"; +import { registerPublisher, getPublisherResources, rotateApiKey, rotateSigningSecret } from "../services/publisherService.js"; import { deliver, webhookUrlProblem } from "../services/webhookService.js"; import { eq, inArray, sum, count, sql, desc } from "drizzle-orm"; import { db } from "../db/client.js"; @@ -23,13 +23,14 @@ type UpdateWebhookConfig = z.infer; // POST /publishers — register a new publisher (public) router.post("/publishers", registerIpRateLimit, validate(publisherRegisterSchema), async (req, res) => { try { - const { publisher, apiKey } = await registerPublisher(req.body); + const { publisher, apiKey, signingSecret } = await registerPublisher(req.body); res.status(201).json({ id: publisher.id, name: publisher.name, email: publisher.email, walletAddress: publisher.walletAddress, - apiKey, // shown once — store it securely + apiKey, // shown once — store it securely + signingSecret, // shown once — use this as the HMAC secret for request signatures (#42) createdAt: publisher.createdAt, }); } catch (err: any) { @@ -90,6 +91,17 @@ router.post("/publishers/me/api-keys/rotate", apiKeyAuth, publisherRateLimit, as }); }); +// POST /publishers/me/signing-secret/rotate — issue a new HMAC signing secret (#42) +// The new secret is returned once in plaintext; all existing request signatures +// signed with the old secret are immediately invalid. +router.post("/publishers/me/signing-secret/rotate", apiKeyAuth, publisherRateLimit, async (req, res) => { + const newSigningSecret = await rotateSigningSecret(req.publisher!.id); + res.json({ + signingSecret: newSigningSecret, // shown once — store it securely, use as HMAC secret + rotatedAt: new Date().toISOString(), + }); +}); + // GET /publishers/me/analytics — earnings and stats (authenticated) router.get("/publishers/me/analytics", apiKeyAuth, async (req, res) => { const publisherId = req.publisher!.id; diff --git a/src/services/publisherService.ts b/src/services/publisherService.ts index a67a575..01ad8f0 100644 --- a/src/services/publisherService.ts +++ b/src/services/publisherService.ts @@ -1,7 +1,8 @@ import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; import { publishers, resources } from "../db/schema.js"; -import { generateApiKey, hashApiKey } from "../utils/crypto.js"; +import { generateApiKey, hashApiKey, generateSigningSecret, encryptSigningSecret } from "../utils/crypto.js"; +import { config } from "../config.js"; export async function registerPublisher(data: { name: string; @@ -11,6 +12,15 @@ export async function registerPublisher(data: { const apiKey = generateApiKey(); const apiKeyHash = hashApiKey(apiKey); + // Issue a signing secret and encrypt it at rest (#42). When + // SIGNING_SECRET_KEY is not configured, signing secrets are stored + // unencrypted (acceptable only in development / before signatures are + // required). + const signingSecret = generateSigningSecret(); + const storedSigningSecret = config.SIGNING_SECRET_KEY + ? encryptSigningSecret(signingSecret, config.SIGNING_SECRET_KEY) + : signingSecret; + const [publisher] = await db .insert(publishers) .values({ @@ -18,10 +28,11 @@ export async function registerPublisher(data: { email: data.email, walletAddress: data.walletAddress, apiKeyHash, + signingSecret: storedSigningSecret, }) .returning(); - return { publisher, apiKey }; + return { publisher, apiKey, signingSecret }; } export async function getPublisherById(id: string) { @@ -61,6 +72,26 @@ export async function rotateApiKey(publisherId: string): Promise { return newApiKey; } +/** + * Rotate the per-publisher HMAC signing secret (#42). + * + * Generates a new secret, encrypts it, persists it, and returns the plaintext + * (shown once — the caller must store it; the server never exposes it again). + */ +export async function rotateSigningSecret(publisherId: string): Promise { + const signingSecret = generateSigningSecret(); + const storedSigningSecret = config.SIGNING_SECRET_KEY + ? encryptSigningSecret(signingSecret, config.SIGNING_SECRET_KEY) + : signingSecret; + + await db + .update(publishers) + .set({ signingSecret: storedSigningSecret }) + .where(eq(publishers.id, publisherId)); + + return signingSecret; +} + export async function getPublisherWebhookConfig(publisherId: string) { const publisher = await getPublisherById(publisherId); if (!publisher) { @@ -74,4 +105,3 @@ export async function getPublisherWebhookConfig(publisherId: string) { enabled: publisher.webhookEnabled ?? false, }; } - diff --git a/src/services/webhookService.test.ts b/src/services/webhookService.test.ts index 4791b0d..e0d86d1 100644 --- a/src/services/webhookService.test.ts +++ b/src/services/webhookService.test.ts @@ -22,7 +22,7 @@ vi.mock("../db/client.js", () => ({ }, })); -import { deliver, emitToPublisher, signPayload, webhookUrlProblem, SIGNATURE_HEADER } from "./webhookService.js"; +import { deliver, emitToPublisher, signPayload, webhookUrlProblem, SIGNATURE_HEADER, DELIVERY_ID_HEADER } from "./webhookService.js"; function publisherRow(overrides: Record = {}) { return { @@ -93,14 +93,23 @@ describe("webhookService", () => { }); describe("signing", () => { - it("signs the exact request body with HMAC-SHA256", async () => { + it("signs ${timestamp}.${body} with HMAC-SHA256 so the timestamp is covered (#45)", async () => { fetchMock.mockResolvedValue(ok()); await emitToPublisher("pub-1", "resource.purchased", { id: "r1" }); const [, init] = fetchMock.mock.calls[0]; - const expected = createHmac("sha256", "super-secret-value-123").update(init.body).digest("hex"); + const ts = init.headers["X-SynapsVault-Timestamp"] as string; + // #45: the HMAC input is "${timestamp}.${body}", not just the body. + const expected = createHmac("sha256", "super-secret-value-123").update(`${ts}.${init.body}`).digest("hex"); expect(init.headers[SIGNATURE_HEADER]).toBe(expected); - expect(signPayload(init.body, "super-secret-value-123")).toBe(expected); + expect(signPayload(init.body, "super-secret-value-123", ts)).toBe(expected); + }); + + it("signature changes when the timestamp changes (#45)", async () => { + const body = JSON.stringify({ event: "resource.purchased", timestamp: "t1", data: {} }); + const sig1 = signPayload(body, "secret", "ts-1"); + const sig2 = signPayload(body, "secret", "ts-2"); + expect(sig1).not.toBe(sig2); }); it("sends the event type and payload", async () => { @@ -113,6 +122,14 @@ describe("webhookService", () => { expect(init.headers["X-SynapsVault-Event"]).toBe("resource.purchased"); }); + it("includes a stable X-SynapsVault-Id delivery header (#45)", async () => { + fetchMock.mockResolvedValue(ok()); + await emitToPublisher("pub-1", "resource.purchased", { id: "r1" }); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers[DELIVERY_ID_HEADER]).toMatch(/^wh_[0-9a-f]{32}$/); + }); + it("omits the signature when no secret is configured", async () => { mockRows.value = [publisherRow({ secret: null })]; fetchMock.mockResolvedValue(ok()); diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index ff0f7c5..5addb6b 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -1,4 +1,4 @@ -import { createHmac } from "node:crypto"; +import { createHmac, randomBytes } from "node:crypto"; import { eq } from "drizzle-orm"; import { config } from "../config.js"; import { db } from "../db/client.js"; @@ -32,10 +32,15 @@ export interface DeliveryResult { const BASE_RETRY_DELAY_MS = 1_000; export const SIGNATURE_HEADER = "X-SynapsVault-Signature"; +export const DELIVERY_ID_HEADER = "X-SynapsVault-Id"; -/** Hex HMAC-SHA256 of the raw request body, keyed by the publisher's webhook secret. */ -export function signPayload(body: string, secret: string): string { - return createHmac("sha256", secret).update(body).digest("hex"); +/** + * Hex HMAC-SHA256 of `${timestamp}.${body}`, keyed by the publisher's webhook + * secret (#45). Including the timestamp in the signed payload means a receiver + * can reject a replayed delivery whose timestamp falls outside a freshness window. + */ +export function signPayload(body: string, secret: string, timestamp: string): string { + return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -51,13 +56,18 @@ export async function deliver( ): Promise { const log = getLogger(); const body = JSON.stringify(payload); + // Each delivery gets a unique, stable id so receivers can deduplicate + // retried deliveries (#45). + const deliveryId = `wh_${randomBytes(16).toString("hex")}`; const headers: Record = { "Content-Type": "application/json", "User-Agent": "SynapsVault-Webhooks/1.0", "X-SynapsVault-Event": payload.event, "X-SynapsVault-Timestamp": payload.timestamp, + [DELIVERY_ID_HEADER]: deliveryId, }; - if (target.secret) headers[SIGNATURE_HEADER] = signPayload(body, target.secret); + // Sign ${timestamp}.${body} so the timestamp is covered by the HMAC (#45). + if (target.secret) headers[SIGNATURE_HEADER] = signPayload(body, target.secret, payload.timestamp); const maxAttempts = Math.max(1, options.maxAttempts ?? config.WEBHOOK_MAX_ATTEMPTS); let lastStatus: number | undefined; diff --git a/src/utils/crypto.test.ts b/src/utils/crypto.test.ts index 370e675..d73c9c8 100644 --- a/src/utils/crypto.test.ts +++ b/src/utils/crypto.test.ts @@ -70,3 +70,47 @@ describe("hashLinkResource (issue #13)", () => { expect(hashLinkResource(url, "Title A")).not.toBe(hashLinkResource(url, "Title B")); }); }); + +import { + generateSigningSecret, + encryptSigningSecret, + decryptSigningSecret, +} from "./crypto.js"; + +describe("signing secret helpers (#42)", () => { + const KEY = "test-encryption-key-32bytes-long!!"; + + it("generateSigningSecret returns a svs_ prefixed hex string", () => { + const s = generateSigningSecret(); + expect(s).toMatch(/^svs_[0-9a-f]{64}$/); + }); + + it("each call returns a unique secret", () => { + expect(generateSigningSecret()).not.toBe(generateSigningSecret()); + }); + + it("encrypt then decrypt round-trips correctly", () => { + const plain = generateSigningSecret(); + const enc = encryptSigningSecret(plain, KEY); + expect(enc).not.toBe(plain); + expect(decryptSigningSecret(enc, KEY)).toBe(plain); + }); + + it("decryptSigningSecret returns null for wrong key", () => { + const enc = encryptSigningSecret("svs_secret", KEY); + expect(decryptSigningSecret(enc, "wrong-key-that-is-at-least-16chars")).toBeNull(); + }); + + it("decryptSigningSecret returns null for tampered ciphertext", () => { + const enc = encryptSigningSecret("svs_secret", KEY); + const tampered = Buffer.from(enc, "base64"); + tampered[tampered.length - 1] ^= 0xff; + expect(decryptSigningSecret(tampered.toString("base64"), KEY)).toBeNull(); + }); + + it("ciphertext changes each call (different IV)", () => { + const enc1 = encryptSigningSecret("svs_secret", KEY); + const enc2 = encryptSigningSecret("svs_secret", KEY); + expect(enc1).not.toBe(enc2); + }); +}); diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 78e7468..0f25693 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -1,4 +1,4 @@ -import { randomBytes, createHash } from "node:crypto"; +import { randomBytes, createHash, createCipheriv, createDecipheriv } from "node:crypto"; export function generateApiKey(): string { return `mv_${randomBytes(32).toString("hex")}`; @@ -44,3 +44,57 @@ function normalizeUrl(raw: string): string { sorted.forEach(([k, v]) => u.searchParams.append(k, v)); return u.toString(); } + +// ── Signing secret helpers (#42) ──────────────────────────────────────────── + +/** Generate a per-publisher HMAC signing secret (shown once, never stored raw). */ +export function generateSigningSecret(): string { + return `svs_${randomBytes(32).toString("hex")}`; +} + +const ALGO = "aes-256-gcm"; +const IV_LEN = 12; // 96-bit nonce recommended for GCM +const TAG_LEN = 16; +const KEY_LEN = 32; + +/** + * Derive a 32-byte AES key from the SIGNING_SECRET_KEY env value using a + * deterministic SHA-256 hash (no salt needed; the key itself is the secret). + */ +function deriveKey(rawKey: string): Buffer { + return createHash("sha256").update(rawKey, "utf8").digest(); +} + +/** + * Encrypt `plaintext` with AES-256-GCM. + * Stores iv + authTag + ciphertext as a single base64 string. + */ +export function encryptSigningSecret(plaintext: string, rawKey: string): string { + const key = deriveKey(rawKey); + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv(ALGO, key, iv, { authTagLength: TAG_LEN }); + const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + // Layout: [iv (12)] [tag (16)] [ciphertext] + return Buffer.concat([iv, tag, enc]).toString("base64"); +} + +/** + * Decrypt a value produced by `encryptSigningSecret`. + * Returns null when the key is wrong or the ciphertext is malformed / tampered. + */ +export function decryptSigningSecret(encoded: string, rawKey: string): string | null { + try { + const key = deriveKey(rawKey); + const buf = Buffer.from(encoded, "base64"); + if (buf.length < IV_LEN + TAG_LEN + 1) return null; + const iv = buf.subarray(0, IV_LEN); + const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN); + const ciphertext = buf.subarray(IV_LEN + TAG_LEN); + const decipher = createDecipheriv(ALGO, key, iv, { authTagLength: TAG_LEN }); + decipher.setAuthTag(tag); + return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8"); + } catch { + return null; + } +}