2018.10.11

パスワードレスを支える技術! 検証! 次世代Web認証 ~WebAuthn入門・PHP実装編~


はじめに

こんにちは。次世代システム研究室のY.Kです。
今回は次世代認証API、WebAuthnについて取り上げたいと思います。WebAuthnの概要からPHPで実装してみた際の注意点などをまとめていきたいと思います。


読むべき対象者

  • WebAuthnについてよくわからないという方
  • WebAuthnは知っているが実装は難しそうという方

WebAuthnとは

“Web Authentication API”の略称で2018年4月にW3Cにて勧告候補になり、盛り上がりを見せている技術です。
一言で言うと「パスワード無しでユーザー認証をするAPI」です。
パスワードなしで認証できるとなにがいいのでしょうか?

パスワード認証の課題

古くからユーザー認証にはパスワードが使われてきましたが、パスワードは以下の課題を持っています。
  • 記憶・入力が面倒
  • 攻撃の対象になる
リスト型攻撃やフィッシングサイトへの対策として、二段階認証やreCAPCHAなどを取り入れるサービスも多くあります。
しかし、ログインフローが増えたり、ユーザーにとって分かりづらい部分もあるためユーザーを守るための技術が逆に離脱を招く原因となります。
また、近年FaceIDやTouchIDといった生体認証技術も普及してきましたが、パスワードの入力を自動でやってくれるだけでパスワードが攻撃者によって漏洩した場合、他の端末からでもログインできてしまいます。
そもそもパスワードという秘匿情報を入力・送信・保存しているのが問題なのです。
問題が多くも使われ続けているのは、パスワードを超える現実的な認証方法が確立されていないというのが原因でした。
そこでこれらの問題を改善すべく考案された認証方式がWebAuthnです。

WebAuthnの概要

WebAuthnはFIDO認証をブラウザで行うためのAPIです。FIDO認証とは”Fast IDentity Online”の略で「速い、簡単、セキュア」を謳っている認証方式です。認証に利用するのは公開鍵認証で、鍵ペアの作成・利用に認証器(Authenticator)を用いるのが特徴です。認証器には様々な認証方式のものが存在し、指紋認証や顔認証、スマホを用いた認証などが例です。つまり、WebAuthnとFIDO認証によって、流行りの生体認証技術を用いてWebサイトにログインできる時代が到来したということです。
これらの技術を用いることでユーザーはパスワードを入力することなく、送信する情報も公開鍵や署名であるため、秘匿情報も通信に乗らない認証を行うことができるというわけです。


WebAuthnのフロー

それでは実際にフローを見ながらコードを確認していきたいと思います。

ユーザー登録

ユーザー登録のフローは以下の図のようになっております。各フローを説明しながらコードを見ていきましょう。


チャレンジの取得(図の①②)

チャレンジとは後に送られてくる鍵の情報が正しいか検証するために生成されるランダムな文字列です。
まず、クライアントがチャレンジを要求します。その際クライアントはユーザーを一意に特定するような情報(今回はメールアドレスなど)を送信します。
要求を受けたサーバーは、チャレンジを生成し、送られてきた情報とともにセッションに保存しておきます。
その後、WebAuthnで利用する形に整形し、クライアントに送り返します。整形はクライアントで行っても構いません。

        Yii::$app->session->set('challenge', $challenge);
        Yii::$app->session->set('username', $data['email']);
        
        return [
            'challenge' => base64_encode($challenge),
            'rp' => [
                'id' => self::RPID,
                'name' => 'WebAuthn',
            ],
            'user' => [
                'id' => base64_encode($data['email']),
                'name' => "test",
                'displayName' => "test"
            ],
            'pubKeyCredParams'=> [
                [
                    'type' => "public-key",
                    'alg'  => -7, // "ES256"
                    // 'alg'  => -257, //RS256
                    // 'alg'  => -37, //PS256
                ]
            ],
            'attestation' => "direct",
        ];

ここで出てくるRPIDはサービスを識別するIDで、デフォルトではサービスのフルドメインが指定されます。
例) https://id.gmo.jpであればRPIDは”id.gmo.jp”
しかし、ドメインの接尾辞を指定することもできます。
例) https://id.gmo.jpであればRPIDは”gmo.jp”
この仕様によって1つのサービスで鍵を生成するとサブドメインを跨いだ複数のサイトで鍵を使い回す事ができます。
なので、ユーザーにとっては複数回登録作業をしなくて良いというメリットが生まれます。

