الكتابات/tutorial/2026/08
Tutorial2 أغسطس 2026·32 دقيقة

التحقق من حركة وكلاء الذكاء الاصطناعي في Next.js باستخدام Web Bot Auth ومعيار RFC 9421

ابنِ مُتحقِّق Web Bot Auth جاهزًا للإنتاج في Next.js. أنشئ مفاتيح Ed25519، وانشر دليل التواقيع، ووقِّع الطلبات الصادرة، وتحقق تشفيريًا من حركة وكلاء الذكاء الاصطناعي الواردة عبر تواقيع رسائل HTTP وفق معيار RFC 9421.

صارت الروبوتات تولِّد اليوم طلبات HTML أكثر مما يولِّده البشر. أما الأدوات التي تعتمد عليها معظم الفرق للتمييز بينهما — سلاسل user-agent وقوائم عناوين IP المسموح بها — فلم تُصمَّم يومًا لتصمد أمام خصم، وهي لا تصمد فعلًا.

يستبدل Web Bot Auth هذا التخمين بالتشفير. يوقِّع مُشغِّل الوكيل كل طلب صادر بمفتاح Ed25519 خاص، وينشر المفتاح العام المقابل على عنوان معروف. ثم يتحقق خادمك من التوقيع. فإما أن يمتلك المُتطفِّل الذي يدّعي أنه GPTBot مفتاح OpenAI الخاص أو لا يمتلكه، ولا يغيّر أي تزوير في الترويسات من هذه الحقيقة شيئًا.

يبني هذا الدرس الدورة كاملة في Next.js: توليد المفاتيح، واستضافة الدليل، وتوقيع الطلبات، ومُتحقِّق مُحصَّن على الخادم موصول بالوسيط (middleware) مع حماية من إعادة التشغيل.

المتطلبات المسبقة

  • Node.js 20 أو أحدث — دعم Ed25519 في WebCrypto ضروري
  • Next.js 15.5 أو 16 مع App Router
  • إتقان TypeScript والتعامل مع async/await
  • إلمام مفاهيمي بترويسات HTTP والتشفير بالمفتاح العام
  • اختياري: نسخة Upstash Redis لخطوة الحماية من إعادة التشغيل

لا تحتاج إلى حساب Cloudflare. كل ما يلي يعمل على Node وحده.

ما الذي ستبنيه

نصفَي بروتوكول واحد:

  1. المُوقِّع — عميل HTTP صادر يوقِّع طلباته، إضافة إلى مسار /.well-known/http-message-signatures-directory ينشر مفتاحك العام. هذا ما تبنيه إن كنت تُشغِّل وكيلًا.
  2. المُتحقِّق — وسيط Next.js يتحقق من التواقيع الواردة مقابل دليل مفاتيح يُجلب عن بُعد ويُخزَّن مؤقتًا، ويرفض المحاولات المُعادة، ويَسِم كل طلب بهوية مُشغِّل مُتحقَّق منها لتوجيهه لاحقًا.

في النهاية، أي طلب يجتاز الوسيط يحمل ضمانة تشفيرية بشأن مُرسِله.

كيف يعمل البروتوكول فعليًا

Web Bot Auth هو ملف تعريف خفيف فوق RFC 9421 (تواقيع رسائل HTTP)، وهو معيار مقترح مُصادَق عليه من IETF. ويُعرَّف هذا الملف في draft-meunier-webbotauth-httpsig-protocol.

يحمل الطلب المُوقَّع ثلاث ترويسات:

GET /ar/services HTTP/1.1
Host: noqta.tn
Signature-Agent: "https://signer.example.com"
Signature-Input: sig1=("@authority" "signature-agent");created=1785667520;
                 keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";
                 alg="ed25519";expires=1785667820;nonce="Rifzo0j0...";tag="web-bot-auth"
Signature: sig1=:mMElbdKxLiXjcOJ7161NxIUscYhA/HMyThrbs7KQLvhm...:

ثلاثة أمور تستحق الانتباه.

المكوّنات المُغطّاة قليلة جدًا. القائمة ("@authority" "signature-agent") تعني أن المُوقَّع هو الجهة المستهدفة وترويسة Signature-Agent فحسب. لا المسار، ولا الطريقة، ولا المحتوى. وهذا مقصود — إذ يُبقي التواقيع صالحة عبر الوسطاء وعمليات إعادة التوجيه — لكنه يحمل أثرًا أمنيًا عليك معالجته، ونتناوله في خطوة الحماية من إعادة التشغيل.

