From fd43511c7469035c7899e676931df78190fab3ba Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 16 May 2026 19:51:33 +0800 Subject: [PATCH] Harden-biometric-verification --- README.md | 1 + src/index.ts | 387 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 340 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 3067467..90c3bee 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$botToken/setWe ## 说明 - 这里的生物识别使用的是 `Telegram.WebApp.BiometricManager`,不是 WebAuthn。 +- 首次使用生物识别时会通过 Telegram WebApp 在本机安全存储一个随机凭据;服务端只保存该凭据的哈希。 - Telegram 小程序的 `initData` 会在服务端校验后,才接受验证结果。 - 公共仓库使用者需要自己准备 Worker 域名、Turnstile、KV namespace 和 AI 凭据。 diff --git a/src/index.ts b/src/index.ts index 93eb000..dded459 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,7 @@ interface TelegramApiResponse { } interface AiReview { - decision: "approve" | "challenge"; + decision: "approve" | "challenge" | "reject"; } interface PendingJoinRecord { @@ -76,11 +76,25 @@ interface RequestContext { const JOIN_PREFIX = "join:"; const ACTIVE_PREFIX = "active:"; +const REJECT_PREFIX = "reject:"; +const BIOMETRIC_PREFIX = "bio:"; const CHALLENGE_TTL_MS = 10 * 60 * 1000; const RECORD_TTL_SECONDS = 30 * 60; +const REJECT_TTL_SECONDS = 60 * 60; +const REJECT_BAN_THRESHOLD = 5; +const BIOMETRIC_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60; const INIT_DATA_MAX_AGE_SECONDS = 10 * 60; type Locale = "zh" | "en"; +interface BiometricCredentialRecord { + userId: number; + deviceId: string; + tokenHash: string; + createdAt: number; + updatedAt: number; + expiresAt: number; +} + export default { async fetch(request, env, ctx): Promise { try { @@ -141,7 +155,8 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise< } const displayName = [join.from.first_name, join.from.last_name].filter(Boolean).join(" ").trim(); - const hasAvatar = await safelyFetchAvatarForUser(env, join.from.id); + const avatar = await safelyFetchAvatarForUser(env, join.from.id); + const hasAvatar = Boolean(avatar); const hasBio = Boolean(join.bio?.trim()); const now = Date.now(); const locale = detectLocale(join.from.language_code); @@ -190,6 +205,8 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise< const aiReview = await safelyReviewWithAi(env, { displayName, bio: join.bio!, + avatarBase64: avatar!.base64, + avatarMimeType: avatar!.mimeType, }); if (!aiReview) { @@ -228,13 +245,20 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise< return json({ ok: true, decision: "challenge", risk: "high" }); } - const risk = decideRiskLevel(aiReview); - if (risk === "low") { + if (aiReview.decision === "approve") { await approveJoinRequest(env, join.chat.id, join.from.id); console.log("join request approved", { chatId: join.chat.id, userId: join.from.id, risk: "low" }); return json({ ok: true, decision: "approved" }); } + if (aiReview.decision === "reject") { + await rejectOrBanJoinRequest(env, join.chat.id, join.from.id, "ai_reject"); + console.log("join request rejected", { chatId: join.chat.id, userId: join.from.id, source: "ai_reject" }); + return json({ ok: true, decision: "rejected" }); + } + + const risk: RiskLevel = "high"; + const token = crypto.randomUUID(); const record: PendingJoinRecord = { token, @@ -288,7 +312,11 @@ async function handleVerifyPage({ env, request }: RequestContext): Promise { - const body = (await request.json()) as { token?: string; turnstileToken?: string; initData?: string }; + const body = (await request.json()) as { + token?: string; + turnstileToken?: string; + initData?: string; + }; if (!body.token || !body.turnstileToken || !body.initData) { return json({ ok: false, error: "missing_fields" }, 400); } @@ -311,8 +339,14 @@ async function handleTurnstileVerification({ env, request }: RequestContext): Pr } async function handleBiometricVerification({ env, request }: RequestContext): Promise { - const body = (await request.json()) as { token?: string; initData?: string }; - if (!body.token || !body.initData) { + const body = (await request.json()) as { + token?: string; + initData?: string; + biometricToken?: string; + biometricDeviceId?: string; + biometricMode?: "auth" | "enroll"; + }; + if (!body.token || !body.initData || !body.biometricToken || !body.biometricDeviceId || !body.biometricMode) { return json({ ok: false, error: "missing_fields" }, 400); } @@ -326,6 +360,11 @@ async function handleBiometricVerification({ env, request }: RequestContext): Pr } await verifyTelegramWebAppInitData(env, body.initData, record.userId); + if (body.biometricMode === "auth") { + await verifyBiometricCredential(env, record.userId, body.biometricDeviceId, body.biometricToken); + } else { + await storeBiometricCredential(env, record.userId, body.biometricDeviceId, body.biometricToken); + } await finalizeVerification(env, record, "biometric"); console.log("verification approved", { chatId: record.chatId, userId: record.userId, method: "biometric" }); @@ -393,20 +432,38 @@ async function verifyTelegramWebAppInitData(env: Env, initData: string, expected } } -async function fetchAvatarForUser(env: Env, userId: number): Promise { - const profilePhotos = await telegramCall<{ total_count: number }>(env, "getUserProfilePhotos", { +async function fetchAvatarForUser(env: Env, userId: number): Promise<{ base64: string; mimeType: string } | null> { + const profilePhotos = await telegramCall<{ total_count: number; photos: Array> }>(env, "getUserProfilePhotos", { user_id: userId, limit: 1, }); - return profilePhotos.total_count > 0; + if (!profilePhotos.total_count || !profilePhotos.photos.length || !profilePhotos.photos[0].length) { + return null; + } + + const sizes = profilePhotos.photos[0]; + const largest = sizes[sizes.length - 1]; + const file = await telegramCall<{ file_path: string }>(env, "getFile", { file_id: largest.file_id }); + const response = await fetch(`https://api.telegram.org/file/bot${env.BOT_TOKEN}/${file.file_path}`); + if (!response.ok) { + throw new HttpError(502, "failed_to_download_avatar"); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + const headerMimeType = response.headers.get("content-type") || ""; + const mimeType = inferImageMimeType(bytes) || (headerMimeType.startsWith("image/") ? headerMimeType : "image/jpeg"); + return { + base64: bytesToBase64(bytes), + mimeType, + }; } -async function safelyFetchAvatarForUser(env: Env, userId: number): Promise { +async function safelyFetchAvatarForUser(env: Env, userId: number): Promise<{ base64: string; mimeType: string } | null> { try { return await fetchAvatarForUser(env, userId); } catch { - return false; + return null; } } @@ -415,6 +472,8 @@ async function reviewWithAi( input: { displayName: string; bio: string; + avatarBase64: string; + avatarMimeType: string; }, ): Promise { const url = new URL("chat/completions", normalizeBaseUrl(env.AI_BASE_URL)).toString(); @@ -427,27 +486,38 @@ async function reviewWithAi( body: JSON.stringify({ model: env.AI_MODEL || "gpt-4.1-mini", temperature: 0, - response_format: { type: "json_object" }, messages: [ { role: "system", content: - "You review Telegram join requests for ad-spam risk using only display name and bio. Return strict JSON only with one field: decision. Decision must be exactly approve for normal genuine users and challenge for suspicious or uncertain users. If the profile looks like a real person, return approve.", + "You review Telegram join requests for ad-spam risk using display name, bio, and avatar image. Return JSON only, with exactly one field: decision. The value must be exactly approve, challenge, or reject. Return approve for normal genuine users. Return challenge for uncertain users who should complete verification. Return reject only for obvious spam/ad accounts, including promotional avatars, QR codes, scam/crypto/forex/gambling ads, impersonation, or direct marketing profiles.", }, { role: "user", - content: JSON.stringify({ - display_name: input.displayName, - bio: input.bio, - task: "Evaluate whether this looks like a real personal profile instead of an ad or spam account.", - }), + content: [ + { + type: "text", + text: JSON.stringify({ + display_name: input.displayName, + bio: input.bio, + task: "Evaluate whether this looks like a real personal profile, an uncertain profile, or obvious spam/ad account. Pay attention to promotional content in the avatar image. Return only JSON like {\"decision\":\"approve\"}, {\"decision\":\"challenge\"}, or {\"decision\":\"reject\"}.", + }), + }, + { + type: "image_url", + image_url: { + url: `data:${input.avatarMimeType};base64,${input.avatarBase64}`, + }, + }, + ], }, ], }), }); if (!response.ok) { - throw new HttpError(502, `ai_request_failed:${response.status}`); + const details = await response.text(); + throw new HttpError(502, `ai_request_failed:${response.status}:${details.slice(0, 500)}`); } const payload = (await response.json()) as { @@ -467,7 +537,7 @@ async function reviewWithAi( const parsed = parseJsonFromText(text) as Partial; return { - decision: parsed.decision === "approve" ? "approve" : "challenge", + decision: parsed.decision === "approve" ? "approve" : parsed.decision === "reject" ? "reject" : "challenge", }; } @@ -476,6 +546,8 @@ async function safelyReviewWithAi( input: { displayName: string; bio: string; + avatarBase64: string; + avatarMimeType: string; }, ): Promise { try { @@ -485,10 +557,6 @@ async function safelyReviewWithAi( } } -function decideRiskLevel(aiReview: AiReview): RiskLevel { - return aiReview.decision === "approve" ? "low" : "high"; -} - async function persistPendingRecord(env: Env, record: PendingJoinRecord): Promise { await env.PENDING_JOINS.put(`${JOIN_PREFIX}${record.token}`, JSON.stringify(record), { expirationTtl: RECORD_TTL_SECONDS }); await env.PENDING_JOINS.put(activeJoinKey(record.chatId, record.userId), record.token, { expirationTtl: RECORD_TTL_SECONDS }); @@ -539,6 +607,68 @@ async function finalizeVerification(env: Env, record: PendingJoinRecord, method: await env.PENDING_JOINS.delete(`${JOIN_PREFIX}${record.token}`); } +async function recordJoinRejection(env: Env, chatId: number, userId: number, source: string): Promise { + const key = rejectKey(chatId, userId); + const current = Number((await env.PENDING_JOINS.get(key)) || "0"); + const next = Number.isFinite(current) ? current + 1 : 1; + await env.PENDING_JOINS.put(key, String(next), { expirationTtl: REJECT_TTL_SECONDS }); + console.warn("join rejection recorded", { + chatId, + userId, + source, + rejections: next, + }); + return next; +} + +async function rejectOrBanJoinRequest(env: Env, chatId: number, userId: number, source: string): Promise { + const declined = await declineJoinRequest(env, chatId, userId); + if (!declined) { + return; + } + + const rejections = await recordJoinRejection(env, chatId, userId, source); + if (rejections >= REJECT_BAN_THRESHOLD) { + console.warn("banning requester after repeated join rejections", { + chatId, + userId, + source, + rejections, + }); + + await banChatMember(env, chatId, userId); + await env.PENDING_JOINS.delete(rejectKey(chatId, userId)); + return; + } +} + +async function handlePendingJoinRejection(env: Env, record: PendingJoinRecord, source: string): Promise { + const declined = await declineJoinRequest(env, record.chatId, record.userId); + if (!declined) { + await deleteVerificationMessage(env, record); + await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId)); + await env.PENDING_JOINS.delete(`${JOIN_PREFIX}${record.token}`); + return; + } + + const rejections = await recordJoinRejection(env, record.chatId, record.userId, source); + if (rejections >= REJECT_BAN_THRESHOLD) { + console.warn("banning requester after repeated join rejections", { + chatId: record.chatId, + userId: record.userId, + source, + rejections, + }); + + await banChatMember(env, record.chatId, record.userId); + await env.PENDING_JOINS.delete(rejectKey(record.chatId, record.userId)); + } + + await deleteVerificationMessage(env, record); + await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId)); + await env.PENDING_JOINS.delete(`${JOIN_PREFIX}${record.token}`); +} + async function processExpiredRequests(env: Env, now: number): Promise { let declinedCount = 0; let cursor: string | undefined; @@ -563,10 +693,14 @@ async function processExpiredRequests(env: Env, now: number): Promise { continue; } - await declineJoinRequest(env, record.chatId, record.userId); - await deleteVerificationMessage(env, record); - await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId)); - await env.PENDING_JOINS.delete(entry.name); + console.log("declining expired join request", { + chatId: record.chatId, + userId: record.userId, + token: record.token, + createdAt: record.createdAt, + expiresAt: record.expiresAt, + }); + await handlePendingJoinRejection(env, record, "verification_timeout"); declinedCount += 1; } } while (cursor); @@ -607,12 +741,86 @@ async function verifyTurnstile(env: Env, request: Request, token: string): Promi } } -async function approveJoinRequest(env: Env, chatId: number, userId: number): Promise { - await telegramCall(env, "approveChatJoinRequest", { chat_id: chatId, user_id: userId }); +async function storeBiometricCredential(env: Env, userId: number, deviceId: string, token: string): Promise { + if (!isValidBiometricDeviceId(deviceId) || !isValidBiometricToken(token)) { + throw new HttpError(403, "invalid_biometric_token"); + } + + const now = Date.now(); + const record: BiometricCredentialRecord = { + userId, + deviceId, + tokenHash: await hashBiometricToken(env, userId, deviceId, token), + createdAt: now, + updatedAt: now, + expiresAt: now + BIOMETRIC_TOKEN_TTL_SECONDS * 1000, + }; + + await env.PENDING_JOINS.put(await biometricCredentialKey(env, userId, deviceId), JSON.stringify(record), { + expirationTtl: BIOMETRIC_TOKEN_TTL_SECONDS, + }); } -async function declineJoinRequest(env: Env, chatId: number, userId: number): Promise { - await telegramCall(env, "declineChatJoinRequest", { chat_id: chatId, user_id: userId }); +async function verifyBiometricCredential( + env: Env, + userId: number, + deviceId: string, + token: string, +): Promise { + if (!isValidBiometricDeviceId(deviceId) || !isValidBiometricToken(token)) { + throw new HttpError(403, "invalid_biometric_token"); + } + + const raw = await env.PENDING_JOINS.get(await biometricCredentialKey(env, userId, deviceId)); + if (!raw) { + throw new HttpError(403, "biometric_not_enrolled"); + } + + const record = JSON.parse(raw) as BiometricCredentialRecord; + if (record.userId !== userId || record.deviceId !== deviceId || record.expiresAt <= Date.now()) { + throw new HttpError(403, "invalid_biometric_token"); + } + + const tokenHash = await hashBiometricToken(env, userId, deviceId, token); + if (!constantTimeEqual(tokenHash, record.tokenHash)) { + throw new HttpError(403, "invalid_biometric_token"); + } + + record.updatedAt = Date.now(); + await env.PENDING_JOINS.put(await biometricCredentialKey(env, userId, deviceId), JSON.stringify(record), { + expirationTtl: BIOMETRIC_TOKEN_TTL_SECONDS, + }); +} + +async function approveJoinRequest(env: Env, chatId: number, userId: number): Promise { + try { + await telegramCall(env, "approveChatJoinRequest", { chat_id: chatId, user_id: userId }); + } catch (error) { + if (error instanceof HttpError && error.message.includes("USER_ALREADY_PARTICIPANT")) { + console.warn("join request already approved", { chatId, userId }); + return; + } + + throw error; + } +} + +async function declineJoinRequest(env: Env, chatId: number, userId: number): Promise { + try { + await telegramCall(env, "declineChatJoinRequest", { chat_id: chatId, user_id: userId }); + return true; + } catch (error) { + if (error instanceof HttpError && error.message.includes("HIDE_REQUESTER_MISSING")) { + console.log("join request missing when declining", { chatId, userId }); + return false; + } + + throw error; + } +} + +async function banChatMember(env: Env, chatId: number, userId: number): Promise { + await telegramCall(env, "banChatMember", { chat_id: chatId, user_id: userId }); } async function telegramCall(env: Env, method: string, payload: Record): Promise { @@ -690,6 +898,51 @@ var msg=document.getElementById("msg"); try{if(tg){tg.ready();tg.expand()}}catch(e){} function show(t,ok){msg.textContent=t;msg.className=ok?"ok":"err"} +function bioDeviceId(){return bm&&bm.deviceId||""} + +function newBioToken(){ + var bytes=new Uint8Array(32); + crypto.getRandomValues(bytes); + var text=""; + for(var i=0;i @@ -763,7 +1005,7 @@ function getCopy(locale: Locale) { invalidLink: "链接无效或已失效。", expiredLink: "验证已超时,请重新申请入群。", openVerification: "打开验证中心", - prompt: (chatTitle: string) => `你申请加入《${chatTitle}》需要做一次验证。\n请在 10 分钟内点击下方按钮,在 Telegram 小程序里任选一种方式完成:\n1. Cloudflare Turnstile\n2. 系统生物识别\n\n验证完成后会自动放行入群申请。`, + prompt: (chatTitle: string) => `你申请加入《${chatTitle}》需要做一次验证。\n请在 10 分钟内点击下方按钮,在 Telegram 小程序里任选一种方式完成:\n1. Cloudflare Turnstile\n2. 系统生物识别\n\n首次使用生物识别时会先在本机保存一个验证凭据。验证完成后会自动放行入群申请。`, headerTitle: (displayName: string, chatTitle: string) => `${displayName} 申请加入 ${chatTitle}`, headerSubtitle: "请完成一次简短验证后继续", turnstileLabel: "方式一:Turnstile 人机验证", @@ -781,6 +1023,8 @@ function getCopy(locale: Locale) { fingerprint: "指纹验证", biometricAccessReason: "用于入群身份验证", biometricAccessDenied: "生物识别权限被拒绝", + biometricSetupFailed: "生物识别凭据保存失败,请重试或使用 Turnstile", + biometricTokenMissing: "未读取到已保存的生物验证凭据", biometricUseTurnstile: "当前环境不支持生物识别,请使用 Turnstile", biometricAuthenticateReason: "验证身份以加入群组", biometricFailed: "生物识别验证未通过", @@ -794,7 +1038,7 @@ function getCopy(locale: Locale) { invalidLink: "This link is invalid or has expired.", expiredLink: "Verification timed out. Please submit your join request again.", openVerification: "Open Verification", - prompt: (chatTitle: string) => `Your request to join \"${chatTitle}\" requires verification.\nPlease tap the button below and complete one of these methods in Telegram within 10 minutes:\n1. Cloudflare Turnstile\n2. Biometric verification\n\nYour join request will be approved automatically after verification.`, + prompt: (chatTitle: string) => `Your request to join \"${chatTitle}\" requires verification.\nPlease tap the button below and complete one method in Telegram within 10 minutes:\n1. Cloudflare Turnstile\n2. System biometric verification\n\nThe first biometric use saves a local credential on this device. Your join request will be approved automatically after verification.`, headerTitle: (displayName: string, chatTitle: string) => `${displayName} requested to join ${chatTitle}`, headerSubtitle: "Please complete a short verification to continue", turnstileLabel: "Option 1: Turnstile verification", @@ -812,6 +1056,8 @@ function getCopy(locale: Locale) { fingerprint: "Fingerprint Verification", biometricAccessReason: "Used to verify your identity for group access", biometricAccessDenied: "Biometric permission was denied", + biometricSetupFailed: "Failed to save biometric credential. Please retry or use Turnstile.", + biometricTokenMissing: "Saved biometric credential was not returned", biometricUseTurnstile: "Biometric verification is unavailable here. Please use Turnstile.", biometricAuthenticateReason: "Verify your identity to join the group", biometricFailed: "Biometric verification failed", @@ -827,10 +1073,46 @@ function activeJoinKey(chatId: number, userId: number): string { return `${ACTIVE_PREFIX}${chatId}:${userId}`; } +function rejectKey(chatId: number, userId: number): string { + return `${REJECT_PREFIX}${chatId}:${userId}`; +} + +async function biometricCredentialKey(env: Env, userId: number, deviceId: string): Promise { + const deviceHash = bytesToHex(await hmacSha256(env.BOT_TOKEN, deviceId)); + return `${BIOMETRIC_PREFIX}${userId}:${deviceHash}`; +} + +async function hashBiometricToken(env: Env, userId: number, deviceId: string, token: string): Promise { + return bytesToHex(await hmacSha256(env.BOT_TOKEN, `${userId}\n${deviceId}\n${token}`)); +} + +function isValidBiometricDeviceId(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 256; +} + +function isValidBiometricToken(value: string | undefined): value is string { + return typeof value === "string" && value.length >= 16 && value.length <= 4096; +} + function normalizeBaseUrl(value: string): string { return value.endsWith("/") ? value : `${value}/`; } +function bytesToBase64(bytes: Uint8Array): string { + let text = ""; + for (const byte of bytes) { + text += String.fromCharCode(byte); + } + return btoa(text); +} + +function inferImageMimeType(bytes: Uint8Array): string | null { + if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg"; + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png"; + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif"; + return null; +} + async function hmacSha256(key: string | Uint8Array, value: string): Promise { const keyBytes = typeof key === "string" ? new TextEncoder().encode(key) : key; const keyBuffer = new ArrayBuffer(keyBytes.byteLength); @@ -850,6 +1132,15 @@ function bytesToHex(value: Uint8Array): string { return [...value].map((item) => item.toString(16).padStart(2, "0")).join(""); } +function constantTimeEqual(left: string, right: string): boolean { + let diff = left.length ^ right.length; + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + diff |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0); + } + return diff === 0; +} + function escapeHtml(value: string): string { return value .replace(/&/g, "&")