Подтвердите право собственности на адрес электронной почты

При сборе адресов электронной почты во время регистрации, входа, оформления заказа или восстановления доступа к аккаунту для подтверждения права собственности обычно требуется отправить одноразовый код доступа или ссылку для входа. Пользователям приходится покидать страницу, ждать письма и копировать код. Это неудобно, повышает риск фишинга и приводит к тому, что пользователи уходят с сайта. Кроме того, поставщик услуг электронной почты узнает, какой сайт посещает пользователь.

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. Как включить эксперимент с источником и показать форму подтверждения

Чтобы участвовать в качестве проверяющего сайта (доверяющей стороны):

  1. Укажите токен эксперимента с источником на странице, где размещена форма, используя заголовок HTTP-ответа Origin-Trial или элемент <meta http-equiv="origin-trial">.
    • Эксперименты со сторонними источниками. Встроенные SDK идентификации могут внедрять сторонний токен эксперимента, если источник зарегистрированного пользователя эксперимента с источником совпадает с доменом издателя (находится на том же сайте, что и издатель, например https://issuer.example).
  2. Генерируйте криптографически случайный одноразовый код для каждого показа формы с энтропией не менее 128 бит (например, randomBytes(32).toString('base64url') из node:crypto), сохраняйте его в состоянии сеанса на стороне сервера с ключом в виде файла cookie идентификатора сеанса HttpOnly, Secure, SameSite=Lax (никогда не сохраняйте ожидаемое значение nonce непосредственно в файле cookie клиента, где злоумышленник может воспроизвести и файл cookie, и токен) и отображайте его в атрибуте 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>

2. Проверка отправленного токена на сервере

При отправке формы считайте поле 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);
}

Чтобы реализовать verifyEmailVerificationToken, всегда используйте для своей серверной платформы проверенные библиотеки SD-JWT и JOSE/JWT (например, @sd-jwt/core и jose в Node.js, sd-jwt-python/jwcrypto в Python или sd-jwt-java/Nimbus JOSE+JWT на JVM), а не пишите собственный код для синтаксического анализа JWT или SD-JWT.

Шаг 1. Проанализируйте утверждения SD-JWT и подтвердите адрес электронной почты

После использования одноразового значения expectedNonce из хранилища сеансов на стороне сервера декодируйте представление SD-JWT (EVT + KB-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-записи DNS и загрузите JWKS издателя

Прежде чем отправлять сетевой запрос на адрес iss, убедитесь, что домен указанного адреса электронной почты делегирует полномочия именно этому издателю через запись _email-verification.<domain> DNS TXT (iss=<issuer-host> -> https://<issuer-host> без порта, пути или косой черты в конце). Когда делегирование DNS будет побайтово совпадать с 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')) для каждого рендеринга формы, сохранять его в состоянии сеанса на стороне сервера (не непосредственно в файле cookie клиента) и обслуживать страницу формы с помощью Cache-Control: no-store.
  • РЕКОМЕНДУЕТСЯ атомарно считывать и удалять ожидаемый параметр nonce из хранилища сеансов на стороне сервера сразу после поступления запроса POST, чтобы перехваченный токен нельзя было использовать повторно.
  • РЕКОМЕНДУЕТСЯ выполнять сравнение без учета регистра между evtPayload.email и адресом электронной почты, указанным в форме (email.toLowerCase()), сохраняя при этом строку с адресом электронной почты.
  • РЕКОМЕНДУЕТСЯ проверять все ключи в JWKS издателя, если в заголовке EVT отсутствует kid (некоторые поставщики, например Gmail, не указывают kid).
  • РЕКОМЕНДУЕТСЯ проверять делегирование DNS (_email-verification.<domain>), прежде чем доверять утверждению iss в EVT, и подтверждать metadata.issuer === iss в ответе .well-known/email-verification.
  • НЕ считайте подтверждение права собственности на адрес электронной почты доказательством того, что письма доставляются в папку "Входящие". EVP подтверждает, что пользователь управляет аккаунтом электронной почты у авторитетного поставщика. При этом по-прежнему отправляются транзакционные приветственные письма и обрабатываются возвраты.
  • НЕ блокируйте создание аккаунта или вход в него, если token пуст или проверка не удалась. Всегда используйте стандартный одноразовый код, отправленный по электронной почте, или ссылку для входа.

Резервная стратегия

Подтверждение адреса электронной почты (autocomplete="email-verification-token") – это прогрессивное улучшение традиционного подтверждения адреса электронной почты вне диапазона (одноразовые коды или ссылки для входа).

  • Неподдерживаемые браузеры. Браузеры, которые не поддерживают autocomplete="email-verification-token", игнорируют скрытые входные данные и отправляют форму с пустым значением token.
  • Неаутентифицированные или не участвующие домены электронной почты. Если пользователь вводит адрес электронной почты, домен которого не публикует запись _email-verification DNS TXT, или если у пользователя нет активного сеанса с поставщиком услуг электронной почты в браузере, браузер отправляет форму с пустым полем token.
  • Просроченные или недействительные токены. Если срок действия nonce истек, он уже был использован или не прошел какой-либо этап verifyEmailVerificationToken в handleSignupSubmission, сервер обнаруживает ошибку и отправляет традиционный одноразовый код или ссылку для входа по электронной почте, не прерывая процесс регистрации или входа пользователя.