go で WebAuthn する(Registration編)
はじめに
go で WebAuthn します。
12月初旬にはおっしゃ実装したろ!という気持ちがありましたが時の流れとは恐ろしいもので以下の go で実装された WebAuthn の example の実装を読んで 2020年へ気持ちを高めていこう。という Go3 Advent Calendar 2019の22日目 の記事です。
以下のブログが実装含めとても詳しいです。
ぜひこちらもご一読ください。
WebAuthn
Web Authentication API(webauthn)は公開鍵暗号を使用しブラウザでパスワードレス認証を実現する仕組みです。
Registration(登録)
とAuthentication(認証)
の2ステップで構成されています。
まずは実際に hbolimovsky/webauthn-example を動かす準備を行っていきます。
YubiKey
たまたま手元にあった YubiKey 5 NFC を利用します。
万が一手元にない方は amazon からも買えます。
Yubico - YubiKey 5 NFC - USB-A - 2つのファクター認証セキュリティキー
- メディア: Personal Computers
hbolimovsky/webauthn-example
hbolimovsky/webauthn-example は go で実装された webauthn の example です。 Github から clone して repository root で実行します。
$ git clone https://github.com/hbolimovsky/webauthn-example.git $ cd ./webauthn-example $ go run . 2019/12/XX XX:XX:XX starting server at :8080
localhost:8080
へアクセスすると以下のような画面が表示されます。
webauthn のRegistration(登録)
とAuthentication(認証)
それぞれのステップを確認することができます。
webauthn Registration(登録)
登録のシーケンスは以下の通りです。
各シーケンスの詳細は MDNのページ を参照してください。
実際に YubiKey で実行してみましょう。
YubiKey を挿した状態で Username に適当なアドレスを設定しRegister
します。
Seq 0 : アプリケーションが登録要求を行う
入力した Username を持ってブラウザがサーバーへ登録リクエストを投げます。ですがこのリクエストのプロトコルとフォーマットは WebAuthn による規定の対象範囲外のようです。
このアプリケーションでは http://localhost:8080/register/begin/<USERNAME>
に対してGETを投げています。
Seq 1 : サーバーが challenge・ユーザー情報・ Relying Party 情報を送信
サーバーは challenge・ユーザ情報・ Relying party 情報をブラウザにレスポンスします。
challenge は duo-labs/webauthn で以下のように crypto/rand
を利用して生成されていました。
16 byte 以上である必要があって 32 byte で利用しています。
package protocol import ( "crypto/rand" "encoding/base64" ) // ChallengeLength - Length of bytes to generate for a challenge const ChallengeLength = 32 // Challenge that should be signed and returned by the authenticator type Challenge URLEncodedBase64 // Create a new challenge to be sent to the authenticator. The spec recommends using // at least 16 bytes with 100 bits of entropy. We use 32 bytes. func CreateChallenge() (Challenge, error) { challenge := make([]byte, ChallengeLength) _, err := rand.Read(challenge) if err != nil { return nil, err } return challenge, nil } func (c Challenge) String() string { return base64.RawURLEncoding.EncodeToString(c) }
ユーザー情報 の displayName
と name
はブラウザに入力した Username です。
id
は webauthn-example/user.go で以下のようにcrypto/rand
から生成されています。
func randomUint64() uint64 { buf := make([]byte, 8) rand.Read(buf) return binary.LittleEndian.Uint64(buf) }
ユーザー情報は webauthn-example/userdb.go に取り回されていてメモリ上に保管しています。
オシャレですね。こういうのがスッと書ける大人になりたいものです。
シングルトンの生成
func DB() *userdb { if db == nil { db = &userdb{ users: make(map[string]*User), } } return db }
に対するPut/Get
func (db *userdb) GetUser(name string) (*User, error) { db.mu.Lock() defer db.mu.Unlock() user, ok := db.users[name] if !ok { return &User{}, fmt.Errorf("error getting user '%s': does not exist", name) } return user, nil } // PutUser stores a new user by the user's username func (db *userdb) PutUser(user *User) { db.mu.Lock() defer db.mu.Unlock() db.users[user.name] = user }
Relying party はwebauthn-example/server.goの中で決めうちに初期化されています。
webAuthn, err = webauthn.New(&webauthn.Config{ RPDisplayName: "Foobar Corp.", // Display Name for your site RPID: "localhost", // Generally the domain name for your site RPOrigin: "http://localhost", // The origin URL for WebAuthn requests // RPIcon: "https://duo.com/logo.png", // Optional icon URL for your site })
Seq 2 : ブラウザーが認証器の authenticatorMakeCredential() を呼び出す
ここは ブラウザーのAPIのお話のようです。
時間が取れたら Angular で書いてみます。
Seq 3 : 認証器が新しい鍵ペアと Attestation を作成
今回は認証器として YubiKey を利用しています。
ここで以下のように登録に同意していることが確認されます。
Attestation については以下の記事が詳しいです。
Attestation は証明書 で言うところの CAの署名と似たような仕組みのようです。
Seq 4 : 認証器がブラウザーにデータを返す
公開鍵、認証ID、Attestation 情報が認証器である Yubikey からブラウザーに返されます。
Seq 5 : ブラウザーが最終的に送信するデータを作成し、アプリケーションがその戻り値をサーバに送信
認証器である Yubikey から渡された情報を整形し、このアプリケーションでは http://localhost:8080/register/finish/<USERNAME>
にPOSTメソッドで送信しています。
このアプリケーションでは以下のようなフォーマットでした。
{ "id": "XXXXXXXXXX-pssY8Ol_lLxQrGR3n58dVpe5olyDTk4PaAo8Ns_xCbJiN2O8nZCzNromMHzNU704_-_XXXXXX", "rawId": "XXXXXXXXXX-pssY8Ol_lLxQrGR3n58dVpe5olyDTk4PaAo8Ns_xCbJiN2O8nZCzNromMHzNU704_-_XXXXXX", "type": "public-key", "response": { "attestationObject": "XXXXXXXXXXFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEgwRgIhAJa7grTJIn1Da_q2a6oZyEhk-AJYi-Cdt27_v7_86ybxAiEA5Jcw106cxK9WNv0dibXjZnXMb9Q8CHa7QV1Mmo-lr8ljeDVjgVkCwDCCArwwggGkoAMCAQICBAOt8BIwDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG0xCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xJjAkBgNVBAMMHVl1YmljbyBVMkYgRUUgU2VyaWFsIDYxNzMwODM0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGZ6HnBYtt9w57kpCoEYWpbMJ_soJL3a-CUj5bW6VyuTMZc1UoFnPvcfJsxsrHWwYRHnCwGH0GKqVS1lqLBz6F6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQ-iuZ3J45QlePkkow0jxBGDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAo67Nn_tHY8OKJ68qf9tgHV8YOmuV8sXKMmxw4yru9hNkjfagxrCGUnw8t_Awxa_2xdbNuY6Iru1gOrcpSgNB5hA5aHiVyYlo7-4dgM9v7IqlpyTi4nOFxNZQAoSUtlwKpEpPVRRnpYN0izoon6wXrfnm3UMAC_tkBa3Eeya10UBvZFMu-jtlXEoG3T0TrB3zmHssGq4WpclUmfujjmCv0PwyyGjgtI1655M5tspjEBUJQQCMrK2HhDNcMYhW8A7fpQHG3DhLRxH-WZVou-Z1M5Vp_G0sf-RTuE22eYSBHFIhkaYiARDEWZTiJuGSG2cnJ_7yThUU1abNFdEuMoLQ3aGF1dGhEYXRhWMRJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0EAAAAH-iuZ3J45QlePkkow0jxBGABAQrYTuRPz1amE-pssY8Ol_lLxQrGR3n58dVpe5olyDTk4PaAo8Ns_xCbJiN2O8nZCzNromMHzNU704_-_mt6CpaUBAgMmIAEhWCB7hdreqjIbhqNp08s4UZWqCURfFrVX53tuTKcx4txetiJYIFZ3HWLG1A-vW3gsEfxClRU5l5fyq7jzQXXXXXXXXXX", "clientDataJSON": "XXXXXXXXXXuZ2UiOiJ5Z21RT3YyYWk4YlZLU3hDUk9abjRGRXlaVHhCVng5N1pscFNsME8xWXZvIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwidHlwZSI6IndlYmF1dGhuLmNXXXXXXXXXX" } }
Seq 6 : サーバーが登録を検証・完了させる
サーバー側で Seq 5 のデータを検証します。
MDN に書かれているのは challengeの検証、originの検証、clientDataHashと認証器としての YubiKey の証明書チェーンを使った attestation の検証 等を行う必要があると書かれています。
このアプリケーションではUserIDの検証を行ったのちwebauthn/webauthn/registration.go のparsedResponse.Verify()
に渡ります。
// Take the response from the authenticator and client and verify the credential against the user's credentials and // session data. func (webauthn *WebAuthn) FinishRegistration(user User, session SessionData, response *http.Request) (*Credential, error) { if !bytes.Equal(user.WebAuthnID(), session.UserID) { return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session") } parsedResponse, err := protocol.ParseCredentialCreationResponse(response) if err != nil { return nil, err } shouldVerifyUser := webauthn.Config.AuthenticatorSelection.UserVerification == protocol.VerificationRequired invalidErr := parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigin) if invalidErr != nil { return nil, invalidErr } return MakeNewCredential(parsedResponse) }
Client Dataに対してRPに保管されたデータの検証を行なったのちに attestation の検証として webauthn/protocol/credential.go のpcc.Response.AttestationObject.Verify()
に渡されています。
// Verifies the Client and Attestation data as laid out by §7.1. Registering a new credential // https://www.w3.org/TR/webauthn/#registering-a-new-credential func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUser bool, relyingPartyID, relyingPartyOrigin string) error { // Handles steps 3 through 6 - Verifying the Client Data against the Relying Party's stored data verifyError := pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, relyingPartyOrigin) if verifyError != nil { return verifyError } // Step 7. Compute the hash of response.clientDataJSON using SHA-256. clientDataHash := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON) // Step 8. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse // structure to obtain the attestation statement format fmt, the authenticator data authData, and the // attestation statement attStmt. is handled while // We do the above step while parsing and decoding the CredentialCreationResponse // Handle steps 9 through 14 - This verifies the attestaion object and verifyError = pcc.Response.AttestationObject.Verify(relyingPartyID, clientDataHash[:], verifyUser) if verifyError != nil { return verifyError } // Step 15. If validation is successful, obtain a list of acceptable trust anchors (attestation root // certificates or ECDAA-Issuer public keys) for that attestation type and attestation statement // format fmt, from a trusted source or from policy. For example, the FIDO Metadata Service provides // one way to obtain such information, using the aaguid in the attestedCredentialData in authData. // [https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-metadata-service-v2.0-id-20180227.html] // TODO: There are no valid AAGUIDs yet or trust sources supported. We could implement policy for the RP in // the future, however. // Step 16. Assess the attestation trustworthiness using outputs of the verification procedure in step 14, as follows: // - If self attestation was used, check if self attestation is acceptable under Relying Party policy. // - If ECDAA was used, verify that the identifier of the ECDAA-Issuer public key used is included in // the set of acceptable trust anchors obtained in step 15. // - Otherwise, use the X.509 certificates returned by the verification procedure to verify that the // attestation public key correctly chains up to an acceptable root certificate. // TODO: We're not supporting trust anchors, self-attestation policy, or acceptable root certs yet // Step 17. Check that the credentialId is not yet registered to any other user. If registration is // requested for a credential that is already registered to a different user, the Relying Party SHOULD // fail this registration ceremony, or it MAY decide to accept the registration, e.g. while deleting // the older registration. // TODO: We can't support this in the code's current form, the Relying Party would need to check for this // against their database // Step 18 If the attestation statement attStmt verified successfully and is found to be trustworthy, then // register the new credential with the account that was denoted in the options.user passed to create(), by // associating it with the credentialId and credentialPublicKey in the attestedCredentialData in authData, as // appropriate for the Relying Party's system. // Step 19. If the attestation statement attStmt successfully verified but is not trustworthy per step 16 above, // the Relying Party SHOULD fail the registration ceremony. // TODO: Not implemented for the reasons mentioned under Step 16 return nil }
webauthn/protocol/attestation.go のattestationObject.AuthData.Verify()
に渡されます。
// Verify - Perform Steps 9 through 14 of registration verification, delegating Steps func (attestationObject *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, verificationRequired bool) error { // Steps 9 through 12 are verified against the auth data. // These steps are identical to 11 through 14 for assertion // so we handle them with AuthData // Begin Step 9. Verify that the rpIdHash in authData is // the SHA-256 hash of the RP ID expected by the RP. rpIDHash := sha256.Sum256([]byte(relyingPartyID)) // Handle Steps 9 through 12 authDataVerificationError := attestationObject.AuthData.Verify(rpIDHash[:], verificationRequired) if authDataVerificationError != nil { return authDataVerificationError } // Step 13. Determine the attestation statement format by performing a // USASCII case-sensitive match on fmt against the set of supported // WebAuthn Attestation Statement Format Identifier values. The up-to-date // list of registered WebAuthn Attestation Statement Format Identifier // values is maintained in the IANA registry of the same name // [WebAuthn-Registries] (https://www.w3.org/TR/webauthn/#biblio-webauthn-registries). // Since there is not an active registry yet, we'll check it against our internal // Supported types. // But first let's make sure attestation is present. If it isn't, we don't need to handle // any of the following steps if attestationObject.Format == "none" { if len(attestationObject.AttStatement) != 0 { return ErrAttestationFormat.WithInfo("Attestation format none with attestation present") } return nil } formatHandler, valid := attestationRegistry[attestationObject.Format] if !valid { return ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation format %s is unsupported", attestationObject.Format)) } // Step 14. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature, by using // the attestation statement format fmt’s verification procedure given attStmt, authData and the hash of the serialized // client data computed in step 7. attestationType, _, err := formatHandler(*attestationObject, clientDataHash) if err != nil { return err.(*Error).WithInfo(attestationType) } return nil }
webauthn/protocol/authenticator.go のattestationObject.AuthData.Verify()
に渡されようやく検証されています。
Relying partyの ID の hash値と認証器としての YubiKey のRelying partyの ID の hash値の検証、ユーザーによって付加される認証器としての YubiKey のフラグ(なんだこれ。。。)の検証を行なっています。
// Verify on AuthenticatorData handles Steps 9 through 12 for Registration // and Steps 11 through 14 for Assertion. func (a *AuthenticatorData) Verify(rpIdHash []byte, userVerificationRequired bool) error { // Registration Step 9 & Assertion Step 11 // Verify that the RP ID hash in authData is indeed the SHA-256 // hash of the RP ID expected by the RP. if !bytes.Equal(a.RPIDHash[:], rpIdHash) { return ErrVerification.WithInfo(fmt.Sprintf("RP Hash mismatch. Expected %+s and Received %+s\n", a.RPIDHash, rpIdHash)) } // Registration Step 10 & Assertion Step 12 // Verify that the User Present bit of the flags in authData is set. if !a.Flags.UserPresent() { return ErrVerification.WithInfo(fmt.Sprintln("User presence flag not set by authenticator")) } // Registration Step 11 & Assertion Step 13 // If user verification is required for this assertion, verify that // the User Verified bit of the flags in authData is set. if userVerificationRequired && !a.Flags.UserVerified() { return ErrVerification.WithInfo(fmt.Sprintln("User verification required but flag not set by authenticator")) } // Registration Step 12 & Assertion Step 14 // Verify that the values of the client extension outputs in clientExtensionResults // and the authenticator extension outputs in the extensions in authData are as // expected, considering the client extension input values that were given as the // extensions option in the create() call. In particular, any extension identifier // values in the clientExtensionResults and the extensions in authData MUST be also be // present as extension identifier values in the extensions member of options, i.e., no // extensions are present that were not requested. In the general case, the meaning // of "are as expected" is specific to the Relying Party and which extensions are in use. // This is not yet fully implemented by the spec or by browsers return nil }
ここで error がなければ alert("successfully registered " + username + "!")
としてブラウザに以下のような alert が表示され、登録が完了しています。
webauthn 認証
認証 についてのシーケンスについては別記事にまとめます。
YubiKey を挿した状態で login
をクリックし YubiKey をタップするとログインすることができます。
まとめ
go で webauthn を学びました。
OAuth2/OIDC には挫折気味なのですが、webauthnはユーザー体験含めて私は良いと思います。
リカバリーフローなんかを追っていきたいと思います。
本家からBlogが出ていることに気づいた。。。