早いもので前回のブログから3年半ほど経ってしまいました。
ブログを書くたびに「久しぶり」と言っている気がします。

今回はChromeの Email Verification Protocol Origin Trial を自宅Kubernetes環境にデプロイして、実際のChromeでメールアドレス検証が通るところまで試した話です。

Chromeのオートフィルでメールアドレスを選択したときに、ブラウザが裏側で検証用のトークンを取得し、サイト側ではそのトークンを検証するという仕組みです。
まだOrigin Trial中の機能なので、本番利用というより、まずは実際に動かして理解するのが目的です。


Email Verification Protocolとは

ざっくり言うと、メールアドレスを入力するフォームで、ブラウザが「このメールアドレスはこのユーザーのものです」という検証済みトークンを一緒に送ってくれる仕組みです。

登場人物は以下の3つです。

  • RP / verifier: メールアドレスを受け取るサービス側
  • issuer: メールアドレスを検証済みとして発行する側
  • browser: Chrome

フォーム側には通常のemail autocompleteに加えて、hidden inputを用意します。

verify form
<input name="email-address" type="email" autocomplete="email">
<input name="token" type="hidden" autocomplete="email-verification-token" nonce="...">

Chromeでメールアドレスを選択して送信すると、hiddenのtokenにEVPのトークンが入ります。
サービス側はそのトークンを検証して、メールアドレスが検証済みかどうかを判断します。

今回やったこと

今回やったことは以下です。

  1. issuer.zitaku.netでissuerを公開する
  2. rp.zitaku.netでverifierを公開する
  3. Chrome 150でオートフィルからメールアドレスを選択する
  4. verifier側でemail_verified:trueになることを確認する

検証用のissuerはGoで実装しました。
ログイン部分はOktaのOIDCを使い、issuerにログイン済みのメールアドレスに対してEVTを発行する形にしています。

全体の流れ

最終的には以下の流れになります。

  1. Chromeでhttps://rp.zitaku.net/を開く
  2. メールアドレス欄でChromeのオートフィルを選択する
  3. ChromeがメールドメインのTXTレコードを確認する
  4. Chromeがissuerのaccounts endpointを確認する
  5. Chromeがissuerのissuance endpointからEVTを取得する
  6. Chromeがverifierのnonceを使ってkey binding JWTを作成する
  7. フォーム送信時にemail-addresstokenが送信される
  8. verifierがDNS、issuer metadata、JWKS、claim、key bindingを検証する
EVPのシーケンス図

実際に検証が通ったときの画面では、検証ステップごとに以下のような内容を確認できるようにしました。

  • フォームからメールアドレスとトークンが送られているか
  • EVTとkey binding JWTをパースできるか
  • DNSのTXTレコードからissuerを確認できるか
  • issuerのJWKSで署名検証できるか
  • email_verifiedやemail claimが期待通りか
  • key bindingのaudience、nonce、sd_hashが期待通りか

DNSでissuerを指定する

メールドメインからissuerを見つけるためにTXTレコードを設定します。

DNS TXT
_email-verification.zitaku.net. TXT "iss=issuer.zitaku.net"

これでgaku@zitaku.netのようなメールアドレスをChromeで選択したときに、issuerとしてhttps://issuer.zitaku.netが使われます。

また、ChromeのAccounts ValidationでeTLD+1側のwell-knownを見に行くケースがありました。
今回の場合は以下です。

well-known
https://zitaku.net/.well-known/web-identity

最初はissuer.zitaku.netだけを用意していたのですが、Chrome DevToolsのNetworkを見るとzitaku.net側を取りに行っていました。
そのためGateway側でapex domainの/.well-known/web-identityもissuerにルーティングするようにしました。

Origin Trialが必要だった

今回かなりハマったのがOrigin Trialです。
Chromeのフラグを有効にしているだけでは、autocomplete="email-verification-token"にトークンが入りませんでした。

Origin Trialのtokenを取得して、rp.zitaku.netのレスポンスヘッダーに以下を返すようにしました。

response header
Origin-Trial: origin-trial-token

これを入れるまでは、フォームを送信したときに以下のようにtokenが空のままでした。

bad submit
email-address
gaku@example.com
token

Origin Trial tokenを入れてからは、フォーム送信時にEVTとkey binding JWTが~で連結されたトークンが入るようになりました。

Gmailアカウントでも検証できた

今回一番おもしろかったのは、Gmailアカウントでも検証が通ったことです。
zitaku.netのメールアドレスは自分で用意したissuerからEVTを発行していますが、Gmailの場合はGoogleがissuerになります。

つまり、自分で作ったverifierがGoogleのissuer metadataを見に行き、GoogleのJWKSでEVTの署名を検証し、最終的にemail_verified:trueまで確認できたということです。