キーペアの作成(図の③)

取得したチャレンジを含むパラメータを引数としてWebAuthnの関数を呼び出します。
(上記例ではチャレンジをBase64でエンコードしているためデコード処理し、バイナリに戻す処理を挟む)

option.challenge = new TextEncoder().encode(Base64.decode(option.challenge))
option.allowCredentials[0].id = new Uint8Array(option.allowCredentials[0].id)
let credential = await navigator.credentials.create({ "publicKey": option })

認証器によるユーザーの認証(図の④⑤)

WebAuthnの関数が呼び出されると認証器が反応し、認証を求められます。
認証器の認証が成功すると鍵が生成されます。

公開鍵情報の送信(図の⑥)

WebAuthnの関数の返り値とメールアドレスを認証サーバーに送信します。

const request_body = {
  email: this.email,
  id:    credential.id,
  raw_id: new Uint8Array(credential.rawId),
  type:  credential.type,
  response: {
    attestationObject: new Uint8Array(credential.response.attestationObject),
    clientDataJSON:    new Uint8Array(credential.response.clientDataJSON),
  }
}


公開鍵情報の検証(図の⑦)

さて、ここからが本番です。まずclientDataJsonの検証から見ていきます。
バイナリで送られてくるのでまずjson文字列にパースすることが必要です。
最低限、確認するべき項目は以下です。
  • clientDataJson.typeが”webauthn.create”であること
  • clientDataJson.challengeが最初に返却したchallengeと一致すること
  • clientDataJson.originがリクエストを送ってきているRPのオリジンと一致すること


// バイナリを文字列に変換
$clientDataJson = implode(array_map("chr", $data['clientDataJson']));
// jsonデコード
$clientDataJson = json_decode($clientDataJson, true);

$origin = Yii::$app->request->origin;
$challenge = Yii::$app->session->get('challenge');

if($clientDataJson['type'] !== "webauthn.create"
  || $clientDataJson['origin'] !== $origin
  || base64_decode($clientDataJson['challenge']) !== $challenge ) {
    throw new Exception("invalid!!! client data is not correct");
}



次にattestationObjectの検証を見ていきます。
まず、バイナリを文字列に変換してCBORでデコードします。
今回PHPでのCBORのパーサーにはこちらを利用しました。
すると以下のような構造のデータが得られます。最低限の機能に必要なのはauthDataになります。

[
    'fmt' => 'fido-u2f', //認証器に依存するフォーマットを表す文字列,
    'attStmt' => [
        'alg' => -7,
        'sig' => CBOR Object,
    ],
    'authData' => CBOR Object,
]

authDataを更にCBORでデコードしてバイナリに変換します。
authDataは下の図のようになっていてこれらを更に検証していきます。

$authData = $attestationObject['authData']->get_byte_string();
$authData_byte_array = array_values(unpack('C*',$authData));


RP ID hashは最初に返却したRPIDをsha256でハッシュ化したもののバイナリです。
以下のように一致するかどうかを検証します。

$rpid_hash = array_slice($authData_byte_array, 0, 32);
$rpid_hash = $this->byteArrayToHex($rpid_hash);
if($rpid_hash !== hash("sha256", self::RPID)){
  throw new Exception("invalid!!! not match rpid");
}

続いてFLAGSですが、1byteをbitに分割して検証します。
1,3,4,5ビット目は現在利用用途はありません。
0ビット目(UP)がユーザーの存在証明をするもので、最も重要でないと記載されています。
2ビット目(UV)がユーザーの認証を通ったかどうかを表すものです。
6ビット目(AT)がAttested credential dataと呼ばれる認証器の正当性をチェックするためのデータが存在するかを表すもの。
7ビット目(ED)がExtensionsを含んでいるかどうかを表すものです。

最低限0,2,6ビット目が1を取ればOKということになります。

$flag = str_pad(decbin($authData_byte_array[32]), 8, 0, STR_PAD_LEFT);

$user_present = substr($flag,7,1); //UP
$user_verified = substr($flag,5,1); //UV
$attested_credential_data = substr($flag,1,1); //AT

if($user_present !== 1 || $user_verified !== 1){
  throw new Exception("invalid!!! ");
}

