一覧SC-04-3 · 本人確認と認証器

パスキーによる認証

署名が正しいだけでは受け入れられない応答を、署名対象と認証先・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 / 公開試料と固定時刻

1
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"}
2
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
3
signed = auth + hashlib.sha256(raw).digest() signature = sign(auth, raw) print(len(signed), signed.hex()) print(signature.hex())
69 87dbaea82c4588af45cad5798f71993a59f771d4e90f5b6a5a974ff10c8da3360500000001aae452cc8a108cff13e073f0a2f565bfa0608beb263dade042e1d6af3811376c
3045022056ebad4d6bdb6be2698560ad4f587ed947e66c7dca67056985cb026bf2d5af62022100ac8b56d190deea5d40e913b7f3fe5481e329d3843c78abd30c5613385dd93b88
4
print(rp.verify(auth, raw, signature)) print(rp.verify(auth, raw, signature))
accepted
challenge_already_used
5
changed = auth[:-1] + b"" print(RP().verify(changed, raw, signature))
signature_invalid
6
other_origin = client(rp.challenge, "https://other.example.test") print(RP().verify(auth, other_origin, sign(auth, other_origin)))
origin_mismatch
7
other_rp = authenticator("other.example.test") print(RP().verify(other_rp, raw, sign(other_rp, raw)))
rp_id_mismatch
8
presence_only = authenticator(flags=1) print(RP().verify(presence_only, raw, sign(presence_only, raw)))
UP_or_UV_missing
9
other_challenge = client(b"other-challenge") print(RP().verify(auth, other_challenge, sign(auth, other_challenge)))
challenge_mismatch

演習完了