diff --git a/server/.env.example b/server/.env.example index 7fa35684..a5307405 100644 --- a/server/.env.example +++ b/server/.env.example @@ -166,6 +166,27 @@ TREASURY_REFILL_CHECK_INTERVAL_MS=300000 # Number of days before DLQ items expire (default: 30) WEBHOOK_DLQ_EXPIRY_DAYS=30 +# --- Redis (API metrics / rate-limit counters / job queues) --- +# Connection string. In production this MUST use the TLS `rediss://` scheme — +# a plaintext `redis://` URL is rejected at startup so metrics are never sent +# in the clear. Plaintext is allowed in non-production profiles only. +REDIS_URL=redis://127.0.0.1:6379 +# PEM file containing the certificate authority used to verify the Redis server +# certificate. REQUIRED in production. Locally, point this at a self-signed CA +# to exercise the encrypted path (see TLS testing notes in the repo). +REDIS_TLS_CA_PATH= +# Optional mutual TLS — set BOTH or NEITHER (client certificate + private key). +REDIS_TLS_CERT_PATH= +REDIS_TLS_KEY_PATH= +# Optional SNI / certificate hostname override. Useful when a local self-signed +# cert is issued for a name (e.g. "localhost") that differs from the host you +# connect to (e.g. 127.0.0.1). +REDIS_TLS_SERVERNAME= +# Local development ONLY. Set to "false" to skip CA verification against an +# untrusted self-signed certificate. This flag is IGNORED in production, where +# certificate-authority verification is always enforced. +REDIS_TLS_REJECT_UNAUTHORIZED=true + NODE_ENV=development LOG_LEVEL=debug # Optional in development. Keep false to preserve JSON logs. diff --git a/server/docs/redis-tls-verification.md b/server/docs/redis-tls-verification.md new file mode 100644 index 00000000..6e97c406 --- /dev/null +++ b/server/docs/redis-tls-verification.md @@ -0,0 +1,108 @@ +# Redis TLS enforcement — environment validation checklist & verification report + +This document covers the production TLS policy enforced by the Redis client +factory (`server/src/utils/redisClientFactory.ts`) and captures verified local +test output produced with self-signed TLS certificates. + +## What changed + +All Redis connections are now built through a single factory that enforces an +encrypted, certificate-authority-verified transport in production: + +- `server/src/utils/redisClientFactory.ts` — new `buildRedisOptions()` / + `createRedisClient()` that apply and validate the TLS policy. +- `server/src/utils/redis.ts`, `server/src/services/auditLog.ts`, + `server/src/services/webhook.ts` — now obtain their client from the factory + instead of constructing `new Redis(process.env.REDIS_URL ...)` directly. + +### Production invariants (fail fast at startup) + +1. **TLS-only protocol.** When `NODE_ENV=production`, `REDIS_URL` **must** use + the `rediss://` scheme. A plaintext `redis://` URL throws on startup so API + metrics are never sent in the clear. +2. **Certificate authority required.** `REDIS_TLS_CA_PATH` must point to the PEM + used to verify the Redis server. The server certificate is always verified + (`rejectUnauthorized: true`); the `REDIS_TLS_REJECT_UNAUTHORIZED=false` + escape hatch is **ignored** in production. + +In non-production profiles the factory stays permissive (plaintext allowed, +self-signed CAs supported) so the encrypted path can be exercised locally. + +## Environment validation checklist + +| Variable | Required | Purpose | +| --- | --- | --- | +| `REDIS_URL` | yes | Connection string. **Must be `rediss://` in production.** Defaults to `redis://127.0.0.1:6379` locally. | +| `REDIS_TLS_CA_PATH` | yes in production | PEM file of the CA that signs the Redis server certificate. | +| `REDIS_TLS_CERT_PATH` | optional | Client certificate PEM for mutual TLS. Set **with** `REDIS_TLS_KEY_PATH` or neither. | +| `REDIS_TLS_KEY_PATH` | optional | Client private key PEM for mutual TLS. | +| `REDIS_TLS_SERVERNAME` | optional | SNI / cert hostname override (e.g. connect to `127.0.0.1` with a cert issued for `localhost`). | +| `REDIS_TLS_REJECT_UNAUTHORIZED` | optional | Local-only opt-out (`false`) to skip CA verification of an untrusted self-signed cert. **Ignored in production.** | + +Pre-deploy verification steps: + +- [ ] `REDIS_URL` begins with `rediss://` in every production profile. +- [ ] `REDIS_TLS_CA_PATH` is set and the file is readable by the service user. +- [ ] If mutual TLS is used, **both** `REDIS_TLS_CERT_PATH` and + `REDIS_TLS_KEY_PATH` are set. +- [ ] `REDIS_TLS_REJECT_UNAUTHORIZED` is left unset / `true` in production. +- [ ] Service boots without throwing a `[Redis]` configuration error. + +## Local testing with self-signed certificates + +Two verifications are provided: a unit suite and an end-to-end TLS connection +demo. Both generate self-signed certificates on the fly (via `node-forge`). + +### 1. Unit suite (verified output) + +Command (run from `server/`): + +```bash +npx vitest run src/utils/redisClientFactory.test.ts +``` + +Output: + +```text + RUN v4.1.4 C:/Users/USER/fluid/server + + Test Files 1 passed (1) + Tests 10 passed (10) + Start at 13:35:36 + Duration 4.38s (transform 104ms, setup 0ms, import 3.21s, tests 884ms, environment 0ms) +``` + +### 2. End-to-end self-signed TLS connection demo (verified output) + +This stands up a minimal RESP-speaking TLS server using a freshly generated +self-signed CA + server certificate, then drives a real `ioredis` client built +by the factory through an encrypted, CA-verified `rediss://` connection. + +Command (run from `server/`): + +```bash +npx ts-node scripts/redisTlsDemo.ts +``` + +Output: + +```text +Self-signed TLS Redis stand-in listening on rediss://127.0.0.1:60118 + + ✓ production redis:// rejected — [Redis] Production requires a TLS connection + ✓ production rediss:// without CA rejected — [Redis] Production TLS requires a certificate authority + ✓ TLS handshake authorized against self-signed CA (cipher TLS_AES_256_GCM_SHA384) + ✓ encrypted SET/GET round-trip succeeded (value="encrypted-in-transit") + ✓ wrong CA rejected by verification — unable to verify the first certificate + +All TLS enforcement checks passed. +``` + +This confirms all three acceptance criteria: + +1. **Restrict connection protocols to TLS in production** — plaintext + `redis://` is rejected. +2. **Require certificate authority checks for client instances** — connections + without a CA are rejected, and a wrong CA fails verification. +3. **Test connections locally using self-signed TLS certificates** — a real + encrypted SET/GET round-trip completes against the self-signed server. diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts index bb02993e..e54bf029 100644 --- a/server/src/services/auditLog.ts +++ b/server/src/services/auditLog.ts @@ -1,10 +1,10 @@ import { Job, Queue, Worker } from "bullmq"; -import Redis from "ioredis"; import prisma from "../utils/db"; import { createLogger, serializeError } from "../utils/logger"; +import { createRedisClient } from "../utils/redisClientFactory"; const logger = createLogger({ component: "audit_log" }); -const connection = new Redis(process.env.REDIS_URL || "redis://localhost:6379"); +const connection = createRedisClient(); // --------------------------------------------------------------------------- // Queue diff --git a/server/src/services/webhook.ts b/server/src/services/webhook.ts index 4546122e..6a3ed794 100644 --- a/server/src/services/webhook.ts +++ b/server/src/services/webhook.ts @@ -2,7 +2,6 @@ import { createHmac } from "node:crypto"; import { Job, Queue, Worker } from "bullmq"; import { createLogger, serializeError } from "../utils/logger"; -import Redis from "ioredis"; import axios from "axios"; import prisma from "../utils/db"; import { @@ -10,7 +9,8 @@ import { mapTransactionStatusToWebhookEventType, type WebhookEventType, } from "./webhookEventTypes"; -const connection = new Redis(process.env.REDIS_URL || "redis://localhost:6379"); +import { createRedisClient } from "../utils/redisClientFactory"; +const connection = createRedisClient(); export const webhookLogger = createLogger({ component: "webhook_service" }); export const webhookQueue = new Queue("webhook-delivery", { diff --git a/server/src/utils/redisClientFactory.test.ts b/server/src/utils/redisClientFactory.test.ts index e7f8c6c0..ccbbb1f7 100644 --- a/server/src/utils/redisClientFactory.test.ts +++ b/server/src/utils/redisClientFactory.test.ts @@ -156,4 +156,4 @@ describe("redisClientFactory", () => { expect(client).toBeInstanceOf(Redis.Cluster); }); }); -}); \ No newline at end of file +}); diff --git a/server/src/utils/redisClientFactory.ts b/server/src/utils/redisClientFactory.ts index 840d157d..7de62607 100644 --- a/server/src/utils/redisClientFactory.ts +++ b/server/src/utils/redisClientFactory.ts @@ -126,4 +126,4 @@ export function loadRedisConfig(): RedisClientConfig { export function createRedisClientFromEnv(): RedisClient { const config = loadRedisConfig(); return createRedisClient(config); -} \ No newline at end of file +}