証明書利用者の実装

サイトでメールアドレスの確認を実装するには、トークンをリクエストするようにフォーム マークアップを更新し、受信トークンのサーバーサイド検証を追加します。

オリジン トライアルに登録する

サイトの所有権を確認するには、サイトでオリジン トライアルが構成されている必要があります。

Chrome 154 以降では、サードパーティのオリジン トライアルがサポートされていますが、重要な注意点があります。トライアルに登録されたオリジンは、発行元と同じサイトである必要があります。次に例を示します。

  • カード発行会社のドメイン: issuer.example
  • OT 登録者: https://issuer.example
  • JavaScript 生成元: https://issuer.example(または https://app.issuer.example(サブドメイン一致))

フォームのフィールドを構成する

メール送信フォームに非表示のトークン フィールドを追加します。

<input
  type="email"
  name="email-address"
  autocomplete="email">
<input
  type="hidden"
  name="token"
  autocomplete="email-verification-token"
  nonce="rAnD0m-VaLuE">

フィールドの要件:

  • メールアドレス フィールド: type="email" と autocomplete="email" を設定して、Chrome がアドレスを自動入力して認識できるようにします。
  • トークン フィールドの属性:
    • autocomplete="email-verification-token" を設定します。Chrome はこのフィールドを識別して、送信時にトークンを入力します。
    • nonce="<VALUE>" を設定する: サイトは、フォームの送信を検証するために、セッションにバインドされた一意の nonce を提供する必要があります。

メール確認トークン(EVT)を検証する

ユーザーがフォームを送信すると、サーバーはメールアドレスとトークンを非表示フィールドから受け取ります。トークン フィールドが空の場合、ブラウザまたはプロバイダが EVP をサポートしていないか、ユーザーが確認をスキップしたことを示します。この場合は、OTP やマジックリンクの送信など、既存の確認プロセスにフォールバックします。

トークンが存在する場合は、次のように検証します。

  1. SD-JWT ライブラリを使用してトークンを解析します。
  2. 期待値とセッション クレームを検証します。
  3. DNS 委任を確認します。
  4. 発行者のメタデータを検出して JWKS を取得します。
  5. 暗号署名と鍵バインディングを確認します。

1. トークンを解析する

トークンは RFC 9901: Selective Disclosure JWT (SD-JWT+KB) 形式を使用します。プラットフォームに適したライブラリを使用して、トークンを解析して検証します。たとえば、Node の場合は @sd-jwt/core と jose を使用できます。未加工の形式では、発行元が署名した JWT の後に 0 個以上の開示が続き、最後にキーバインディング JWT が続きます。各コンポーネントはチルダで区切られます。

<Issuer-signed EVT>~<Disclosure 1>~...~<Disclosure N>~<Key Binding JWT>

現在の実装では、トークンに開示(<Issuer-signed EVT>~<Key Binding JWT>)は含まれていませんが、今後変更される可能性があります。

ライブラリを使用してトークンをデコードします。

import { decodeSdJwtSync } from "@sd-jwt/core";
import { createHash } from "node:crypto";
const hasher = (data, alg) =>
  createHash(alg === "sha-256" ? "sha256" : alg)
    .update(data)
    .digest();
const decoded = decodeSdJwtSync(rawToken, hasher);
const evtPayload = decoded.jwt.payload;
const kbPayload = decoded.kbJwt?.payload;

verifier.example が demo@provider.example を検証すると、デコードされたトークンは次のようになります。