keyid بصمة وليس اسمًا. إنه بصمة JWK وفق RFC 7638 للمفتاح العام، مُرمَّزة بـ base64url. أنت لا تبحث عن مفتاح بتسمية يقرأها البشر، بل ببصمة تجزئة للمادة التشفيرية نفسها. ولهذا السبب يكون تدوير المفاتيح آمنًا: المفتاح الجديد يُنتج بصمة جديدة ولا يمكن الخلط بينه وبين القديم.

tag="web-bot-auth" يحدّد نطاق التوقيع. معيار RFC 9421 آلية عامة الغرض. والوسم يمنع إعادة استخدام توقيع صادر لغرض آخر بوصفه ادعاءً بهوية روبوت. والتحقق من هذا الوسم مسؤوليتك أنت، فالمكتبة لن تقوم به نيابةً عنك.

وهذا دليل حقيقي، متاح اليوم على https://chatgpt.com/.well-known/http-message-signatures-directory:

{
  "keys": [
    {
      "crv": "Ed25519",
      "kty": "OKP",
      "x": "7F_3jDlxaquwh291MiACkcS3Opq88NksyHiakzS-Y1g",
      "kid": "otMqcjr17mGyruktGvJU8oojQTSMHlVm7uO-lrcqbdg",
      "use": "sig",
      "nbf": 1735689600,
      "exp": 1786272375
    }
  ],
  "signature_agent": "https://chatgpt.com",
  "purpose": "ai"
}

إنه مجموعة مفاتيح JWK مع حقلين إضافيين: signature_agent (ويجب أن يطابق الترويسة) وpurpose (إشارة إلى الغرض من الحركة — مثل ai وrag).

الخطوة 1: تهيئة المشروع

ثبّت الحزم المرجعية التي تصونها Cloudflare:

npm install web-bot-auth jose

تجلب web-bot-auth بإصدار 0.1.3 كلًا من http-message-sig وjsonwebkey-thumbprint كاعتماديات غير مباشرة. أما jose فتلزم لتوليد المفاتيح في الخطوة التالية فقط.

تكشف الحزمة نقطتَي دخول:

// منطق البروتوكول والثوابت والتحقق
import { verify, signatureHeaders, REQUEST_COMPONENTS } from "web-bot-auth";
 
// محوّلات تشفيرية فوق WebCrypto
import { signerFromJWK, verifierFromJWK } from "web-bot-auth/crypto";

يعرض ملف README المنشور على npm مساعدًا باسم recommendedComponents("sig1"). هذا التصدير غير موجود في الإصدار 0.1.3 — فالملف سابق للإصدار الفعلي. استخدم بدلًا منه الثابت REQUEST_COMPONENTS وهو المكافئ المُصدَّر. هذه العقبة تُوقِع جميع من يجرّب الحزمة لأول مرة تقريبًا.

الخطوة 2: توليد زوج مفاتيح Ed25519

أنشئ scripts/generate-key.ts. يُنفَّذ هذا السكربت مرة واحدة دون اتصال، ومخرجاته هي أصل هويتك بالكامل.

import { generateKeyPair, exportJWK } from "jose";
import { jwkToKeyID, helpers } from "web-bot-auth";
import { writeFileSync } from "node:fs";
 
async function main() {
  const { publicKey, privateKey } = await generateKeyPair("Ed25519", {
    extractable: true,
  });
 
  const privateJWK = { ...(await exportJWK(privateKey)), kty: "OKP", crv: "Ed25519" };
  const publicJWK = { ...(await exportJWK(publicKey)), kty: "OKP", crv: "Ed25519" };
 
  // بصمة RFC 7638 — تصبح هي `keyid` في كل توقيع تُصدره
  const kid = await jwkToKeyID(
    publicJWK,
    helpers.WEBCRYPTO_SHA256,
    helpers.BASE64URL_DECODE
  );
 
  publicJWK.kid = kid;
  privateJWK.kid = kid;
 
  writeFileSync("keys/public.jwk.json", JSON.stringify({ ...publicJWK, use: "sig" }, null, 2));
  writeFileSync("keys/private.jwk.json", JSON.stringify(privateJWK, null, 2));
 
  console.log("معرّف المفتاح (البصمة):", kid);
}
 
main();

شغّله:

mkdir -p keys && npx tsx scripts/generate-key.ts

حساب البصمة نقطة جوهرية. لاحظ ترتيب المعاملات: تستقبل jwkToKeyID كائن JWK ثم دالة تجزئة ثم دالة فك ترميز. ويوفّر كائن helpers تطبيقين مبنيين على WebCrypto لكليهما، فلا تضطر إلى كتابة توحيد الصيغة القياسي لـ RFC 7638 بنفسك.

