技術約15分で読めます

phpMyAdmin 5.2系のWebAuthnキー登録がAAGUID生バイト保存でTypeErrorになる

いけさん目次

TL;DR

現象 予備実装 CustomServer が動くphpMyAdmin 5.2.1から5.2.3で、WebAuthnのセキュリティキーを登録しようとすると画面に Internal error: TypeError が出て設定が保存されない

原因 予備の実装 CustomServer がAAGUID(認証器の機種を示す16バイト)をBase64化しないで、生のバイト列のままJSONへ入れている。
AAGUIDは実質ランダムな16バイトのため、UTF-8として正しくない並びになる。json_encode() が false を返し、strict_types 指定のファイルから文字列型の引数へ bool が渡って TypeError になる

実測 2026年10月29日、127.0.0.1 限定のDocker環境で実際の登録フローを通して確認した。
AAGUIDがすべて 0x00 なら登録は通ったが、0x80 以上のバイトを含む3通りはすべて TypeError で失敗した。登録できたレコードを公式tarball側の環境へ移すと、二要素認証の画面が出る前に Safe\Exceptions\UrlException でログインできなくなる。同じレコードの aaguid だけをUUID文字列に直すと二要素認証が成功した

修正状況 開発ブランチの QA_5_2 と master では2026年9月29日のコミットでUUID文字列へ変換するよう修正済み。リリースにはまだ入っておらず、最新のリリースは2025年10月8日の5.2.3のまま

対象 CustomServer が動く環境のみ。公式サイトのtarballやzip、Docker公式イメージには web-auth/webauthn-lib が同梱されていて CustomServer は使われない。リリースタグから composer install --no-dev で入れた場合も同梱の composer.lock に本番依存として載っているためインストールされる


前回のphpMyAdmin 5.2.3のWebAuthn二要素認証バイパスの検証で、署名検証の欠陥とは別に、同じ CustomServer がセキュリティキー登録時のレコードを壊す問題も見つかった。
署名を検証しない話はそちらに整理したが、ここでは登録した認証器レコードが破損して二要素認証が使えなくなる問題を扱う。

セキュリティキーの登録でTypeErrorが出る

phpMyAdminには二要素認証の方式として「Hardware Security Key (WebAuthn/FIDO2)」がある。
ユーザー設定の二要素認証の画面でこれを選ぶと、ブラウザがセキュリティキーに登録リクエストを送り、phpMyAdminが応答を検証して設定ストレージへ保存する。

web-auth/webauthn-lib が入っていない環境では、この検証を自前の CustomServer クラスが行う。
ライブラリが無くてもWebAuthnを動かすための予備実装だ。

この CustomServer が動く環境でセキュリティキーを登録すると、保存の段階で失敗する。
画面のエラー表示はこれだけになる。

Internal error: TypeError

phpMyAdminのエラーハンドラが詳細を隠すため、画面からは原因が分からない。
二要素認証の設定は保存されないため、次回ログイン時も二要素認証は有効にならない。

CustomServerでAAGUIDがBase64化されない原因

AAGUIDは認証器の機種を識別する16バイトの値で、WebAuthnの仕様ではUUIDとして定義されている。
登録応答の authenticatorData の中に生バイトで入ってくる。

CustomServer はこの16バイトを2つの関数を通してそのまま使っている。

authData を分解する getAuthenticatorData() が、AAGUIDを生バイトで切り出す。

/** Authenticator Attestation GUID */
$aaguid = $authDataStream->take(16);

切り出した値は $attestedCredentialData['aaguid'] に入り、ここでは変換されない。

登録応答を検証する parseAndValidateAttestationResponse() が、そこから認証器レコードを組み立てて返す。
同じ返り値のなかで、ほかの3つのフィールドは明示的にBase64 URLセーフ形式へ変換している。