{
  "evtJwtDecodedHeader": {
    "typ": "evt+jwt",
    "alg": "EdDSA",
    "kid": "issuer-key-id"
  },
  "evtJwtDecodedPayload": {
    "iss": "https://provider.example",
    "iat": 12345678901,
    "exp": 12345679901,
    "cnf": {
      "jwk": {
        "kty": "OKP",
        "crv": "Ed25519",
        "x": "pUbLiCkEy123pUbLiCkEy123pUbLiCkEy123"
      }
    },
    "email": "demo@provider.example",
    "email_verified": true
  },
  "kbJwtDecodedHeader": {
    "alg": "EdDSA",
    "typ": "kb+jwt"
  },
  "kbJwtDecodedPayload": {
    "aud": "https://verifier.example",
    "iat": 12345678901,
    "nonce": "rAnDoM123rAnDoM123rAnDoM123rAnDoM123",
    "sd_hash": "hAsH456hAsH456hAsH456hAsH456hAsH456"
  },
  "disclosures": []
}

2. 想定される値とセッション クレームを検証する

ペイロードの基本値が、指定した値および想定される値と一致していることを確認します。

  • email_verified: true でなければなりません。
  • email: フォームで送信されたメールアドレスと一致する必要があります。
  • aud(オーディエンス): サイトのオリジンと一致している必要があります。
  • nonce: フォームで指定したノンスと一致している必要があります。
  • iat(発行日時)と exp(有効期限): トークンが有効期間内であり、期限切れになっていないことを確認します。

3. DNS 委任を確認する

メールアドレスのドメインの _email-verification DNS レコードを確認します。たとえば、demo@gmail.com の場合は _email-verification.gmail.com TXT レコードをクエリします。このプロバイダの場合、クエリはアカウント プロバイダの場所(accounts.google.com)を返します。

$ dig +short TXT _email-verification.gmail.com
"iss=accounts.google.com"

発行者スキームが https:// であり、https://<domain> が EVT の iss クレームと一致することを確認します。

4. EVT 署名を検証する

https://<issuer>/.well-known/email-verification から発行者の検出メタデータを取得します。

{
  "issuance_endpoint": "https://accounts.google.com/gsi/email-verification/issue",
  "jwks_uri": "https://verifiablecredentials-pa.googleapis.com/.well-known/vc-public-jwks",
  "signing_alg_values_supported": ["EdDSA"]
}

jwks_uri から JSON Web Key Set を取得します。

SD-JWT ライブラリを使用して、トークン パッケージを検証します。ライブラリは検証を調整します。

  1. フェッチされた JWKS に対して EVT の発行者の署名を検証します。
  2. cnf.jwk のエフェメラル公開鍵を使用して、KB-JWT のブラウザの署名を検証します。
  3. キー バインディングの検証(aud、nonce、ダイジェスト ハッシュ sd_hash)。

Node.js の検証ロジックの例:

import { SDJwtInstance } from "@sd-jwt/core";
import { importJWK, compactVerify } from "jose";
import { createHash } from "node:crypto";
const hasher = (data, alg) =>
  createHash(alg === "sha-256" ? "sha256" : alg)
    .update(data)
    .digest();
const sdJwt = new SDJwtInstance({ hasher });
sdJwt.config({
  hasher,
  // Verifier for the Issuer-signed EVT
  verifier: async (data, sig) => {
    const token = `${data}.${sig}`;
    const header = decoded.jwt.header;
    const headerAlg = header.alg || "ES256";
    // Match by kid if present, or iterate across matching algorithm keys
    const keysToTry = header.kid
      ? jwksData.keys.filter(k => k.kid === header.kid)
      : jwksData.keys;
    for (const jwk of keysToTry) {
      try {
        const pubKey = await importJWK(jwk, jwk.alg || headerAlg);
        await compactVerify(token, pubKey);
        return true;
      } catch {
        // Try next candidate key
      }
    }
    return false;
  },
  // Verifier for the Key Binding JWT (KB-JWT)
  kbVerifier: async (data, sig) => {
    try {
      const browserJwkKey = evtPayload.cnf?.jwk;
      if (!browserJwkKey) return false;
      const pubKey = await importJWK(browserJwkKey, decoded.kbJwt.header.alg || "ES256");
      await compactVerify(`${data}.${sig}`, pubKey);
      return true;
    } catch {
      return false;
    }
  },
});
// The library automatically verifies EVT signature, KB-JWT signature, audience, nonce, and sd_hash
const result = await sdJwt.verify(rawToken, {
  kb: {
    expectedNonce: sessionNonce,
    expectedAudience: "https://example.com",
    required: true,
  },
});
const verifiedPayload = result.payload;

