From e28412d5454a555e5b447f72d9e1dd43a61af6f7 Mon Sep 17 00:00:00 2001 From: flipclip22 Date: Mon, 31 Aug 2026 00:05:17 +0100 Subject: [PATCH] fix: track unlock challenge nonces to block signature replay (#446) Reserve each wallet nonce after verification and return CHALLENGE_REPLAY on reuse for prompt and bundle unlock flows. Co-authored-by: Cursor --- api/auth/challenge.test.ts | 3 + api/bundles/unlock.test.ts | 2 +- api/bundles/unlock.ts | 19 +-- api/prompts/unlock.test.ts | 27 ++++ api/prompts/unlock.ts | 24 ++-- src/lib/api/errorCodes.ts | 5 + src/lib/auth/challenge.test.ts | 6 + .../observability/replayProtection.test.ts | 81 ++++++++++++ src/lib/observability/replayProtection.ts | 124 +++++++++++++++--- 9 files changed, 259 insertions(+), 32 deletions(-) create mode 100644 src/lib/observability/replayProtection.test.ts diff --git a/api/auth/challenge.test.ts b/api/auth/challenge.test.ts index 73fa6e3b..851388b9 100644 --- a/api/auth/challenge.test.ts +++ b/api/auth/challenge.test.ts @@ -91,6 +91,9 @@ describe("challenge API rate limiting and abuse prevention", () => { expect(statusCode).toBe(200); expect(responseData.token).toBeTruthy(); expect(responseData.challenge).toContain("prompt-hash unlock:"); + expect(responseData.nonce).toBeTruthy(); + expect(responseData.challenge).toContain(String(responseData.nonce)); + expect(responseData.expiresAt).toBeGreaterThan(Date.now()); }); it("returns MISSING_FIELDS for malformed bodies", async () => { diff --git a/api/bundles/unlock.test.ts b/api/bundles/unlock.test.ts index 7cb8e843..4eaf73e1 100644 --- a/api/bundles/unlock.test.ts +++ b/api/bundles/unlock.test.ts @@ -41,7 +41,7 @@ vi.mock("../../src/lib/observability/rateLimiter", () => ({ })); vi.mock("../../src/lib/observability/replayProtection", () => ({ - checkReplayProtection: vi.fn().mockResolvedValue({ valid: true }), + checkUnlockReplayProtection: vi.fn().mockResolvedValue({ valid: true }), })); vi.mock("../../src/lib/observability/metrics", () => ({ diff --git a/api/bundles/unlock.ts b/api/bundles/unlock.ts index e15d0ae6..b4de8b23 100644 --- a/api/bundles/unlock.ts +++ b/api/bundles/unlock.ts @@ -40,7 +40,7 @@ import { recordSuccessfulAuth, verifyCaptchaToken, } from "../../src/lib/auth/abuseProtection"; -import { checkReplayProtection } from "../../src/lib/observability/replayProtection"; +import { checkUnlockReplayProtection } from "../../src/lib/observability/replayProtection"; import { metrics } from "../../src/lib/observability/metrics"; import { dispatchEvent } from "../../server/src/services/webhookDispatcher"; import { recordAuditEvent } from "../../server/src/services/auditTrail"; @@ -333,13 +333,16 @@ async function handler(req: any, res: any) { } // 2. Replay protection - const replayCheck = await checkReplayProtection( - String(token), - String(signedMessage), - ); + const replayCheck = await checkUnlockReplayProtection({ + nonce: payload.nonce, + expiresAt: payload.expiresAt, + address: String(address), + token: String(token), + signedMessage: String(signedMessage), + }); if (!replayCheck.valid) { req.logger.warn({ address, bundleId }, "Replay attack detected"); - metrics.trackUnlockFailure(String(address), String(bundleId), "replay_detected"); + metrics.trackUnlockFailure(String(address), String(bundleId), replayCheck.reason ?? "replay_detected"); void recordAuditEvent({ action: "bundle_unlock_replay_detected", result: "blocked", @@ -347,11 +350,11 @@ async function handler(req: any, res: any) { walletAddress: String(address), requestId: req.requestId ?? null, clientIp, - reason: "replay_attack", + reason: replayCheck.reason ?? "replay_attack", }); res.status(400).json( apiError( - ErrorCode.TEMPORARY_FAILURE, + ErrorCode.CHALLENGE_REPLAY, "This unlock request has already been processed.", ), ); diff --git a/api/prompts/unlock.test.ts b/api/prompts/unlock.test.ts index 6dbf5c4e..cce8ea6c 100644 --- a/api/prompts/unlock.test.ts +++ b/api/prompts/unlock.test.ts @@ -9,6 +9,7 @@ import { } from "../../src/lib/auth/challenge"; import { ErrorCode } from "../../src/lib/api/errorCodes"; import { resetAbuseProtectionState } from "../../src/lib/auth/abuseProtection"; +import { resetReplayProtectionState } from "../../src/lib/observability/replayProtection"; const hasAccessMock = vi.fn(); const getPromptMock = vi.fn(); @@ -161,6 +162,7 @@ describe("unlock API integrity checks", () => { beforeEach(() => { vi.clearAllMocks(); resetAbuseProtectionState(); + resetReplayProtectionState(); }); it("returns plaintext when decrypted content matches the stored hash", async () => { @@ -384,6 +386,30 @@ describe("unlock API integrity checks", () => { ); }); + it("rejects replay of a captured wallet signature", async () => { + const { buyer, promptId, challenge, signedMessage } = + await setupUnlockFixture(); + + const first = await invokeUnlock({ + token: challenge.token, + promptId, + address: buyer.publicKey(), + signedMessage, + }); + expect(first.statusCode).toBe(200); + + const replay = await invokeUnlock({ + token: challenge.token, + promptId, + address: buyer.publicKey(), + signedMessage, + }); + + expect(replay.statusCode).toBe(400); + expect(replay.responseData.code).toBe(ErrorCode.CHALLENGE_REPLAY); + expect(replay.responseData.plaintext).toBeUndefined(); + }); + it("rejects unlock when wallet signature is invalid", async () => { const { buyer, promptId, challenge } = await setupUnlockFixture(); const wrongSigner = Keypair.random(); @@ -513,6 +539,7 @@ describe("unlock challenge message contract", () => { describe("unlock API with encryption rotation", () => { beforeEach(() => { vi.clearAllMocks(); + resetReplayProtectionState(); }); it("returns v1 plaintext for a buyer who purchased before rotation", async () => { diff --git a/api/prompts/unlock.ts b/api/prompts/unlock.ts index e7191158..f20f0400 100644 --- a/api/prompts/unlock.ts +++ b/api/prompts/unlock.ts @@ -26,7 +26,7 @@ import { recordSuccessfulAuth, verifyCaptchaToken, } from "../../src/lib/auth/abuseProtection"; -import { checkReplayProtection } from "../../src/lib/observability/replayProtection"; +import { checkUnlockReplayProtection } from "../../src/lib/observability/replayProtection"; import { metrics } from "../../src/lib/observability/metrics"; import { dispatchEvent } from "../../server/src/services/webhookDispatcher"; import { recordAuditEvent } from "../../server/src/services/auditTrail"; @@ -340,10 +340,13 @@ async function handler(req: any, res: any) { return; } - const replayCheck = await checkReplayProtection( - unlockRequest.token, - unlockRequest.signedMessage, - ); + const replayCheck = await checkUnlockReplayProtection({ + nonce: payload.nonce, + expiresAt: payload.expiresAt, + address: unlockRequest.address, + token: unlockRequest.token, + signedMessage: unlockRequest.signedMessage, + }); if (!replayCheck.valid) { req.logger.warn( { address: unlockRequest.address, promptId: unlockRequest.promptId }, @@ -352,7 +355,7 @@ async function handler(req: any, res: any) { metrics.trackUnlockFailure( unlockRequest.address, unlockRequest.promptId, - "replay_detected", + replayCheck.reason ?? "replay_detected", ); void recordAuditEvent({ action: "unlock_replay_detected", @@ -361,10 +364,15 @@ async function handler(req: any, res: any) { walletAddress: unlockRequest.address, requestId: req.requestId ?? null, clientIp, - reason: "replay_attack", + reason: replayCheck.reason ?? "replay_attack", }); res.status(400).json( - apiError(ErrorCode.TEMPORARY_FAILURE, "This unlock request has already been processed.", undefined, version), + apiError( + ErrorCode.CHALLENGE_REPLAY, + "This unlock request has already been processed.", + undefined, + version, + ), ); return; } diff --git a/src/lib/api/errorCodes.ts b/src/lib/api/errorCodes.ts index 3cdbe358..1da646bc 100644 --- a/src/lib/api/errorCodes.ts +++ b/src/lib/api/errorCodes.ts @@ -28,6 +28,9 @@ export const ErrorCode = { /** The challenge token is invalid (bad signature, wrong address/promptId). */ CHALLENGE_INVALID: "CHALLENGE_INVALID", + /** The unlock challenge nonce was already consumed (signature replay). */ + CHALLENGE_REPLAY: "CHALLENGE_REPLAY", + /** The wallet signature does not match the challenge message. */ INVALID_SIGNATURE: "INVALID_SIGNATURE", @@ -135,6 +138,8 @@ export const ERROR_MESSAGES: Record = { INVALID_INPUT: "Some of the information you entered isn't valid. Please review your entries and try again.", CHALLENGE_EXPIRED: "Your unlock session has expired for your security. Please restart the unlock flow to get a new one.", CHALLENGE_INVALID: "This unlock request is no longer valid. Please restart the unlock flow from the prompt page.", + CHALLENGE_REPLAY: + "This unlock signature was already used. Request a fresh challenge from the prompt page and sign again.", INVALID_SIGNATURE: "We couldn't verify your wallet signature. Please try signing the request again in your wallet.", ACCESS_NOT_PURCHASED: "You haven't purchased access to this prompt yet. Purchase it from the prompt page to unlock the content.", RATE_LIMIT_IP: "Too many requests from your network. Please wait a minute before trying again.", diff --git a/src/lib/auth/challenge.test.ts b/src/lib/auth/challenge.test.ts index 9cc50133..4a3f7f92 100644 --- a/src/lib/auth/challenge.test.ts +++ b/src/lib/auth/challenge.test.ts @@ -18,6 +18,10 @@ describe("unlock challenge verification", () => { const promptId = "42"; const challenge = createChallengeToken(secret, address, promptId, 1_700_000_000_000); + expect(challenge.nonce).toBeTruthy(); + expect(challenge.challenge).toContain(challenge.nonce); + expect(challenge.challenge).toContain(String(1_700_000_300_000)); + const payload = verifyChallengeToken( secret, challenge.token, @@ -28,6 +32,8 @@ describe("unlock challenge verification", () => { expect(payload.address).toBe(address); expect(payload.promptId).toBe(promptId); + expect(payload.nonce).toBe(challenge.nonce); + expect(payload.expiresAt).toBe(1_700_000_300_000); const message = buildChallengeMessage(payload); const signedMessage = Buffer.from( diff --git a/src/lib/observability/replayProtection.test.ts b/src/lib/observability/replayProtection.test.ts new file mode 100644 index 00000000..4569ac24 --- /dev/null +++ b/src/lib/observability/replayProtection.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it } from "vitest"; +import { + checkUnlockReplayProtection, + resetReplayProtectionState, +} from "./replayProtection"; + +describe("unlock replay protection", () => { + beforeEach(() => { + resetReplayProtectionState(); + }); + + it("accepts the first use of a challenge nonce", async () => { + const result = await checkUnlockReplayProtection( + { + nonce: "nonce-abc", + expiresAt: 1_700_000_300_000, + address: "GBUYER123", + }, + {}, + 1_700_000_000_000, + ); + + expect(result).toEqual({ valid: true }); + }); + + it("rejects replay of the same nonce for the same wallet", async () => { + const input = { + nonce: "nonce-replay", + expiresAt: 1_700_000_300_000, + address: "GBUYER123", + token: "token-value", + signedMessage: "signed-value", + }; + + expect(await checkUnlockReplayProtection(input, {}, 1_700_000_000_000)).toEqual({ + valid: true, + }); + + expect(await checkUnlockReplayProtection(input, {}, 1_700_000_000_000)).toEqual({ + valid: false, + reason: "nonce_reused", + }); + }); + + it("allows the same nonce for a different wallet address", async () => { + const expiresAt = 1_700_000_300_000; + const now = 1_700_000_000_000; + + expect( + await checkUnlockReplayProtection( + { nonce: "shared-nonce", expiresAt, address: "GBUYER_A" }, + {}, + now, + ), + ).toEqual({ valid: true }); + + expect( + await checkUnlockReplayProtection( + { nonce: "shared-nonce", expiresAt, address: "GBUYER_B" }, + {}, + now, + ), + ).toEqual({ valid: true }); + }); + + it("rejects expired challenges before reserving the nonce", async () => { + const result = await checkUnlockReplayProtection( + { + nonce: "expired-nonce", + expiresAt: 1_700_000_000_000, + address: "GBUYER123", + }, + {}, + 1_700_000_100_000, + ); + + expect(result).toEqual({ valid: false, reason: "challenge_expired" }); + }); +}); diff --git a/src/lib/observability/replayProtection.ts b/src/lib/observability/replayProtection.ts index de6697f1..a733e2df 100644 --- a/src/lib/observability/replayProtection.ts +++ b/src/lib/observability/replayProtection.ts @@ -9,38 +9,122 @@ const defaultConfig: ReplayCheckConfig = { ttlMs: 10 * 60 * 1000, }; -const fallbackCache = new LRUCache({ +const fallbackNonceCache = new LRUCache({ max: 10000, ttl: defaultConfig.ttlMs, }); +const fallbackSignatureCache = new LRUCache({ + max: 10000, + ttl: defaultConfig.ttlMs, +}); + +export interface UnlockReplayInput { + /** Unique nonce embedded in the signed challenge message. */ + nonce: string; + /** Challenge expiry (Unix ms) — controls how long the nonce stays reserved. */ + expiresAt: number; + /** Wallet address bound to the challenge (scopes the replay key). */ + address: string; + /** Optional legacy composite key for defense-in-depth. */ + token?: string; + signedMessage?: string; +} + +function computeNonceKey(address: string, nonce: string): string { + return `unlock:nonce:${address}:${nonce}`; +} + function computeSignatureHash(token: string, signedMessage: string): string { - return `${token}:${signedMessage}`; + return `replay:${token}:${signedMessage}`; +} + +function resolveTtlMs(expiresAt: number, config: ReplayCheckConfig, now = Date.now()): number { + const remainingMs = Math.max(0, expiresAt - now); + // Keep the nonce reserved until the challenge expires, with a small buffer. + const ttlMs = remainingMs + 60_000; + return Math.min(Math.max(ttlMs, 1_000), config.ttlMs); } -async function redisCheckAndStore( +async function redisSetIfAbsent( redis: Awaited>, - signatureHash: string, - config: ReplayCheckConfig, + key: string, + ttlMs: number, ): Promise { - const key = `replay:${signatureHash}`; - const ttlSec = Math.ceil(config.ttlMs / 1000); - + const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000)); const result = await redis!.set(key, "1", { NX: true, EX: ttlSec }); return result === "OK"; } -function inMemoryCheckAndStore( - signatureHash: string, - config: ReplayCheckConfig, +function inMemorySetIfAbsent( + cache: LRUCache, + key: string, + ttlMs: number, ): boolean { - if (fallbackCache.has(signatureHash)) { + if (cache.has(key)) { return false; } - fallbackCache.set(signatureHash, true, { ttl: config.ttlMs }); + cache.set(key, true, { ttl: ttlMs }); return true; } +async function reserveReplayKey(key: string, ttlMs: number): Promise { + try { + const redis = await getRedisClient(); + if (redis) { + return redisSetIfAbsent(redis, key, ttlMs); + } + } catch { + // Redis unavailable — fall back to in-memory. + } + + return inMemorySetIfAbsent(fallbackNonceCache, key, ttlMs); +} + +/** + * Reserve an unlock challenge nonce so a captured wallet signature cannot be + * replayed after the first successful verification. + * + * The signed challenge message already binds address, promptId, nonce, and + * expiresAt; this store ensures each nonce is accepted at most once for the + * lifetime of the challenge. + */ +export async function checkUnlockReplayProtection( + input: UnlockReplayInput, + config: Partial = {}, + now = Date.now(), +): Promise<{ valid: boolean; reason?: string }> { + const finalConfig = { ...defaultConfig, ...config }; + + if (!input.nonce || !input.address) { + return { valid: false, reason: "invalid_replay_input" }; + } + + if (input.expiresAt < now) { + return { valid: false, reason: "challenge_expired" }; + } + + const ttlMs = resolveTtlMs(input.expiresAt, finalConfig, now); + const nonceKey = computeNonceKey(input.address, input.nonce); + const nonceReserved = await reserveReplayKey(nonceKey, ttlMs); + if (!nonceReserved) { + return { valid: false, reason: "nonce_reused" }; + } + + if (input.token && input.signedMessage) { + const signatureKey = computeSignatureHash(input.token, input.signedMessage); + const signatureReserved = await reserveReplayKey(signatureKey, ttlMs); + if (!signatureReserved) { + return { valid: false, reason: "signature_reused" }; + } + } + + return { valid: true }; +} + +/** + * @deprecated Prefer {@link checkUnlockReplayProtection} with explicit nonce tracking. + */ export async function checkReplayProtection( token: string, signedMessage: string, @@ -52,7 +136,7 @@ export async function checkReplayProtection( try { const redis = await getRedisClient(); if (redis) { - const isValid = await redisCheckAndStore(redis, signatureHash, finalConfig); + const isValid = await redisSetIfAbsent(redis, signatureHash, finalConfig.ttlMs); if (!isValid) { return { valid: false, reason: "replay_detected" }; } @@ -62,9 +146,19 @@ export async function checkReplayProtection( // Redis unavailable — fall back to in-memory. } - const isValid = inMemoryCheckAndStore(signatureHash, finalConfig); + const isValid = inMemorySetIfAbsent( + fallbackSignatureCache, + signatureHash, + finalConfig.ttlMs, + ); if (!isValid) { return { valid: false, reason: "replay_detected" }; } return { valid: true }; } + +/** Test helper — clears in-memory replay state between unit tests. */ +export function resetReplayProtectionState(): void { + fallbackNonceCache.clear(); + fallbackSignatureCache.clear(); +}