$encodedCredentialId = sodium_bin2base64(
    $authenticatorData['attestedCredentialData']['credentialId'],
    SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
$encodedCredentialPublicKey = sodium_bin2base64(
    $authenticatorData['attestedCredentialData']['credentialPublicKey'],
    SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
$userHandle = sodium_bin2base64(
    sodium_base642bin($creationOptions['user']['id'], SODIUM_BASE64_VARIANT_ORIGINAL),
    SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);

return [
    'publicKeyCredentialId' => $encodedCredentialId,
    'type' => 'public-key',
    'transports' => [],
    'attestationType' => $attestationObject['fmt'],
    'aaguid' => $authenticatorData['attestedCredentialData']['aaguid'],
    'credentialPublicKey' => $encodedCredentialPublicKey,
    'userHandle' => $userHandle,
    'counter' => $authenticatorData['signCount'],
];

ほかの3つはBase64に変換した値を渡しているのに対し、aaguid だけ getAuthenticatorData() が返した生バイトをそのまま入れている。
UUID文字列への変換が抜けている状態だ。

この返り値はそのままJSONへシリアライズされて、設定ストレージの pma__userconfig テーブルの config_data 列に入る。

Docker環境での登録フロー検証

phpMyAdminの実際の登録フローを最後まで通して、実際に何が保存されるかを確かめた。

検証環境

2つの環境を別々のDocker Composeで立て、どちらも 127.0.0.1 からのみアクセスできるようにした。
CustomServer が動くほうを「CustomServer側」、web-auth/webauthn-lib が入ったほうを「ライブラリ側」と呼ぶ。

項目CustomServer側ライブラリ側
phpMyAdmin5.2.35.2.3
入手元Gitのタグ RELEASE_5_2_3公式サイトのtarball
web-auth/webauthn-lib無し(vendor/web-auth を削除)v3.3.12
動くWebAuthn実装CustomServerWebauthnLibServer
PHP8.2.358.2.35(bcmath・gmp 拡張入り)
スキームHTTPHTTPS(自己署名証明書)
ポート1808018443
データベースMySQL 8.0MySQL 8.0

ライブラリ側に bcmath と gmp を入れているのは、暗号計算の拡張機能が無いと楕円曲線の署名検証を行う前に Requires GMP or bcmath extension. で止まるためだ。
HTTPSにしているのは、webauthn-lib がスキームを検証して Invalid scheme. HTTPS required. で例外を投げるためだ。

どちらの環境も class_exists('Webauthn\Server') の結果によって読み込まれる実装が分かれるので、CustomServer側は false、ライブラリ側は true になることをコンテナ内で確認した。

登録リクエストの送信テスト

phpMyAdminの登録は index.php?route=/preferences/two-factor への2回のPOSTで進む。

1回目は 2fa_configure=WebAuthn だけを送る。
phpMyAdminが getCredentialCreationOptions() でチャレンジなどを生成し、セッションへ保存したうえで、応答HTMLの data-creation-options 属性にJSONを埋め込む。

2回目は同じパラメータに加えて webauthn_creation_response を送る。
内容はブラウザが本来作る登録応答のJSONで、clientDataJSON と attestationObject をBase64で入れたものだ。

CustomServer::parseAndValidateAttestationResponse() が確かめる条件は次の項目になる。

  • clientDataJSON の type が webauthn.create であること
  • チャレンジが1回目のものと一致すること
  • origin のホストがリクエストのホストと一致すること
  • authData の先頭32バイトがホストのSHA-256と一致すること
  • authData のフラグの User Present(ユーザー存在確認)ビットが立っていること
  • attestationObject の fmt が none で attStmt が空であること

これらを満たす応答をPythonで組み立てた。
P-256の鍵を生成し、COSE形式の公開鍵をCBORでエンコードして authData に入れている。
実物のセキュリティキーは使っていないので、AAGUIDの16バイトは自由に指定できる。

保存されたJSON

AAGUIDをすべて 0x00 にして登録すると成功した。
pma__userconfig の config_data に入った値は次のとおりだ(一部を省略)。

{"2fa":{"backend":"WebAuthn","settings":{
  "userHandle":"uWvKafjzCMr_qrGal8Zy3CsnjMaxGzDNxcDOvmJ-gc8",
  "credentials":{"oaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8A=":{
    "publicKeyCredentialId":"oaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2-v8A",
    "type":"public-key",
    "transports":[],
    "attestationType":"none",
    "aaguid":"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
    "credentialPublicKey":"pQECAyYgASFYIMcanRPTFu2i8NQciTugui9e1qVy8GbeDRw71ae2M7b3IlggqED-nDQ7lsuV8swEl-uwQ-fSp-HMno8ENBosrEr2Mfs",
    "userHandle":"uWvKafjzCMr_qrGal8Zy3CsnjMaxGzDNxcDOvmJ-gc8",
    "counter":0}}}}}

publicKeyCredentialId、credentialPublicKey、userHandle はBase64の文字列になっている。
aaguid だけが \u0000 を16個並べた値、つまり16バイトの生バイト列をJSONのエスケープで表現したものが入った。

PHP側で json_decode() すると、この値は長さ16の文字列になり、中身はヌルバイト(0x00)16個になる。36文字のUUID文字列ではない。

AAGUIDを4通り変えて試した結果

同じ登録フローでAAGUIDだけを変えて4回試した。

AAGUID0x80 以上のバイトUTF-8として正しい登録結果config_data
00000000000000000000000000000000含まない正しい成功605バイト書き込まれた
2fc0579f814410bd0000000000000000含む正しくない失敗書き込みなし
cb69481e8ff7403993ec0a2729a154a8含む正しくない失敗書き込みなし
6028b017b1d44c02b4b3afcdafc96bb2含む正しくない失敗書き込みなし

失敗した3回は画面に Internal error: TypeError が出て、config_data の書き込み自体が行われなかった。
成否は0x80 以上のバイトを含むかどうかで分かれる。

ランダムなAAGUIDでUTF-8が通る確率

AAGUIDはUUIDのため実質ランダムな16バイトで、この16バイトがたまたまUTF-8として妥当になる確率を、UUIDを20万個生成して計算した。

項目件数割合
生成したUUID200,000100%
0x80 以上のバイトを含む200,000100%
UTF-8として正しい160.0080%
json_encode() が失敗する199,98499.9920%

16バイトすべてが 0x7F 以下になる確率自体が2の16乗分の1程度なので、0x80 以上のバイトを含むのはほぼ確実だ。
そして 0x80 以上のバイトを含む並びがUTF-8として正しいのは、連続するバイトが多バイト文字の並びと偶然一致した場合に限られる。

そのため登録が成功するのは、AAGUIDがすべて 0x00 のような特殊な場合に限られる。
一部のプラットフォーム認証器(端末内蔵認証器)はこの値を返すので動くケースもあるが、大半のセキュリティキーでは失敗する。

TypeErrorが発生する流れ

graph TD
    A["認証器が返すauthenticatorData"] --> B["CustomServerが take(16) で<br/>AAGUIDを生16バイトのまま取り出す"]
    B --> C{"16バイトはUTF-8として正しいか"}
    C -->|正しい| D["json_encode が成功して登録される"]
    C -->|正しくない| E["json_encode が false を返す"]
    E --> F["escapeString に bool が渡り TypeError"]
    D --> G["aaguid だけ生バイトで保存される"]
    G --> H["webauthn-lib が読めず UrlException"]

UserPreferences::save() はユーザー設定をJSONにしてSQL文へ埋め込む。

$config_data = json_encode($config_array);
if ($has_config) {
    $query = 'UPDATE ' . $query_table
        . ' SET `timevalue` = NOW(), `config_data` = \''
        . $dbi->escapeString($config_data)
        . '\''
        // ...

PHPの json_encode() は、渡された配列の中にUTF-8として正しくない文字列があると、例外を投げずに false を返す。
戻り値の型が string|false になる。

UserPreferences.php の先頭には declare(strict_types=1); があり、DatabaseInterface::escapeString() の第1引数は string 型で宣言されている。

public function escapeString(string $str, $link = self::CONNECT_USER)

strict_types の指定があるファイルからは、bool を string の引数へ暗黙に変換して渡せないため、ここで TypeError になる。
save() は json_encode() の戻り値を検証していないため、escapeString() の呼び出しでエラーになる。

コンテナ内でPHPから同じ呼び出しを再現すると、次のログが出た。

json_encode result type: bool
json_last_error_msg: Malformed UTF-8 characters, possibly incorrectly encoded
escapeString(false) => TypeError: PhpMyAdmin\DatabaseInterface::escapeString():
Argument #1 ($str) must be of type string, bool given

登録できたレコードを公式tarballへ移すとエラーになる

AAGUIDがすべて 0x00 で登録できたレコードも、保存された形式が web-auth/webauthn-lib の期待する形と違うため、ライブラリ入りの環境へ移行すると読み込めなくなる。

webauthn-lib v3.3.12 の PublicKeyCredentialSource::createFromArray() は aaguid を次のように解釈する。

switch (true) {
    case 36 === mb_strlen($data['aaguid'], '8bit'):
        $uuid = Uuid::fromString($data['aaguid']);
        break;
    default: // Kept for compatibility with old format
        $decoded = base64_decode($data['aaguid'], true);
        $uuid = Uuid::fromBytes($decoded);
}

36文字ならUUID文字列として扱い、それ以外はBase64文字列としてデコードしたうえで16バイトへ戻す仕様だ。
16バイトの生バイト列はどちらの条件にも当てはまらない。

このファイルは use function Safe\base64_decode; を読み込んでいて、Safe 版の base64_decode() は失敗すると false を返す代わりに例外を投げる。
ヌルバイト16個はBase64として正しくないため、ここで Safe\Exceptions\UrlException が発生する。

CustomServer 側で登録したレコードを、UNHEX() を使ってライブラリ側の pma__userconfig へバイト単位でそのまま書き込み、正しい署名でログインを試した。

レコードの aaguid長さパスワード認証後二要素認証
生16バイト(CustomServer 側が保存したまま)16バイトInternal error: Safe\Exceptions\UrlException: An error occured画面に到達せず
00000000-0000-0000-0000-000000000000 に書き換え36文字二要素認証の画面が出る正しい署名で成功

2行目は1行目と同じレコードで、aaguid の値だけをUUID文字列に置き換えたものだ。
ほかのフィールドは変えておらず、差はこの1項目だけだ。

生バイトのほうはパスワード認証の応答を組み立てる段階でエラー停止するため、二要素認証の入力欄すら表示されない。

trustPath はphpMyAdmin側で補っている

webauthn-lib の createFromArray() は aaguid 以外のフィールドのチェックも厳密で、クラスのプロパティ名を走査して $data に無ければ The parameter "..." is missing で例外を投げる。

CustomServer が返すレコードには trustPath が無いが、phpMyAdminの WebauthnLibServer が読み込み時に補っている。

private function read(): array
{
    $credentials = $this->twoFactor->config['settings']['credentials'];
    foreach ($credentials as &$credential) {
        if (isset($credential['trustPath'])) {
            continue;
        }

        $credential['trustPath'] = ['type' => EmptyTrustPath::class];
    }

    return $credentials;
}

2つの実装のあいだのレコード形式の違いについて、phpMyAdminは trustPath は補っているが、aaguid は補っていないため例外が発生する。

逆に WebauthnLibServer が保存するときは jsonSerialize() を通るので、aaguid は $this->aaguid->toString()、つまり36文字のUUID文字列で書かれる。
ライブラリ側の環境で登録したレコードを CustomServer の環境へ移す場合は、生バイトを渡されても CustomServer が aaguid を参照しないため問題にならない。

開発ブランチでの修正状況

CustomServer.php の変更履歴では、この行は2026年9月末に直っている。

$aaguid = $this->uuidFromBytes($authDataStream->take(16));

追加された uuidFromBytes() は16バイトを小文字のUUID文字列へ変換する。

private function uuidFromBytes(string $bytes): string
{
    $uuid = bin2hex($bytes);

    return strtolower(sprintf(
        '%s-%s-%s-%s-%s',
        substr($uuid, 0, 8),
        substr($uuid, 8, 4),
        substr($uuid, 12, 4),
        substr($uuid, 16, 4),
        substr($uuid, 20, 12)
    ));
}

これで createFromArray() の36文字の分岐に入る。

修正コミットは 63949c36b「Fix WebAuthn\CustomServer credential save」で、コミット作成日(AuthorDate)は2026年9月28日、QA_5_2 へのコミット日時は2026年9月29日だ。
master へは2026年9月30日のマージコミット 1a7bf606 で反映されている。

同じコミットでテストも修正されており、期待値が生バイトからUUID文字列へ変わっている。

-                'aaguid' => hex2bin('00000000000000000000000000000000'),
+                'aaguid' => '00000000-0000-0000-0000-000000000000',

修正前のテストは hex2bin() で作った生バイトを使っていて、しかも値はすべて 0x00 だった。
すべて 0x00 はUTF-8として正しいので json_encode() は失敗しない。テストが通っていたのはそのためで、0x80以上のバイトを含むAAGUIDを通すテストは無かった。

各ブランチ・タグでの CustomServer.php の状態をまとめた。

リファレンスuuidFromBytesaaguid の形式コードの状態
RELEASE_5_2_1(2023年2月8日)無し生16バイト未修正
RELEASE_5_2_2(2025年1月21日)無し生16バイト未修正
RELEASE_5_2_3(2025年10月8日)無し生16バイト未修正
QA_5_2(2026年10月29日時点)有り36文字のUUID文字列修正済み
master(2026年10月29日時点)有り36文字のUUID文字列修正済み

WebAuthnのサポート自体は2022年12月19日のコミット f143d2a70「Add support for Web Authentication API」で入り、リリースとしては5.2.1が最初となる。5.2.0には CustomServer.php が存在しない。

修正は2026年10月29日時点でどの公式リリースにも入っていない。最新リリースは2025年10月8日の5.2.3のままだ。

なお、別の記事で扱った署名を検証しない問題は、QA_5_2 と master の CustomServer.php でもまだ直っていない。
今回修正されたのは aaguid の形式にとどまる。

影響する環境

影響を受けるのは CustomServer が実際に使われる環境だ。

入手方法web-auth/webauthn-lib実装この問題
公式サイトのtarball・zip(5.2.1から5.2.3)同梱WebauthnLibServer影響なし
Dockerの公式イメージ同梱WebauthnLibServer影響なし
Gitのタグ RELEASE_5_2_3 を composer install --no-devインストールされるWebauthnLibServer影響なし
Gitの QA_5_2 を composer update のあと --no-dev削除されるCustomServer2026年9月29日以前のコミットは影響あり、以降は修正済み
Gitの master を composer update のあと --no-dev削除されるCustomServer2026年9月30日以前のコミットは影響あり、以降は修正済み

リリースタグでライブラリが残る理由

web-auth/webauthn-lib はどのリファレンスでも composer.json の require-dev に書かれている。
RELEASE_5_2_1・RELEASE_5_2_2・RELEASE_5_2_3・QA_5_2 は ^3.3 系、master は ^5.3 だ。
本来は開発用依存のため、省いてインストールすると入らないはずだが、同梱の composer.lock に記載されているセクションが違う。

リファレンスcomposer.lockweb-auth/webauthn-lib のセクション--no-dev の結果
RELEASE_5_2_1有りpackages(v3.3.12)残る
RELEASE_5_2_2有りpackages(v3.3.12)残る
RELEASE_5_2_3有りpackages(v3.3.12)残る
QA_5_2無し—入らない
master有りpackages-dev(v5.3.9)除外される

composer install は composer.json ではなく composer.lock に従うため、packages に載っていれば --no-dev でもインストールされる。
master のように packages-dev に載っている場合は省かれる。
QA_5_2 にはlockが無いため composer update が走り、require-dev は開発用として扱われる。

実際に CustomServer が動くのは、開発ブランチを --no-dev で入れた環境か、ライブラリが入っていない配布物を使っている環境になる。

対処

CustomServer が動いているかはファイル構成で確認できる。

ls vendor/web-auth/webauthn-lib/src/Server.php

ファイルがあればライブラリ版なので今回の問題は当てはまらない。無ければ CustomServer が動いている。

CustomServer の環境でセキュリティキーの登録が Internal error: TypeError になるなら、AAGUIDが原因と思われる。
対処はライブラリを追加することだ。QA_5_2 なら ^3.3.2、master なら ^5.3 が require-dev に書かれているので、その版を composer require で追加すれば解決する。

ライブラリを入れる際の注意点として、GMPか bcmath のどちらかが無いと Requires GMP or bcmath extension. で失敗する。phpMyAdminの composer.json の推奨には載っていないため、composer install の出力からは気づきにくい。

すでに CustomServer で登録してある認証器は、ライブラリ版では読み込めない。
二要素認証の画面が出る前に UrlException が発生するため、phpMyAdminの画面からは削除できない。
pma__userconfig の config_data を直接直すか、該当ユーザーの行を削除して登録し直すことになる。

config_data を直す場合は aaguid の値を16バイトの生バイト列から36文字のUUID文字列へ置き換える。
16進で32文字並んだ値を8-4-4-4-12の位置で区切って小文字にすればよい。ヌルバイト16個なら 00000000-0000-0000-0000-000000000000 だ。

Gitから取得したコードなら、QA_5_2 は2026年9月29日以降、master は9月30日以降のコミットで取得すれば CustomServer のままでも直っている。ただし署名検証は入っていないため、ライブラリ版への切り替えを推奨する。

検証した範囲

  • phpMyAdmin 5.2.3の実際の登録フローをHTTPで実行し、pma__userconfig に保存された値をMySQLから読み戻した
  • AAGUIDを4通り変えて登録の成否を比較した
  • 保存されたレコードをバイト単位でそのままライブラリ版の環境へ移し、正しい署名でログインをテストした
  • aaguid だけをUUID文字列に変えた比較用データで成功することを確認した
  • json_encode() が false を返し、escapeString() でTypeErrorになることをコンテナ内のPHPで再現した
  • リリースタグと開発ブランチの CustomServer.php を取得して uuidFromBytes() の有無を比べた
  • 5つのブランチ・タグの composer.json と composer.lock を取得し、web-auth/webauthn-lib が require-dev にあることと、lockの記載セクションを確かめた