أضف keys/private.jwk.json إلى ملف .gitignore فورًا، ثم انقله إلى مدير الأسرار لديك. وفي بيئة الإنتاج، حمّله من متغير بيئة:

const privateJWK = JSON.parse(process.env.WEB_BOT_AUTH_PRIVATE_JWK!);

لا تودع المفتاح الخاص في المستودع أبدًا. تسريب مفتاح Ed25519 خاص يتيح لأي شخص انتحال شخصية وكيلك أمام كل خادم يثق بك، وعلاجك الوحيد عندها هو نشر مفتاح جديد ثم انتظار انقضاء ذاكرة التخزين المؤقت لدى كل مُتحقِّق.

الخطوة 3: نشر دليل التواقيع الخاص بك

إن كنت تُشغِّل وكيلًا، فالخوادم بحاجة إلى وسيلة لجلب مفتاحك العام. أنشئ app/.well-known/http-message-signatures-directory/route.ts:

import { MediaType } from "web-bot-auth";
import publicJWK from "@/keys/public.jwk.json";
 
export const runtime = "nodejs";
export const dynamic = "force-static";
 
export function GET() {
  const directory = {
    keys: [publicJWK],
    signature_agent: process.env.SIGNATURE_AGENT_ORIGIN ?? "https://agent.example.com",
    purpose: "ai",
  };
 
  return new Response(JSON.stringify(directory), {
    headers: {
      "Content-Type": MediaType.HTTP_MESSAGE_SIGNATURES_DIRECTORY,
      "Cache-Control": "public, max-age=86400",
    },
  });
}

نوع المحتوى هو application/http-message-signatures-directory+json، وتوفّره المكتبة كثابت حتى لا تقع في خطأ طباعي.

تفصيلان يتسببان في إخفاقات تشغيل بينيّ حقيقية:

  • يجب أن يطابق signature_agent تمامًا القيمة التي يضعها المُوقِّع في ترويسة Signature-Agent، بما في ذلك المخطط ومن دون أي شرطة مائلة في النهاية. المُتحقِّقون الصارمون يرفضون أي اختلاف.
  • أبقِ المفاتيح القديمة ضمن المصفوفة أثناء التدوير. انشر المفتاح الجديد إلى جانب القديم لمدة لا تقل عن أطول مدة تخزين مؤقت أعلنت عنها، ثم أزل القديم. حذفه لحظة التدوير يُعطِّل كل مُتحقِّق يحتفظ بنسخة مخزّنة من الدليل.

تحقق من أن المسار يستجيب كما ينبغي:

curl -s http://localhost:3000/.well-known/http-message-signatures-directory | jq

الخطوة 4: توقيع الطلبات الصادرة

ننتقل الآن إلى المُوقِّع. أنشئ lib/web-bot-auth/sign.ts:

import { signatureHeaders, REQUEST_COMPONENTS, generateNonce } from "web-bot-auth";
import { signerFromJWK } from "web-bot-auth/crypto";
 
const SIGNATURE_AGENT = process.env.SIGNATURE_AGENT_ORIGIN!;
const VALIDITY_SECONDS = 300;
 
// خزّن المُوقِّع مؤقتًا — استيراد CryptoKey مع كل طلب هدر للموارد
let signerPromise: ReturnType<typeof signerFromJWK> | null = null;
 
function getSigner() {
  if (!signerPromise) {
    signerPromise = signerFromJWK(JSON.parse(process.env.WEB_BOT_AUTH_PRIVATE_JWK!));
  }
  return signerPromise;
}
 
export async function signedFetch(url: string, init: RequestInit = {}) {
  // قيمة Signature-Agent سلسلة نصية بين علامتَي اقتباس
  const signatureAgent = `"${SIGNATURE_AGENT}"`;
 
  const request = new Request(url, {
    ...init,
    headers: { ...init.headers, "Signature-Agent": signatureAgent },
  });
 
  const created = new Date();
  const headers = await signatureHeaders(request, await getSigner(), {
    created,
    expires: new Date(created.getTime() + VALIDITY_SECONDS * 1000),
    nonce: generateNonce(),
    components: REQUEST_COMPONENTS, // ["@authority", "signature-agent"]
    key: "sig1",
  });
 
  return fetch(url, {
    ...init,
    headers: {
      ...init.headers,
      "Signature-Agent": signatureAgent,
      Signature: headers["Signature"],
      "Signature-Input": headers["Signature-Input"],
    },
  });
}

