始めに
※この記事は、 WebAuthn を使用したユーザ登録フローに関する学習メモです。
作成したソースコード一式はこちらに格納しておきます。
FIDO2 について
TL; DR
FIDOという非営利団体が推進する認証技術、規格のこと
生体認証などで用いられるような専用の機器が不要
FIDO2 = WebAuthn + CTAP
W3C WebAuthn
FIDO認証のサポートを可能にするためにブラウザおよびプラットフォームに組み込まれている標準Web APIのこと。登録と認証の機能を持つ。
navigator.credentials.create()
:publicKeyオプションと併用すると、新しいアカウントの登録または、既存アカウントへの新しい非対称鍵ペア(公開鍵と秘密鍵)の関連付けを行うための新しい認証情報を作成します。navigator.credentials.get()
:publicKeyオプションと併用すると、サービスに対する認証のために、ログインまたは二要素認証要素として既存の認証情報セットを使用します。
CTAP(Client to Authentication Protocol)
名前の通り、クライアントと認証器の通信プロトコルです。
仕組み
FIDOのプロトコルでは、標準的な公開鍵暗号方式を用いて、認証を実現しています。
以下、基本的な処理シーケンスです。
このように、クライアント(認証器)/サーバ間で認証情報(パスワード等)をやり取りしないため、従来のID/Password方式の認証方式よりも安全であると、言われています。また、クライアント側に認証に必要な秘密鍵を保持することによって、ユーザがパスワードを記憶する必要がなくなるというメリットも存在します。
ただし、Yubikeyや端末本体等の認証器を紛失した場合は、パスワード紛失と同じようにセキュリティリスクがあります。
認証サーバ(rp)を作って遊んでみる
登録の処理シーケンス
※MDMより引用
- 認証サーバに対して、ユーザーの登録要求を行う リクエストのプロトコルやフォーマットは特にWebAuthnで規定されているわけではありません。
- 認証サーバで生成したchallenge, ユーザ情報、サーバの情報をクライアントに返却する
以下の点に気を付ける必要があります。
- challengeは、ランダムに生成したバッファーであること。(少なくとも16バイト以上)
- challengeは、必ずサーバ上で生成すること。(登録過程のセキュリティを確保するため)
- クライアント/サーバ間でバッファーソースは、base64url encode/decodeして扱う
- 認証器に対して、認証情報の生成要求を行う
認証サーバから取得したデータを元に、
navigator.credentials.create()
呼び出しに必要なパラメータを組み立てる。 ※challenge等はbase64url encodeされているため、create()
呼び出し前にデコードする必要があります。 - ユーザーの確認後、非対称鍵ペアとAttestationを生成する
- 生成したデータをクライアントに返却する
- 認証器から取得した認証情報をサーバに送信する こちらも、リクエストのプロトコルやフォーマットは特にWebAuthnで規定されているわけではありません。 ※ArrayBufferプロパティは、サーバに送信する前にbase64url エンコードする必要があります。
- 認証情報のチェックを実施する
クライアントから送信された、認証情報に対して以下のチェックを行います。
- challengeが認証サーバで作成されたものと同一であるか?
- originが期待通りのoriginであるか?
- attestationObjectが有効か?
WebAuthnで規定されていない箇所に関しては自分で仕様を考えて実装する必要があります。
従って、今回作成するのは以下の赤枠部分となります。(認証サーバとJavaScript Application)
実装言語・FW
- 認証サーバ;Nest.js v6.13.3 (https://gitlab.com/s.kawamura/webauthn-nestjs-sample)
- JavaScript Application;Angular v9.0.4 (https://gitlab.com/s.kawamura/webauthn-angular-sample)
- Database;MongoDB
で作成しました。好きな言語、フレームワークで作成してみてください。
認証サーバ作成
思ったよりも量が多くなってしまったので、かいつまんで掲載します。
ソースコードすべてを参照したい方は、こちらから参照してください。
ユーザの登録要求 ~ challenge、ユーザ情報、サーバ情報のレスポンス
最終的に以下のようなリクエスト、レスポンスとなるように作りこんでいきます。(登録の処理シーケンスの0 ~ 1の部分)
リクエスト
POSThttp://localhost:3000/webauthn/registerHTTP/1.1Content-Type:application/json{"email":"sample@sample.com"}
レスポンス
HTTP/1.1201OKContent-Type:application/json;charset=UTF-8{"status":"ok","challenge":"<ランダムな文字列>",//ArrayBufferをbase64Urlエンコードしたもの"rp":{"name":"webauthn-server-sample"},"user":{"id":"<ランダムな文字列>",//ArrayBufferをbase64urlエンコードしたもの"name":"sample name","displayName":"sample display name"},"attestation":"direct"}
@Injectable()exportclassWebauthnService{constructor(@InjectModel('User')privateuserModel:Model<User>){}/**
* 認証器が鍵の生成に必要なパラメータを生成します。
* @param createUserDto リクエストボディー
*/asynccreateUserCreationOptions(createUserDto:CreateUserDto):Promise<UserCreationOptions>{// challenge, userIdをuuidを元に作成するconstchallenge=Buffer.from(Uint8Array.from(uuid(),c=>c.charCodeAt(0)));constuserId=Buffer.from(Uint8Array.from(uuid(),c=>c.charCodeAt(0)));// UserCreationOptionsのパラメータを組み立てるconstuserCreationOptions:UserCreationOptions={email:createUserDto.email,challenge:base64url.encode(challenge),rp:{name:'webauthn-server-nestjs-sample',},user:{id:base64url.encode(userId),name:createUserDto.email,displayName:createUserDto.email,},attestation:'direct',};// DBに保存するconstsaveResult=awaitthis.saveUser(userCreationOptions);// falsyだった場合、nullを返却するif(!saveResult){returnnull;}returnuserCreationOptions;}
認証情報のチェック
最終的に、以下のようなリクエスト、レスポンスとなるように作りこんでいきます。
リクエスト
POSThttp://localhost:3000/webauthn/responseHTTP/1.1Content-Type:application/json{"rawId":"Gw7nqgWzci8jwIX9yXzYtynmJbQAAAAAAAAAAAAAAAAAAAAA...","response":{"attestationObject":"o2NmbXRoZmlkby11MmZnYXR0U3RtdKJ...","clientDataJSON":"eyJjaGFsbGVuZ2UiOiJLMlEwdHdnXzV..."},"id":"Gw7nqgWzci8jwIX9yXzYtynmJbQAAAAAAAAAAAAAAAAAAAAA...","type":"public-key"}
レスポンス
HTTP/1.1201OKContent-Type:application/json;charset=UTF-8{"status":"ok",}
challenge, originの検証
リクエストボディに含まれるclientDataJSON
をbase64Urlデコードし、JSONにパースすると以下のようなJSONを取得できます。
{"challenge":"upYb6sib9exL7fvSfQhIEazOkBh8_YJXVPzSx0T16B0","origin":"http://localhost:4200","type":"webauthn.create"}
これに対して、
- challengeは、サーバで生成されたchallengeと一致しているか?(データベースに保存しておいたchallengeと一致しているか?)
- originは、期待通りか?
という検証を行えば十分です。
AttestationObjectの検証
AttestationObjectは、base64urlエンコードされているCBORとなっています。実際の構成は以下の通りとなっています。
※W3Cより引用
検証では、AttestationObjectをパースして得られるパラメータを使用して、Attestation Signatureの有効性を検証します。
実際の検証ロジックは、fido-seminar-webauthn-tutorialが非常に参考になりました。
/**
* AttestationObjectの検証を行います。
* @param createCredentialDto 認証器が生成した認証データ
*/privateasyncverifyAuthenticatorAttestationResponse(createCredentialDto:CreateCredentialDto):Promise<VerifiedAuthenticatorAttestationResponse>{// 認証器でbase64urlエンコードされているので、認証サーバでデコードするconstattestationBuffer=base64url.toBuffer(createCredentialDto.response.attestationObject);// attestationObjectをCBORデコードするconstctapMakeCredentialResponse:CborParseAttestationObject=Decoder.decodeAllSync(attestationBuffer)[0];Logger.debug(ctapMakeCredentialResponse,'WebAuthnService',true);constresponse:VerifiedAuthenticatorAttestationResponse={verified:false,};if(ctapMakeCredentialResponse.fmt==='fido-u2f'){constauthDataStruct=this.parseMakeCredAuthData(ctapMakeCredentialResponse.authData);if(!authDataStruct.flags){thrownewError('User was NOT presented durring authentication!');}constclientDataHash=crypto.createHash('SHA256').update(base64url.toBuffer(createCredentialDto.response.clientDataJSON)).digest();constreservedByte=Buffer.from([0x00]);constpublicKey=this.convertToRawPkcsKey(authDataStruct.cosePublicKey);constsignatureBase=Buffer.concat([reservedByte,authDataStruct.rpIdHash,clientDataHash,authDataStruct.credID,publicKey]);constpemCertificate=this.convertPemTextFormat(ctapMakeCredentialResponse.attStmt.x5c[0]);constsignature=ctapMakeCredentialResponse.attStmt.sig;response.verified=this.verifySignature(signature,signatureBase,pemCertificate);constvalidateResult=this.verifySignature(signature,signatureBase,pemCertificate);// Attestation Signatureの有効性を検証するreturnvalidateResult?{verified:validateResult,authInfo:{fmt:'fido-u2f',publicKey:base64url.encode(publicKey),counter:authDataStruct.counter,credId:base64url.encode(authDataStruct.credID),},}:response;}}
クライアントサイド作成
htmlとcssは適当に作ってください。(丸投げ)
/**
* ユーザ登録処理のサービスクラスです。
*/@Injectable({providedIn:'root'})exportclassSignUpService{constructor(privatereadonlyhttpClient:HttpClient){}/**
* ユーザの登録処理を実行します。
* @param email メールアドレス
*/asyncsignUp(email:string):Promise<boolean>{// challengeの作成constregisterResponse=awaitthis.createChallenge(email);// `navigator.credentials.create()呼び出しのために必要なパラメータの組み立てconstpublicKeyCredentialCreationOptions:PublicKeyCredentialCreationOptions={challenge:Buffer.from(base64url.decode(registerResponse.data.challenge)),rp:registerResponse.data.rp,user:{id:Buffer.from(base64url.decode(registerResponse.data.user.id)),name:registerResponse.data.user.name,displayName:registerResponse.data.user.displayName,},attestation:registerResponse.data.attestation,pubKeyCredParams:[{type:'public-key'as'public-key',alg:-7,}],authenticatorSelection:{authenticatorAttachment:'cross-platform',requireResidentKey:false,userVerification:'discouraged'}};// 明示的にPublicKeyCredentialにキャストするconstattestationObject=awaitthis.createAttestationObject(publicKeyCredentialCreationOptions)asPublicKeyCredential;// 公開鍵をサーバに送信するreturnthis.registerPublicKey(attestationObject);}/**
* WebAuthn認証サーバに対して、チャレンジの生成要求を行います。
* @param email メールアドレス
*/privateasynccreateChallenge(email:string):Promise<User>{constregisterResponse=awaitthis.httpClient.post<User>(Uri.USER_REGISTER,{email},{headers:{'Content-Type':'application/json'},observe:'response',}).toPromise();console.log(registerResponse.body);returnregisterResponse.body;}/**
* 認証器に対して公開鍵の生成要求を行います。
* @param publicKeyCreationOptions 認証情報生成オプション
*/privateasynccreateAttestationObject(publicKeyCreationOptions:PublicKeyCredentialCreationOptions):Promise<Credential>{returnnavigator.credentials.create({publicKey:publicKeyCreationOptions});}/**
* 認証情報をBase64Urlエンコードして認証サーバにPOSTします。
* @param credential 認証器で生成した認証情報
*/privateasyncregisterPublicKey(publicKeyCredential:PublicKeyCredential):Promise<boolean>{constattestationResponse=awaitthis.httpClient.post(Uri.ATTESTATION_RESPONSE,{rawId:base64url.encode(Buffer.from(publicKeyCredential.rawId)),response:{attestationObject:base64url.encode(Buffer.from(publicKeyCredential.response.attestationObject)),clientDataJSON:base64url.encode(Buffer.from(publicKeyCredential.response.clientDataJSON)),},id:publicKeyCredential.id,type:publicKeyCredential.type},{headers:{'Content-Type':'application/json'},observe:'response'}).toPromise();returnattestationResponse.body?true:false;}}
完成イメージ
終わりに
次は、認証のフローについて書きます。