go で WebAuthn する(Registration編)

はじめに

go で WebAuthn します。
12月初旬にはおっしゃ実装したろ!という気持ちがありましたが時の流れとは恐ろしいもので以下の go で実装された WebAuthn の example の実装を読んで 2020年へ気持ちを高めていこう。という Go3 Advent Calendar 2019の22日目 の記事です。

github.com

以下のブログが実装含めとても詳しいです。
ぜひこちらもご一読ください。

tech.mercari.com

WebAuthn

Web Authentication API(webauthn)公開鍵暗号を使用しブラウザでパスワードレス認証を実現する仕組みです。

Registration(登録)Authentication(認証)の2ステップで構成されています。

まずは実際に hbolimovsky/webauthn-example を動かす準備を行っていきます。

YubiKey

たまたま手元にあった YubiKey 5 NFC を利用します。
万が一手元にない方は amazon からも買えます。

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(認証)それぞれのステップを確認することができます。

f:id:nananao_dev:20191219171603p:plain

webauthn Registration(登録)

登録のシーケンスは以下の通りです。
各シーケンスの詳細は MDNのページ を参照してください。

f:id:nananao_dev:20191219221947p:plain
https://developer.mozilla.org/ より引用

実際に YubiKey で実行してみましょう。
YubiKey を挿した状態で Username に適当なアドレスを設定しRegisterします。

f:id:nananao_dev:20191219203754p:plain

Seq 0 : アプリケーションが登録要求を行う

入力した Username を持ってブラウザがサーバーへ登録リクエストを投げます。ですがこのリクエストのプロトコルとフォーマットは WebAuthn による規定の対象範囲外のようです。

このアプリケーションでは http://localhost:8080/register/begin/<USERNAME> に対してGETを投げています。

Seq 1 : サーバーが challenge・ユーザー情報・ Relying Party 情報を送信

サーバーは challenge・ユーザ情報・ Relying party 情報をブラウザにレスポンスします。

f:id:nananao_dev:20191219232528p:plain

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)
}

ユーザー情報 の displayNamename はブラウザに入力した Username です。
idwebauthn-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 を利用しています。
ここで以下のように登録に同意していることが確認されます。

f:id:nananao_dev:20191219203926p:plain

Attestation については以下の記事が詳しいです。
Attestation は証明書 で言うところの CAの署名と似たような仕組みのようです。

blog.haniyama.com

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.goparsedResponse.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.gopcc.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.goattestationObject.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.goattestationObject.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 が表示され、登録が完了しています。

f:id:nananao_dev:20191219203949p:plain

webauthn 認証

認証 についてのシーケンスについては別記事にまとめます。
YubiKey を挿した状態で login をクリックし YubiKey をタップするとログインすることができます。

f:id:nananao_dev:20191219204024p:plain

f:id:nananao_dev:20191219204055p:plain

まとめ

go で webauthn を学びました。 OAuth2/OIDC には挫折気味なのですが、webauthnはユーザー体験含めて私は良いと思います。
リカバリーフローなんかを追っていきたいと思います。

本家からBlogが出ていることに気づいた。。。

www.herbie.dev