すべての手順が成功すると、プロバイダに対してメールアドレスが確認されます。確認に失敗した場合は、通常のフローを使用してユーザーに確認メールを送信します。

トラブルシューティング

検証が失敗した場合や、ブラウザがトークンを提供しない場合は、次の一般的な問題を確認してください。

送信時にトークン フィールドが空になる

  • オリジン トライアルの登録: ページで Origin-Trial ヘッダーまたは <meta> タグが配信されていることを確認します。サードパーティのオリジン トライアル(Chrome 154 以降)の場合、登録されたトライアル オリジンは発行者(https://<issuer-domain>)と同じサイトである必要があります。サイトのオリジン トライアル構成は、DevTools の [Application] > [Frames] >(関連するフレームを選択)> [Origin trials] で確認できます。
  • フォーム マークアップ: <input type="email" autocomplete="email"> と <input type="hidden" autocomplete="email-verification-token" nonce="..."> の両方が同じ <form> 要素内にある(Shadow DOM の境界を越えて分離されていない)必要があり、nonce は空であってはなりません。
  • 早期送信または再利用されたページ: メールアドレスが入力または自動入力された後、ブラウザはバックグラウンドでトークンを取得します。リクエストが完了する前に送信すると、トークンは空になります。メールアドレスを入力した後に、ユーザーが Return キーを押してフォームを送信した場合に、この問題が発生することがあります。
  • ブラウザとプロバイダの前提条件: ユーザーは、同じブラウザ プロファイルで参加プロバイダにログインし、Chrome の設定(chrome://settings/contactInfo)で確認済みのメールアドレスを有効にしている必要があります。

発行者の署名の検証に失敗する

  • kid ヘッダーがない: EVT ヘッダーと JWKS の kid(鍵 ID)クレームは省略可能です(たとえば、Gmail では kid が省略されています)。kid がない場合は、鍵 ID のルックアップで失敗するのではなく、発行者の jwks_uri の候補鍵をすべて反復処理します。
  • アルゴリズム識別子(EdDSA と Ed25519): 発行者とライブラリは、EdDSA または Ed25519(ES256 とともに)を指定できます。JWK のインポートと検証のロジックが両方の識別子を受け入れるようにしてください。
  • 発行者(iss)のオリジン形式: DNS TXT レコード(_email-verification.<domain>)にはベアホスト名(iss=accounts.issuer.example)が含まれていますが、EVT iss クレームは完全な HTTPS オリジン(https://accounts.issuer.example、末尾のスラッシュなし)です。比較する前に、DNS レコード値に https:// を接頭辞として追加します。

キーバインディング(KB-JWT)の検証に失敗する

  • nonce の不一致または有効期限切れ: <input> でレンダリングされた nonce が、サーバー上のアクティブなセッション nonce と一致し、別のタブによって上書きされていないこと、または以前のリクエストによって使用されていないことを確認します。
  • オーディエンス(aud)の不一致: aud クレームは、検証ツールの HTTPS オリジン(https://verifier.example、パスまたは末尾のスラッシュなし)です。

メールのクレーム(email)の比較に失敗する

  • 大文字と小文字の区別と正規化: Chrome 156 以降では、フォームに入力されたとおりに email クレームがバイト単位で返されますが、以前のブラウザ バージョンやプロバイダでは、正規化されたアドレス(first.last@example.com の場合は First.Last@example.com など)が返されることがあります。トークンの email クレームと送信されたフォームの値を照合する際は、大文字と小文字を区別しない比較を使用してください。