diff --git a/src/audit/index.ts b/src/audit/index.ts index 57ac6e8..d341687 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -57,7 +57,9 @@ type AuditEvent = | "webhook.created" | "webhook.updated" | "webhook.deleted" - | "webhook.secret_rotated"; + | "webhook.secret_rotated" + | "rate_limit.exceeded" + | "cache.invalidated"; interface AuditFields { userId?: string; diff --git a/src/config/index.ts b/src/config/index.ts index 4d8b207..4012f6f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -54,6 +54,22 @@ const envSchema = z.object({ "JWT_SECRET must be a real secret, not a placeholder" ), + // JWT key rotation — comma-separated list of previous secrets. Tokens + // signed with any of these are still accepted, but new tokens are always + // signed with JWT_SECRET. Set this when rotating the signing key so old + // tokens remain valid during the transition window. + JWT_SECRET_PREVIOUS: z + .string() + .optional() + .transform((val) => + val + ? val + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : undefined, + ), + // Stellar STELLAR_NETWORK: z.enum(["testnet", "mainnet"]).default("testnet"), STELLAR_HORIZON_URL: z.string().url(), @@ -171,17 +187,13 @@ function loadConfig(): Env { fieldErrors: result.error.flatten().fieldErrors, }, "Missing env vars in test mode (expected if mocking config)" - // are preserved; only non-critical vars get obviously-fake test defaults. + ); // Every field is passed through from process.env (populated above by // real environment variables, then .env.test, in that precedence) // consistently, not just the ones that happened to need a fallback — // config.NODE_ENV itself was previously dropped this way and silently // defaulted to "development", which meant logger.ts's test-mode branch // never actually activated during a test run. - console.warn( - "Missing env vars in test mode (expected if mocking config):", - result.error.flatten().fieldErrors - ); return envSchema.parse({ DATABASE_URL: process.env.DATABASE_URL, REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379", @@ -256,12 +268,21 @@ export const config: Env = ensureConfig(); * * When CORS_ORIGINS is set it wins outright. Otherwise this falls back to the * exact per-environment defaults the server used before CORS_ORIGINS existed — - * chainlearn.io in production, localhost:3000 everywhere else — so an unset - * CORS_ORIGINS is a no-op change in behavior. + * chainlearn.io in production, localhost:3000 in development. For test/CI + * environments, CORS_ORIGINS must be explicitly set; a missing value falls + * back to localhost:3000 with a warning. */ export const corsOrigins: string[] = config.CORS_ORIGINS && config.CORS_ORIGINS.length > 0 ? config.CORS_ORIGINS : config.NODE_ENV === "production" ? ["https://chainlearn.io"] - : ["http://localhost:3000"]; + : (() => { + if (config.NODE_ENV === "test") { + logger.warn( + "CORS_ORIGINS not set in test/CI environment — defaulting to localhost:3000. " + + "Set CORS_ORIGINS explicitly in staging/CI for stricter control." + ); + } + return ["http://localhost:3000"]; + })(); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index a972520..b860def 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import { logger } from "../utils/logger.js"; import { redis } from "../config/redis.js"; import { sessionService } from "../modules/auth/session.service.js"; +import { safeEqual } from "../utils/crypto.js"; const JWT_DENYLIST_PREFIX = "jwt:revoked:"; @@ -81,7 +82,7 @@ export async function authGuard( // Validate that the stellarAddress in the JWT matches the database record // This provides defense-in-depth against token forgery scenarios - if (decoded.stellarAddress !== user.stellarAddress) { + if (!safeEqual(decoded.stellarAddress, user.stellarAddress)) { throw new UnauthorizedError("Token stellarAddress mismatch"); } diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts index 75b3d9d..cdbb8cc 100644 --- a/src/middleware/rate-limit.ts +++ b/src/middleware/rate-limit.ts @@ -2,6 +2,7 @@ import type { FastifyRateLimitOptions, RateLimitOptions } from "@fastify/rate-li import type { FastifyRequest } from "fastify"; import { config } from "../config/index.js"; import type { AuthenticatedRequest } from "./auth.js"; +import { auditLog } from "../audit/index.js"; const errorResponseBuilder = ( _request: FastifyRequest, @@ -22,6 +23,15 @@ export function rateLimitOptions(): FastifyRateLimitOptions { return authReq.authUser?.id ?? request.ip; }, errorResponseBuilder, + onExceeded: (request: FastifyRequest) => { + const authReq = request as AuthenticatedRequest; + auditLog("rate_limit.exceeded", { + ip: request.ip, + userId: authReq.authUser?.id, + url: request.url, + method: request.method, + }).catch(() => {}); + }, }; } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 3b48b6a..126a8a2 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -10,6 +10,7 @@ import { revokeToken } from "../../middleware/auth.js"; import type { AuthenticatedRequest } from "../../middleware/auth.js"; import { sessionService } from "./session.service.js"; import { logger } from "../../utils/logger.js"; +import { auditLog } from "../../audit/index.js"; import type { ChallengeBody, VerifyBody, @@ -49,11 +50,20 @@ export class AuthController { ): Promise { const { stellarAddress, challengeId, signedChallenge } = request.body; - const authResult = await authService.verifyChallenge( - stellarAddress, - challengeId, - signedChallenge - ); + let authResult; + try { + authResult = await authService.verifyChallenge( + stellarAddress, + challengeId, + signedChallenge + ); + } catch (err) { + auditLog("auth.login_failed", { + ip: request.ip, + stellarAddress, + }).catch(() => {}); + throw err; + } // Generate JWT — jti enables per-token revocation via the Redis denylist. const token = request.server.jwt.sign( @@ -72,6 +82,11 @@ export class AuthController { authResult.user.stellarAddress ); + auditLog("auth.login", { + userId: authResult.user.id, + ip: request.ip, + }).catch(() => {}); + reply.send({ success: true, data: { diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 527b069..5844ee0 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -8,6 +8,7 @@ import { RateLimitError, UnauthorizedError } from "../../utils/errors.js"; import { logger } from "../../utils/logger.js"; import { eq } from "drizzle-orm"; import type { ChallengeResponse, AuthResponse } from "./auth.types.js"; +import { safeEqual } from "../../utils/crypto.js"; import { checkAuthLockout, clearAuthFailures, @@ -230,7 +231,7 @@ export class AuthService { throw new UnauthorizedError("Invalid challenge transaction: missing manageData operation"); } const submittedNonce = Buffer.from(submittedNonceOp.value).toString("base64"); - if (submittedNonce !== issuedNonce) { + if (!safeEqual(submittedNonce, issuedNonce)) { throw new UnauthorizedError("Challenge transaction does not match the issued challenge"); } diff --git a/src/server.ts b/src/server.ts index e1395e6..5a72187 100644 --- a/src/server.ts +++ b/src/server.ts @@ -180,13 +180,46 @@ async function buildApp() { await app.register(cors, { origin: corsOrigins, credentials: true, + hook: "onRequest", }); + app.addHook("onRequest", (request, _reply, done) => { + const origin = request.headers.origin; + if (origin) { + logger.debug( + { origin, allowed: corsOrigins.includes(origin) }, + "CORS request" + ); + } + done(); + }); + + const previousSecrets = config.JWT_SECRET_PREVIOUS ?? []; await app.register(jwt, { secret: config.JWT_SECRET, sign: { expiresIn: "24h" }, }); + // Support key rotation: verify tokens against previous secrets when the + // current secret fails. New tokens are always signed with the latest secret. + if (previousSecrets.length > 0) { + const originalVerify = app.jwt.verify.bind(app.jwt); + app.jwt.verify = function (token: string, ...args: any[]) { + try { + return originalVerify(token, ...args); + } catch (err) { + for (const prevSecret of previousSecrets) { + try { + return originalVerify(token, { secret: prevSecret }, ...args.slice(1)); + } catch { + // try next + } + } + throw err; + } + }; + } + await app.register(rateLimit, rateLimitOptions()); await app.register(multipart, { diff --git a/src/stellar/sequence-cache.ts b/src/stellar/sequence-cache.ts index 515b213..6b15080 100644 --- a/src/stellar/sequence-cache.ts +++ b/src/stellar/sequence-cache.ts @@ -1,5 +1,6 @@ import { redis } from "../config/redis.js"; import { stellarClient } from "./client.js"; +import { auditLog } from "../audit/index.js"; const KEY_PREFIX = "chainlearn:stellar:sequence:"; // Bounds how long a cached sequence can be trusted before we re-sync with @@ -72,6 +73,9 @@ export class SequenceCache { async invalidate(accountId: string): Promise { await redis.del(this.key(accountId)); + auditLog("cache.invalidated", { + url: `stellar:sequence:${accountId}`, + }).catch(() => {}); } async resetTo(accountId: string, seq: bigint): Promise { diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 056fb0e..e29e240 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -6,3 +6,25 @@ import crypto from "node:crypto"; export function sha256Hash(data: string): string { return crypto.createHash("sha256").update(data).digest("hex"); } + +/** + * Constant-time string comparison to prevent timing side-channel attacks. + * Uses crypto.timingSafeEqual internally; pads the shorter buffer so both + * are the same length (required by the Node.js API). + */ +export function safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + // Pad the shorter buffer to match the longer one so timingSafeEqual + // doesn't throw. The length mismatch itself is not secret — an + // attacker learns nothing from it that they couldn't already see by + // comparing the strings directly. + const padded = Buffer.alloc(Math.max(bufA.length, bufB.length)); + bufA.copy(padded); + const paddedB = Buffer.alloc(padded.length); + bufB.copy(paddedB); + return crypto.timingSafeEqual(padded, paddedB); + } + return crypto.timingSafeEqual(bufA, bufB); +}