هنگام جمعآوری نشانی ایمیل درطول ثبتنام، ورود به سیستم، تسویهحساب، یا بازیابی حساب، تأیید مالکیت بهطور سنتی مستلزم ارسال گذرنویسه یکبارمصرف (OTP) یا پیوند جادویی خارج از باند است. این کار کاربران را مجبور میکند صفحهتان را ترک کنند، منتظر تحویل ایمیل بمانند، و کدها را کپی کنند—که باعث ایجاد اصطکاک، ریزش، و خطر رمزگیری میشود و همچنین به ارائهدهنده ایمیل نشان میدهد که کاربر از کدام طرف اعتمادکننده (RP) بازدید میکند.
Email Verification API (افزونه HTML سمت کارخواه) و Email Verification Protocol (EVP) (پروتکل رمزنگاری باکاند) به طرف متکی امکان میدهند بدون ارسال ایمیل درستیسنجی، درستیسنجی کند که کاربر نشانی ایمیلی را کنترل میکند. مرورگر بین طرف معتمد و ارائهدهنده ایمیل کاربر (صادرکننده) میانجیگری میکند:
- طرف متکی شامل یک
<input>پنهان باautocomplete="email-verification-token"و یکnonceرمزنگاری تکمصرف در همان<form>ورودی ایمیل است. - وقتی کاربر نشانی ایمیلی را انتخاب یا وارد میکند، مرورگر سابقه
_email-verification.<domain>DNSTXTدامنه را جستجو میکند، تأیید میکند که کاربر جلسه فعالی با صادرکننده معتبر دارد، و بدون افشای هویت طرف متکی برای صادرکننده، توکن درستیسنجی ایمیل (EVT) درخواست میکند. - مرورگر EVT را به مبدأ و جلسه طرف متکی
nonceدر یک JWT مقید به کلید (KB-JWT) که با جفت کلید موقت مرورگر (EVT + KB-JWT) امضا شده است مقید میکند، ورودی پنهان را تکمیل میکند، و فرم را ارسال میکند. - بخش پشتی طرف متکی زنجیره رمزنگاری را تأیید میکند و اگر معتبر باشد، از مرحله گذرواژه یکبارمصرف ایمیلی یا پیوند جادویی رد میشود.
۱. فعال کردن «آزمایش معرفی ویژگی جدید» و ارائه «فرم درستیسنجی»
برای مشارکت بهعنوان سایت درستیسنجی (طرف متکی):
- ژتون «آزمایش معرفی ویژگی جدید» را در صفحه میزبان فرم بااستفاده از سرایند پاسخ HTTP
Origin-Trialیا عنصر<meta http-equiv="origin-trial">ارائه دهید.- آزمایشهای معرفی ویژگی جدید شخص ثالث: کیتهای توسعه نرمافزار هویت جاسازیشده میتوانند یک کد آزمایش شخص ثالث را تزریق کنند، مشروط بر اینکه مبدأ ثبتکننده «آزمایش معرفی ویژگی جدید» با دامنه صادرکننده مطابقت داشته باشد (همسایت با صادرکننده، مانند
https://issuer.example).
- آزمایشهای معرفی ویژگی جدید شخص ثالث: کیتهای توسعه نرمافزار هویت جاسازیشده میتوانند یک کد آزمایش شخص ثالث را تزریق کنند، مشروط بر اینکه مبدأ ثبتکننده «آزمایش معرفی ویژگی جدید» با دامنه صادرکننده مطابقت داشته باشد (همسایت با صادرکننده، مانند
- یک عدد یکبارمصرف تصادفی رمزنگاریشده برای هر رندر فرم با حداقل ۱۲۸ بیت آنتروپی (برای مثال،
randomBytes(32).toString('base64url')ازnode:crypto) تولید کنید، آن را در وضعیت جلسه سمت سرور با کلید کوکی شناسه جلسهHttpOnly،Secure،SameSite=Laxذخیره کنید (هرگز مقدارnonceموردانتظار را مستقیماً در کوکی مشتری ذخیره نکنید، زیرا مهاجم میتواند هم کوکی و هم توکن را بازپخش کند)، و آن را در مشخصهnonceورودی توکن پنهان رندر کنید. صفحه را باCache-Control: no-storeارائه دهید تا ناوبری بین صفحات از یک عدد یکبارمصرف استفادهشده مجدداً استفاده نکند. - ورودی کد پنهان (
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 حاوی چند کلید است،joseERR_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']) تأیید کنید:
- امضای EVT (
verifier): درستیسنجی دربرابرissuerJwks(گرفتنERR_JWKS_MULTIPLE_MATCHING_KEYSبرای تکرار کلیدهای نامزد وقتیkidحذف شده است)، اعمالissو پنجره تازگی محدود (maxTokenAge: '5m'،clockTolerance: '1m'). - اتصال دارنده (گزینههای
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ناموفق باشد، سرور خطا را شناسایی میکند و یک گذرواژه یکبارمصرف سنتی یا ایمیل پیوند جادویی ارسال میکند، بدون اینکه در جریان ثبتنام یا ورود به سیستم کاربر اختلالی ایجاد شود.