これらの検証が通ってようやくユーザーを作成し完了となります。
最低限保存しなければならないデータは以下の4つです。
  • ユーザーを一意に定める情報(今回はメールアドレス)
  • 公開鍵
  • CredentialID
  • Counterの値

さて、では肝心の公開鍵はどのように取得するのでしょうか?
取得したauthDataの”ATTESTED CRED.DATA”に含まれています。
これは以下のような構成であるためパースをします。
  • aaguid (16byte)
  • credentialIdLength (2byte)
  • credentialId (credentialIdLength byte)
  • credentialPublicKey (var)

$aaguid = array_slice($authData_byte_array, 37, 16);
$credentialIdLength  = array_slice($authData_byte_array, 53, 2);
$credentialIdLength = $this->byteArrayToEndian($credentialIdLength);
$credentialId = array_slice($authData_byte_array, 55, $credentialIdLength);
$credentialPublicKey = array_slice($authData_byte_array, 55 + $credentialIdLength);

これではまだ公開鍵はバイナリです。我々の馴染み深いpem形式のデータに変換しましょう。

$pubkey_hex = "3059301306072a8648ce3d020106082a8648ce3d030107034200" . $this->byteArrayToHex($pubkey_byte);
// 10進数のbyte arrayへ変換
$pubkey = $this->hexToByteArray($pubkey_hex);
// byte arrayからbase64へ
$pubkey = $this->byteArrayToString($pubkey);
$pubkey = base64_encode($pubkey);
// PEMに整形
$pubkey = chunk_split($pubkey,64, "\n");
$pubkey_pem = "-----BEGIN PUBLIC KEY-----\n$pubkey-----END PUBLIC KEY-----\n";

// example
// -----BEGIN PUBLIC KEY-----
// MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEX30hUUc22ojT81B1fmHWdHYTIJlT
// N+zhajhY3ZVhWyM4KE5mfqNkqFa8tmO9/F9/+waC5LYUtps1H1bQZMaqaQ==
// -----END PUBLIC KEY-----

これでよく見る公開鍵の形になったと思います。我々が普段sshに使っている公開鍵より短いのは暗号化アルゴリズムが違うからです。
WebAuthnは楕円曲線暗号を用いています。短いからと言って脆弱であるということはありません。

ユーザー認証

登録が無事にできたので、認証のフローも見ていきましょう。フローは以下の図のようになります。


チャレンジの取得(図の①②)

登録時と同様にチャレンジを取得します。後述する署名の取得にcredentialIDが必要になるため、ここでもメールアドレスを送信し、紐付いているcredentialIDを返却します。

Yii::$app->session->set('challenge', $challenge);
return [
  'challenge' => base64_encode($challenge),
  'rpId' => $rpid,
  'allowCredentials' => [
    [
      'id' => $credentialId,
      'type' => "public-key",
    ],
  ],
];

秘密鍵による署名の取得(図の③)

登録時は鍵の生成リクエストをしましたが、認証時は代わりに署名のリクエストをします。
同様に取得したチャレンジやcredencialIDを含むパラメータを引数として関数を呼び出します。

option.challenge = new TextEncoder().encode(Base64.decode(option.challenge))
option.allowCredentials[0].id = new Uint8Array(option.allowCredentials[0].id)
let credential = await navigator.credentials.get({ "publicKey": option})    

認証器によるユーザーの認証(図の④⑤)

登録時と同様に認証器による認証が求められます。
認証に成功すると署名を含むデータが生成されます。

署名など認証情報の送信(図の⑥)

関数の返り値とメールアドレスを認証サーバーに送信します。

const request_body = {
  email: this.email,
  id: credential.id,
  raw_id: new Uint8Array(credential.rawId),
  type: credential.type, // "public-key"
  response: {
    authenticatorData: new Uint8Array(credential.response.authenticatorData),
    clientDataJSON: new Uint8Array(credential.response.clientDataJSON),
    signature: new Uint8Array(credential.response.signature),
  }
}

署名の検証(図の⑦)


さて、最後に送られてきた署名と保存してある公開鍵によって認証を行います。
登録時と同様にClientDataJsonから見ていきましょう。
最低限、確認するべき項目は以下です。
  • clientDataJson.typeが”webauthn.get”であること
  • clientDataJson.challengeが最初に返却したchallengeと一致すること
  • clientDataJson.originがリクエストを送ってきているRPのオリジンと一致すること

