Harden-biometric-verification

This commit is contained in:
Codex
2026-05-16 19:51:33 +08:00
parent a7f7c12ac5
commit fd43511c74
2 changed files with 340 additions and 48 deletions

View File

@@ -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 凭据。

View File

@@ -45,7 +45,7 @@ interface TelegramApiResponse<T> {
}
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<Response> {
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<Respo
}
async function handleTurnstileVerification({ env, request }: RequestContext): Promise<Response> {
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<Response> {
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<boolean> {
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<Array<{ file_id: string }>> }>(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<boolean> {
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<AiReview> {
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<AiReview>;
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<AiReview | null> {
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<void> {
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<number> {
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<void> {
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<void> {
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<void> {
let declinedCount = 0;
let cursor: string | undefined;
@@ -563,10 +693,14 @@ async function processExpiredRequests(env: Env, now: number): Promise<void> {
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<void> {
await telegramCall(env, "approveChatJoinRequest", { chat_id: chatId, user_id: userId });
async function storeBiometricCredential(env: Env, userId: number, deviceId: string, token: string): Promise<void> {
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<void> {
await telegramCall(env, "declineChatJoinRequest", { chat_id: chatId, user_id: userId });
async function verifyBiometricCredential(
env: Env,
userId: number,
deviceId: string,
token: string,
): Promise<void> {
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<void> {
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<boolean> {
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<void> {
await telegramCall(env, "banChatMember", { chat_id: chatId, user_id: userId });
}
async function telegramCall<T>(env: Env, method: string, payload: Record<string, unknown>): Promise<T> {
@@ -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<bytes.length;i++)text+=String.fromCharCode(bytes[i]);
return btoa(text).replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");
}
function postBio(mode,biometricToken){
show(${JSON.stringify(copy.submittingVerification)},true);
fetch(O+"/api/verify/biometric",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:T,initData:initData,biometricToken:biometricToken,biometricDeviceId:bioDeviceId(),biometricMode:mode})})
.then(function(r){return r.json()})
.then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
.catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
}
function withBioAccess(next){
if(bm.isAccessGranted){next();return}
bm.requestAccess({reason:${JSON.stringify(copy.biometricAccessReason)}},function(ok){
if(ok){next()}else{show(${JSON.stringify(copy.biometricAccessDenied)},false)}
});
}
function enrollBio(){
var biometricToken;
try{biometricToken=newBioToken()}catch(e){show(${JSON.stringify(copy.biometricSetupFailed)},false);return}
withBioAccess(function(){
bm.updateBiometricToken(biometricToken,function(ok){
if(!ok){show(${JSON.stringify(copy.biometricSetupFailed)},false);return}
postBio("enroll",biometricToken);
});
});
}
function authBio(){
withBioAccess(function(){
bm.authenticate({reason:${JSON.stringify(copy.biometricAuthenticateReason)}},function(ok,biometricToken){
if(!ok){show(${JSON.stringify(copy.biometricFailed)},false);return}
if(!biometricToken){show(${JSON.stringify(copy.biometricTokenMissing)},false);return}
postBio("auth",biometricToken);
});
});
}
function done(){
show(${JSON.stringify("__SUCCESS__")},true);
@@ -715,11 +968,6 @@ function initBio(){
if(!bm.isBiometricAvailable){bioBtn.textContent=${JSON.stringify(copy.biometricUnavailable)};bioBtn.disabled=true;return}
var label=bm.biometricType==="face"?${JSON.stringify(copy.faceId)}:bm.biometricType==="finger"?${JSON.stringify(copy.fingerprint)}:${JSON.stringify(copy.biometricLabel)};
bioBtn.textContent=label;
if(!bm.isAccessGranted){
bm.requestAccess({reason:${JSON.stringify(copy.biometricAccessReason)}},function(ok){
if(!ok){bioBtn.textContent=${JSON.stringify(copy.biometricAccessDenied)};bioBtn.disabled=true}
});
}
});
}
@@ -728,14 +976,8 @@ initBio();
document.getElementById("bio").addEventListener("click",function(){
if(!initData){show(${JSON.stringify(copy.invalidEnvironment)},false);return}
if(!bm||!bm.isInited||!bm.isBiometricAvailable){show(${JSON.stringify(copy.biometricUseTurnstile)},false);return}
bm.authenticate({reason:${JSON.stringify(copy.biometricAuthenticateReason)}},function(ok){
if(!ok){show(${JSON.stringify(copy.biometricFailed)},false);return}
show(${JSON.stringify(copy.submittingVerification)},true);
fetch(O+"/api/verify/biometric",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:T,initData:initData})})
.then(function(r){return r.json()})
.then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
.catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
});
if(!bioDeviceId()){show(${JSON.stringify(copy.biometricUnavailable)},false);return}
if(bm.isBiometricTokenSaved){authBio()}else{enrollBio()}
});
</script>
</body>
@@ -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<string> {
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<string> {
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<Uint8Array> {
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, "&amp;")