diff --git a/api/auth/challenge.test.ts b/api/auth/challenge.test.ts index 9542e49d..3f75103a 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 46bdeaeb..6b624cd8 100644 --- a/api/bundles/unlock.ts +++ b/api/bundles/unlock.ts @@ -1,505 +1,508 @@ -/** - * POST /api/bundles/unlock - * - * Unlock all prompts in a purchased bundle in one authenticated round-trip. - * - * Flow: - * 1. Verify challenge token + wallet signature (same as single-prompt unlock). - * 2. Verify on-chain bundle access via `has_bundle_access`. - * 3. Retrieve the BundlePurchase snapshot to get the purchased prompt IDs. - * (Falls back to current bundle.prompt_ids when snapshot unavailable.) - * 4. For each prompt, decrypt and integrity-check the plaintext. - * 5. Return the array of {promptId, title, contentHash, plaintext}. - * - * Per-prompt access checks are NOT relaxed — every prompt is decrypted only - * because the bundle purchase itself proves entitlement to every member. - */ -import { - buildChallengeMessage, - verifyChallengeSignature, - verifyChallengeToken, -} from "../../src/lib/auth/challenge"; -import { - decryptPromptCiphertext, - hashPromptPlaintext, - normalizeContentHash, - unwrapPromptKey, -} from "../../src/lib/crypto/promptCrypto"; -import { - getPrompt, - getBundle, - hasBundleAccess, - type PromptHashConfig, -} from "../../src/lib/stellar/promptHashClient"; -import { withObservability } from "../../src/lib/observability/wrapper"; -import { checkRateLimit } from "../../src/lib/observability/rateLimiter"; -import { - isAccountLocked, - isCaptchaRequired, - recordFailedAuthAttempt, - recordSuccessfulAuth, - verifyCaptchaToken, -} from "../../src/lib/auth/abuseProtection"; -import { checkReplayProtection } 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"; -import { apiError, ErrorCode } from "../../src/lib/api/errorCodes"; -import { validateUnlockSecrets } from "../../src/lib/validation/envValidator"; - -// Fail-fast module load validation -try { - validateUnlockSecrets(); -} catch (err: any) { - console.error(err.message); -} - -function getActiveSecrets(primarySecret: string): string[] { - const secrets = [primarySecret]; - const previousSecret = process.env.CHALLENGE_TOKEN_SECRET_PREVIOUS; - const rotationTimestamp = parseInt( - process.env.CHALLENGE_TOKEN_ROTATION_TIMESTAMP || "0", - 10, - ); - const gracePeriodMs = parseInt( - process.env.CHALLENGE_TOKEN_GRACE_PERIOD_MS || "300000", - 10, - ); - if (previousSecret && rotationTimestamp) { - const timeSinceRotation = Date.now() - rotationTimestamp; - if (timeSinceRotation < gracePeriodMs) { - secrets.push(previousSecret); - } - } - return secrets; -} - -function getServerConfig(): PromptHashConfig { - const rpcUrl = - process.env.PUBLIC_STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; - const networkPassphrase = - process.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? - "Test SDF Network ; September 2015"; - const promptHashContractId = - process.env.PUBLIC_PROMPT_HASH_CONTRACT_ID ?? ""; - const nativeAssetContractId = - process.env.PUBLIC_STELLAR_NATIVE_ASSET_CONTRACT_ID ?? - "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - const simulationAccount = - process.env.PUBLIC_STELLAR_SIMULATION_ACCOUNT ?? - process.env.UNLOCK_PUBLIC_KEY ?? - ""; - - return { - rpcUrl, - networkPassphrase, - promptHashContractId, - nativeAssetContractId, - simulationAccount, - allowHttp: new URL(rpcUrl).hostname === "localhost", - }; -} - -export interface UnlockedPrompt { - promptId: string; - title: string; - contentHash: string; - plaintext: string; -} - -async function handler(req: any, res: any) { - try { - validateUnlockSecrets(); - } catch (err: any) { - req.logger.error("Configuration validation failed", { error: err.message }); - res - .status(500) - .json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.")); - return; - } - - if (req.method !== "POST") { - res - .status(405) - .json(apiError(ErrorCode.METHOD_NOT_ALLOWED, "Method not allowed.")); - return; - } - - const clientIp = ( - req.headers["x-forwarded-for"] || req.socket.remoteAddress - ) as string; - const { token, bundleId, address, signedMessage, captchaToken } = req.body ?? {}; - - const challengeSecret = process.env.CHALLENGE_TOKEN_SECRET; - const unlockPublicKey = process.env.UNLOCK_PUBLIC_KEY; - const unlockPrivateKey = process.env.UNLOCK_PRIVATE_KEY; - - if (!challengeSecret || !unlockPublicKey || !unlockPrivateKey) { - req.logger.error("Unlock service is missing configuration secrets."); - res - .status(500) - .json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.")); - return; - } - - if (!token || !bundleId || !address || !signedMessage) { - res.status(400).json( - apiError( - ErrorCode.MISSING_FIELDS, - "token, bundleId, address, and signedMessage are required.", - ), - ); - return; - } - - // Rate-limit: IP bucket (unauthenticated / most strict) - const ipRateLimit = await checkRateLimit("bundle_unlock", clientIp, false); - if (!ipRateLimit.success) { - req.logger.warn({ clientIp }, "Rate limit exceeded for bundle unlock (IP)"); - metrics.trackRateLimitHit("bundle_unlock_ip", clientIp); - void recordAuditEvent({ - action: "bundle_unlock_rate_limited", - result: "blocked", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "rate_limit", - }); - res.status(429).json( - apiError( - ErrorCode.RATE_LIMIT_IP, - "Too many requests. Please wait before retrying.", - ), - ); - return; - } - - // Check if wallet account is locked - if (address && typeof address === "string") { - const lockStatus = await isAccountLocked(address); - if (lockStatus.locked) { - req.logger.warn({ address }, "Bundle unlock requested for locked account"); - void recordAuditEvent({ - action: "bundle_unlock_account_locked", - result: "blocked", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "account_locked", - }); - res.status(423).json( - apiError( - ErrorCode.ACCOUNT_LOCKED, - "Account is locked due to too many failed authentication attempts.", - { lockedUntil: lockStatus.lockedUntil }, - ), - ); - return; - } - } - - // Rate-limit: wallet bucket (authenticated / slightly looser) - const walletRateLimit = await checkRateLimit( - "bundle_unlock", - String(address), - true, - ); - if (!walletRateLimit.success) { - req.logger.warn({ address }, "Rate limit exceeded for bundle unlock (wallet)"); - metrics.trackRateLimitHit("bundle_unlock_wallet", String(address)); - res.status(429).json( - apiError( - ErrorCode.RATE_LIMIT_WALLET, - "Too many requests. Please wait before retrying.", - ), - ); - return; - } - - // Check if CAPTCHA is required due to repeated failures - const addressStr = typeof address === "string" ? address : undefined; - const captchaNeeded = await isCaptchaRequired(addressStr, clientIp); - if (captchaNeeded) { - const resolvedCaptchaToken = - captchaToken || req.headers["x-captcha-token"]; - - if (!resolvedCaptchaToken || typeof resolvedCaptchaToken !== "string") { - req.logger.warn({ address: addressStr, clientIp }, "CAPTCHA required for bundle unlock request"); - void recordAuditEvent({ - action: "bundle_unlock_captcha_required", - result: "blocked", - promptId: String(bundleId), - walletAddress: addressStr ?? null, - requestId: req.requestId ?? null, - clientIp, - reason: "captcha_required", - }); - res.status(403).json( - apiError( - ErrorCode.CAPTCHA_REQUIRED, - "CAPTCHA verification is required to proceed.", - { captchaRequired: true }, - ), - ); - return; - } - - const captchaResult = await verifyCaptchaToken(resolvedCaptchaToken, clientIp); - if (!captchaResult.valid) { - req.logger.warn( - { address: addressStr, clientIp, reason: captchaResult.reason }, - "Invalid CAPTCHA token for bundle unlock", - ); - void recordAuditEvent({ - action: "bundle_unlock_captcha_failed", - result: "blocked", - promptId: String(bundleId), - walletAddress: addressStr ?? null, - requestId: req.requestId ?? null, - clientIp, - reason: captchaResult.reason ?? "invalid_captcha", - }); - res.status(403).json( - apiError( - ErrorCode.CAPTCHA_INVALID, - "Invalid or expired CAPTCHA verification.", - { captchaRequired: true }, - ), - ); - return; - } - } - - try { - // 1. Verify challenge token - const activeSecrets = getActiveSecrets(challengeSecret); - const payload = verifyChallengeToken( - activeSecrets, - String(token), - String(address), - String(bundleId), - ); - const challengeMessage = buildChallengeMessage(payload); - const validSignature = verifyChallengeSignature( - String(address), - challengeMessage, - String(signedMessage), - ); - - if (!validSignature) { - req.logger.warn({ address, bundleId }, "Invalid wallet signature"); - metrics.trackUnlockFailure(String(address), String(bundleId), "invalid_signature"); - - const failureStatus = await recordFailedAuthAttempt(String(address), clientIp); - - if (failureStatus.locked) { - req.logger.warn({ address }, "Account locked after 5 failed auth attempts"); - void recordAuditEvent({ - action: "bundle_account_locked", - result: "blocked", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "max_failed_auth_attempts_exceeded", - }); - res.status(423).json( - apiError( - ErrorCode.ACCOUNT_LOCKED, - "Account is locked due to too many failed authentication attempts.", - { lockedUntil: failureStatus.lockedUntil }, - ), - ); - return; - } - - void recordAuditEvent({ - action: "bundle_unlock_invalid_signature", - result: "failure", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "invalid_signature", - }); - res - .status(401) - .json( - apiError(ErrorCode.INVALID_SIGNATURE, "Invalid wallet signature."), - ); - return; - } - - // 2. Replay protection - const replayCheck = await checkReplayProtection( - String(token), - String(signedMessage), - ); - if (!replayCheck.valid) { - req.logger.warn({ address, bundleId }, "Replay attack detected"); - metrics.trackUnlockFailure(String(address), String(bundleId), "replay_detected"); - void recordAuditEvent({ - action: "bundle_unlock_replay_detected", - result: "blocked", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "replay_attack", - }); - res.status(400).json( - apiError( - ErrorCode.TEMPORARY_FAILURE, - "This unlock request has already been processed.", - ), - ); - return; - } - - // 3. Verify on-chain bundle access - const config = getServerConfig(); - const bid = BigInt(bundleId); - const access = await hasBundleAccess(config, String(address), bid); - if (!access) { - req.logger.warn({ address, bundleId }, "Bundle access denied"); - metrics.trackUnlockFailure(String(address), String(bundleId), "no_access"); - void recordAuditEvent({ - action: "bundle_unlock_no_access", - result: "failure", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "no_access", - }); - res.status(403).json( - apiError( - ErrorCode.ACCESS_NOT_PURCHASED, - "Bundle access has not been purchased.", - ), - ); - return; - } - - // 4. Resolve which prompt IDs belong to this bundle purchase. - // Using bundle.prompt_ids (live) is intentional — the purchasedPromptIds - // snapshot lives purely on-chain. The server unlocks only the prompts the - // buyer is entitled to; the challenge token is bound to the bundleId, so - // the set cannot be widened by the caller. - const bundle = await getBundle(config, bid); - - // 5. Decrypt each member prompt - const results: UnlockedPrompt[] = []; - for (const promptIdBig of bundle.promptIds) { - const prompt = await getPrompt(config, promptIdBig); - const keyBytes = await unwrapPromptKey( - prompt.wrappedKey as string, - unlockPublicKey, - unlockPrivateKey, - ); - const plaintext = await decryptPromptCiphertext( - prompt.encryptedPrompt as string, - prompt.encryptionIv as string, - keyBytes, - ); - const contentHash = await hashPromptPlaintext(plaintext); - const storedHash = normalizeContentHash(prompt.contentHash as string); - if (contentHash !== storedHash) { - req.logger.error( - { address, bundleId, promptId: promptIdBig.toString() }, - "Prompt integrity check failed inside bundle unlock", - ); - metrics.trackUnlockFailure( - String(address), - promptIdBig.toString(), - "integrity_failure", - ); - void recordAuditEvent({ - action: "bundle_unlock_integrity_failure", - result: "failure", - promptId: promptIdBig.toString(), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "integrity_failure", - }); - res.status(500).json( - apiError( - ErrorCode.INTEGRITY_FAILURE, - `Prompt #${promptIdBig.toString()} integrity check failed.`, - ), - ); - return; - } - results.push({ - promptId: promptIdBig.toString(), - title: prompt.title, - contentHash, - plaintext, - }); - } - - // 6. Audit + metrics - await recordSuccessfulAuth(String(address), clientIp); - metrics.trackUnlockSuccess(String(address), String(bundleId)); - req.logger.info( - { address, bundleId, count: results.length }, - "Bundle unlocked successfully", - ); - void recordAuditEvent({ - action: "bundle_unlock_success", - result: "success", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: null, - }); - - // 7. Notify creator (fire-and-forget) - void Promise.resolve( - dispatchEvent(bundle.creator ?? "", "BundlePurchased", { - bundleId: bid.toString(), - buyer: String(address), - title: bundle.title, - itemCount: results.length, - }), - ).catch(() => {}); - - res.status(200).json({ - bundleId: bid.toString(), - title: bundle.title, - items: results, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to unlock bundle."; - req.logger.error({ address, bundleId, error: message }, "Bundle unlock attempt failed"); - metrics.trackUnlockFailure(String(address), String(bundleId), "error"); - - const isExpired = message.toLowerCase().includes("expired"); - void recordAuditEvent({ - action: isExpired - ? "bundle_unlock_expired_challenge" - : "bundle_unlock_error", - result: "failure", - promptId: String(bundleId), - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: isExpired ? "expired_challenge" : "error", - }); - - if (isExpired) { - res.status(401).json( - apiError(ErrorCode.CHALLENGE_EXPIRED, "The challenge token has expired. Please request a new one."), - ); - } else { - res.status(500).json(apiError(ErrorCode.CONFIGURATION_ERROR, message)); - } - } -} - -export default withObservability(handler); +/** + * POST /api/bundles/unlock + * + * Unlock all prompts in a purchased bundle in one authenticated round-trip. + * + * Flow: + * 1. Verify challenge token + wallet signature (same as single-prompt unlock). + * 2. Verify on-chain bundle access via `has_bundle_access`. + * 3. Retrieve the BundlePurchase snapshot to get the purchased prompt IDs. + * (Falls back to current bundle.prompt_ids when snapshot unavailable.) + * 4. For each prompt, decrypt and integrity-check the plaintext. + * 5. Return the array of {promptId, title, contentHash, plaintext}. + * + * Per-prompt access checks are NOT relaxed — every prompt is decrypted only + * because the bundle purchase itself proves entitlement to every member. + */ +import { + buildChallengeMessage, + verifyChallengeSignature, + verifyChallengeToken, +} from "../../src/lib/auth/challenge"; +import { + decryptPromptCiphertext, + hashPromptPlaintext, + normalizeContentHash, + unwrapPromptKey, +} from "../../src/lib/crypto/promptCrypto"; +import { + getPrompt, + getBundle, + hasBundleAccess, + type PromptHashConfig, +} from "../../src/lib/stellar/promptHashClient"; +import { withObservability } from "../../src/lib/observability/wrapper"; +import { checkRateLimit } from "../../src/lib/observability/rateLimiter"; +import { + isAccountLocked, + isCaptchaRequired, + recordFailedAuthAttempt, + recordSuccessfulAuth, + verifyCaptchaToken, +} from "../../src/lib/auth/abuseProtection"; +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"; +import { apiError, ErrorCode } from "../../src/lib/api/errorCodes"; +import { validateUnlockSecrets } from "../../src/lib/validation/envValidator"; + +// Fail-fast module load validation +try { + validateUnlockSecrets(); +} catch (err: any) { + console.error(err.message); +} + +function getActiveSecrets(primarySecret: string): string[] { + const secrets = [primarySecret]; + const previousSecret = process.env.CHALLENGE_TOKEN_SECRET_PREVIOUS; + const rotationTimestamp = parseInt( + process.env.CHALLENGE_TOKEN_ROTATION_TIMESTAMP || "0", + 10, + ); + const gracePeriodMs = parseInt( + process.env.CHALLENGE_TOKEN_GRACE_PERIOD_MS || "300000", + 10, + ); + if (previousSecret && rotationTimestamp) { + const timeSinceRotation = Date.now() - rotationTimestamp; + if (timeSinceRotation < gracePeriodMs) { + secrets.push(previousSecret); + } + } + return secrets; +} + +function getServerConfig(): PromptHashConfig { + const rpcUrl = + process.env.PUBLIC_STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; + const networkPassphrase = + process.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? + "Test SDF Network ; September 2015"; + const promptHashContractId = + process.env.PUBLIC_PROMPT_HASH_CONTRACT_ID ?? ""; + const nativeAssetContractId = + process.env.PUBLIC_STELLAR_NATIVE_ASSET_CONTRACT_ID ?? + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + const simulationAccount = + process.env.PUBLIC_STELLAR_SIMULATION_ACCOUNT ?? + process.env.UNLOCK_PUBLIC_KEY ?? + ""; + + return { + rpcUrl, + networkPassphrase, + promptHashContractId, + nativeAssetContractId, + simulationAccount, + allowHttp: new URL(rpcUrl).hostname === "localhost", + }; +} + +export interface UnlockedPrompt { + promptId: string; + title: string; + contentHash: string; + plaintext: string; +} + +async function handler(req: any, res: any) { + try { + validateUnlockSecrets(); + } catch (err: any) { + req.logger.error("Configuration validation failed", { error: err.message }); + res + .status(500) + .json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.")); + return; + } + + if (req.method !== "POST") { + res + .status(405) + .json(apiError(ErrorCode.METHOD_NOT_ALLOWED, "Method not allowed.")); + return; + } + + const clientIp = ( + req.headers["x-forwarded-for"] || req.socket.remoteAddress + ) as string; + const { token, bundleId, address, signedMessage, captchaToken } = req.body ?? {}; + + const challengeSecret = process.env.CHALLENGE_TOKEN_SECRET; + const unlockPublicKey = process.env.UNLOCK_PUBLIC_KEY; + const unlockPrivateKey = process.env.UNLOCK_PRIVATE_KEY; + + if (!challengeSecret || !unlockPublicKey || !unlockPrivateKey) { + req.logger.error("Unlock service is missing configuration secrets."); + res + .status(500) + .json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.")); + return; + } + + if (!token || !bundleId || !address || !signedMessage) { + res.status(400).json( + apiError( + ErrorCode.MISSING_FIELDS, + "token, bundleId, address, and signedMessage are required.", + ), + ); + return; + } + + // Rate-limit: IP bucket (unauthenticated / most strict) + const ipRateLimit = await checkRateLimit("bundle_unlock", clientIp, false); + if (!ipRateLimit.success) { + req.logger.warn({ clientIp }, "Rate limit exceeded for bundle unlock (IP)"); + metrics.trackRateLimitHit("bundle_unlock_ip", clientIp); + void recordAuditEvent({ + action: "bundle_unlock_rate_limited", + result: "blocked", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "rate_limit", + }); + res.status(429).json( + apiError( + ErrorCode.RATE_LIMIT_IP, + "Too many requests. Please wait before retrying.", + ), + ); + return; + } + + // Check if wallet account is locked + if (address && typeof address === "string") { + const lockStatus = await isAccountLocked(address); + if (lockStatus.locked) { + req.logger.warn({ address }, "Bundle unlock requested for locked account"); + void recordAuditEvent({ + action: "bundle_unlock_account_locked", + result: "blocked", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "account_locked", + }); + res.status(423).json( + apiError( + ErrorCode.ACCOUNT_LOCKED, + "Account is locked due to too many failed authentication attempts.", + { lockedUntil: lockStatus.lockedUntil }, + ), + ); + return; + } + } + + // Rate-limit: wallet bucket (authenticated / slightly looser) + const walletRateLimit = await checkRateLimit( + "bundle_unlock", + String(address), + true, + ); + if (!walletRateLimit.success) { + req.logger.warn({ address }, "Rate limit exceeded for bundle unlock (wallet)"); + metrics.trackRateLimitHit("bundle_unlock_wallet", String(address)); + res.status(429).json( + apiError( + ErrorCode.RATE_LIMIT_WALLET, + "Too many requests. Please wait before retrying.", + ), + ); + return; + } + + // Check if CAPTCHA is required due to repeated failures + const addressStr = typeof address === "string" ? address : undefined; + const captchaNeeded = await isCaptchaRequired(addressStr, clientIp); + if (captchaNeeded) { + const resolvedCaptchaToken = + captchaToken || req.headers["x-captcha-token"]; + + if (!resolvedCaptchaToken || typeof resolvedCaptchaToken !== "string") { + req.logger.warn({ address: addressStr, clientIp }, "CAPTCHA required for bundle unlock request"); + void recordAuditEvent({ + action: "bundle_unlock_captcha_required", + result: "blocked", + promptId: String(bundleId), + walletAddress: addressStr ?? null, + requestId: req.requestId ?? null, + clientIp, + reason: "captcha_required", + }); + res.status(403).json( + apiError( + ErrorCode.CAPTCHA_REQUIRED, + "CAPTCHA verification is required to proceed.", + { captchaRequired: true }, + ), + ); + return; + } + + const captchaResult = await verifyCaptchaToken(resolvedCaptchaToken, clientIp); + if (!captchaResult.valid) { + req.logger.warn( + { address: addressStr, clientIp, reason: captchaResult.reason }, + "Invalid CAPTCHA token for bundle unlock", + ); + void recordAuditEvent({ + action: "bundle_unlock_captcha_failed", + result: "blocked", + promptId: String(bundleId), + walletAddress: addressStr ?? null, + requestId: req.requestId ?? null, + clientIp, + reason: captchaResult.reason ?? "invalid_captcha", + }); + res.status(403).json( + apiError( + ErrorCode.CAPTCHA_INVALID, + "Invalid or expired CAPTCHA verification.", + { captchaRequired: true }, + ), + ); + return; + } + } + + try { + // 1. Verify challenge token + const activeSecrets = getActiveSecrets(challengeSecret); + const payload = verifyChallengeToken( + activeSecrets, + String(token), + String(address), + String(bundleId), + ); + const challengeMessage = buildChallengeMessage(payload); + const validSignature = verifyChallengeSignature( + String(address), + challengeMessage, + String(signedMessage), + ); + + if (!validSignature) { + req.logger.warn({ address, bundleId }, "Invalid wallet signature"); + metrics.trackUnlockFailure(String(address), String(bundleId), "invalid_signature"); + + const failureStatus = await recordFailedAuthAttempt(String(address), clientIp); + + if (failureStatus.locked) { + req.logger.warn({ address }, "Account locked after 5 failed auth attempts"); + void recordAuditEvent({ + action: "bundle_account_locked", + result: "blocked", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "max_failed_auth_attempts_exceeded", + }); + res.status(423).json( + apiError( + ErrorCode.ACCOUNT_LOCKED, + "Account is locked due to too many failed authentication attempts.", + { lockedUntil: failureStatus.lockedUntil }, + ), + ); + return; + } + + void recordAuditEvent({ + action: "bundle_unlock_invalid_signature", + result: "failure", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "invalid_signature", + }); + res + .status(401) + .json( + apiError(ErrorCode.INVALID_SIGNATURE, "Invalid wallet signature."), + ); + return; + } + + // 2. Replay protection + 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), replayCheck.reason ?? "replay_detected"); + void recordAuditEvent({ + action: "bundle_unlock_replay_detected", + result: "blocked", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: replayCheck.reason ?? "replay_attack", + }); + res.status(400).json( + apiError( + ErrorCode.CHALLENGE_REPLAY, + "This unlock request has already been processed.", + ), + ); + return; + } + + // 3. Verify on-chain bundle access + const config = getServerConfig(); + const bid = BigInt(bundleId); + const access = await hasBundleAccess(config, String(address), bid); + if (!access) { + req.logger.warn({ address, bundleId }, "Bundle access denied"); + metrics.trackUnlockFailure(String(address), String(bundleId), "no_access"); + void recordAuditEvent({ + action: "bundle_unlock_no_access", + result: "failure", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "no_access", + }); + res.status(403).json( + apiError( + ErrorCode.ACCESS_NOT_PURCHASED, + "Bundle access has not been purchased.", + ), + ); + return; + } + + // 4. Resolve which prompt IDs belong to this bundle purchase. + // Using bundle.prompt_ids (live) is intentional — the purchasedPromptIds + // snapshot lives purely on-chain. The server unlocks only the prompts the + // buyer is entitled to; the challenge token is bound to the bundleId, so + // the set cannot be widened by the caller. + const bundle = await getBundle(config, bid); + + // 5. Decrypt each member prompt + const results: UnlockedPrompt[] = []; + for (const promptIdBig of bundle.promptIds) { + const prompt = await getPrompt(config, promptIdBig); + const keyBytes = await unwrapPromptKey( + prompt.wrappedKey as string, + unlockPublicKey, + unlockPrivateKey, + ); + const plaintext = await decryptPromptCiphertext( + prompt.encryptedPrompt as string, + prompt.encryptionIv as string, + keyBytes, + ); + const contentHash = await hashPromptPlaintext(plaintext); + const storedHash = normalizeContentHash(prompt.contentHash as string); + if (contentHash !== storedHash) { + req.logger.error( + { address, bundleId, promptId: promptIdBig.toString() }, + "Prompt integrity check failed inside bundle unlock", + ); + metrics.trackUnlockFailure( + String(address), + promptIdBig.toString(), + "integrity_failure", + ); + void recordAuditEvent({ + action: "bundle_unlock_integrity_failure", + result: "failure", + promptId: promptIdBig.toString(), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "integrity_failure", + }); + res.status(500).json( + apiError( + ErrorCode.INTEGRITY_FAILURE, + `Prompt #${promptIdBig.toString()} integrity check failed.`, + ), + ); + return; + } + results.push({ + promptId: promptIdBig.toString(), + title: prompt.title, + contentHash, + plaintext, + }); + } + + // 6. Audit + metrics + await recordSuccessfulAuth(String(address), clientIp); + metrics.trackUnlockSuccess(String(address), String(bundleId)); + req.logger.info( + { address, bundleId, count: results.length }, + "Bundle unlocked successfully", + ); + void recordAuditEvent({ + action: "bundle_unlock_success", + result: "success", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: null, + }); + + // 7. Notify creator (fire-and-forget) + void Promise.resolve( + dispatchEvent(bundle.creator ?? "", "BundlePurchased", { + bundleId: bid.toString(), + buyer: String(address), + title: bundle.title, + itemCount: results.length, + }), + ).catch(() => {}); + + res.status(200).json({ + bundleId: bid.toString(), + title: bundle.title, + items: results, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to unlock bundle."; + req.logger.error({ address, bundleId, error: message }, "Bundle unlock attempt failed"); + metrics.trackUnlockFailure(String(address), String(bundleId), "error"); + + const isExpired = message.toLowerCase().includes("expired"); + void recordAuditEvent({ + action: isExpired + ? "bundle_unlock_expired_challenge" + : "bundle_unlock_error", + result: "failure", + promptId: String(bundleId), + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: isExpired ? "expired_challenge" : "error", + }); + + if (isExpired) { + res.status(401).json( + apiError(ErrorCode.CHALLENGE_EXPIRED, "The challenge token has expired. Please request a new one."), + ); + } else { + res.status(500).json(apiError(ErrorCode.CONFIGURATION_ERROR, message)); + } + } +} + +export default withObservability(handler); diff --git a/api/prompts/unlock.test.ts b/api/prompts/unlock.test.ts index 70b475f9..ac6a9c53 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 () => { @@ -383,6 +385,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(); @@ -514,6 +540,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 d1c8e9a6..61abf95f 100644 --- a/api/prompts/unlock.ts +++ b/api/prompts/unlock.ts @@ -1,581 +1,589 @@ -import { - buildChallengeMessage, - verifyChallengeSignature, - verifyChallengeToken, -} from "../../src/lib/auth/challenge"; -import { - decryptPromptCiphertext, - hashPromptPlaintext, - normalizeContentHash, - unwrapPromptKey, -} from "../../src/lib/crypto/promptCrypto"; -import { fetchFromBlobStorage, isBlobReference } from "../../src/lib/stellar/blobStorage"; -import { - getPrompt, - getPromptEncryptionVersion, - getPurchaseDetails, - hasAccess, - type PromptHashConfig, -} from "../../src/lib/stellar/promptHashClient"; -import { withObservability } from "../../src/lib/observability/wrapper"; -import { withBodySizeLimit } from "../../src/lib/api/bodySizeLimit"; -import { checkRateLimit } from "../../src/lib/observability/rateLimiter"; -import { - isAccountLocked, - isCaptchaRequired, - recordFailedAuthAttempt, - recordSuccessfulAuth, - verifyCaptchaToken, -} from "../../src/lib/auth/abuseProtection"; -import { checkReplayProtection } 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"; -import { apiError, ErrorCode } from "../../src/lib/api/errorCodes"; -import { validateUnlockSecrets } from "../../src/lib/validation/envValidator"; -import { negotiateVersion } from "../../src/lib/api/versionGuard"; -import { withVersion } from "../../src/lib/api/payloadVersion"; -import { - parseRequestBody, - UnlockRequestBody, -} from "../../src/lib/api/requestSchemas"; - -// Fail-fast module load validation -try { - validateUnlockSecrets(); -} catch (err: any) { - console.error(err.message); -} - - -/** - * Get active secrets for token verification - * Supports multiple secrets during rotation grace period - */ -function getActiveSecrets(primarySecret: string): string[] { - const secrets = [primarySecret]; - - // Check for previous secret within grace period - const previousSecret = process.env.CHALLENGE_TOKEN_SECRET_PREVIOUS; - const rotationTimestamp = parseInt( - process.env.CHALLENGE_TOKEN_ROTATION_TIMESTAMP || "0", - 10 - ); - const gracePeriodMs = parseInt( - process.env.CHALLENGE_TOKEN_GRACE_PERIOD_MS || "300000", // 5 minutes default - 10 - ); - - if (previousSecret && rotationTimestamp) { - const timeSinceRotation = Date.now() - rotationTimestamp; - if (timeSinceRotation < gracePeriodMs) { - secrets.push(previousSecret); - } - } - - return secrets; -} - -function getServerConfig(): PromptHashConfig { - const rpcUrl = - process.env.PUBLIC_STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; - const networkPassphrase = - process.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? - "Test SDF Network ; September 2015"; - const promptHashContractId = process.env.PUBLIC_PROMPT_HASH_CONTRACT_ID ?? ""; - const nativeAssetContractId = - process.env.PUBLIC_STELLAR_NATIVE_ASSET_CONTRACT_ID ?? - "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - const simulationAccount = - process.env.PUBLIC_STELLAR_SIMULATION_ACCOUNT ?? process.env.UNLOCK_PUBLIC_KEY ?? ""; - - return { - rpcUrl, - networkPassphrase, - promptHashContractId, - nativeAssetContractId, - simulationAccount, - allowHttp: new URL(rpcUrl).hostname === "localhost", - }; -} - -async function handler(req: any, res: any) { - try { - validateUnlockSecrets(); - } catch (err: any) { - req.logger.error("Configuration validation failed", { error: err.message }); - res.status(500).json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.")); - return; - } - - if (req.method !== "POST") { - res.status(405).json(apiError(ErrorCode.METHOD_NOT_ALLOWED, "Method not allowed.")); - return; - } - - const version = negotiateVersion(req, res); - if (!version) return; - - const parsed = parseRequestBody(UnlockRequestBody, req.body); - if (!parsed.success) { - res.status(400).json( - apiError( - ErrorCode.MISSING_FIELDS, - "token, promptId, address, and signedMessage are required.", - undefined, - version, - ), - ); - return; - } - - const unlockRequest = parsed.data; - const clientIp = (req.headers["x-forwarded-for"] || req.socket.remoteAddress) as string; - const address = unlockRequest.address; - const promptId = unlockRequest.promptId; - - // Authenticated bucket: wallet address is present. - const isAuthenticated = Boolean(address); - - // Rate limit by IP (unauthenticated bucket — strictest guard). - const ipRateLimit = await checkRateLimit("unlock", clientIp, false); - if (!ipRateLimit.success) { - req.logger.warn({ clientIp }, "Rate limit exceeded for unlock (IP)"); - metrics.trackRateLimitHit("unlock_ip", clientIp); - void recordAuditEvent({ - action: "unlock_rate_limited", - result: "blocked", - promptId: promptId ? String(promptId) : null, - walletAddress: address ? String(address) : null, - requestId: req.requestId ?? null, - clientIp, - reason: "ip_rate_limit_exceeded", - }); - res.setHeader("X-RateLimit-Limit", ipRateLimit.limit); - res.setHeader("X-RateLimit-Remaining", 0); - res.setHeader("X-RateLimit-Reset", ipRateLimit.reset); - res.status(429).json( - apiError(ErrorCode.RATE_LIMIT_IP, "Too many requests. Please try again later.", { - reset: ipRateLimit.reset, - }, version), - ); - return; - } - - // Check if wallet account is locked after repeated auth failures - if (address && typeof address === "string") { - const lockStatus = await isAccountLocked(address); - if (lockStatus.locked) { - req.logger.warn({ address }, "Unlock requested for locked account"); - void recordAuditEvent({ - action: "unlock_account_locked", - result: "blocked", - promptId: promptId ? String(promptId) : null, - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "account_locked", - }); - res.status(423).json( - apiError( - ErrorCode.ACCOUNT_LOCKED, - "Account is locked due to too many failed authentication attempts.", - { lockedUntil: lockStatus.lockedUntil }, - version, - ), - ); - return; - } - } - - // Rate limit by wallet address (authenticated bucket — per-wallet brute-force guard). - if (address) { - const walletRateLimit = await checkRateLimit("unlock", String(address), isAuthenticated); - if (!walletRateLimit.success) { - req.logger.warn({ address }, "Rate limit exceeded for unlock (Wallet)"); - metrics.trackRateLimitHit("unlock_wallet", String(address)); - void recordAuditEvent({ - action: "unlock_rate_limited", - result: "blocked", - promptId: promptId ? String(promptId) : null, - walletAddress: String(address), - requestId: req.requestId ?? null, - clientIp, - reason: "wallet_rate_limit_exceeded", - }); - res.setHeader("X-RateLimit-Limit", walletRateLimit.limit); - res.setHeader("X-RateLimit-Remaining", 0); - res.setHeader("X-RateLimit-Reset", walletRateLimit.reset); - res.status(429).json( - apiError(ErrorCode.RATE_LIMIT_WALLET, "Too many unlock attempts for this wallet.", { - reset: walletRateLimit.reset, - }, version), - ); - return; - } - } - - // Check if CAPTCHA is required due to repeated failures - const addressStr = typeof address === "string" ? address : undefined; - const captchaNeeded = await isCaptchaRequired(addressStr, clientIp); - if (captchaNeeded) { - const captchaToken = - unlockRequest.captchaToken || - req.headers["x-captcha-token"]; - - if (!captchaToken || typeof captchaToken !== "string") { - req.logger.warn({ address: addressStr, clientIp }, "CAPTCHA required for unlock request"); - void recordAuditEvent({ - action: "unlock_captcha_required", - result: "blocked", - promptId: promptId ? String(promptId) : null, - walletAddress: addressStr ?? null, - requestId: req.requestId ?? null, - clientIp, - reason: "captcha_required", - }); - res.status(403).json( - apiError( - ErrorCode.CAPTCHA_REQUIRED, - "CAPTCHA verification is required to proceed.", - { captchaRequired: true }, - version, - ), - ); - return; - } - - const captchaResult = await verifyCaptchaToken(captchaToken, clientIp); - if (!captchaResult.valid) { - req.logger.warn( - { address: addressStr, clientIp, reason: captchaResult.reason }, - "Invalid CAPTCHA token for unlock", - ); - void recordAuditEvent({ - action: "unlock_captcha_failed", - result: "blocked", - promptId: promptId ? String(promptId) : null, - walletAddress: addressStr ?? null, - requestId: req.requestId ?? null, - clientIp, - reason: captchaResult.reason ?? "invalid_captcha", - }); - res.status(403).json( - apiError( - ErrorCode.CAPTCHA_INVALID, - "Invalid or expired CAPTCHA verification.", - { captchaRequired: true }, - version, - ), - ); - return; - } - } - - const challengeSecret = process.env.CHALLENGE_TOKEN_SECRET; - const unlockPublicKey = process.env.UNLOCK_PUBLIC_KEY; - const unlockPrivateKey = process.env.UNLOCK_PRIVATE_KEY; - - if (!challengeSecret || !unlockPublicKey || !unlockPrivateKey) { - req.logger.error("Unlock service is missing configuration secrets."); - res.status(500).json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.", undefined, version)); - return; - } - - try { - // Support multiple active secrets during rotation grace period - const activeSecrets = getActiveSecrets(challengeSecret); - - const payload = verifyChallengeToken( - activeSecrets, - unlockRequest.token, - unlockRequest.address, - unlockRequest.promptId, - ); - const challengeMessage = buildChallengeMessage(payload); - const validSignature = verifyChallengeSignature( - unlockRequest.address, - challengeMessage, - unlockRequest.signedMessage, - ); - - if (!validSignature) { - req.logger.warn({ address: unlockRequest.address, promptId: unlockRequest.promptId }, "Invalid wallet signature"); - metrics.trackUnlockFailure(unlockRequest.address, unlockRequest.promptId, "invalid_signature"); - - const failureStatus = await recordFailedAuthAttempt(unlockRequest.address, clientIp); - - if (failureStatus.locked) { - req.logger.warn({ address: unlockRequest.address }, "Account locked after 5 failed auth attempts"); - void recordAuditEvent({ - action: "account_locked", - result: "blocked", - promptId: unlockRequest.promptId, - walletAddress: unlockRequest.address, - requestId: req.requestId ?? null, - clientIp, - reason: "max_failed_auth_attempts_exceeded", - }); - res.status(423).json( - apiError( - ErrorCode.ACCOUNT_LOCKED, - "Account is locked due to too many failed authentication attempts.", - { lockedUntil: failureStatus.lockedUntil }, - version, - ), - ); - return; - } - - void recordAuditEvent({ - action: "unlock_invalid_signature", - result: "failure", - promptId: unlockRequest.promptId, - walletAddress: unlockRequest.address, - requestId: req.requestId ?? null, - clientIp, - reason: "invalid_signature", - }); - res.status(401).json(apiError(ErrorCode.INVALID_SIGNATURE, "Invalid wallet signature.", undefined, version)); - return; - } - - const replayCheck = await checkReplayProtection( - unlockRequest.token, - unlockRequest.signedMessage, - ); - if (!replayCheck.valid) { - req.logger.warn( - { address: unlockRequest.address, promptId: unlockRequest.promptId }, - "Replay attack detected", - ); - metrics.trackUnlockFailure( - unlockRequest.address, - unlockRequest.promptId, - "replay_detected", - ); - void recordAuditEvent({ - action: "unlock_replay_detected", - result: "blocked", - promptId: unlockRequest.promptId, - walletAddress: unlockRequest.address, - requestId: req.requestId ?? null, - clientIp, - reason: "replay_attack", - }); - res.status(400).json( - apiError(ErrorCode.TEMPORARY_FAILURE, "This unlock request has already been processed.", undefined, version), - ); - return; - } - - const config = getServerConfig(); - const id = BigInt(unlockRequest.promptId); - const access = await hasAccess(config, unlockRequest.address, id); - if (!access) { - req.logger.warn( - { address: unlockRequest.address, promptId: unlockRequest.promptId }, - "Prompt access denied", - ); - metrics.trackUnlockFailure( - unlockRequest.address, - unlockRequest.promptId, - "no_access", - ); - void recordAuditEvent({ - action: "unlock_no_access", - result: "failure", - promptId: unlockRequest.promptId, - walletAddress: unlockRequest.address, - requestId: req.requestId ?? null, - clientIp, - reason: "no_access", - }); - res.status(403).json( - apiError(ErrorCode.ACCESS_NOT_PURCHASED, "Prompt access has not been purchased.", undefined, version), - ); - return; - } - - const prompt = await getPrompt(config, id); - - // Determine the correct encryption version for this buyer. - // If the caller is the creator they always get the current version; - // otherwise we resolve the version that was locked in at purchase time. - const currentVersion = prompt.encryptionVersion ?? 1; - let targetVersion = currentVersion; - if (prompt.creator?.toLowerCase() !== String(address).toLowerCase()) { - const purchase = await getPurchaseDetails(config, id, String(address)); - // If no purchase record exists (legacy buyer), fall back to current version. - targetVersion = purchase?.encryptionVersion ?? currentVersion; - } - - // Fetch the encrypted payload for the resolved version. - let encryptedPayload: { - encryptedPrompt: string; - encryptionIv: string; - wrappedKey: string; - contentHash: string; - }; - if (targetVersion === currentVersion) { - // Current version – use the prompt's live fields. - encryptedPayload = { - encryptedPrompt: prompt.encryptedPrompt!, - encryptionIv: prompt.encryptionIv!, - wrappedKey: prompt.wrappedKey!, - contentHash: prompt.contentHash, - }; - } else { - // Archived version – fetch from versioned storage. - const archived = await getPromptEncryptionVersion( - config, - id, - targetVersion, - ); - encryptedPayload = { - encryptedPrompt: archived.encryptedPrompt, - encryptionIv: archived.encryptionIv, - wrappedKey: archived.wrappedKey, - contentHash: archived.contentHash, - }; - } - - if (isBlobReference(encryptedPayload.encryptedPrompt)) { - try { - encryptedPayload.encryptedPrompt = await fetchFromBlobStorage(encryptedPayload.encryptedPrompt); - } catch (error) { - req.logger.error( - { address: unlockRequest.address, promptId: unlockRequest.promptId, error: error instanceof Error ? error.message : String(error) }, - "Failed to fetch encrypted prompt from blob storage" - ); - res.status(502).json( - apiError(ErrorCode.TEMPORARY_FAILURE, "Failed to fetch prompt data from blob storage.", undefined, version), - ); - return; - } - } - - const keyBytes = await unwrapPromptKey( - encryptedPayload.wrappedKey, - unlockPublicKey, - unlockPrivateKey, - ); - const plaintext = await decryptPromptCiphertext( - encryptedPayload.encryptedPrompt, - encryptedPayload.encryptionIv, - keyBytes, - ); - const contentHash = await hashPromptPlaintext(plaintext); - const storedHash = encryptedPayload.contentHash - ? normalizeContentHash(encryptedPayload.contentHash) - : ""; - - // Determine integrity state exposed to the buyer - const integrity = { - status: ((): "verified" | "failed" | "unavailable" => { - if (!encryptedPayload.contentHash) return "unavailable"; - if (contentHash !== storedHash) return "failed"; - return "verified"; - })(), - computedHash: contentHash, - storedHash: encryptedPayload.contentHash ?? null, - }; - - if (integrity.status === "failed") { - req.logger.error( - { address: unlockRequest.address, promptId: unlockRequest.promptId }, - "Prompt integrity check failed", - ); - metrics.trackUnlockFailure(unlockRequest.address, unlockRequest.promptId, "integrity_failure"); - void recordAuditEvent({ - action: "unlock_integrity_failure", - result: "failure", - promptId: unlockRequest.promptId, - walletAddress: unlockRequest.address, - requestId: req.requestId ?? null, - clientIp, - reason: "integrity_failure", - }); - void Promise.resolve( - dispatchEvent(prompt.creator ?? "", "PromptIntegrityViolation", { - promptId: prompt.id.toString(), - buyer: String(unlockRequest.address), - computedHash: integrity.computedHash, - storedHash: integrity.storedHash, - }), - ).catch(() => {}); - } - - await recordSuccessfulAuth(unlockRequest.address, clientIp); - metrics.trackUnlockSuccess(unlockRequest.address, unlockRequest.promptId); - req.logger.info( - { address: unlockRequest.address, promptId: unlockRequest.promptId }, - "Prompt unlocked successfully", - ); - void recordAuditEvent({ - action: "unlock_success", - result: "success", - promptId: unlockRequest.promptId, - walletAddress: unlockRequest.address, - requestId: req.requestId ?? null, - clientIp, - reason: null, - }); - - void Promise.resolve( - dispatchEvent(prompt.creator ?? "", "PromptPurchased", { - promptId: prompt.id.toString(), - buyer: unlockRequest.address, - title: prompt.title, - }), - ).catch(() => {}); - - res.status(200).json( - withVersion( - { - promptId: prompt.id.toString(), - title: prompt.title, - contentHash, - ...(integrity.status === "failed" ? {} : { plaintext }), - integrity, - }, - version, - ), - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to unlock prompt."; - req.logger.error( - { - address: unlockRequest.address, - promptId: unlockRequest.promptId, - error: message, - }, - "Unlock attempt failed", - ); - metrics.trackUnlockFailure(unlockRequest.address, unlockRequest.promptId, "error"); - - // Distinguish expired-challenge errors for finer-grained audit reasons and error codes. - const isExpired = message.toLowerCase().includes("expired"); - void recordAuditEvent({ - action: isExpired ? "unlock_expired_challenge" : "unlock_error", - result: "failure", - promptId: unlockRequest.promptId, - walletAddress: unlockRequest.address, - requestId: req.requestId ?? null, - clientIp, - reason: isExpired ? "expired_challenge" : "error", - }); - - if (isExpired) { - res.status(400).json( - apiError(ErrorCode.CHALLENGE_EXPIRED, "The challenge token has expired. Please request a new one.", undefined, version), - ); - } else { - res.status(400).json( - apiError(ErrorCode.TEMPORARY_FAILURE, "Failed to unlock prompt. Please try again.", undefined, version), - ); - } - } -} - -export default withObservability(withBodySizeLimit(handler), "prompts/unlock"); +import { + buildChallengeMessage, + verifyChallengeSignature, + verifyChallengeToken, +} from "../../src/lib/auth/challenge"; +import { + decryptPromptCiphertext, + hashPromptPlaintext, + normalizeContentHash, + unwrapPromptKey, +} from "../../src/lib/crypto/promptCrypto"; +import { fetchFromBlobStorage, isBlobReference } from "../../src/lib/stellar/blobStorage"; +import { + getPrompt, + getPromptEncryptionVersion, + getPurchaseDetails, + hasAccess, + type PromptHashConfig, +} from "../../src/lib/stellar/promptHashClient"; +import { withObservability } from "../../src/lib/observability/wrapper"; +import { withBodySizeLimit } from "../../src/lib/api/bodySizeLimit"; +import { checkRateLimit } from "../../src/lib/observability/rateLimiter"; +import { + isAccountLocked, + isCaptchaRequired, + recordFailedAuthAttempt, + recordSuccessfulAuth, + verifyCaptchaToken, +} from "../../src/lib/auth/abuseProtection"; +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"; +import { apiError, ErrorCode } from "../../src/lib/api/errorCodes"; +import { validateUnlockSecrets } from "../../src/lib/validation/envValidator"; +import { negotiateVersion } from "../../src/lib/api/versionGuard"; +import { withVersion } from "../../src/lib/api/payloadVersion"; +import { + parseRequestBody, + UnlockRequestBody, +} from "../../src/lib/api/requestSchemas"; + +// Fail-fast module load validation +try { + validateUnlockSecrets(); +} catch (err: any) { + console.error(err.message); +} + + +/** + * Get active secrets for token verification + * Supports multiple secrets during rotation grace period + */ +function getActiveSecrets(primarySecret: string): string[] { + const secrets = [primarySecret]; + + // Check for previous secret within grace period + const previousSecret = process.env.CHALLENGE_TOKEN_SECRET_PREVIOUS; + const rotationTimestamp = parseInt( + process.env.CHALLENGE_TOKEN_ROTATION_TIMESTAMP || "0", + 10 + ); + const gracePeriodMs = parseInt( + process.env.CHALLENGE_TOKEN_GRACE_PERIOD_MS || "300000", // 5 minutes default + 10 + ); + + if (previousSecret && rotationTimestamp) { + const timeSinceRotation = Date.now() - rotationTimestamp; + if (timeSinceRotation < gracePeriodMs) { + secrets.push(previousSecret); + } + } + + return secrets; +} + +function getServerConfig(): PromptHashConfig { + const rpcUrl = + process.env.PUBLIC_STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; + const networkPassphrase = + process.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? + "Test SDF Network ; September 2015"; + const promptHashContractId = process.env.PUBLIC_PROMPT_HASH_CONTRACT_ID ?? ""; + const nativeAssetContractId = + process.env.PUBLIC_STELLAR_NATIVE_ASSET_CONTRACT_ID ?? + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + const simulationAccount = + process.env.PUBLIC_STELLAR_SIMULATION_ACCOUNT ?? process.env.UNLOCK_PUBLIC_KEY ?? ""; + + return { + rpcUrl, + networkPassphrase, + promptHashContractId, + nativeAssetContractId, + simulationAccount, + allowHttp: new URL(rpcUrl).hostname === "localhost", + }; +} + +async function handler(req: any, res: any) { + try { + validateUnlockSecrets(); + } catch (err: any) { + req.logger.error("Configuration validation failed", { error: err.message }); + res.status(500).json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.")); + return; + } + + if (req.method !== "POST") { + res.status(405).json(apiError(ErrorCode.METHOD_NOT_ALLOWED, "Method not allowed.")); + return; + } + + const version = negotiateVersion(req, res); + if (!version) return; + + const parsed = parseRequestBody(UnlockRequestBody, req.body); + if (!parsed.success) { + res.status(400).json( + apiError( + ErrorCode.MISSING_FIELDS, + "token, promptId, address, and signedMessage are required.", + undefined, + version, + ), + ); + return; + } + + const unlockRequest = parsed.data; + const clientIp = (req.headers["x-forwarded-for"] || req.socket.remoteAddress) as string; + const address = unlockRequest.address; + const promptId = unlockRequest.promptId; + + // Authenticated bucket: wallet address is present. + const isAuthenticated = Boolean(address); + + // Rate limit by IP (unauthenticated bucket — strictest guard). + const ipRateLimit = await checkRateLimit("unlock", clientIp, false); + if (!ipRateLimit.success) { + req.logger.warn({ clientIp }, "Rate limit exceeded for unlock (IP)"); + metrics.trackRateLimitHit("unlock_ip", clientIp); + void recordAuditEvent({ + action: "unlock_rate_limited", + result: "blocked", + promptId: promptId ? String(promptId) : null, + walletAddress: address ? String(address) : null, + requestId: req.requestId ?? null, + clientIp, + reason: "ip_rate_limit_exceeded", + }); + res.setHeader("X-RateLimit-Limit", ipRateLimit.limit); + res.setHeader("X-RateLimit-Remaining", 0); + res.setHeader("X-RateLimit-Reset", ipRateLimit.reset); + res.status(429).json( + apiError(ErrorCode.RATE_LIMIT_IP, "Too many requests. Please try again later.", { + reset: ipRateLimit.reset, + }, version), + ); + return; + } + + // Check if wallet account is locked after repeated auth failures + if (address && typeof address === "string") { + const lockStatus = await isAccountLocked(address); + if (lockStatus.locked) { + req.logger.warn({ address }, "Unlock requested for locked account"); + void recordAuditEvent({ + action: "unlock_account_locked", + result: "blocked", + promptId: promptId ? String(promptId) : null, + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "account_locked", + }); + res.status(423).json( + apiError( + ErrorCode.ACCOUNT_LOCKED, + "Account is locked due to too many failed authentication attempts.", + { lockedUntil: lockStatus.lockedUntil }, + version, + ), + ); + return; + } + } + + // Rate limit by wallet address (authenticated bucket — per-wallet brute-force guard). + if (address) { + const walletRateLimit = await checkRateLimit("unlock", String(address), isAuthenticated); + if (!walletRateLimit.success) { + req.logger.warn({ address }, "Rate limit exceeded for unlock (Wallet)"); + metrics.trackRateLimitHit("unlock_wallet", String(address)); + void recordAuditEvent({ + action: "unlock_rate_limited", + result: "blocked", + promptId: promptId ? String(promptId) : null, + walletAddress: String(address), + requestId: req.requestId ?? null, + clientIp, + reason: "wallet_rate_limit_exceeded", + }); + res.setHeader("X-RateLimit-Limit", walletRateLimit.limit); + res.setHeader("X-RateLimit-Remaining", 0); + res.setHeader("X-RateLimit-Reset", walletRateLimit.reset); + res.status(429).json( + apiError(ErrorCode.RATE_LIMIT_WALLET, "Too many unlock attempts for this wallet.", { + reset: walletRateLimit.reset, + }, version), + ); + return; + } + } + + // Check if CAPTCHA is required due to repeated failures + const addressStr = typeof address === "string" ? address : undefined; + const captchaNeeded = await isCaptchaRequired(addressStr, clientIp); + if (captchaNeeded) { + const captchaToken = + unlockRequest.captchaToken || + req.headers["x-captcha-token"]; + + if (!captchaToken || typeof captchaToken !== "string") { + req.logger.warn({ address: addressStr, clientIp }, "CAPTCHA required for unlock request"); + void recordAuditEvent({ + action: "unlock_captcha_required", + result: "blocked", + promptId: promptId ? String(promptId) : null, + walletAddress: addressStr ?? null, + requestId: req.requestId ?? null, + clientIp, + reason: "captcha_required", + }); + res.status(403).json( + apiError( + ErrorCode.CAPTCHA_REQUIRED, + "CAPTCHA verification is required to proceed.", + { captchaRequired: true }, + version, + ), + ); + return; + } + + const captchaResult = await verifyCaptchaToken(captchaToken, clientIp); + if (!captchaResult.valid) { + req.logger.warn( + { address: addressStr, clientIp, reason: captchaResult.reason }, + "Invalid CAPTCHA token for unlock", + ); + void recordAuditEvent({ + action: "unlock_captcha_failed", + result: "blocked", + promptId: promptId ? String(promptId) : null, + walletAddress: addressStr ?? null, + requestId: req.requestId ?? null, + clientIp, + reason: captchaResult.reason ?? "invalid_captcha", + }); + res.status(403).json( + apiError( + ErrorCode.CAPTCHA_INVALID, + "Invalid or expired CAPTCHA verification.", + { captchaRequired: true }, + version, + ), + ); + return; + } + } + + const challengeSecret = process.env.CHALLENGE_TOKEN_SECRET; + const unlockPublicKey = process.env.UNLOCK_PUBLIC_KEY; + const unlockPrivateKey = process.env.UNLOCK_PRIVATE_KEY; + + if (!challengeSecret || !unlockPublicKey || !unlockPrivateKey) { + req.logger.error("Unlock service is missing configuration secrets."); + res.status(500).json(apiError(ErrorCode.CONFIGURATION_ERROR, "Configuration error.", undefined, version)); + return; + } + + try { + // Support multiple active secrets during rotation grace period + const activeSecrets = getActiveSecrets(challengeSecret); + + const payload = verifyChallengeToken( + activeSecrets, + unlockRequest.token, + unlockRequest.address, + unlockRequest.promptId, + ); + const challengeMessage = buildChallengeMessage(payload); + const validSignature = verifyChallengeSignature( + unlockRequest.address, + challengeMessage, + unlockRequest.signedMessage, + ); + + if (!validSignature) { + req.logger.warn({ address: unlockRequest.address, promptId: unlockRequest.promptId }, "Invalid wallet signature"); + metrics.trackUnlockFailure(unlockRequest.address, unlockRequest.promptId, "invalid_signature"); + + const failureStatus = await recordFailedAuthAttempt(unlockRequest.address, clientIp); + + if (failureStatus.locked) { + req.logger.warn({ address: unlockRequest.address }, "Account locked after 5 failed auth attempts"); + void recordAuditEvent({ + action: "account_locked", + result: "blocked", + promptId: unlockRequest.promptId, + walletAddress: unlockRequest.address, + requestId: req.requestId ?? null, + clientIp, + reason: "max_failed_auth_attempts_exceeded", + }); + res.status(423).json( + apiError( + ErrorCode.ACCOUNT_LOCKED, + "Account is locked due to too many failed authentication attempts.", + { lockedUntil: failureStatus.lockedUntil }, + version, + ), + ); + return; + } + + void recordAuditEvent({ + action: "unlock_invalid_signature", + result: "failure", + promptId: unlockRequest.promptId, + walletAddress: unlockRequest.address, + requestId: req.requestId ?? null, + clientIp, + reason: "invalid_signature", + }); + res.status(401).json(apiError(ErrorCode.INVALID_SIGNATURE, "Invalid wallet signature.", undefined, version)); + return; + } + + 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 }, + "Replay attack detected", + ); + metrics.trackUnlockFailure( + unlockRequest.address, + unlockRequest.promptId, + replayCheck.reason ?? "replay_detected", + ); + void recordAuditEvent({ + action: "unlock_replay_detected", + result: "blocked", + promptId: unlockRequest.promptId, + walletAddress: unlockRequest.address, + requestId: req.requestId ?? null, + clientIp, + reason: replayCheck.reason ?? "replay_attack", + }); + res.status(400).json( + apiError( + ErrorCode.CHALLENGE_REPLAY, + "This unlock request has already been processed.", + undefined, + version, + ), + ); + return; + } + + const config = getServerConfig(); + const id = BigInt(unlockRequest.promptId); + const access = await hasAccess(config, unlockRequest.address, id); + if (!access) { + req.logger.warn( + { address: unlockRequest.address, promptId: unlockRequest.promptId }, + "Prompt access denied", + ); + metrics.trackUnlockFailure( + unlockRequest.address, + unlockRequest.promptId, + "no_access", + ); + void recordAuditEvent({ + action: "unlock_no_access", + result: "failure", + promptId: unlockRequest.promptId, + walletAddress: unlockRequest.address, + requestId: req.requestId ?? null, + clientIp, + reason: "no_access", + }); + res.status(403).json( + apiError(ErrorCode.ACCESS_NOT_PURCHASED, "Prompt access has not been purchased.", undefined, version), + ); + return; + } + + const prompt = await getPrompt(config, id); + + // Determine the correct encryption version for this buyer. + // If the caller is the creator they always get the current version; + // otherwise we resolve the version that was locked in at purchase time. + const currentVersion = prompt.encryptionVersion ?? 1; + let targetVersion = currentVersion; + if (prompt.creator?.toLowerCase() !== String(address).toLowerCase()) { + const purchase = await getPurchaseDetails(config, id, String(address)); + // If no purchase record exists (legacy buyer), fall back to current version. + targetVersion = purchase?.encryptionVersion ?? currentVersion; + } + + // Fetch the encrypted payload for the resolved version. + let encryptedPayload: { + encryptedPrompt: string; + encryptionIv: string; + wrappedKey: string; + contentHash: string; + }; + if (targetVersion === currentVersion) { + // Current version – use the prompt's live fields. + encryptedPayload = { + encryptedPrompt: prompt.encryptedPrompt!, + encryptionIv: prompt.encryptionIv!, + wrappedKey: prompt.wrappedKey!, + contentHash: prompt.contentHash, + }; + } else { + // Archived version – fetch from versioned storage. + const archived = await getPromptEncryptionVersion( + config, + id, + targetVersion, + ); + encryptedPayload = { + encryptedPrompt: archived.encryptedPrompt, + encryptionIv: archived.encryptionIv, + wrappedKey: archived.wrappedKey, + contentHash: archived.contentHash, + }; + } + + if (isBlobReference(encryptedPayload.encryptedPrompt)) { + try { + encryptedPayload.encryptedPrompt = await fetchFromBlobStorage(encryptedPayload.encryptedPrompt); + } catch (error) { + req.logger.error( + { address: unlockRequest.address, promptId: unlockRequest.promptId, error: error instanceof Error ? error.message : String(error) }, + "Failed to fetch encrypted prompt from blob storage" + ); + res.status(502).json( + apiError(ErrorCode.TEMPORARY_FAILURE, "Failed to fetch prompt data from blob storage.", undefined, version), + ); + return; + } + } + + const keyBytes = await unwrapPromptKey( + encryptedPayload.wrappedKey, + unlockPublicKey, + unlockPrivateKey, + ); + const plaintext = await decryptPromptCiphertext( + encryptedPayload.encryptedPrompt, + encryptedPayload.encryptionIv, + keyBytes, + ); + const contentHash = await hashPromptPlaintext(plaintext); + const storedHash = encryptedPayload.contentHash + ? normalizeContentHash(encryptedPayload.contentHash) + : ""; + + // Determine integrity state exposed to the buyer + const integrity = { + status: ((): "verified" | "failed" | "unavailable" => { + if (!encryptedPayload.contentHash) return "unavailable"; + if (contentHash !== storedHash) return "failed"; + return "verified"; + })(), + computedHash: contentHash, + storedHash: encryptedPayload.contentHash ?? null, + }; + + if (integrity.status === "failed") { + req.logger.error( + { address: unlockRequest.address, promptId: unlockRequest.promptId }, + "Prompt integrity check failed", + ); + metrics.trackUnlockFailure(unlockRequest.address, unlockRequest.promptId, "integrity_failure"); + void recordAuditEvent({ + action: "unlock_integrity_failure", + result: "failure", + promptId: unlockRequest.promptId, + walletAddress: unlockRequest.address, + requestId: req.requestId ?? null, + clientIp, + reason: "integrity_failure", + }); + void Promise.resolve( + dispatchEvent(prompt.creator ?? "", "PromptIntegrityViolation", { + promptId: prompt.id.toString(), + buyer: String(unlockRequest.address), + computedHash: integrity.computedHash, + storedHash: integrity.storedHash, + }), + ).catch(() => {}); + } + + await recordSuccessfulAuth(unlockRequest.address, clientIp); + metrics.trackUnlockSuccess(unlockRequest.address, unlockRequest.promptId); + req.logger.info( + { address: unlockRequest.address, promptId: unlockRequest.promptId }, + "Prompt unlocked successfully", + ); + void recordAuditEvent({ + action: "unlock_success", + result: "success", + promptId: unlockRequest.promptId, + walletAddress: unlockRequest.address, + requestId: req.requestId ?? null, + clientIp, + reason: null, + }); + + void Promise.resolve( + dispatchEvent(prompt.creator ?? "", "PromptPurchased", { + promptId: prompt.id.toString(), + buyer: unlockRequest.address, + title: prompt.title, + }), + ).catch(() => {}); + + res.status(200).json( + withVersion( + { + promptId: prompt.id.toString(), + title: prompt.title, + contentHash, + ...(integrity.status === "failed" ? {} : { plaintext }), + integrity, + }, + version, + ), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to unlock prompt."; + req.logger.error( + { + address: unlockRequest.address, + promptId: unlockRequest.promptId, + error: message, + }, + "Unlock attempt failed", + ); + metrics.trackUnlockFailure(unlockRequest.address, unlockRequest.promptId, "error"); + + // Distinguish expired-challenge errors for finer-grained audit reasons and error codes. + const isExpired = message.toLowerCase().includes("expired"); + void recordAuditEvent({ + action: isExpired ? "unlock_expired_challenge" : "unlock_error", + result: "failure", + promptId: unlockRequest.promptId, + walletAddress: unlockRequest.address, + requestId: req.requestId ?? null, + clientIp, + reason: isExpired ? "expired_challenge" : "error", + }); + + if (isExpired) { + res.status(400).json( + apiError(ErrorCode.CHALLENGE_EXPIRED, "The challenge token has expired. Please request a new one.", undefined, version), + ); + } else { + res.status(400).json( + apiError(ErrorCode.TEMPORARY_FAILURE, "Failed to unlock prompt. Please try again.", undefined, version), + ); + } + } +} + +export default withObservability(withBodySizeLimit(handler), "prompts/unlock"); 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 8368d056..4d93a449 100644 --- a/src/lib/auth/challenge.test.ts +++ b/src/lib/auth/challenge.test.ts @@ -20,6 +20,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, @@ -30,6 +34,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(); +}