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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 64 additions & 7 deletions __tests__/lib/security/kyc-signed-urls.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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")
Expand All @@ -55,23 +68,67 @@ 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")
})

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")
})
})
})
42 changes: 42 additions & 0 deletions lib/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 46 additions & 8 deletions lib/security/kyc-signed-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,47 @@ 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 = {
documentId: string
userId: string
expiresAt: number
nonce: string
keyId: string
}

export function createSignedDocumentUrl(
Expand All @@ -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 }
Expand All @@ -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 {
Expand All @@ -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." }
}

Expand Down
Loading