عدة قرارات مُضمَّنة هنا:

أرسل قيمة nonce دائمًا. هي اختيارية في المواصفة، لكنها ما يجعل كشف إعادة التشغيل ممكنًا على الخادم. وتُنتج generateNonce() أربعة وستين بايتًا عشوائيًا، وهو قدر وافٍ.

أبقِ نافذة الصلاحية قصيرة. خمس دقائق تحدّ من المدة التي يظل فيها توقيع مُلتقَط صالحًا للاستغلال. أما التوقيع بمهلة أربع وعشرين ساعة — كما تفعل بعض الأمثلة المنشورة — فيمنح المهاجم يومًا كاملًا من نافذة إعادة التشغيل.

وقِّع باستخدام REQUEST_COMPONENTS. يشمل هذا الثابت signature-agent ضمن المكوّنات المُغطّاة. وإن أغفلت الترويسة من الطلب، ترتد المكتبة بصمت إلى REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT، فيرفضك كل مُتحقِّق يتوقع ارتباط التوقيع بالوكيل.

اختبر عملك مقابل نقطة التشخيص العامة من Cloudflare Research، التي تتحقق من تطبيقك وتُبلغك بما رصدته:

const res = await signedFetch(
  "https://http-message-signatures-example.research.cloudflare.com/debug"
);
console.log(await res.json());

الخطوة 5: استخراج المفاتيح العامة على الخادم

نبدّل الجهة الآن. قبل أي تحقق، يحتاج خادمك إلى المفاتيح العامة للمُوقِّع — تُجلب من أصل Signature-Agent وتُخزَّن مؤقتًا بصرامة، لأن رحلة شبكة كاملة مع كل طلب وارد أمر غير عملي.

أنشئ lib/web-bot-auth/directory.ts:

import { HTTP_MESSAGE_SIGNATURES_DIRECTORY } from "web-bot-auth";
 
interface Directory {
  keys: JsonWebKey[];
  signature_agent?: string;
  purpose?: string;
}
 
interface CacheEntry {
  keys: JsonWebKey[];
  expiresAt: number;
}
 
const cache = new Map<string, CacheEntry>();
const TTL_MS = 60 * 60 * 1000; // ساعة واحدة
const FETCH_TIMEOUT_MS = 2000;
 
// هؤلاء المُشغِّلون وحدهم موثوقون. جلب أي أصل يسمّيه الطلب دون قيد
// يفتح ثغرة تزوير طلبات من جهة الخادم.
const ALLOWED_AGENTS = new Set([
  "https://chatgpt.com",
  "https://anthropic.com",
  "https://http-message-signatures-example.research.cloudflare.com",
]);
 
export function parseSignatureAgent(header: string | null): string | null {
  if (!header) return null;
  // القيمة سلسلة بين علامتَي اقتباس، وقد تتبعها معاملات حقول مهيكلة
  const match = header.match(/"([^"]+)"/);
  if (!match) return null;
  try {
    const url = new URL(match[1]);
    if (url.protocol !== "https:") return null;
    return url.origin;
  } catch {
    return null;
  }
}
 
export async function resolveKeys(agentOrigin: string): Promise<JsonWebKey[]> {
  if (!ALLOWED_AGENTS.has(agentOrigin)) {
    throw new Error(`وكيل توقيع غير موثوق: ${agentOrigin}`);
  }
 
  const cached = cache.get(agentOrigin);
  if (cached && cached.expiresAt > Date.now()) return cached.keys;
 
  const res = await fetch(agentOrigin + HTTP_MESSAGE_SIGNATURES_DIRECTORY, {
    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
    headers: { Accept: "application/http-message-signatures-directory+json" },
  });
 
  if (!res.ok) {
    // قدّم النسخة القديمة بدل الإخفاق بسبب عطل عابر
    if (cached) return cached.keys;
    throw new Error(`فشل جلب الدليل: ${res.status}`);
  }
 
  const directory = (await res.json()) as Directory;
  const keys = (directory.keys ?? []).filter(isUsableKey);
 
  cache.set(agentOrigin, { keys, expiresAt: Date.now() + TTL_MS });
  return keys;
}
 
