Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
91 changes: 89 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<your signing secret>",
method: "POST",
path: "/resources/res-1/register",
timestamp,
bodyHash,
});

// Add to request headers:
// x-api-key: <your API key>
// X-Timestamp: <timestamp>
// X-Signature: <signature>
```

**Rotating the signing secret**

```
POST /publishers/me/signing-secret/rotate
x-api-key: <your 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.
Expand Down
5 changes: 5 additions & 0 deletions drizzle/0014_publisher_signing_secret.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
42 changes: 39 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -336,4 +345,31 @@ if (networkIssues.length > 0) {
process.exit(1);
}

export const config = parsed.data;
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);
}
4 changes: 4 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading