From 00df72ab595145c4ce25df0b6c897ad9f4e913d9 Mon Sep 17 00:00:00 2001 From: Big Della <296652908+BigDella@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:41:33 +0100 Subject: [PATCH] Remove fallback KYC signing key --- .env.example | 5 ++ .../lib/security/kyc-signed-urls.test.ts | 71 +++++++++++++++++-- lib/config/schema.ts | 42 +++++++++++ lib/security/kyc-signed-urls.ts | 54 +++++++++++--- 4 files changed, 157 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index ee7932e0..f01b4cb2 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ KYC_ACTIVE_KEY_VERSION=kyc-v1 KYC_PREVIOUS_KEY_VERSIONS= # Optional JSON keyring for rotation. Values shown are placeholders; never commit real keys. KYC_ENCRYPTION_KEYS_JSON= +# Dedicated signed-document URL keys. Do not reuse auth or encryption keys. +KYC_DOCUMENT_SIGNING_KEY=replace_with_separate_document_url_signing_secret +KYC_DOCUMENT_SIGNING_KEY_ID=document-url-v1 +# Rotation form: {"active":{"id":"document-url-v2","secret":"..."},"previous":[{"id":"document-url-v1","secret":"..."}]} +KYC_DOCUMENT_SIGNING_KEYS_JSON= # Tamper-evident audit checkpoints AUDIT_CHECKPOINT_PRIVATE_KEY=replace_with_local_audit_checkpoint_hmac_key diff --git a/__tests__/lib/security/kyc-signed-urls.test.ts b/__tests__/lib/security/kyc-signed-urls.test.ts index dbd41d9e..35d48371 100644 --- a/__tests__/lib/security/kyc-signed-urls.test.ts +++ b/__tests__/lib/security/kyc-signed-urls.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, beforeEach } from "vitest" +import { createHmac } from "crypto" +import { afterEach, beforeEach, describe, expect, it } from "vitest" import { createSignedDocumentUrl, verifySignedDocumentUrl, @@ -7,6 +8,18 @@ import { describe("kyc-signed-urls", () => { const documentId = "doc_123" const userId = "user_456" + const originalEnv = { ...process.env } + + beforeEach(() => { + process.env.MONGODB_URI = "mongodb://localhost:27017/chainmove-test" + process.env.KYC_DOCUMENT_SIGNING_KEY = "test-only-document-signing-secret" + process.env.KYC_DOCUMENT_SIGNING_KEY_ID = "document-url-v1" + delete process.env.KYC_DOCUMENT_SIGNING_KEYS_JSON + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) describe("createSignedDocumentUrl", () => { it("returns a URL with token parameter", () => { @@ -42,10 +55,10 @@ describe("kyc-signed-urls", () => { const { url } = createSignedDocumentUrl(documentId, userId) const token = new URL(url, "http://localhost").searchParams.get("token")! const parts = token.split("|") - const payload = JSON.parse(Buffer.from(parts[0], "base64url").toString()) + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) payload.userId = "tampered_user" const tamperedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url") - const tamperedToken = `${tamperedPayload}|${parts[1]}` + const tamperedToken = `${parts[0]}|${tamperedPayload}|${parts[2]}` const result = verifySignedDocumentUrl(tamperedToken) expect(result.valid).toBe(false) expect(result.error).toContain("Invalid signature") @@ -55,10 +68,13 @@ describe("kyc-signed-urls", () => { const { url } = createSignedDocumentUrl(documentId, userId, 1) const token = new URL(url, "http://localhost").searchParams.get("token")! const parts = token.split("|") - const payload = JSON.parse(Buffer.from(parts[0], "base64url").toString()) + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) payload.expiresAt = Math.floor(Date.now() / 1000) - 10 const expiredPayload = Buffer.from(JSON.stringify(payload)).toString("base64url") - const expiredToken = `${expiredPayload}|${parts[1]}` + const signature = createHmac("sha256", process.env.KYC_DOCUMENT_SIGNING_KEY!) + .update(expiredPayload) + .digest("base64url") + const expiredToken = `${parts[0]}|${expiredPayload}|${signature}` const result = verifySignedDocumentUrl(expiredToken) expect(result.valid).toBe(false) expect(result.error).toContain("expired") @@ -66,12 +82,53 @@ describe("kyc-signed-urls", () => { it("rejects malformed token", () => { expect(verifySignedDocumentUrl("garbage").valid).toBe(false) - expect(verifySignedDocumentUrl("a|b|c").valid).toBe(false) + expect(verifySignedDocumentUrl("a|b|c|d").valid).toBe(false) expect(verifySignedDocumentUrl("").valid).toBe(false) }) it("rejects token with invalid base64", () => { - expect(verifySignedDocumentUrl("!!!invalid!!!|signature").valid).toBe(false) + expect(verifySignedDocumentUrl("key|!!!invalid!!!|signature").valid).toBe(false) + }) + + it("rejects the former public fallback signing key", () => { + const payload = Buffer.from(JSON.stringify({ + documentId, + userId, + expiresAt: Math.floor(Date.now() / 1000) + 60, + nonce: "known", + keyId: "document-url-v1", + })).toString("base64url") + const signature = createHmac("sha256", "fallback-signing-key").update(payload).digest("base64url") + + expect(verifySignedDocumentUrl(`document-url-v1|${payload}|${signature}`).valid).toBe(false) + }) + + it("verifies only explicitly retained previous keys during rotation", () => { + process.env.KYC_DOCUMENT_SIGNING_KEYS_JSON = JSON.stringify({ + active: { id: "document-url-v1", secret: "first-document-signing-secret-123" }, + previous: [], + }) + delete process.env.KYC_DOCUMENT_SIGNING_KEY + const { url } = createSignedDocumentUrl(documentId, userId) + const token = new URL(url, "http://localhost").searchParams.get("token")! + + process.env.KYC_DOCUMENT_SIGNING_KEYS_JSON = JSON.stringify({ + active: { id: "document-url-v2", secret: "second-document-signing-secret-456" }, + previous: [{ id: "document-url-v1", secret: "first-document-signing-secret-123" }], + }) + expect(verifySignedDocumentUrl(token).valid).toBe(true) + + process.env.KYC_DOCUMENT_SIGNING_KEYS_JSON = JSON.stringify({ + active: { id: "document-url-v2", secret: "second-document-signing-secret-456" }, + previous: [], + }) + expect(verifySignedDocumentUrl(token)).toMatchObject({ valid: false, error: "Unknown or retired signing key." }) + }) + + it("fails closed when the dedicated key is missing", () => { + delete process.env.KYC_DOCUMENT_SIGNING_KEY + expect(() => createSignedDocumentUrl(documentId, userId)).toThrow("KYC_DOCUMENT_SIGNING_KEY") + expect(() => verifySignedDocumentUrl("document-url-v1|payload|signature")).toThrow("KYC_DOCUMENT_SIGNING_KEY") }) }) }) diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 7892ecb4..5e17e094 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -30,6 +30,9 @@ const envSchema = z KYC_ACTIVE_KEY_VERSION: z.string().trim().optional(), KYC_PREVIOUS_KEY_VERSIONS: z.string().trim().optional(), KYC_ENCRYPTION_KEYS_JSON: z.string().trim().optional(), + KYC_DOCUMENT_SIGNING_KEY: z.string().trim().optional(), + KYC_DOCUMENT_SIGNING_KEY_ID: z.string().trim().optional(), + KYC_DOCUMENT_SIGNING_KEYS_JSON: z.string().trim().optional(), PRIVY_APP_ID: z.string().trim().optional(), PRIVY_APP_SECRET: z.string().trim().optional(), PRIVY_JWKS_URL: optionalUrl, @@ -79,6 +82,45 @@ const envSchema = z } } + if (!value.KYC_DOCUMENT_SIGNING_KEY && !value.KYC_DOCUMENT_SIGNING_KEYS_JSON) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["KYC_DOCUMENT_SIGNING_KEY"], + message: "A dedicated KYC document signing key is required in production.", + }) + } else if (value.KYC_DOCUMENT_SIGNING_KEY && ( + value.KYC_DOCUMENT_SIGNING_KEY.length < 32 || PLACEHOLDER_PATTERN.test(value.KYC_DOCUMENT_SIGNING_KEY) + )) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["KYC_DOCUMENT_SIGNING_KEY"], + message: "KYC_DOCUMENT_SIGNING_KEY must be a strong non-placeholder production value.", + }) + } + + if (value.KYC_DOCUMENT_SIGNING_KEYS_JSON) { + try { + const signingKeys = JSON.parse(value.KYC_DOCUMENT_SIGNING_KEYS_JSON) as { + active?: { id?: string; secret?: string } + previous?: Array<{ id?: string; secret?: string }> + } + const keys = [signingKeys.active, ...(signingKeys.previous || [])] + if (keys.some((key) => !key?.id || !key.secret || key.secret.length < 32 || PLACEHOLDER_PATTERN.test(key.secret))) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["KYC_DOCUMENT_SIGNING_KEYS_JSON"], + message: "Every document signing key must have an ID and a strong non-placeholder secret.", + }) + } + } catch { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["KYC_DOCUMENT_SIGNING_KEYS_JSON"], + message: "KYC_DOCUMENT_SIGNING_KEYS_JSON must be valid JSON.", + }) + } + } + if (value.ENABLE_MOCK_PAYMENTS || value.ENABLE_MOCK_EMAILS || value.ENABLE_MOCK_STELLAR) { context.addIssue({ code: z.ZodIssueCode.custom, diff --git a/lib/security/kyc-signed-urls.ts b/lib/security/kyc-signed-urls.ts index 8d2c31b1..cd0800ca 100644 --- a/lib/security/kyc-signed-urls.ts +++ b/lib/security/kyc-signed-urls.ts @@ -4,9 +4,39 @@ import { parseAppConfig } from "@/lib/config/schema" const SIGNED_URL_TTL_SECONDS = 5 * 60 const SIGNED_URL_SEPARATOR = "|" -function getSigningSecret(): string { +type SigningKey = { id: string; secret: string } +type SigningKeyring = { active: SigningKey; previous: SigningKey[] } + +function getSigningKeyring(): SigningKeyring { const config = parseAppConfig(process.env) - return config.JWT_SECRET || config.AUTH_SESSION_SECRET || config.KYC_DOCUMENT_ENCRYPTION_KEY || "fallback-signing-key" + + if (config.KYC_DOCUMENT_SIGNING_KEYS_JSON) { + let parsed: SigningKeyring + try { + parsed = JSON.parse(config.KYC_DOCUMENT_SIGNING_KEYS_JSON) as SigningKeyring + } catch { + throw new Error("KYC document signing keyring must be valid JSON.") + } + + const keys = [parsed.active, ...(parsed.previous || [])] + if (!parsed.active || keys.some((key) => !key?.id || !key.secret || key.secret.length < 16)) { + throw new Error("KYC document signing keyring contains an invalid key.") + } + if (new Set(keys.map((key) => key.id)).size !== keys.length) { + throw new Error("KYC document signing key IDs must be unique.") + } + return { active: parsed.active, previous: parsed.previous || [] } + } + + const secret = config.KYC_DOCUMENT_SIGNING_KEY + if (!secret || secret.length < 16) { + throw new Error("KYC_DOCUMENT_SIGNING_KEY must be configured with at least 16 characters.") + } + + return { + active: { id: config.KYC_DOCUMENT_SIGNING_KEY_ID || "document-url-v1", secret }, + previous: [], + } } export type SignedUrlPayload = { @@ -14,6 +44,7 @@ export type SignedUrlPayload = { userId: string expiresAt: number nonce: string + keyId: string } export function createSignedDocumentUrl( @@ -23,19 +54,21 @@ export function createSignedDocumentUrl( ): { url: string; expiresAt: number } { const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds const nonce = randomBytes(16).toString("hex") + const keyring = getSigningKeyring() const payload: SignedUrlPayload = { documentId, userId, expiresAt, nonce, + keyId: keyring.active.id, } const payloadJson = JSON.stringify(payload) const payloadBase64 = Buffer.from(payloadJson).toString("base64url") - const signature = createHmac("sha256", getSigningSecret()).update(payloadBase64).digest("base64url") + const signature = createHmac("sha256", keyring.active.secret).update(payloadBase64).digest("base64url") - const token = `${payloadBase64}${SIGNED_URL_SEPARATOR}${signature}` + const token = `${keyring.active.id}${SIGNED_URL_SEPARATOR}${payloadBase64}${SIGNED_URL_SEPARATOR}${signature}` const url = `/api/kyc-documents/${documentId}?token=${encodeURIComponent(token)}` return { url, expiresAt } @@ -45,13 +78,18 @@ export function verifySignedDocumentUrl( token: string, ): { valid: boolean; payload?: SignedUrlPayload; error?: string } { const parts = token.split(SIGNED_URL_SEPARATOR) - if (parts.length !== 2) { + if (parts.length !== 3) { return { valid: false, error: "Invalid token format." } } - const [payloadBase64, signatureBase64] = parts + const [keyId, payloadBase64, signatureBase64] = parts + const keyring = getSigningKeyring() + const signingKey = [keyring.active, ...keyring.previous].find((key) => key.id === keyId) + if (!signingKey) { + return { valid: false, error: "Unknown or retired signing key." } + } - const expectedSignature = createHmac("sha256", getSigningSecret()).update(payloadBase64).digest("base64url") + const expectedSignature = createHmac("sha256", signingKey.secret).update(payloadBase64).digest("base64url") let signatureValid = false try { @@ -75,7 +113,7 @@ export function verifySignedDocumentUrl( return { valid: false, error: "Invalid payload." } } - if (!payload.documentId || !payload.userId || !payload.expiresAt || !payload.nonce) { + if (!payload.documentId || !payload.userId || !payload.expiresAt || !payload.nonce || payload.keyId !== keyId) { return { valid: false, error: "Incomplete payload." } }