function isUsableKey(key: JsonWebKey & { nbf?: number; exp?: number }): boolean {
  if (key.kty !== "OKP" || key.crv !== "Ed25519" || !key.kid) return false;
 
  // ينشر المُشغِّلون nbf و exp بصيغ متضاربة — بعضهم بالثواني وبعضهم
  // بالمللي ثانية. وحّد الوحدات قبل أي مقارنة.
  const now = Date.now();
  const toMs = (t: number) => (t > 1e11 ? t : t * 1000);
 
  if (key.nbf !== undefined && toMs(key.nbf) > now) return false;
  if (key.exp !== undefined && toMs(key.exp) < now) return false;
  return true;
}

ثلاث نقاط مكتسبة من التجربة.

قائمة السماح ليست اختيارية. بدونها يرسل المهاجم Signature-Agent: "https://internal.your-vpc.local" فيقوم خادمك بطاعة بإصدار طلب صادر إلى أي وجهة يسمّيها. هذا تزوير طلبات من جهة الخادم بصورته النموذجية. الثقة قرار صريح، مُشغِّلًا تلو الآخر.

قدّم النسخة المخزّنة القديمة عند فشل الجلب. إن مرّ دليل أحد المُشغِّلين بدقيقة سيئة، فأنت لا تريد أن يبدأ كل وكلائه بالإخفاق في التحقق. والارتداد إلى النسخة المخزّنة هو المفاضلة الصحيحة.

وحّد وحدات nbf وexp. تنشر Cloudflare Research قيمة nbf هكذا 1743465600000 — بالمللي ثانية. بينما تنشرها OpenAI هكذا 1735689600 — بالثواني. وكلاهما متاح الآن. ومقارنة القيم الخام بـ Date.now() ترفض إحداهما بصمت.

الخطوة 6: كتابة المُتحقِّق

أنشئ lib/web-bot-auth/verify.ts:

import { verify, HTTP_MESSAGE_SIGNATURE_TAG } from "web-bot-auth";
import { verifierFromJWK } from "web-bot-auth/crypto";
 
export interface VerifiedAgent {
  agentOrigin: string;
  keyid: string;
  nonce?: string;
  expires: Date;
}
 
const MAX_CLOCK_SKEW_MS = 60_000;
 
export async function verifySignedRequest(
  request: Request,
  agentOrigin: string,
  keys: JsonWebKey[]
): Promise<VerifiedAgent> {
  const index = new Map(keys.map((k) => [(k as { kid: string }).kid, k]));
  let result: VerifiedAgent | null = null;
 
  // نُركّب فحوصنا الخاصة حول عملية التحقق التي تجريها المكتبة
  const verifier = async (
    data: string,
    signature: Uint8Array,
    params: { keyid: string; created: Date; expires: Date; tag: string; nonce?: string }
  ) => {
    if (params.tag !== HTTP_MESSAGE_SIGNATURE_TAG) {
      throw new Error(`وسم غير متوقع: ${params.tag}`);
    }
 
    const jwk = index.get(params.keyid);
    if (!jwk) throw new Error(`معرّف مفتاح مجهول: ${params.keyid}`);
 
    if (params.created.getTime() - Date.now() > MAX_CLOCK_SKEW_MS) {
      throw new Error("التوقيع مُنشأ في المستقبل");
    }
 
    result = {
      agentOrigin,
      keyid: params.keyid,
      nonce: params.nonce,
      expires: params.expires,
    };
 
    const inner = await verifierFromJWK(jwk);
    return inner(data, signature, params);
  };
 
  await verify(request, verifier);
 
  if (!result) throw new Error("لم تُنتج عملية التحقق أي نتيجة");
  return result;
}

نمط التركيب هو الفكرة المهمة هنا. تُحلّل verify() الترويسات، وتُعيد بناء أساس التوقيع، ثم تُمرّر إلى دالتك البيانات الخام مع المعاملات المُحلَّلة. وتلك الدالة هي الموضع الذي تفرض فيه كل ما لا تفرضه المكتبة: تحديد النطاق بالوسم، واختيار المفتاح بالبصمة، وحدود انحراف الساعة.

ما تتكفل به المكتبة: إعادة بناء أساس التوقيع، والتحقق من Ed25519، وانتهاء الصلاحية. فالطلب الذي تجاوز expires يرفع الخطأ Signature expired قبل أن تُعاد نتيجة دالتك.

وما لا تتكفل به: الوسم، واستخراج المفاتيح، وانحراف الساعة، وإعادة التشغيل. تلك خطوط الدفاع الأربعة التي كتبتها للتو — يبقى خط أخير.

الخطوة 7: الحماية من إعادة التشغيل