// バイナリを文字列に変換
$clientDataJson = implode(array_map("chr", $data['clientDataJson']));
// jsonデコード
$clientDataJson = json_decode($clientDataJson, true);
 
$origin = Yii::$app->request->origin;
$challenge = Yii::$app->session->get('challenge');

if($json['type'] !== "webauthn.get"
  || $json['origin'] !== $origin
  || base64_decode($json['challenge']) !== $challenge ) {
  throw new Exception("invalid!!! client data is not correct");
}

続いて、authenticatorData をパースします。
登録時のauthDataと同じ形式ですので、バイナリを分解してrpidとflagの検証をします。
認証時は、加えて保存してあるcounterの値が送られてきたcounterよりも小さいことを確認します。

// rpidの検証
$rpid_hash = array_slice($authenticatorData, 0, 32);
$rpid_hash = $this->byteArrayToHex($rpid_hash);
if($rpid_hash !== hash("sha256", self::RPID)){
  throw new Exception("invalid!!! not match rpid");
}

 // flagの検証
$flag = str_pad(decbin($authenticatorData[32]), 8, 0, STR_PAD_LEFT);
 
$user_present = substr($flag,7,1); //UP
$user_verified = substr($flag,5,1); //UV
$attested_credential_data = substr($flag,1,1); //AT
 
if($user_present !== 1 || $user_verified !== 1 || $attested_credential_data !== 1){
  throw new Exception("invalid!!! ");
}

// countの検証
$sign_count = $this->byteArrayToEndian(array_slice($authenticatorData, 33, 4));
if($user['sign_count'] >= $sign_count) {
  throw new Exception("invalid!!! sign_count larger than send param.");
}

最後に公開鍵による署名の検証です。
まず、ClientDataJSONをSHA256でハッシュ化します。
その後、得られたハッシュとauthenticatorDataをつなげたものを公開鍵で署名したものがsignatureと一致すれば認証完了です。

// ClientDataJsonをハッシュ化
$clientDataStr = $this->byteArrayToString($clientDataJson);
$clientDataHash = hash('sha256', $clientDataStr);

// authenticatorDataと連結し文字列に変換
$confirm_sig = $this->byteArrayToString(array_merge($authenticatorData, $this->hexToByteArray($clientDataHash)));
// 送られてきたsignatureのバイナリも文字列に変換
$signature = $this->byteArrayToString($signature);
// ユーザーに紐づく公開鍵の取得
$pubkey = $this->getUser($email)['publickey'];

// 認証完了
if(openssl_verify($confirm_sig, $signature, $pubkey, 'sha256')) {
  return true;
}

少々長くなりましたが以上が、WebAuthnのフローと実装になります。

ポイント・特徴

一番の特徴としては、オフラインでのユーザー認証とオンラインでのデータの検証に別れているということです。
これにより秘匿情報が通信にすら乗らず、ユーザーは安全にサイトを利用することができますし、あるサイトの秘密鍵が漏洩したとしても他のサービスでは影響がありません。
また、我々開発者視点に立ったときの特徴として、認証器の認証方式には非関与であるということです。
開発者は、認証器に鍵の生成や署名のリクエストを送るだけなので、顔認証であろうが指紋認証であろうが特有の処理を書くことはありません。
逆に言えば、顔認証でのみ登録を許したりすることは今の所できません。
実装で苦労した点で言えば、基本的にバイナリ・暗号のやり取りなので直感的に分かりづらいこと、最後の署名チェックまで間違っているかどうかもわからないという点です。しっかり仕様書を読んで確認しながら実装しましょう。
今回紹介したフローはこちらに置いてありますので、よろしければ参考にしてください。
時間があればきれいにしていきます(汗


まとめ

いかがでしたでしょうか?
まだまだ、検証段階でWebAuthnのみの認証で実サービスを運用できるのか議論の余地はありますが、パスワードレスを推進する要注目の技術です。今回は最小限の機能を実装しました。サービスに組み込む場合はライブラリなどの登場・充実を待つのもいいと思いますが、この記事がWebAuthnの仕組みを理解する一助になれば幸いです。次回はもう少しサービスに導入する際の懸念点などを考察したいと思います。


最後に

次世代システム研究室では、このような最新技術をキャッチアップし、ユーザーにより便利なサービス開発を行っていくエンジニアを募集しています。興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧からご応募をお願いします。