تأكيد ملكية عنوان البريد الإلكتروني

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

تتيح واجهة برمجة التطبيقات لتأكيد عنوان البريد الإلكتروني (إضافة HTML من جهة العميل) وبروتوكول تأكيد عنوان البريد الإلكتروني (EVP) (بروتوكول تشفير في الخلفية) للجهة المعتمدة التأكّد من أنّ المستخدم يتحكّم في عنوان بريد إلكتروني بدون إرسال رسالة تأكيد. يتوسّط المتصفّح بين الطرف المعتمِد ومزوِّد خدمة البريد الإلكتروني للمستخدم (جهة الإصدار):

  1. تتضمّن الجهة المعتمدة <input> مخفيًا مع autocomplete="email-verification-token" وnonce تشفيرًا للاستخدام الواحد في <form> نفسه مثل إدخال البريد الإلكتروني.
  2. عندما يختار المستخدم عنوان بريد إلكتروني أو يُدخله، يبحث المتصفّح عن سجلّ _email-verification.<domain> نظام أسماء النطاقات TXT للنطاق، ويتحقّق من أنّ المستخدم لديه جلسة نشطة مع جهة الإصدار الموثوقة، ويطلب رمزًا مميزًا لتأكيد عنوان البريد الإلكتروني (EVT) بدون الإفصاح عن هوية الجهة المعتمدة لجهة الإصدار.
  3. يربط المتصفّح رمز EVT بمصدر الطرف المعتمِد وجلسة nonce في رمز JWT لربط المفاتيح (KB-JWT) الموقَّع باستخدام مفتاحَي تشفير متصفّح مؤقت (EVT + KB-JWT)، ويملأ حقل الإدخال المخفي، ويرسل النموذج.
  4. تتحقّق الواجهة الخلفية للجهة المعتمدة من سلسلة التشفير، وإذا كانت صالحة، يتم تخطّي خطوة كلمة المرور الصالحة لمرة واحدة (OTP) أو الرابط السحري.

1. تفعيل مرحلة التجربة والتقييم وعرض نموذج إثبات الهوية