تذكّر أن التوقيع لا يغطي سوى @authority وsignature-agent. فهو لا يغطي المسار ولا الطريقة ولا المحتوى. أي أن توقيعًا مُلتقَطًا من طلب إلى /ar/pricing يظل صالحًا بايتًا ببايت على طلب POST إلى /api/admin/delete على المضيف نفسه، إلى أن ينتهي أجله.

وهذه أهم نقطة تحتاج إلى استيعابها بشأن Web Bot Auth: إنه يوثّق المُرسِل لا الطلب. تعامل معه بوصفه هوية، لا تفويضًا أبدًا.

وداخل نافذة صلاحيته، يبقى تتبّع قيم nonce هو الدفاع الوحيد. أنشئ lib/web-bot-auth/replay.ts:

import { Redis } from "@upstash/redis";
 
const redis = Redis.fromEnv();
 
/**
 * يسجّل قيمة nonce ويُبلغ إن كانت قد شوهدت من قبل.
 * تنتهي صلاحية المفتاح مع انتهاء التوقيع، فيبقى التخزين محدودًا.
 */
export async function isReplay(keyid: string, nonce: string, expires: Date): Promise<boolean> {
  const ttlSeconds = Math.ceil((expires.getTime() - Date.now()) / 1000);
  if (ttlSeconds <= 0) return true; // منتهٍ أصلًا، يُعامَل كإعادة تشغيل
 
  const key = `wba:nonce:${keyid}:${nonce}`;
  // تُعيد SET NX القيمة null عندما يكون المفتاح موجودًا سلفًا
  const stored = await redis.set(key, "1", { nx: true, ex: ttlSeconds });
  return stored === null;
}

ولأن مدة البقاء مربوطة بانتهاء صلاحية التوقيع نفسه، لا ينمو المخزن أبدًا بما يتجاوز حجم حركة نافذة واحدة. لذا فإن نوافذ الصلاحية القصيرة ليست خاصية أمنية فحسب، بل هي ما يجعل هذا الحل ميسور التكلفة.

وإن وصل توقيع بلا قيمة nonce، فأمامك خيار. الرفض هو الأسلم افتراضيًا لأي مسار يُعدِّل الحالة، والقبول مُبرَّر لمسارات المحتوى للقراءة فقط. اتخذ هذا القرار صراحةً لا بالإغفال.

الخطوة 8: الربط مع وسيط Next.js

أنشئ middleware.ts في جذر المشروع:

import { NextRequest, NextResponse } from "next/server";
import { parseSignatureAgent, resolveKeys } from "@/lib/web-bot-auth/directory";
import { verifySignedRequest } from "@/lib/web-bot-auth/verify";
import { isReplay } from "@/lib/web-bot-auth/replay";
 
