// 人机验证(自研「一键验证」) // // 设计目标:正常读者**无感**(浏览器静默算一个 20~80ms 的 PoW,什么也不用手动做), // 命中可疑信号时才让访客「点一下」,再可疑才回退到图形验证码。 // // 为什么不用 Turnstile/reCAPTCHA: // 1) 国内加载不稳定(Google/Cloudflare 的脚本经常拉不下来),一旦失败评论就发不出去; // 2) 会把访客 IP/浏览器指纹送给第三方。 // 自研版靠 PoW + 行为信号,挡得住脚本刷评论和低成本机器人,对个人博客足够。 // // 关键点:**挑战是无状态的**(HMAC 签名,不写库);通行证放 KV(不占 D1 额度)。 import type { Env } from '../types'; /** PoW 难度:sha256(challenge + nonce) 的十六进制前缀要有这么多个 0 */ export const POW_DIFFICULTY = 4; const CHALLENGE_TTL_MS = 10 * 60 * 1000; /** 通行证有效期(秒):过了一次就不用再验 */ const PASS_TTL_SEC = 1800; /** 开关:存 KV,读一次比读 D1 settings 便宜 */ const ENABLED_KEY = 'human_check'; export interface HumanChallenge { challenge: string; difficulty: number; exp: number; sig: string; } function bytesToHex(buf: ArrayBuffer): string { return [...new Uint8Array(buf)] .map((b) => b.toString(16).padStart(2, '0')) .join(''); } export async function sha256Hex(input: string): Promise { const data = new TextEncoder().encode(input); return bytesToHex(await crypto.subtle.digest('SHA-256', data)); } async function hmacHex(secret: string, msg: string): Promise { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(msg)); return bytesToHex(sig); } // ------------------------------------------------------------------ 开关 export async function isHumanCheckEnabled(env: Env): Promise { try { return (await env.RSS_KV.get(ENABLED_KEY)) === '1'; } catch { return false; } } export async function setHumanCheck(env: Env, on: boolean): Promise { try { await env.RSS_KV.put(ENABLED_KEY, on ? '1' : '0'); } catch { /* 开关写失败不影响主流程 */ } } // ---------------------------------------------------------------- 挑战签发 export async function issueChallenge(env: Env): Promise { const buf = crypto.getRandomValues(new Uint8Array(12)); const challenge = bytesToHex(buf.buffer); const exp = Date.now() + CHALLENGE_TTL_MS; const sig = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`); return { challenge, difficulty: POW_DIFFICULTY, exp, sig }; } /** 校验 PoW 证明:签名有效 + 未过期 + 哈希前缀达标 */ export async function verifyProof( env: Env, p: { challenge?: string; nonce?: number | string; exp?: number; sig?: string }, ): Promise { const challenge = String(p.challenge || ''); const exp = Number(p.exp || 0); const sig = String(p.sig || ''); if (!challenge || !exp || !sig || p.nonce === undefined || p.nonce === null) return false; if (Date.now() > exp) return false; const expect = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`); if (expect !== sig) return false; const hash = await sha256Hex(`${challenge}${p.nonce}`); return hash.startsWith('0'.repeat(POW_DIFFICULTY)); } // ---------------------------------------------------------------- 通行证 export function humanPassKey(ip: string): string { return `human:pass:${ip}`; } export async function hasHumanPass(env: Env, ip: string): Promise { try { return (await env.RSS_KV.get(humanPassKey(ip))) === '1'; } catch { return false; } } export async function grantHumanPass(env: Env, ip: string, ttl = PASS_TTL_SEC): Promise { try { await env.RSS_KV.put(humanPassKey(ip), '1', { expirationTtl: ttl }); } catch { /* 通行证写失败 → 下次再验,不影响本次放行 */ } } // ------------------------------------------------------------ 行为信号评分 export interface HumanSignals { /** 蜜罐字段:人类看不见也不会填,填了就一定是脚本 */ honeypot?: string; /** 从拿到挑战到提交验证的耗时(毫秒) */ elapsedMs?: number; /** 页面上的鼠标/键盘/滚动/触摸事件次数 */ events?: number; /** navigator.webdriver(自动化浏览器通常为 true) */ webdriver?: boolean; /** 是否是「点一下」触发的验证(点击本身就是人类信号) */ clicked?: boolean; } export type RiskLevel = 'low' | 'medium' | 'high'; export interface RiskResult { level: RiskLevel; reasons: string[]; } /** * 信号评分。 * low → 直接发通行证(读者无感) * medium → 要求「点一下」(点击会补齐 elapsedMs / events 信号,重试即通过) * high → 回退图形验证码 */ export function assessSignals(s: HumanSignals): RiskResult { const reasons: string[] = []; if (s.honeypot) { return { level: 'high', reasons: ['honeypot filled'] }; } if (s.webdriver === true) { reasons.push('webdriver'); } const elapsed = Number(s.elapsedMs || 0); const events = Number(s.events || 0); if (elapsed > 0 && elapsed < 1200) reasons.push('too fast'); if (elapsed > 2 * 3600 * 1000) reasons.push('too slow'); if (events <= 0 && !s.clicked) reasons.push('no interaction'); if (reasons.includes('webdriver')) return { level: 'high', reasons }; if (s.clicked) { // 点击过就直接放行(点击 + PoW 已经足够;elapsed 太短仍视为可疑) return reasons.includes('too fast') && elapsed < 400 ? { level: 'medium', reasons } : { level: 'low', reasons }; } if (reasons.length) return { level: 'medium', reasons }; return { level: 'low', reasons: [] }; }