درستی‌سنجی مالکیت نشانی ایمیل

هنگام جمع‌آوری نشانی ایمیل درطول ثبت‌نام، ورود به سیستم، تسویه‌حساب، یا بازیابی حساب، تأیید مالکیت به‌طور سنتی مستلزم ارسال گذرنویسه یک‌بارمصرف (OTP) یا پیوند جادویی خارج از باند است. این کار کاربران را مجبور می‌کند صفحه‌تان را ترک کنند، منتظر تحویل ایمیل بمانند، و کدها را کپی کنند—که باعث ایجاد اصطکاک، ریزش، و خطر رمزگیری می‌شود و همچنین به ارائه‌دهنده ایمیل نشان می‌دهد که کاربر از کدام طرف اعتمادکننده (RP) بازدید می‌کند.

Email Verification API (افزونه HTML سمت کارخواه) و Email Verification Protocol (EVP) (پروتکل رمزنگاری باک‌اند) به طرف متکی امکان می‌دهند بدون ارسال ایمیل درستی‌سنجی، درستی‌سنجی کند که کاربر نشانی ایمیلی را کنترل می‌کند. مرورگر بین طرف معتمد و ارائه‌دهنده ایمیل کاربر (صادرکننده) میانجی‌گری می‌کند:

  1. طرف متکی شامل یک <input> پنهان با autocomplete="email-verification-token" و یک nonce رمزنگاری تک‌مصرف در همان <form> ورودی ایمیل است.
  2. وقتی کاربر نشانی ایمیلی را انتخاب یا وارد می‌کند، مرورگر سابقه _email-verification.<domain> DNS TXT دامنه را جستجو می‌کند، تأیید می‌کند که کاربر جلسه فعالی با صادرکننده معتبر دارد، و بدون افشای هویت طرف متکی برای صادرکننده، توکن درستی‌سنجی ایمیل (EVT) درخواست می‌کند.
  3. مرورگر EVT را به مبدأ و جلسه طرف متکی nonce در یک JWT مقید به کلید (KB-JWT) که با جفت کلید موقت مرورگر (EVT + KB-JWT) امضا شده است مقید می‌کند، ورودی پنهان را تکمیل می‌کند، و فرم را ارسال می‌کند.
  4. بخش پشتی طرف متکی زنجیره رمزنگاری را تأیید می‌کند و اگر معتبر باشد، از مرحله گذرواژه یک‌بارمصرف ایمیلی یا پیوند جادویی رد می‌شود.

۱. فعال کردن «آزمایش معرفی ویژگی جدید» و ارائه «فرم درستی‌سنجی»

برای مشارکت به‌عنوان سایت درستی‌سنجی (طرف متکی):

  1. ژتون «آزمایش معرفی ویژگی جدید» را در صفحه میزبان فرم بااستفاده از سرایند پاسخ HTTP Origin-Trial یا عنصر <meta http-equiv="origin-trial"> ارائه دهید.
    • آزمایش‌های معرفی ویژگی جدید شخص ثالث: کیت‌های توسعه نرم‌افزار هویت جاسازی‌شده می‌توانند یک کد آزمایش شخص ثالث را تزریق کنند، مشروط بر اینکه مبدأ ثبت‌کننده «آزمایش معرفی ویژگی جدید» با دامنه صادرکننده مطابقت داشته باشد (هم‌سایت با صادرکننده، مانند https://issuer.example).
  2. یک عدد یک‌بارمصرف تصادفی رمزنگاری‌شده برای هر رندر فرم با حداقل ۱۲۸ بیت آنتروپی (برای مثال، randomBytes(32).toString('base64url') از node:crypto) تولید کنید، آن را در وضعیت جلسه سمت سرور با کلید کوکی شناسه جلسه HttpOnly، Secure، SameSite=Lax ذخیره کنید (هرگز مقدار nonce موردانتظار را مستقیماً در کوکی مشتری ذخیره نکنید، زیرا مهاجم می‌تواند هم کوکی و هم توکن را بازپخش کند)، و آن را در مشخصه nonce ورودی توکن پنهان رندر کنید. صفحه را با Cache-Control: no-store ارائه دهید تا ناوبری بین صفحات از یک عدد یک‌بارمصرف استفاده‌شده مجدداً استفاده نکند.
  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>

۲. درستی‌سنجی کردن «نشان» ارسال‌شده در سرور

وقتی فرم ارسال شد، فیلد token را به‌عنوان ورودی غیرقابل‌اعتماد درنظر بگیرید. nonce موردانتظار را به‌صورت اتمی از فروشگاه جلسه سمت سرور خود در رسید بخوانید و حذف کنید. اگر فیلد token خالی باشد (برای مثال، در مرورگرهایی که از EVP پشتیبانی نمی‌کنند یا وقتی کاربر از سیستم ارائه‌دهنده ایمیل خود خارج شده باشد) یا اگر درستی‌سنجی ناموفق باشد، فوراً به جریان پیوند جادویی یا گذرواژه یک‌بارمصرف ایمیل استاندارد خود برگردید:

// 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 در JVM) برای پیاده‌سازی verifyEmailVerificationToken استفاده کنید، نه اینکه کد تجزیه JWT یا SD-JWT سفارشی را به‌صورت دستی بنویسید.

مرحله ۱: تجزیه ادعاهای 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.');
}

مرحله ۲: اعتبارسنجی واگذاری ساناد TXT و بار کردن JWKS صادرکننده

