🔐

パスキー認証実装をサボりたかったけど挫折した

2023/12/31に公開

はじめに

2023年はパスキー元年と言われています。

https://blog.agektmr.com/2023/12/passkey-mythbusting

※ この記事ではパスキーについては言及しないです。パスキーについて知りたい方は上記記事をご確認ください。

私も go-webauthn/webauthn という Go のライブラリを使ってパスキー認証を実装しました。
実装しましたが、もうちょっと簡単に実装できないかなというポイントがあったので言及してみたという記事です。
結果的にはうまくいかず挫折しましたがどんなことを検討したか共有します。

go-webauthn

パスキー認証の実装には go-webauthn/webauthn ライブラリを使っています。

https://github.com/go-webauthn/webauthn

webauthn.io という WebAuthn をお試しできるデモサイトで紹介されています。

https://webauthn.io/

サンプル実装が提供されていたり

https://github.com/go-webauthn/example

teamhanko/hankozitadel/zitadel という OSS で活用されていたりします。

https://github.com/teamhanko/hanko

https://github.com/zitadel/zitadel

なぜやりたかった??

go-webauthn の実装を眺めていると構造体には以下のように json タグが指定されています。

https://github.com/go-webauthn/webauthn/blob/v0.10.0/protocol/options.go#L23-L33

この値は JavaScript で呼び出す navigator.credentials.create() に渡すことになります。

https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create

teamhanko/hanko では OpenAPI によってエンドポイントが定義されているのですが

https://docs.hanko.io/api-reference/public/webauthn/initialize-webauthn-login

Options の構造が OpenAPI で改めて定義されています。

... これって二重管理 ... ???
(そうそう定義が変わることないだろうけど)管理するのめんどくさくない ... ???
 「なんか雑に JSON 渡すのでそのまま Web API に渡してください」みたいな実装にしたくない ... ???
「フロントエンド実装者がパスキーのパラメータ詳細に知らなくてもOK」みたいな実装にしたくない ... ???

そんなモチベーションがあったのでいろいろ試行してみました。

JSON をそのまま返してみる

「詳細は定義せずレスポンスでJSONを返すのでそのまま Web API 渡してください」を試してみます。

OpenAPI で定義するとこんな感じです。

openapi.yaml
  /attestation:
    description: https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Attestation_and_Assertion#attestation
    get:
      tags:
        - Passkey
      summary: Initialize Attestation JSON
      description: Initialize Attestation JSON
      operationId: initializeAttestationJSON
      responses:
        '200':
          description: OK
          content:
            text/plain:
              schema:
                type: string
          headers:
            Set-Cookie:
              description: Set-Cookie
              schema:
                type: string
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

OpenAPI にて JSON だけを返すように定義ができました。

では JavaScript 側の実装をしてみましょう。

以下のような実装になります。

index.js
const bufferDecode = (value) =>
    Uint8Array.from(atob(value), (c) => c.charCodeAt(0));

const attestationJSON = async () => {
    event.preventDefault();

    const result = await fetch("http://localhost:8080/attestation/json", {
        method: "GET",
        credentials: "include",
        headers: {
            "Content-Type": "text/plain"
        },
    })

    const publicKey = await result.json();

    publicKey.challenge = bufferDecode(publicKey.challenge);

    publicKey.user.id = bufferDecode(publicKey.user.id);

    if (publicKey.excludeCredentials) {
        publicKey.excludeCredentials = publicKey.excludeCredentials.forEach((credential) => {
            credential.id = bufferDecode(credential.id);
        });
    }

    const credential = await navigator.credentials.create({
        publicKey: publicKey,
    })

    console.log(credential);

    // NOTE: このあと登録完了を実施
}

そうなのです。そのまま渡せないのです。
ところどころ bufferDecode() という関数を使って Base64 を ArrayBuffer に変換してあげる必要があります。

※ go-webauthn で以下のような実装となっており JSON に Marshal すると Base64 に変換されます。

base64.go

OpenAPI で 定義を省略したにも関わらず JavaScript 側では ArrayBuffer に変換するという作業が必要になります。そのためにはどの値を変換しなければいけないかを知らなければいけません。

... イケてないですね。全然実装サボれないですね。なんならドキュメント(OpenAPI)に記述されなくなったので使いづらくなっていますね。

JSON 以外でエンコードしてみる

「データ指向アプリケーションデザイン」という書籍にて

https://www.oreilly.co.jp/books/9784873118703/

存在を知った MessagePack が使えないかなって検証しました。

https://msgpack.org/ja.html

結論、動きました!拍手

