電話番号ハッシュ照合で作る Amazon Connect 二段階認証
はじめに
SMS認証やTOTPが普及した今、なぜ電話発信で認証するのか。
そう思う人もいるだろう。だが、SMS は SIM スワップ攻撃に弱く、TOTP はデバイス紛失時のリカバリが設計の重荷になる。電話発信認証は、ユーザーが物理的にその端末を手にしていることと、登録した番号を本人が所持していることを同時に証明できる。キャリア網を経由するため、ネットワーク層での傍受が難しいという特性もある。
このブログでは、Amazon Connect を起点に Lambda と DynamoDB を組み合わせて構築した二段階認証フローを、設計の意図とともに整理する。
なお、記載されているコードは実際に組んだロジックではなく、ブログ用に1から書き直したものである。
アーキテクチャについても所々で濁して記載している。
認証フロー全体像
ユーザーがログインした後、電話番号を登録してから実際に電話をかけるまでの一連の動きを図にした。
フローを一言で表すなら「登録時と着信時に同じ方法でハッシュを作り、DynamoDB で突き合わせる」というだけのことだ。シンプルに見えるが、細部に判断が必要な箇所がある。
データ設計
DynamoDB テーブル構造
┌─────────────────────────────────────────────────────┐
│ テーブル名: AUTH_SESSIONS │
├──────────────────┬──────────┬───────────────────────┤
│ カラム名 │ 型 │ 備考 │
├──────────────────┼──────────┼───────────────────────┤
│ phone_hash (PK) │ String │ ハッシュ化済み電話番号 │
│ user_id │ String │ ユーザーID │
│ session_id (GSI)│ String │ ログインセッションID │
│ status │ String │ pending/verified │
│ ttl │ Number │ Unix秒 (登録時+300) │
│ created_at │ String │ ISO8601 │
└──────────────────┴──────────┴───────────────────────┘
ハッシュ値をパーティションキーにしているのは、Lambda の着信処理でインデックスなし GetItem が使えるようにするためだ。電話番号の平文はどこにも保存しない。
TTL は DynamoDB のネイティブ機能に任せる。300 秒を過ぎたレコードは自動で消えるため、認証試行をタイムアウトさせる処理を Lambda 側に書く必要がない。ただし DynamoDB の TTL 削除には最大 48 時間の遅延が生じることがあるため、Lambda 側でも created_at + 300秒 < 現在時刻 の条件チェックは入れておく方が安全だ。
ハッシュ化の実装
電話番号のハッシュ化は登録側と着信側の両 Lambda で共通化する。ここがずれると認証が永遠に失敗する。
import hashlib
import hmac
import os
SALT = os.environ["PHONE_HASH_SALT"] # Systems Manager Parameter Store から取得
def hash_phone_number(raw_number: str) -> str:
"""
E.164 形式に正規化してから HMAC-SHA256 でハッシュ化する。
例: 090-1234-5678 → +819012345678 → hash
"""
normalized = normalize_to_e164(raw_number)
return hmac.new(
SALT.encode("utf-8"),
normalized.encode("utf-8"),
hashlib.sha256
).hexdigest()
def normalize_to_e164(number: str) -> str:
"""国内形式を E.164 形式に変換する(日本の場合)"""
digits = "".join(filter(str.isdigit, number))
if digits.startswith("0"):
digits = "81" + digits[1:]
return "+" + digits
ポイントは二つある。
正規化を先に行うこと。 Amazon Connect が渡す CallerID は +81XXXXXXXXXX の E.164 形式だが、ユーザーが Web フォームに入力するのは 090-XXXX-XXXX のような国内形式が多い。正規化せずにハッシュ化すると、同じ番号でもハッシュ値が変わって照合に失敗する。
HMAC を使うこと。 単純な SHA-256 だと、ハッシュ値からレインボーテーブルで番号を逆引きできる可能性がある。HMAC に秘密の salt を混ぜることで、その salt が漏れない限り逆引き攻撃は現実的でなくなる。salt は Parameter Store の SecureString で管理し、Lambda の環境変数に平文で埋め込まない。
Amazon Connect フロー設計
Connect のコンタクトフローは Lambda の戻り値をそのまま分岐に使える。Lambda が返す JSON に authResult: "success" / "failed" / "expired" を含めれば、フロー上の条件分岐にそのまま使える。
1回の通話で認証が完了するよう、電話が繋がった時点で即 Lambda を発火させる設計にする。着信音が鳴り終わる前に処理が終わる必要はなく、ガイダンス再生前であれば間に合う。
Lambda 着信処理の実装
import boto3
import json
import os
from datetime import datetime, timezone
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["AUTH_TABLE_NAME"])
def lambda_handler(event, context):
# Connect が渡す発信元番号
caller_id = event["Details"]["ContactData"]["CustomerEndpoint"]["Address"]
phone_hash = hash_phone_number(caller_id)
response = table.get_item(Key={"phone_hash": phone_hash})
item = response.get("Item")
if not item:
return {"authResult": "failed"}
# Lambda 側でも TTL を二重チェック
created_at = datetime.fromisoformat(item["created_at"])
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
if elapsed > 300:
return {"authResult": "expired"}
if item["status"] != "pending":
# 認証済み or すでに使用済みのセッション
return {"authResult": "failed"}
# ステータスを verified に更新(再利用防止)
table.update_item(
Key={"phone_hash": phone_hash},
UpdateExpression="SET #s = :v",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":v": "verified"}
)
return {"authResult": "success", "sessionId": item["session_id"]}
認証に成功した瞬間にステータスを verified に書き換えるのが重要だ。同じハッシュで複数回通話されても、2回目以降は pending でないため失敗として扱われる。
セキュリティ上の注意点
発信者番号は偽装できる
CallerID は信頼できない。PSTN 経由の一般的な発信でも、SIP trunk や一部の VoIP サービスを使えば任意の番号をセットすることは技術的に可能だ。「電話番号が一致した」という事実だけで本人確認が完結すると考えると、このシステムは穴がある。
この認証の正直な立ち位置は「その番号の電話機を物理的に持っている可能性が高い」ことの確認であって、番号の所有そのものの証明ではない。それを前提に設計の範囲を決める必要がある。
完全には防げないが、以下の組み合わせでリスクを下げられる。
- TTL を 300 秒に絞ることで、偽装発信を試みる時間窓を狭める
- 1セッション 1回限りの認証にする(上記の
verified更新がこれを担う) - ログインセッションの IP と電話番号登録の IP を記録し、地理的な乖離があれば別途アラートを出す
DTMF 入力を組み合わせる選択肢
発信者番号だけで認証する現在の構成に加え、通話中にプッシュ番号でコードを入力させる DTMF 認証を組み合わせることで、偽装耐性を上げられる。仕組みとしては、認証セッション生成時に数桁のワンタイムコードを DynamoDB に保存しておき、Connect のフロー内で DTMF 入力を受け取って Lambda に渡して照合するだけだ。
ただし、フリーダイヤルで提供している場合は通話時間が長くなるほどコストが上がる。DTMF 入力の待ち時間が加わることで 1 件あたりの通話が数十秒伸び、それが認証回数の規模に応じてじわじわ効いてくる。どの程度のリスクに対してそのコストを払うかは、サービスの性質次第だ。
電話番号を持つ人は審査を通過している
メール認証やTOTPと比較したときに見落とされがちな点がある。電話番号はキャリアの審査と契約を経た人だけが持てる。フリーメールアカウントはほぼ誰でも即座に作れるし、認証アプリも端末さえあれば設定できる。だが携帯電話番号は、少なくとも国内では本人確認書類の提出が義務付けられており、番号の取得者に最低限の社会的な実体がある。
これは「なりすましが不可能」という意味ではなく、「匿名で大量にアカウントを作って攻撃する」ようなシナリオに対して構造的に強いということだ。メール認証が突破されるような大規模な不正登録攻撃は、電話番号を軸にした認証では現実的なコストが跳ね上がる。
ハッシュ値の推測耐性
電話番号は 11 桁前後と、エントロピーが限られている。HMAC の salt が漏洩した場合、全番号空間を総当たりする攻撃が現実的になる。salt のローテーション運用と、Parameter Store へのアクセスを Lambda の実行ロールだけに絞る IAM 設計を組み合わせておく。
さいごに
SMS 認証や TOTP では解決しにくい「物理デバイスの所持証明」を、Amazon Connect という既存のコンタクトセンター基盤で実現できた。インフラとして特別なものを用意したわけではなく、Connect と Lambda と DynamoDB を組み合わせただけの構成だ。
300 秒という TTL と 1 回限りの認証という制約が、このシステムの使いやすさとセキュリティのバランスを支えている。ここをどう調整するかは、サービスの性質と攻撃リスクの想定次第になる。