قبل‌از ارسال هرگونه درخواست شبکه به iss، تأیید کنید که دامنه ایمیل ارسال‌شده ازطریق سابقه _email-verification.<domain> ساناد TXT (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) را تکرار کنید تا هر کلید را به‌صورت آزمایشی درستی‌سنجی کنید، همان‌طور که در مرحله ۳ نشان داده شده است.

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));

مرحله ۳: درستی‌سنجی امضای EVT و Key Binding 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,
});

روال‌های مطلوب

  • حتماً همه درستی‌سنجی‌های کد را به‌طور دقیق در سرور انجام دهید. نباید توکن‌ها را در جاوا اسکریپت سمت کارخواه اعتبارسنجی کنید یا به هیچ وضعیت درستی‌سنجی سمت کارخواه اعتماد کنید—بررسی‌های سمت کارخواه را می‌توان به‌راحتی دور زد یا جعل کرد و هیچ تضمین اعتمادی به طرف متکی ارائه نمی‌دهد.
  • حتماً از کتابخانه‌های SD-JWT و JOSE/JWT تثبیت‌شده برای پلاتفرم پسین خود (برای مثال، @sd-jwt/core و jose در Node.js) استفاده کنید تا ارائه EVT~KB-JWT را تجزیه و درستی‌سنجی کنید و keyBindingNonce را به sdJwt.verify() ارسال کنید تا درستی‌سنجی «اتصال کلید» دور زده نشود. به‌هیچ‌وجه روال‌های تجزیه و درستی‌سنجی امضای JWT،‏ SD-JWT، یا sd_hash سفارشی را به‌صورت دستی ایجاد نکنید.
  • باید برای هر رندر فرم، یک nonce رمزنگاری جدید (حداقل ۱۲۸ بیت، مانند randomBytes(32).toString('base64url')) در سرور تولید کنید، آن را در وضعیت جلسه سمت سرور (نه مستقیماً در کوکی کارخواه) ذخیره کنید، و صفحه فرم را با Cache-Control: no-store ارائه دهید.
  • باید به‌محض رسیدن درخواست POST، nonce موردانتظار را به‌صورت اتمی از فضای ذخیره‌سازی جلسه سمت سرور بخوانید و حذف کنید تا نتوان از رمز رهگیری‌شده مجدداً استفاده کرد.
  • باید evtPayload.email و نشانی ایمیل فرم ارسال‌شده (email.toLowerCase()) را بدون درنظر گرفتن حروف بزرگ و کوچک مقایسه کنید و درعین‌حال رشته ایمیل ارسال‌شده را حفظ کنید.
  • وقتی kid در سرصفحه EVT وجود ندارد، همه کلیدها را در JWKS صادرکننده به‌صورت آزمایشی درستی‌سنجی کنید (ارائه‌دهندگانی مثل Gmail،‏ kid را حذف می‌کنند).
  • حتماً پیش‌از اعتماد به هرگونه ادعای iss در EVT، واگذاری DNS (_email-verification.<domain>) را درستی‌سنجی کنید و metadata.issuer === iss را در پاسخ .well-known/email-verification تأیید کنید.
  • درستی‌سنجی مالکیت ایمیل را به‌عنوان مدرک تحویل‌پذیری صندوق ورودی درنظر نگیرید. EVP ثابت می‌کند که کاربر حساب ایمیل را با رساننده معتبر کنترل می‌کند؛ همچنان ایمیل‌های خوشامدگویی تراکنشی ارسال کنید و برگشتی‌ها را به‌طور معمول مدیریت کنید.
  • هنگامی که token خالی است یا درستی‌سنجی ناموفق است، ایجاد حساب یا ورود به سیستم را مسدود نکنید—همیشه به‌آرامی به جریان گذرواژه یک‌بارمصرف ایمیل استاندارد یا پیوند جادویی تنزل دهید.

راهبرد جایگزین

«درستی‌سنجی ایمیل» (autocomplete="email-verification-token") به‌عنوان ارتقای پیش‌رو نسبت به درستی‌سنجی ایمیل خارج از باند سنتی (کدهای گذرواژه یک‌بارمصرف یا پیوندهای جادویی) طراحی شده است:

  • مرورگرهای پشتیبانی‌نشده: مرورگرهایی که از autocomplete="email-verification-token" پشتیبانی نمی‌کنند، ورودی پنهان را نادیده می‌گیرند و فرم را با مقدار token خالی ارسال می‌کنند.
  • دامنه‌های ایمیل تأییدنشده یا غیرمشارکت‌کننده: وقتی کاربر نشانی ایمیلی را وارد می‌کند که دامنه آن سابقه _email-verification ساناد TXT را منتشر نمی‌کند، یا وقتی کاربر جلسه فعالی با ارائه‌دهنده ایمیل خود در مرورگر ندارد، مرورگر فرم را با token خالی ارسال می‌کند.
  • نشان‌های منقضی‌شده یا نامعتبر: اگر nonce منقضی شده باشد، قبلاً مصرف شده باشد، یا در هر مرحله‌ای از verifyEmailVerificationToken در handleSignupSubmission ناموفق باشد، سرور خطا را شناسایی می‌کند و یک گذرواژه یک‌بارمصرف سنتی یا ایمیل پیوند جادویی ارسال می‌کند، بدون اینکه در جریان ثبت‌نام یا ورود به سیستم کاربر اختلالی ایجاد شود.