Files
blog/blog-admin/src/lib/human.ts
T

171 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 人机验证(自研「一键验证」)
//
// 设计目标:正常读者**无感**(浏览器静默算一个 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<string> {
const data = new TextEncoder().encode(input);
return bytesToHex(await crypto.subtle.digest('SHA-256', data));
}
async function hmacHex(secret: string, msg: string): Promise<string> {
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<boolean> {
try {
return (await env.RSS_KV.get(ENABLED_KEY)) === '1';
} catch {
return false;
}
}
export async function setHumanCheck(env: Env, on: boolean): Promise<void> {
try {
await env.RSS_KV.put(ENABLED_KEY, on ? '1' : '0');
} catch {
/* 开关写失败不影响主流程 */
}
}
// ---------------------------------------------------------------- 挑战签发
export async function issueChallenge(env: Env): Promise<HumanChallenge> {
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<boolean> {
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<boolean> {
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<void> {
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: [] };
}