OpenAPI の定義はこちら
  /attestation:
    description: https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Attestation_and_Assertion#attestation
    get:
      tags:
        - Passkey
      summary: Initialize Attestation
      description: Initialize Attestation
      operationId: initializeAttestation
      responses:
        '200':
          description: OK
          content:
            application/x-msgpack:
              schema:
                type: string
                format: binary
          headers:
            Set-Cookie:
              description: Set-Cookie
              schema:
                type: string
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
サーバー (Go) 側の実装はこちら
	options, session, err := hdl.webAuthn.BeginRegistration(&User{
		ID: "passkey",
	})
	if err != nil {
		return &api.ErrorResponse{
			Message: fmt.Sprintf("failed to begin registration. error: %s", err),
		}, nil
	}

	var buf bytes.Buffer

	enc := msgpack.NewEncoder(&buf)

	enc.SetCustomStructTag("json")

	if err := enc.Encode(options.Response); err != nil {
		return &api.ErrorResponse{
			Message: fmt.Sprintf("failed to encode credential creation options. error: %s", err),
		}, err
	}
	
	// buf をクライアントへ返す
ブラウザ (HTML/JavaScript) 側の実装はこちら

index.html

    <script src="https://rawgithub.com/kawanet/msgpack-lite/master/dist/msgpack.min.js"></script>

index.js

    const result = await fetch("http://localhost:8080/attestation", {
        method: "GET",
        credentials: "include",
        headers: {
            "Content-Type": "application/x-msgpack"
        },
    })

    const buf = await result.arrayBuffer();

    const publicKey = msgpack.decode(new Uint8Array(buf.slice()));

    console.info(publicKey);

    console.log(document.cookie)

    const credential = await navigator.credentials.create({
        publicKey: publicKey,
    })

Go では vmihailenco/msgpack というライブラリを使いエンコードし
JavaScript では kawanet/msgpack-lite というライブラリを使いデコードしています。

あれ、挫折したのでは ... ???
はい、動いたのは Attestation の開始処理だけです。完了処理は動かせませんでした ...

JavaScript で navigator.credentials.create() の出力 credential を MessagePack にエンコードができませんでした ...
というかできているのかどうかもわかりませんでした ... 涙

あとで調べる: SessionData って漏れたらダメな情報 ... ???

go-webauthn/webauthn で下記のように SessionData というものが定義されています。

types.go

Attestation 処理の開始で払い出され、処理の完了で必要になります。
サンプルコードなどを見ているとこちらの値を Redis や インメモリ にて保存しキーとなる値をセッションにて連れ回しているようでした。
そのコード書くのめんどくさい(そうでもないけど)な。でもこれってそうやって管理するってことは漏れたらダメな情報だったりする ... ???

そう思った私は cookie に対して暗号化してこの値をそのまま連れ回すように実装してみました。これが正しいのかわかりませんがひとつのアイデアとして共有します。

Cipher の初期化
key := []byte("passw0rdpassw0rdpassw0rdpassw0rd")

block, err := aes.NewCipher(key)
if err != nil {
	panic(err)
}
Attestation 開始
_, session, err := hdl.webAuthn.BeginRegistration(&User{
	ID: "passkey",
})
if err != nil {
	return err
}

jsonSession, err := json.Marshal(session)
if err != nil {
	return err
}

cipherSession := make([]byte, aes.BlockSize+len(jsonSession))

iv := cipherSession[:aes.BlockSize]

if _, err := io.ReadFull(rand.Reader, iv); err != nil {
	return err
}

encryptStream := cipher.NewCTR(hdl.block, iv)

encryptStream.XORKeyStream(cipherSession[aes.BlockSize:], jsonSession)

cookie := http.Cookie{
	Name:     "session",
	Value:    base64.StdEncoding.EncodeToString(cipherSession),
	Path:     "/",
	Domain:   "",
	Secure:   true,
	HttpOnly: true,
	SameSite: http.SameSiteNoneMode,
	MaxAge:   0,
}

// cookie をレスポンスに含める
Attestation 完了
// NOTE: セッションを無効にするための cookie
cookie := http.Cookie{
	Name:   "session",
	Value:  "",
	MaxAge: -1,
}

dec, err := base64.StdEncoding.DecodeString(params.Session)
if err != nil {
	return err
}

decryptedSession := make([]byte, len(dec[aes.BlockSize:]))

decryptStream := cipher.NewCTR(hdl.block, dec[:aes.BlockSize])

decryptStream.XORKeyStream(decryptedSession, dec[aes.BlockSize:])

var session webauthn.SessionData

if err := json.Unmarshal(decryptedSession, &session); err != nil {
	return err
}

// session を登録完了処理に渡す

おわりに

JavaScript で MessagePack でエンコードしてサーバーに送信し Go でデコードする方法を知っている方がいたら教えてください ...!!!

噂では MessagePack ってもうモダンじゃないとか ... ???
こんな変なことするくらいならドキュメント書いて実装サンプルも書いてフロントエンドに実装してもらう方がよいですね。

今回実装したコードは以下に置いておきます。2023年お気に入りとなった ogen-go/ogen を使ってサーバー構築してます。ご参考まで。

https://github.com/otakakot/sample-go-webauthn-passkey

※ Attestation だけの実装です。( Credential の永続化はしていないです。 ) Assertion は実装していないです。また、フロントエンドの実装は壊滅的です。ご了承ください。

そのほかつぶやき

https://x.com/otakakot/status/1740788540563501079

https://x.com/otakakot/status/1740818360575930555

Discussion