ステートレスなメール認証コードの一構想

簡体字中国語からAIで翻訳しました。原文を読む

最近、Cloudflare Pages とその Functions を使って、CAS に似たシンプルな統合認証システムを実装しようとしている。サーバーレス自体がステートレスであるという制約に加えて、サーバーレスの Redis サービス(Upstash など)を新たに導入したくないので、メール認証コードの場面では、従来の Redis / Session に依存する方式は使えない(;´д`)ゞ

TOTP

ステートレスな認証コードといえば TOTP で、これもよくある二要素認証コードの一種だ。TOTP の生成方法はだいたい、現在のタイムスタンプを有効期限で割り、HMAC-SHA1 / HMAC-SHA256 のような署名アルゴリズムで署名し、最後に署名を固定桁数に切り詰めて認証コードとする、というものだ。サーバー側で同じ計算を行えば、TOTP の有効性を検証できる。

ならば、似たようなステートレスなメール認証コードを実現するときにもこの考え方が使える。メールアドレスと時間ウィンドウ番号(こう呼んでいいはず?)を連結し、先頭数桁を切り出してメール認証コードとし、サーバー側で同じ計算を行えばメールアドレスの有効性を検証できる。

同時に、TOTP のような「時間窓の境界で期限切れになる」問題を防ぐために:

たとえば現在のタイムスタンプが 1721284009 で、有効期限を 10 秒に設定すると、時間ウィンドウ番号は 1721284009 / 10 = 172128400 と計算される。

1 秒後、時間ウィンドウ番号は 172128401 になり、TOTP は無効になる。そのため、その時点で生成された TOTP の有効期限は 1 秒しかない。

サーバー側で有効性を検証するときに、1 つ前の時間ウィンドウまで余分に計算して 2 つの認証コードを算出し、どちらか一方が一致すればよいようにすれば、認証コードの有効期限が常に 1 つの時間ウィンドウの長さより長くなることが保証される。

検証フロー

以上を踏まえた、僕が考えるステートレスなメール認証のフローは次のとおり:

  1. ユーザーがメールアドレスを入力した後、サーバーのメール検証 API を呼び出す
  2. メール検証 API 内で:
    1. 現在のタイムスタンプ(ミリ秒) / 600000(10 分)を計算し、Time Window と呼ぶ
    2. メールアドレスと Time Window を連結し、Msg と呼ぶ
    3. 設定された Server Secret を使って Msg に HMAC-SHA256 署名を行い、結果を 16 進文字列に変換し、Full Code と呼ぶ
    4. Full Code の先頭 6 文字を切り出し、認証コードとする
    5. 認証コードをメールに送信する
  3. ユーザーがメール認証コードを入力した後、登録 API を呼び出す
  4. 登録 API 内で:
    1. 上記の計算を行い、Time Window と Time Window - 1 に対応する認証コードを得る
    2. どちらか一方と一致するか判定する

 

TypeScript 実装

Cloudflare の Edge Runtime では Node.js の crypto ライブラリを使えないけれど、幸いなことに Web Crypto API に対応しているので、上記の HMAC-SHA256 署名を行い、16 進文字列に変換できる:

export async function hmacSha256String(msg: string, key: string) {
  const encoder = new TextEncoder()
  const keyBuffer = encoder.encode(key)
  const msgBuffer = encoder.encode(msg)

  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyBuffer,
    { name: 'HMAC', hash: { name: 'SHA-256' } },
    false,
    ['sign'],
  )

  const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, msgBuffer)
  const signatureArray = new Uint8Array(signatureBuffer)

  const hexString = Array.from(signatureArray)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')

  return hexString
}

そして、上記のフローに沿って、次のような生成関数と検証関数を書ける:

function getCurrentTimeWindow() {
  const timestamp = new Date().getTime()
  return Math.floor(timestamp / CODE_TIME_WINDOW_SIZE)
}

async function genCodeWithTimeWindow(payload: string[], timeWindow: number) {
  const msg = payload.join('-') + timeWindow.toString()
  const fullCode = await hmacSha256String(msg, SERVER_SECRET)

  return fullCode.substring(0, 6)
}

export async function genCode(
  payload: string[],
): Promise<{ code: string; expiration: number }> {
  const timeWindow = getCurrentTimeWindow()

  const code = await genCodeWithTimeWindow(payload, timeWindow)
  const expiration = (timeWindow + 2) * CODE_TIME_WINDOW_SIZE

  return {
    code,
    expiration,
  }
}

export async function checkCode(payload: string[], code: string) {
  const timeWindow = getCurrentTimeWindow()

  const codes = await Promise.all([
    genCodeWithTimeWindow(payload, timeWindow),
    genCodeWithTimeWindow(payload, timeWindow - 1),
  ])

  if (codes.includes(code)) {
    return true
  }
  return false
}

メールアドレスを検証するだけなら、Payload にはメールアドレスだけを渡せば十分だ。しかし、異なる操作を区別したい場合(たとえば、データベース削除前のメール検証と登録前のメール検証を区別する場合)は、Payload に現在の操作を示す文字列を加えて、認証コードが混用されないようにする必要がある。

制約

  1. 認証コードは一度生成されると取り消す方法がなく、有効期限内はずっと有効。これもステートレスな方式すべてに共通する難点だよね XD
  2. メール送信を悪用されやすい。この点は CAPTCHA(Cloudflare Turnstile や Google reCAPTCHA など)を追加することで緩和できる
  3. ……追加募集中 (?)

このステートレスなメール認証コードはあくまで僕のごく初歩的な構想で、他にも潜在的なセキュリティ上の懸念があるかもしれない。本番環境で他に制約がないなら、素直に従来の Redis / Session 方式を使ったほうがいい。

余談

Cloudflare の中国語公式名が「科赋锐」だと知って、ウケる 2333333