Googleのissuer metadataは以下のような内容です。

Google issuer metadata
{
  "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"]
}

GmailアカウントでChromeにログイン済みの状態で、rp.zitaku.netのフォームからGmailアドレスを選択すると、ChromeがGoogleのissuance endpointからEVTを取得してくれました。
verifier側ではGoogleのJWKSを取得し、EdDSA署名を検証します。

最初はtokenが空だったり401になったりしましたが、最終的にはGmailのメールアドレスでもemail_verified:trueになりました。
自前issuerだけでなく、Google issuerが発行したトークンも同じverifierで検証できたので、EVPの全体像がかなり理解しやすくなりました。

Oktaの設定でハマった

issuer側のログインはOktaのOIDCで行いました。
最初はログイン時にOktaから400 access_deniedが返ってきていました。

原因はAuthorization Server側にAccess PolicyとRuleがなかったことでした。
Oktaの画面では以下のような状態です。

Okta
No access policies added

Policies and rules are required for clients to access this resource

対象のclientを許可するPolicyと、Authorization Codeを許可するRuleを追加したところログインできるようになりました。

デプロイ構成

デプロイ先は自宅Kubernetesです。
Goで実装したissuer/verifierをGitLab CIでコンテナイメージ化し、ArgoCDでissuer.zitaku.netrp.zitaku.netに反映する構成にしました。

デプロイ周りは今回の主題ではないので詳細は省略しますが、実際のChromeで試すためにTLS、DNS、Origin Trialのレスポンスヘッダーまで含めて外から到達できる状態にしました。

証明書でハマった

issuer.zitaku.netrp.zitaku.netは問題なかったのですが、apex domainであるzitaku.netでもwell-knownを返す必要が出ました。
最初はapexの証明書を管理していなかったため、cert-managerでHTTP-01を使って取得するようにしました。

一度ACME orderがinvalidになったのですが、これはルーター側の80番ポートの向き先が間違っていたのが原因でした。
ルーティングを直して再発行したら通りました。

curlでHTTP-01のパスがKubernetes側に届いているか確認できる状態にしておくと切り分けが楽でした。

probe
$ curl -I http://zitaku.net/.well-known/acme-challenge/probe
HTTP/1.1 404 Not Found

404であってもKubernetes側から返っていれば、少なくともルーティングは届いています。

nonceとsessionをRedisに保存した

最初の実装ではissuerのsession、OIDC state、verifierのnonceはインメモリでした。
replicaが1つならこれでも動きます。

ただしPod再起動や複数replicaを考えると、以下が問題になります。

  • issuerにログイン済みかどうかがPodに依存する
  • OIDC callbackのstateがPodに依存する
  • verifierのnonceがPodに依存する
  • issuerの署名鍵が起動ごとに変わる

署名鍵はSecretから読み込むようにし、session/state/nonceはRedisに保存するようにしました。
今回の用途では一時データなのでRedisのデータが消えても再ログインや再検証をすれば済みます。

Redis化後はissuer Podを再起動しても再ログインなしでEVPの発行まで通るようになりました。

サービス提供者として何が嬉しいか

最初は「これ何に使うんだろう」という気持ちもありました。
実際に動かしてみると、サービス提供者側のメリットは以下かなと思いました。

  • メールアドレスの打ち間違いを減らせる
  • メール送信前にブラウザ経由で検証済みか確認できる
  • メール認証リンクを踏む手間を減らせる
  • 捨てアドレスやなりすましに対して追加のシグナルになる
  • RP側はメールプロバイダごとのログインを直接実装しなくてよい

ただしissuerにログイン済みであることが前提なので、そこは思ったより難しいポイントでした。
Googleアカウントのように普段からログインしているissuerであれば自然ですが、独自issuerの場合は「まずissuerにログインしておく」という導線が必要になります。

最後に

今回やってみて、EVPはブラウザ、DNS、issuer、verifier、Origin Trial、証明書、Kubernetesの全てが少しずつ関係するので、動くところまで持っていくのは思ったより大変でした。
特に以下の3つはハマりやすいと思います。

  1. Origin Trial tokenがないとhidden inputのtokenに値が入らない
  2. eTLD+1の/.well-known/web-identityをChromeが見に行くことがある
  3. nonce/session/signing keyをインメモリにするとPod再起動や複数replicaで困る

逆にここを越えると、Chromeでメールアドレスを選択して送信するだけでemail_verified:trueになる体験はなかなか面白いです。

まだOrigin Trial中なので仕様変更はありそうですが、メール認証のUXを改善する方向としてはかなり期待できそうだなと思いました。