パスキーによる認証
署名が正しいだけでは受け入れられない応答を、署名対象と認証先・challengeの対応から説明します。
説明
WebAuthnの認証応答では、認証器データとclientDataJSONのハッシュを連結したバイト列を署名します。サーバーは署名だけでなく、challenge、origin、RP ID、利用者の操作・確認を示すフラグなども検証します。
登録済み公開鍵がある状態から、P-256の公開試料鍵で認証応答を組み立てます。RP IDハッシュ32バイト、flags1バイト、counter4バイトを開き、署名検証と各受入条件を一つずつ変えます。ブラウザAPIの登録手続や認証器の生体認証はこのバイト列の演習とは別の処理です。
署名が正しいだけでは受け入れられない応答を、署名対象と認証先・challengeの対応から説明します。
1challengeとclientDataJSON
RPは認証を要求するサービスです。新しいRP試料が持つ32バイトのchallengeをclientDataJSONへ入れます。originはhttps://login.example.testです。
from passkey import RP, sign
from sc04 import client, authenticator
import hashlib
rp = RP()今回のchallengeを持つ検証状態を作る
raw = client(rp.challenge)challengeとoriginを含むJSONを作る
auth = authenticator()認証器データの固定試料を作る
print(raw.decode())clientDataJSONを読むtypeはwebauthn.get、challengeはBase64url表現です。JSONはこの文字列のバイト列をそのままハッシュへ渡します。
2認証器データの37バイト
先頭32バイトがRP IDのSHA-256、次の1バイトがflags、最後の4バイトが署名カウンターです。この試料は拡張データを持ちません。
print(len(auth)認証器データの長さを読む)
print(auth[:32].hex()RP IDハッシュを取り出す)
print(f"flags={auth[32]flagsを読む:02x} counter={int.from_bytes(auth[33:37]4バイトのカウンターを読む, 'big')}")37バイトでflags=05、counter=1です。05はUPの0x01とUVの0x04が立った状態です。UPは利用者の操作、UVは認証器での利用者確認を示します。
3署名対象を組み立てる
authenticatorDataの後ろへclientDataJSONのSHA-256を連結します。この連結値へECDSA-SHA-256で署名します。
signed = auth + hashlib.sha256(raw).digest()69バイトの署名対象を作る
signature = sign(auth, raw)公開試料鍵でECDSA署名する
print(len(signed), signed.hex())署名対象全体を読む
print(signature.hex())実際の署名バイト列を読む署名対象は37+32=69バイトです。表示された署名はDER形式のECDSA署名で、認証器データだけへ署名したものではありません。
4受入れと同じchallengeの再利用
登録済み公開鍵と期待値に合う応答を検証します。受理したchallengeは使用済みにします。
print(rp.verify(auth, raw, signature)署名と受入条件を検証する)
print(rp.verify(auth, raw, signature)同じ応答を再送する)初回はaccepted、2回目はchallenge_already_usedです。正しい署名の再送だけで、同じ認証要求を繰り返し受け入れてはいけません。
5署名後のバイト列改変
authの末尾1バイトを変え、元の署名を提示します。別のRP試料で検証するため、使用済み判定とは分けて確認できます。
changed = auth[:-1] + b""認証器データの末尾を変更する
print(RP().verify(changed, raw, signature)改変したバイト列を元の署名で検証する)signature_invalidです。署名対象のカウンターが変わり、元の署名と一致しません。
6署名が正しくてもoriginが違う
別originを持つclientDataJSONを作り、その内容に対して正しい試料署名を付けます。
other_origin = client(rp.challenge, "https://other.example.test")異なるoriginの応答を作る
print(RP().verify署名成功後のorigin条件を確認する(auth, other_origin, sign(auth, other_origin)))origin_mismatchです。鍵の署名が正しいことと、想定したWebの利用先から来たことは別の条件です。
7RP IDハッシュを照合する
認証器データを別のRP IDから作り、そのデータへ正しく署名します。
other_rp = authenticator("other.example.test")別RP IDのハッシュを持つデータを作る
print(RP().verify認証器データ内のRP IDを照合する(other_rp, raw, sign(other_rp, raw)))rp_id_mismatchです。originとRP IDは関連しますが、比較する場所と値は異なります。
8利用者確認のフラグを検査する
このサービスはUPとUVの両方を要求します。UPだけのflags=01で署名した応答を検証します。
presence_only = authenticator(flags=1)UPだけが立った応答を作る
print(RP().verify要求するUP・UV条件を検査する(presence_only, raw, sign(presence_only, raw)))UP_or_UV_missingです。必要な利用者確認の方針を、署名で保護されたフラグと照合しています。
9別challengeの応答を拒否する
正しい認証先とフラグでも、今回の要求と異なるchallengeを持つ応答は受け入れません。
other_challenge = client(b"other-challenge")別challengeの応答を作る
print(RP().verify今回の要求との対応を検査する(auth, other_challenge, sign(auth, other_challenge)))challenge_mismatchです。署名・認証先・利用者確認が合っても、今回の要求へ結び付かない応答は拒否します。署名カウンターの運用は認証器ごとに異なり、値だけで複製を断定できません。
まとめ
署名対象はauthenticatorDataとclientDataJSONのハッシュの連結です。
署名成功後もorigin・RP ID・challenge・必要なフラグを検査します。
challengeの一回性と認証器のカウンターは別の条件です。
操作履歴と解説を振り返れます。
Python 3.15 / cryptography 50.0.2 / 公開試料と固定時刻
from passkey import RP, sign
from sc04 import client, authenticator
import hashlib
rp = RP()
raw = client(rp.challenge)
auth = authenticator()
print(raw.decode()){"type":"webauthn.get","challenge":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8","origin":"https://login.example.test"}print(len(auth))
print(auth[:32].hex())
print(f"flags={auth[32]:02x} counter={int.from_bytes(auth[33:37], 'big')}")37 87dbaea82c4588af45cad5798f71993a59f771d4e90f5b6a5a974ff10c8da336 flags=05 counter=1
signed = auth + hashlib.sha256(raw).digest()
signature = sign(auth, raw)
print(len(signed), signed.hex())
print(signature.hex())69 87dbaea82c4588af45cad5798f71993a59f771d4e90f5b6a5a974ff10c8da3360500000001aae452cc8a108cff13e073f0a2f565bfa0608beb263dade042e1d6af3811376c 3045022056ebad4d6bdb6be2698560ad4f587ed947e66c7dca67056985cb026bf2d5af62022100ac8b56d190deea5d40e913b7f3fe5481e329d3843c78abd30c5613385dd93b88
print(rp.verify(auth, raw, signature))
print(rp.verify(auth, raw, signature))accepted challenge_already_used
changed = auth[:-1] + b""
print(RP().verify(changed, raw, signature))signature_invalid
other_origin = client(rp.challenge, "https://other.example.test")
print(RP().verify(auth, other_origin, sign(auth, other_origin)))origin_mismatch
other_rp = authenticator("other.example.test")
print(RP().verify(other_rp, raw, sign(other_rp, raw)))rp_id_mismatch
presence_only = authenticator(flags=1)
print(RP().verify(presence_only, raw, sign(presence_only, raw)))UP_or_UV_missing
other_challenge = client(b"other-challenge")
print(RP().verify(auth, other_challenge, sign(auth, other_challenge)))challenge_mismatch
演習完了