للمشاركة كموقع إلكتروني يتحقّق من الهوية (الجهة المعتمدة):

  1. قدِّم الرمز المميّز لمرحلة التجربة والتقييم في الصفحة التي تستضيف النموذج باستخدام عنوان الاستجابة Origin-Trial HTTP أو عنصر <meta http-equiv="origin-trial">.
    • مراحل التجربة والتقييم التابعة لجهات خارجية: يمكن لحِزم تطوير البرامج (SDK) المضمّنة للهوية إدخال رمز مميّز تجريبي تابع لجهة خارجية، شرط أن يتطابق مصدر المسجِّل في مرحلة التجربة والتقييم مع نطاق جهة الإصدار (الموقع الإلكتروني نفسه مع جهة الإصدار، مثل https://issuer.example).
  2. أنشِئ رقمًا خاصًا عشوائيًا مشفّرًا لكل عرض نموذج باستخدام 128 بت على الأقل من الإنتروبيا (على سبيل المثال، randomBytes(32).toString('base64url') من node:crypto)، وخزِّنه في حالة الجلسة من جهة الخادم التي تم إدخالها بواسطة ملف تعريف ارتباط معرّف جلسة HttpOnly أو Secure أو SameSite=Lax (لا تخزِّن أبدًا قيمة nonce المتوقعة مباشرةً داخل ملف تعريف ارتباط العميل حيث يمكن للمهاجم إعادة تشغيل كل من ملف تعريف الارتباط والرمز المميز)، واعرضه في سمة nonce لإدخال الرمز المميز المخفي. اعرض الصفحة باستخدام Cache-Control: no-store كي لا يعيد التنقّل للخلف أو للأمام استخدام رمز nonce سبق استخدامه.
  3. أضِف حقل إدخال الرمز المميّز المخفي (autocomplete="email-verification-token") داخل علامة <form> نفسها التي تحتوي على حقل إدخال البريد الإلكتروني (autocomplete="email").
<!-- Serve with:
     Origin-Trial: <YOUR_ORIGIN_TRIAL_TOKEN>
     Cache-Control: no-store -->
<form method="POST" action="/api/verify-email">
  <label for="email">Email address:</label>
  <input
    type="email"
    id="email"
    name="email"
    autocomplete="email"
    required
  />

  <!-- MANDATORY: Must be in the same <form> as the email input.
       The nonce value must be freshly generated on the server per form render. -->
  <input
    type="hidden"
    name="token"
    nonce="SERVER_GENERATED_PER_FORM_NONCE"
    autocomplete="email-verification-token"
  />

  <button type="submit">Continue</button>
</form>

2. التحقّق من الرمز المميّز المُرسَل على الخادم

عند إرسال النموذج، يجب التعامل مع حقل token كإدخال غير موثوق به. قراءة nonce المتوقّع وحذفه تلقائيًا من مخزن الجلسات من جهة الخادم عند الاستلام إذا كان الحقل token فارغًا (على سبيل المثال، في المتصفّحات التي لا تتيح استخدام EVP أو عندما يسجّل المستخدم الخروج من مزوِّد خدمة البريد الإلكتروني) أو إذا تعذّر إثبات الهوية، يمكنك العودة فورًا إلى مسار كلمة المرور العادية الصالحة لمرة واحدة (OTP) أو الرابط السحري:

// Replace with your public relying-party origin
const RP_ORIGIN = 'https://rp.example.com';

export async function handleSignupSubmission(request) {
  const formData = await request.formData();
  const submittedEmail = String(formData.get('email') || '').trim();
  const rawToken = String(formData.get('token') || '').trim();
  // Atomically read and delete the single-use nonce from server-side session state
  const expectedNonce = await consumeSessionNonce(request);

  if (rawToken && expectedNonce) {
    try {
      await verifyEmailVerificationToken({
        rawToken,
        submittedEmail,
        expectedNonce,
        expectedAudience: RP_ORIGIN,
      });
      // Fast path: email ownership is cryptographically verified; skip OTP email.
      return completeAccountCreation(submittedEmail);
    } catch (err) {
      // Log token verification failures before falling back to standard email verification.
      console.error('Email verification token check failed:', err);
    }
  }

  // Fallback path: send standard OTP code or magic link email to submittedEmail.
  return sendTraditionalVerificationEmail(submittedEmail);
}

استخدِم دائمًا مكتبات SD-JWT وJOSE/JWT المعتمدة لمنصة الخلفية (مثل @sd-jwt/core وjose في Node.js أو sd-jwt-python / jwcrypto في Python أو sd-jwt-java / Nimbus JOSE+JWT على جهاز Java الافتراضي) لتنفيذ verifyEmailVerificationToken بدلاً من استخدام رمز مخصّص لتحليل JWT أو SD-JWT.

الخطوة 1: تحليل مطالبات SD-JWT والتحقّق من عنوان البريد الإلكتروني

بعد استهلاك الرمز expectedNonce للاستخدام الواحد من مساحة تخزين الجلسة من جهة الخادم، عليك فك ترميز عرض SD-JWT ‏ (EVT + KB-JWT) والتحقّق مما يلي:

  • يتوفّر كلّ من رمز JWT الخاص بجهة الإصدار (jwt) ورمز JWT لربط المفتاح (kbJwt)، بالإضافة إلى مطالبة جهة الإصدار (iss).
  • evtPayload.email_verified === true.
  • يتطابق evtPayload.email مع عنوان البريد الإلكتروني في النموذج الذي تم إرساله باستخدام مقارنة غير حساسة لحالة الأحرف (toLowerCase()).
import { createHash } from 'node:crypto';
import { decodeSdJwtSync } from '@sd-jwt/core';

const hasher = (data, alg) =>
  createHash(alg === 'sha-256' ? 'sha256' : alg).update(data).digest();

const decoded = decodeSdJwtSync(rawToken, hasher);
const evtPayload = decoded.jwt.payload;
const iss = evtPayload.iss;

if (!expectedNonce || !decoded.kbJwt || !iss || evtPayload.email_verified !== true) {
  throw new Error('Missing nonce, KB-JWT, issuer, or email_verified !== true.');
}
// MANDATORY: Compare email addresses case-insensitively
if (evtPayload.email?.toLowerCase() !== submittedEmail.toLowerCase()) {
  throw new Error('Submitted email does not match the EVT email claim.');
}

الخطوة 2: التحقّق من صحة تفويض سجلّ TXT في نظام أسماء النطاقات وتحميل JWKS لجهة الإصدار

قبل إرسال أي طلب شبكة إلى iss، يجب التأكّد من أنّ نطاق البريد الإلكتروني المُرسَل يفوّض جهة الإصدار هذه تحديدًا من خلال سجلّ TXT لنظام أسماء النطاقات _email-verification.<domain> (iss=<issuer-host> -> https://<issuer-host> بدون منفذ أو مسار أو شرطة مائلة لاحقة). عندما يتطابق تفويض نظام أسماء النطاقات مع iss بايت ببايت، يتم جلب ${iss}/.well-known/email-verification عبر HTTPS، والتأكّد من أنّ metadata.issuer === iss، وتحميل مجموعة مفاتيح الويب JSON (JWKS) الخاصة بجهة الإصدار.

ملاحظة حول معالجة kid: يحذف بعض مزوّدي خدمة البريد الإلكتروني الرئيسيين (مثل Gmail) السمة kid من عنوان EVT. عندما لا يتوفّر kid ويحتوي JWKS على مفاتيح متعددة، يعرض jose الخطأ ERR_JWKS_MULTIPLE_MATCHING_KEYS (AsyncIterable من CryptoKey المطابقة). يمكنك تكرار for await (const publicKey of err) للتحقّق من كل مفتاح تجريبيًا، كما هو موضّح في الخطوة 3.

import dns from 'node:dns/promises';
import { createRemoteJWKSet } from 'jose';

const domain = submittedEmail.split('@')[1];
const txtRecords = (await dns.resolveTxt(`_email-verification.${domain}`)).map((r) => r.join(''));
if (!txtRecords.some((txt) => txt.startsWith('iss=') && `https://${txt.slice(4).trim()}` === iss)) {
  throw new Error(`Issuer "${iss}" is not delegated via DNS for "${domain}".`);
}

const discoveryUrl = new URL('/.well-known/email-verification', iss);
if (discoveryUrl.protocol !== 'https:') throw new Error('Issuer must use HTTPS.');
const metadata = await (await fetch(discoveryUrl)).json();
if (metadata.issuer !== iss || !metadata.jwks_uri?.startsWith('https://')) {
  throw new Error('Invalid issuer metadata or non-HTTPS jwks_uri.');
}
const issuerJwks = createRemoteJWKSet(new URL(metadata.jwks_uri));

الخطوة 3: التحقّق من توقيع EVT ورمز JWT لربط المفتاح (KB-JWT)

أخيرًا، تحقَّق من كلا التوقيعَين والربط المشفَّر بين الرمزَين المميّزَين باستخدام القائمة المسموح بها الصريحة للخوارزمية غير المتماثلة (['Ed25519', 'EdDSA', 'ES256']):

  1. توقيع EVT ‏ (verifier): يتم التحقّق منه من خلال issuerJwks (يتم رصد ERR_JWKS_MULTIPLE_MATCHING_KEYS لتكرار مفاتيح المرشحين عند حذف kid)، مع فرض iss ونافذة تحديث ضيقة (maxTokenAge: '5m' وclockTolerance: '1m').
  2. ربط الحامل (الخياران kbVerifier وsdJwt.verify): استخرِج المفتاح العام المؤقت للمتصفّح من evtPayload.cnf.jwk، وتأكَّد من أنّ كلّ من cnf.jwk.alg وdecoded.kbJwt.header.alg في ALLOWED_ALGS ومتطابقان (مع التعامل مع 'Ed25519' و'EdDSA' على أنّهما متكافئان أثناء انتقال عمليات تنفيذ "مرحلة التجربة والتقييم" إلى RFC 9864 'Ed25519')، وتحقَّق من توقيع KB-JWT باستخدام typ: 'kb+jwt'. يجب تمرير keyBindingNonce: expectedNonce وexpectedKeyBindingAudience: expectedAudience وkeyBindingMaxAgeSeconds: 300 إلى sdJwt.verify()، لأنّ @sd-jwt/core يستدعي kbVerifier فقط ويتحقّق من صحة nonce وaud وiat وsd_hash عند توفير keyBindingNonce.
import { SDJwtInstance } from '@sd-jwt/core';
import { importJWK, jwtVerify } from 'jose';

// IETF draft-hardt-email-verification-02 (§3.3, §5.1.1) specifies RFC 9864 'Ed25519' and 'ES256'.
// TODO: Remove 'EdDSA' once live issuers (e.g. Gmail) and Chrome Origin Trial builds finish
// transitioning from 'EdDSA' (with crv: 'Ed25519') to 'Ed25519'.
const ALLOWED_ALGS = ['Ed25519', 'EdDSA', 'ES256'];
const normalizeAlg = (alg) => (alg === 'Ed25519' ? 'EdDSA' : alg);
const evtOptions = {
  typ: 'ev+sd-jwt',
  issuer: iss,
  algorithms: ALLOWED_ALGS,
  maxTokenAge: '5m',
  clockTolerance: '1m',
};

const sdJwt = new SDJwtInstance({
  hasher,
  verifier: async (data, sig) => {
    const compactEvt = `${data}.${sig}`;
    try {
      await jwtVerify(compactEvt, issuerJwks, evtOptions);
    } catch (err) {
      // When `kid` is omitted (e.g. Gmail) and multiple keys match in the JWKS,
      // jose throws JWKSMultipleMatchingKeys, which yields each matching CryptoKey.
      if (err?.code !== 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') throw err;
      let matched = false;
      for await (const publicKey of err) {
        try {
          await jwtVerify(compactEvt, publicKey, evtOptions);
          matched = true;
          break;
        } catch {}
      }
      if (!matched) throw err;
    }
    return true;
  },
  kbVerifier: async (data, sig) => {
    const holderJwk = evtPayload.cnf?.jwk;
    const holderAlg = holderJwk?.alg;
    const kbAlg = decoded.kbJwt.header.alg;
    if (
      !holderAlg ||
      !kbAlg ||
      !ALLOWED_ALGS.includes(holderAlg) ||
      !ALLOWED_ALGS.includes(kbAlg) ||
      normalizeAlg(kbAlg) !== normalizeAlg(holderAlg)
    ) {
      throw new Error('Missing, disallowed, or mismatched KB-JWT algorithm.');
    }
    const holderKey = await importJWK(holderJwk, kbAlg);
    await jwtVerify(`${data}.${sig}`, holderKey, {
      typ: 'kb+jwt',
      audience: expectedAudience,
      algorithms: [kbAlg],
      maxTokenAge: '5m',
      clockTolerance: '1m',
    });
    return true;
  },
});

const verified = await sdJwt.verify(rawToken, {
  requiredClaimKeys: ['email', 'email_verified'],
  keyBindingNonce: expectedNonce,
  expectedKeyBindingAudience: expectedAudience,
  keyBindingMaxAgeSeconds: 300,
  skewSeconds: 60,
});

أفضل الممارسات

  • يجب إجراء جميع عمليات التحقّق من الرموز المميّزة على الخادم فقط. لا تتحقّق من الرموز المميّزة للتحقق في JavaScript من جهة العميل أو تثق في أي حالة تحقّق من جهة العميل، إذ يمكن بسهولة تجاوز عمليات التحقّق من جهة العميل أو تزويرها، ولا توفّر أي ضمان ثقة للجهة المعتمِدة.
  • يجب استخدام مكتبات SD-JWT وJOSE/JWT المعتمدة لمنصة الخلفية (على سبيل المثال، @sd-jwt/core وjose في Node.js) لتحليل عرض EVT~KB-JWT والتحقّق من صحته، وتمرير keyBindingNonce إلى sdJwt.verify() حتى لا يمكن تجاوز عملية التحقّق من ربط المفتاح. لا تستخدِم إجراءات مخصّصة لتحليل وتوقيع JWT أو SD-JWT أو sd_hash.
  • يجب إنشاء nonce تشفير جديد (لا يقل عن 128 بت، مثل randomBytes(32).toString('base64url')) على الخادم لكل عرض نموذج، وتخزينه في حالة جلسة من جهة الخادم (وليس مباشرةً في ملف تعريف ارتباط العميل)، وعرض صفحة النموذج باستخدام Cache-Control: no-store.
  • يجب قراءة nonce المتوقَّع وحذفه بشكل ذري من مساحة تخزين الجلسة من جهة الخادم فور وصول طلب POST حتى لا تتم إعادة تشغيل الرمز المميّز الذي تم اعتراضه.
  • يجب إجراء مقارنة غير حساسة لحالة الأحرف بين evtPayload.email وعنوان البريد الإلكتروني في النموذج الذي تم إرساله (email.toLowerCase())، مع الحفاظ على سلسلة البريد الإلكتروني المُرسَلة.
  • يجب التحقّق من جميع المفاتيح في JWKS لجهة الإصدار عندما لا يتوفّر kid في عنوان EVT (تحذف بعض مقدّمي الخدمات، مثل Gmail، kid).
  • يجب التحقّق من تفويض نظام أسماء النطاقات (_email-verification.<domain>) قبل الوثوق بأي مطالبة iss في EVT، وتأكيد metadata.issuer === iss في ردّ .well-known/email-verification.
  • لا تتعامل مع عملية التحقّق من ملكية البريد الإلكتروني كدليل على إمكانية تسليم الرسائل إلى البريد الوارد. يُثبت EVP أنّ المستخدم يتحكّم في حساب البريد الإلكتروني لدى مقدّم الخدمة الموثوق به، ويستمر في إرسال رسائل الترحيب الإلكترونية المتعلّقة بالمعاملات والتعامل مع الرسائل المرتدة بشكل طبيعي.
  • لا تحظر إنشاء الحساب أو تسجيل الدخول عندما يكون token فارغًا أو عندما يتعذّر إثبات الهوية، بل انتقِل دائمًا بسلاسة إلى كلمة المرور الصالحة لمرة واحدة (OTP) العادية التي يتم إرسالها عبر البريد الإلكتروني أو إلى مسار الرابط السحري.

استراتيجية احتياطية

تم تصميم ميزة "تأكيد عنوان البريد الإلكتروني" (autocomplete="email-verification-token") كـ تحسين تدريجي مقارنةً بالطرق التقليدية لتأكيد عنوان البريد الإلكتروني خارج النطاق (رموز كلمات المرور الصالحة لمرة واحدة أو الروابط السحرية):

  • المتصفّحات غير المتوافقة: تتجاهل المتصفّحات التي لا تتوافق مع autocomplete="email-verification-token" الإدخال المخفي وترسل النموذج بقيمة token فارغة.
  • نطاقات البريد الإلكتروني غير المصادَق عليها أو غير المشارِكة: عندما يُدخل المستخدم عنوان بريد إلكتروني لا ينشر نطاقه سجلّ TXT لنظام أسماء النطاقات (DNS) لـ _email-verification، أو عندما لا يكون لدى المستخدم جلسة نشطة مع مزوِّد خدمة البريد الإلكتروني في المتصفّح، يرسل المتصفّح النموذج مع قيمة token فارغة.
  • الرموز المميّزة منتهية الصلاحية أو غير الصالحة: إذا انتهت صلاحية nonce أو تم استخدامه من قبل أو تعذّر تنفيذ أي خطوة من خطوات verifyEmailVerificationToken في handleSignupSubmission، يرصد الخادم الخطأ ويرسل رسالة إلكترونية تقليدية تتضمّن كلمة مرور صالحة لمرة واحدة أو رابطًا سحريًا بدون مقاطعة عملية تسجيل المستخدم أو تسجيل دخوله.