import { createHash } from 'node:crypto'; import type { CaptchaProvider } from '@nexumi/shared'; import { redis } from './redis'; import { env } from './env'; const CAPTCHA_PREFIX = 'verify:captcha:'; export type CaptchaChallenge = { token: string; guildId: string; userId: string; provider: CaptchaProvider; question?: string; answerHash?: string; }; export async function getCaptchaChallenge(token: string): Promise { const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`); if (!raw) { return null; } try { const parsed = JSON.parse(raw) as CaptchaChallenge; if (!parsed.provider) { parsed.provider = 'MATH'; } return parsed; } catch { return null; } } export async function deleteCaptchaChallenge(token: string): Promise { await redis.del(`${CAPTCHA_PREFIX}${token}`); } export function hashCaptchaAnswer(answer: string): string { return createHash('sha256').update(answer.trim()).digest('hex'); } export function hashClientSignal(value: string): string | null { const salt = env.CAPTCHA_IP_HASH_SALT; if (!salt || !value) { return null; } return createHash('sha256').update(`${salt}:${value}`).digest('hex'); } export function getClientIp(request: Request): string | null { const forwarded = request.headers.get('x-forwarded-for'); if (forwarded) { const first = forwarded.split(',')[0]?.trim(); if (first) { return first; } } const realIp = request.headers.get('x-real-ip')?.trim(); return realIp || null; } export type CaptchaPublicConfig = { provider: CaptchaProvider; siteKey: string | null; recaptchaV3MinScore: number; }; export function getAvailableCaptchaProviders(): CaptchaProvider[] { const providers: CaptchaProvider[] = ['MATH']; if (env.RECAPTCHA_SITE_KEY && env.RECAPTCHA_SECRET_KEY) { providers.push('RECAPTCHA_V2'); } const v3Site = env.RECAPTCHA_V3_SITE_KEY ?? env.RECAPTCHA_SITE_KEY; const v3Secret = env.RECAPTCHA_V3_SECRET_KEY ?? env.RECAPTCHA_SECRET_KEY; if (v3Site && v3Secret) { providers.push('RECAPTCHA_V3'); } if (env.HCAPTCHA_SITE_KEY && env.HCAPTCHA_SECRET_KEY) { providers.push('HCAPTCHA'); } if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY) { providers.push('TURNSTILE'); } return providers; } export function getCaptchaPublicConfig(provider: CaptchaProvider): CaptchaPublicConfig { switch (provider) { case 'RECAPTCHA_V2': return { provider, siteKey: env.RECAPTCHA_SITE_KEY ?? null, recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE }; case 'RECAPTCHA_V3': return { provider, siteKey: env.RECAPTCHA_V3_SITE_KEY ?? env.RECAPTCHA_SITE_KEY ?? null, recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE }; case 'HCAPTCHA': return { provider, siteKey: env.HCAPTCHA_SITE_KEY ?? null, recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE }; case 'TURNSTILE': return { provider, siteKey: env.TURNSTILE_SITE_KEY ?? null, recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE }; default: return { provider: 'MATH', siteKey: null, recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE }; } } type SiteVerifyResult = { success: boolean; score?: number; 'error-codes'?: string[] }; async function postSiteVerify( url: string, secret: string, responseToken: string, remoteip?: string | null ): Promise { const body = new URLSearchParams(); body.set('secret', secret); body.set('response', responseToken); if (remoteip) { body.set('remoteip', remoteip); } const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }); if (!res.ok) { return { success: false, 'error-codes': [`http_${res.status}`] }; } return (await res.json()) as SiteVerifyResult; } export async function verifyProviderToken( provider: CaptchaProvider, responseToken: string, remoteip?: string | null ): Promise<{ ok: boolean; error?: string }> { if (!responseToken.trim()) { return { ok: false, error: 'missing_token' }; } if (provider === 'RECAPTCHA_V2') { if (!env.RECAPTCHA_SECRET_KEY) { return { ok: false, error: 'provider_not_configured' }; } const result = await postSiteVerify( 'https://www.google.com/recaptcha/api/siteverify', env.RECAPTCHA_SECRET_KEY, responseToken, remoteip ); return result.success ? { ok: true } : { ok: false, error: 'provider_rejected' }; } if (provider === 'RECAPTCHA_V3') { const secret = env.RECAPTCHA_V3_SECRET_KEY ?? env.RECAPTCHA_SECRET_KEY; if (!secret) { return { ok: false, error: 'provider_not_configured' }; } const result = await postSiteVerify( 'https://www.google.com/recaptcha/api/siteverify', secret, responseToken, remoteip ); if (!result.success) { return { ok: false, error: 'provider_rejected' }; } const score = result.score ?? 0; if (score < env.RECAPTCHA_V3_MIN_SCORE) { return { ok: false, error: 'score_too_low' }; } return { ok: true }; } if (provider === 'HCAPTCHA') { if (!env.HCAPTCHA_SECRET_KEY) { return { ok: false, error: 'provider_not_configured' }; } const result = await postSiteVerify( 'https://hcaptcha.com/siteverify', env.HCAPTCHA_SECRET_KEY, responseToken, remoteip ); return result.success ? { ok: true } : { ok: false, error: 'provider_rejected' }; } if (provider === 'TURNSTILE') { if (!env.TURNSTILE_SECRET_KEY) { return { ok: false, error: 'provider_not_configured' }; } const result = await postSiteVerify( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', env.TURNSTILE_SECRET_KEY, responseToken, remoteip ); return result.success ? { ok: true } : { ok: false, error: 'provider_rejected' }; } return { ok: false, error: 'unsupported_provider' }; }