// Ed25519 عبر WebCrypto إضافة إلى طلب شبكي — بيئة تشغيل Node مطلوبة
export const config = {
  runtime: "nodejs",
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
 
export async function middleware(request: NextRequest) {
  const headers = new Headers(request.headers);
 
  // احذف أي قيمة يرسلها العميل حتى لا يُخدع الكود اللاحق
  headers.delete("x-verified-agent");
  headers.delete("x-agent-keyid");
 
  const agentOrigin = parseSignatureAgent(request.headers.get("signature-agent"));
 
  if (agentOrigin && request.headers.get("signature")) {
    try {
      const keys = await resolveKeys(agentOrigin);
      const verified = await verifySignedRequest(request, agentOrigin, keys);
 
      if (verified.nonce && (await isReplay(verified.keyid, verified.nonce, verified.expires))) {
        return new NextResponse("توقيع مُعاد", { status: 401 });
      }
 
      headers.set("x-verified-agent", verified.agentOrigin);
      headers.set("x-agent-keyid", verified.keyid);
    } catch (error) {
      // التوقيع الفاشل إشارة أقوى من غياب التوقيع تمامًا:
      // ثمة من حاول وأخطأ. سجّل الحدث ولا تتجاهله بصمت.
      console.warn("فشل التحقق من web-bot-auth", {
        agentOrigin,
        reason: error instanceof Error ? error.message : "غير معروف",
      });
      return new NextResponse("توقيع غير صالح", { status: 401 });
    }
  }
 
  return NextResponse.next({ request: { headers } });
}

عنصران هنا يحملان ثقل الأمان.

حذف الترويسات قبل تعيينها. إن اكتفيت بتعيين x-verified-agent عند النجاح، أمكن للعميل أن يرسل تلك الترويسة بنفسه فيصدّقها كل معالج لاحق. وتنظيف النسخ الواردة أولًا يسدّ هذه الثغرة.

الرفض بدل التساهل عند الفشل. الطلب غير المُوقَّع مجهول الهوية، وهذا مقبول. أما الطلب ذو التوقيع المكسور فهو محاولة نشطة لشيء ما. والرد بـ 401 هو التصرف الصحيح، وتسجيله يمنحك إشارة بأن أحدهم يستكشف دفاعاتك.

والسطر export const config = { runtime: "nodejs" } مستقر في إصدارات Next.js الحالية ولا يحتاج إلى أي راية تجريبية. وهو ضروري هنا: دعم Ed25519 في بيئة Edge يتفاوت بحسب المنصة، كما تحتاج إلى fetch بمهلة زمنية لاستخراج الدليل.

الخطوة 9: التصرف بناءً على النتيجة

لا يستحق التحقق عناءه ما لم يتغير شيء في المراحل التالية. داخل Server Component أو Route Handler:

import { headers } from "next/headers";
 
export default async function ServicesPage() {
  const agent = (await headers()).get("x-verified-agent");
 
  if (agent) {
    // مُشغِّل مُتحقَّق منه — قدّم تمثيلًا مهيكلًا وخفيفًا.
    // تجاوز الغلاف التسويقي يقلّص الحمولة ويحسّن دقة التحليل.
    return <ServicesStructuredView operator={agent} />;
  }
 
  return <ServicesMarketingPage />;
}

وفيما يلي مستويات السياسة الطبيعية، من الأكثر ثقة إلى الأقل:

فئة الحركةالمعاملة
وكيل مُتحقَّق منه بطلب من مستخدمقدّم المحتوى كاملًا، بحد معدل سخي، وتابعه كقناة
زاحف بحث مُتحقَّق منهقدّم المحتوى بميزانية زحف معتادة
زاحف تدريب مُتحقَّق منهقرار تجاري — السماح أو القياس أو اشتراط ترخيص
روبوت غير مُوقَّع يُعرّف نفسهحدّ المعدل وطبّق robots.txt
توقيع مكسورارفض بـ 401 مع تنبيه

لاحظ أن «حظر كل شيء» نادرًا ما يكون الأداة الصحيحة. فحظر حركة الوكلاء التي يطلقها المستخدمون في 2026 يعطي أثرًا مشابهًا لحظر متصفحات الهاتف في 2010.

اختبار تطبيقك

ينشر الملحق B.1.4 من RFC 9421 زوج مفاتيح للاختبار، ما يتيح كتابة اختبارات حتمية دون أي إدارة للمفاتيح. أنشئ lib/web-bot-auth/verify.test.ts:

import { describe, it, expect } from "vitest";
import { signatureHeaders, REQUEST_COMPONENTS, generateNonce } from "web-bot-auth";
import { signerFromJWK } from "web-bot-auth/crypto";
import { verifySignedRequest } from "./verify";
 
// مفتاح اختبار RFC 9421 الملحق B.1.4 — معلوم للجميع، لا يُستخدم في الإنتاج
const PRIVATE_JWK = {
  kty: "OKP",
  crv: "Ed25519",
  d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
  x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
};
 
const PUBLIC_JWK = {
  kty: "OKP",
  crv: "Ed25519",
  x: PRIVATE_JWK.x,
  kid: "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U", // بصمة المفتاح أعلاه
};
 
const AGENT = "https://signer.example.com";
 
async function makeSignedRequest(url = "https://noqta.tn/ar/services", offsetMs = 0) {
  const signatureAgent = `"${AGENT}"`;
  const base = new Request(url, { headers: { "Signature-Agent": signatureAgent } });
  const created = new Date(Date.now() + offsetMs);
 
  const headers = await signatureHeaders(base, await signerFromJWK(PRIVATE_JWK), {
    created,
    expires: new Date(created.getTime() + 300_000),
    nonce: generateNonce(),
    components: REQUEST_COMPONENTS,
    key: "sig1",
  });
 
  return new Request(url, {
    headers: {
      "Signature-Agent": signatureAgent,
      Signature: headers["Signature"],
      "Signature-Input": headers["Signature-Input"],
    },
  });
}
 
describe("verifySignedRequest", () => {
  it("يقبل توقيعًا صالحًا", async () => {
    const request = await makeSignedRequest();
    const result = await verifySignedRequest(request, AGENT, [PUBLIC_JWK]);
    expect(result.keyid).toBe(PUBLIC_JWK.kid);
  });
 
  it("يرفض توقيعًا مرتبطًا بجهة أخرى", async () => {
    const signed = await makeSignedRequest();
    // الترويسات نفسها بمضيف مختلف — لم تعد @authority مطابقة
    const moved = new Request("https://evil.example/ar/services", {
      headers: signed.headers,
    });
    await expect(verifySignedRequest(moved, AGENT, [PUBLIC_JWK])).rejects.toThrow();
  });
 
  it("يرفض معرّف مفتاح مجهول", async () => {
    const request = await makeSignedRequest();
    const wrongKey = { ...PUBLIC_JWK, kid: "بصمة-غير-صحيحة" };
    await expect(verifySignedRequest(request, AGENT, [wrongKey])).rejects.toThrow(/مجهول/);
  });
 
  it("يرفض توقيعًا منتهي الصلاحية", async () => {
    const request = await makeSignedRequest("https://noqta.tn/ar/services", -7_200_000);
    await expect(verifySignedRequest(request, AGENT, [PUBLIC_JWK])).rejects.toThrow(/expired/i);
  });
});

الاختبار الثاني هو ما يُثبت أن الآلية تعمل. فنقل الترويسات المُوقَّعة إلى مضيف آخر يُبطل التوقيع لأن @authority مكوّن مُغطّى — وهي بالضبط الخاصية التي تجعل تزوير الترويسات بلا جدوى.

شغّل الاختبارات بـ npx vitest run.

حل المشكلات الشائعة

recommendedComponents is not exported — يوثّق ملف README على npm واجهة غير مُصدَرة بعد. استخدم REQUEST_COMPONENTS من web-bot-auth.

«معرّف مفتاح مجهول» مع مُشغِّل حقيقي — أنت على الأرجح تقارن بحقل kid في JWK بينما يحمل التوقيع بصمة RFC 7638. ومع أن معظم المُشغِّلين يجعلون kid مطابقًا للبصمة عمليًا، فلا تفترض ذلك. احسب البصمة بنفسك عبر jwkToKeyID وقارن بها.

التوقيع صالح محليًا ويفشل خلف وسيط — تُشتق @authority من عنوان الطلب. فإن أعاد موزّع الأحمال كتابة ترويسة Host، اختلفت الجهة المُعاد بناؤها عن تلك المُوقَّعة. تأكد من وصول المضيف الأصلي إلى المُتحقِّق، عادةً عبر التعامل مع X-Forwarded-Host.

Signature expired في كل طلب — راجع مزامنة ساعة الخادم المُتحقِّق. فمع نوافذ من خمس دقائق، يكفي انحراف دقيقتين لرفض كل شيء. بروتوكول NTP ليس اختياريًا هنا.

الدليل يستجيب بـ 200 لكن دون مفاتيح صالحة — على الأرجح تُسقطها عملية توحيد nbf/exp لديك. سجّل القيم الخام؛ فإن كانت من ثلاثة عشر رقمًا فهي بالمللي ثانية.

التحقق ينجح لكن الوسيط لا يعمل إطلاقًا — راجع matcher. فالإعداد الافتراضي يتخطى _next/static، ومن السهل أن تستثني بالخطأ المسارات التي تهمك.

الخطوات التالية

الخاتمة

صار لديك الآن تطبيق Web Bot Auth متكامل: مفاتيح Ed25519 ببصمات RFC 7638، ودليل تواقيع منشور، وعميل موقِّع، ومُتحقِّق على الخادم يفحص الوسم، ويستخرج المفاتيح مقابل قائمة سماح، ويحدّ انحراف الساعة، ويرفض المحاولات المُعادة.

أما ما يستحق أن تحمله معك فهو الحدّ الفاصل. يخبرك Web Bot Auth من أرسل الطلب بيقين تشفيري، ولا يقول شيئًا عمّا يُسمح لذلك الطلب بفعله. فالمكوّنات المُغطّاة تقتصر على الجهة وترويسة الوكيل، ما يعني أن التوقيع الصالح ادعاء هوية لا صلاحية. ابنِ التفويض فوقه، لا بديلًا عنه.

اضبط هذا الحدّ بدقة وستكسب شيئًا جديدًا حقًا: القدرة على التعامل مع حركة وكلاء الذكاء الاصطناعي بوصفها قناة معروفة ومُصنَّفة وقابلة للقياس، بدل أن تكون فئة إساءة تُحظر بشكل تلقائي.


هل تبني للويب الوكيلي؟ تساعد نُقطة الفرق في تونس والسعودية على تصميم بنية تحتية جاهزة للوكلاء — من هوية الروبوتات وسياسات الحافة إلى تكامل MCP. تواصل معنا.