AWS

Amazon Cognito でパスキー認証の裏側を見てみよう【前編】

tak

こんにちは、DWS 2人目の大島です。


最近パスキー認証が流行りに流行っていますね。
実は AWS の ID 管理サービスである Amazon Cognito でもパスキー認証への対応を始めています。

公式でパスワードレス認証に関する Cognito のライブラリを作成してくれていたり、ハンズオンも用意がされています。

そこで今回はこのハンズオンをベースに、
実際にリソースをデプロイしてみて、Cognito でパスキー認証を実装すると、裏側ではどのようなことが起こっているのか、を見てきたいと思います。

パスキー認証の動作と、Cognito での実装の両面を理解できる記事になれば幸いです。

一度ハンズオンを実施していただいた上で、
裏の仕組みの理解を深めるような形で読んでいただくことをお勧めします。

取り扱うハンズオン

AWS が Cognito でパスキー認証を使うためのハンズオンはこちらです。
(英語のみです)

参考
Implement Passwordless authentication with Amazon Cognito and WebAuthn
Implement Passwordless authentication with Amazon Cognito and WebAuthn

また、上記のハンズオンで使用するソースコードは Github にも公開されており、非常に丁寧に解説されておりますので、一読するとより理解が深まります。

参考
amazon-cognito-passwordless-auth
amazon-cognito-passwordless-auth


補足

こちらのハンズオンおよびソースコードは、様々なパスワードレス認証を Cognito で取り扱うためのものです。
今回メインで取り扱うパスキーだけでなく、SMS によりワンタイムパスワードや、マジックリンクによるサインインも可能です。
(今回は、ワンタイムパスワードやマジックリンクについては取り扱いません)

今回取り扱うことと取り扱わないこと

今回は、主に Cognito でのパスキーの実装を見て、どのような仕組みで動いているのか、を確認していきます。

この記事で取り扱うこと
  • パスキーについての基本的な概要
  • Cognito でパスキーを使用するフローの仕組み
  • パスキーの実装の裏側
この記事で取り扱わないこと
  • ハンズオンのデプロイの方法
  • 実案件での Cognito へのパスキーの組み込み方法
  • マジックリンク・ワンタイムパスワード認証

まず初めに

パスキーとは?

正式名称は、「マルチデバイス対応FIDO認証資格情報」です。

ざっくり言ってしまうと、
今まで使用していたパスワードでの認証の代わりに、公開鍵暗号方式を使用する認証方式です。

パスワードマネージャーツールなどを使用していた方ならば、今までパスワードマネージャーなどで管理していたパスワードの代わりに、公開鍵暗号方式の秘密鍵を管理するものをイメージしてもらうとわかりやすいです。

事前に「認証器」上で秘密鍵と公開鍵のペアを生成し、「リライングパーティ」側へ公開鍵を登録しておきます。

注意点

「認証器・リライングパーティー」ともに、パスキーにおける用語です。
詳細な説明は仕様を参照していただければと思います。

やや意味が掴みづらいため、ここでは正確ではありませんが、

認証器 = パスワードマネージャー
リライングパーティー = アプリケーション

と思っていただければ、ざっくりとした理解は進むかと思います。

鍵による認証の際には、「リライングパーティー」側からチャレンジ値を送信し、
「認証器」の持つ秘密鍵でチャレンジを署名、出来上がった署名をリライングパーティ側で登録されている公開鍵を使用して検証を行います。

認証フロー図

鍵の登録の際にも、「リライングパーティー」側でチャレンジ値を送信します。
「認証器」で秘密鍵/公開鍵を生成した後は、公開鍵を秘密鍵で署名して、「リライングパーティー」へチャレンジ値と一緒に送信します。

登録フロー図
注意点

どちらのフローについても正確な図ではなく、
全体像を掴むためのおおまかな図となっておりますので、詳細については以下の WebAuthn の仕様を確認ください。

参考
Web Authentication: An API for accessing Public Key Credentials Level 3
Web Authentication: An API for accessing Public Key Credentials Level 3

Cognito でパスキーを使うには

Cognito でパスキーの認証を導入するには、ネイティブの機能だけではできないため、独自の認証機能を導入する必要があります。

そのような場合 Cognito では、
ユーザー側で Lambda を使用して、認証処理をカスタマイズすることができ、これを「カスタム認証フロー」と言います。

カスタム認証フローでは、3つの関数を用意する必要があります。

関数名説明
Define auth Challenge行う認証の順番などを制御する関数です。一番最初に呼び出されます。
Create auth Challenge実際に実装する認証の内容を定義する関数です。
Verify auth Challenge ResponseCreate auth Challenge で実装した認証に対して、
認証が正しいか検証して、レスポンスを返すための関数です。

Cognito のカスタム認証フローでは、
最初にアプリケーションから、InitiateAuthと呼ばれる Cognito の API が呼び出され、Cognito から「Define auth Challenge」関数と「Create auth Challenge」関数が呼び出されます。

次に、アプリケーション上で認証操作を行ったのちに、
RespondToAuthChallengeという Cognito の API が呼ばれ、Cognito から「Verify auth Challenge Response」が呼ばれ、認証が完了するフローとなっています。

参考)https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html

Cognito + パスキーに必要なアーキテクチャの確認

今回参照しているハンズオンでは CloudFormation を用いて必要なリソースを簡単にデプロイできます。
デプロイされるリソースは以下の通りとなります。


デプロイ方法は前述の通りに、公式のチュートリアルにある通りですので、割愛します。
今回デプロイされる方式では、ユーザーのサインアップフローはデプロイされないので、事前にユーザーは Cognito 上に作成されている必要があります。

アーキテクチャ図
リソース概要
サービス名説明
CognitoID管理のためのサービスです。
S3, CloudFrontAWS では定番の静的ウェブサイトホスティングをするためのサービスで、
アプリケーションをホストするために必要です。
DynamoDB認証に必要となる公開鍵やチャレンジ値を保存するために必要です。
Lambdaバックエンドの認証周りを処理するために必要です。
大きく以下2種類の Lambda がデプロイされます。
・Cognito と紐づくカスタム認証フローを実行するための Lambda
・API Gateway と紐づく公開鍵の登録/削除/更新をするための Lambda
API Gateway認証に必要な Lambda の窓口として必要です。
※JWT authorizer というサービスも付属してデプロイされ、
Cognito でログインした後の ID トークンの認証なども API Gateway 側で実施してくれます。
補足

その他、 WAFや、SES、KMS などのリソースもデプロイされますが、
パスキー認証に直接関係あるものではなかったり、マジックリンクでの認証や、ワンタイムパスワードによる認証に必要なものとなりますので、ここでは割愛します。

今回確認していく3つのフロー

パスキーを使用する上では押さえておきたいフローとして、鍵の登録と鍵を使った認証の大きく2つのフローがあります。

さらに、今回デプロイされるリソースでは認証には2つのフローが実装されているため、合計で3つのフローを見ていこうと思います。

  1. 鍵の登録
  2. 鍵を使った認証
    • username あり認証
      • ログイン時に、事前にどのユーザーでログインをするのかをユーザーから指定する方式
    • username なし認証
      • ログイン時に、サイトの情報などをもとにリライングパーティーで使用するパスキーを事前に指定してログインする方法
注意点

username あり/なし 認証は、正式な名称ではありませんが、ここではわかりやすさを重視して、そのように呼びます。

全てのフローを一度に紹介すると、膨大な量となってしまうので、前編と後編の2つに分けて記事を記載します。

【前編】では、まず「鍵の登録」のフローを見ていきます

鍵の登録フロー

公開鍵を登録するフローでは、API Gateway とやりとりすることになります。

公開鍵を登録するためには、すでにログインしている状態からスタートする必要がありますが、
最初はもちろん鍵を登録していませんので、パスキーによるログインできません。

最初は、マジックリンクでの認証を使用して、ログインが必要です。

アプリケーション画面

それでは、ログインをしてからの、鍵を登録するフローを見ていきます。

1. 登録フローの開始

デプロイしたサイトへのログインが完了すると、鍵の登録を行なっていない場合、画面右上に以下のようなUIが表示されます。
こちらのボタンを押すと、パスキーの登録のフローが開始されます。

ログイン後画面
貼り付けた画像_2023_11_17_16_36.png (121.7 kB)

この時、裏側では、API Gateway に登録されている /register-authenticator/startのエンドポイントを叩いています。

step1 登録フローの開始

2. チャレンジ値の生成

API Gateway の/register-authentiction/startのエンドポイントの裏側には Lambda が存在し、要求されたパスに応じて処理が走ります。

API Gateway には JWT authorizer が付帯していますので、
事前に JWT の検証も事前に実施され、ここでユーザーがログインしているかどうかの確認も行われます。

/register-authentiction/startの Lambda では登録フローに必要なチャレンジ値を生成し、DynamoDB へとチャレンジ値を格納しています。

step2 チャレンジ値の生成

3. チャレンジ値の返却

DynamoDB へチャレンジ値を格納したら、
アプリケーションに、チャレンジ値をoptionsというオブジェクトに入れて返却します。

step3 チャレンジ値の返却

このoptionsとは↓の Lambda 上のコードにあるオブジェクトで、
チャレンジ値と合わせて、ユーザーの情報・リライングパーティーの情報などが返却されています。

// Lambda /register-authenticator/start
// https://github.com/aws-samples/amazon-cognito-passwordless-auth/blob/main/cdk/custom-auth/fido2-credentials-api.ts#L393

async function requestCredentialsChallenge({ userId, name, displayName, rpId }) {
  logger.info("Requesting credential challenge ...");
  const existingCredentials = await getExistingCredentialsForUser({
    userId,
    rpId
  });
  const options = {
    challenge: randomBytes(64).toString("base64url"),
    attestation: process.env.ATTESTATION ?? "none",
    rp: {
      name: relyingPartyName,
      id: rpId
    },
    user: {
      id: userId,
      name,
      displayName
    },
    pubKeyCredParams: Object.keys(allowedAlg).map((alg) => ({
      type: "public-key",
      alg: Number(alg)
    })),
    authenticatorSelection: {
      userVerification: process.env.USER_VERIFICATION,
      authenticatorAttachment: process.env.AUTHENTICATOR_ATTACHMENT || void 0,
      residentKey: process.env.REQUIRE_RESIDENT_KEY || void 0,
      requireResidentKey: process.env.REQUIRE_RESIDENT_KEY && process.env.REQUIRE_RESIDENT_KEY === "required" || void 0
    },
    timeout: authenticatorRegistrationTimeout,
    excludeCredentials: existingCredentials.map((credential) => ({
      id: credential.credentialId,
      type: "public-key"
    }))
  };
  await storeAuthenticatorChallenge(options);
  return options;
}

4. navigator.credentials.create()

次に受け取ったチャレンジ値を、WebAPI のnavigator.credentials.create()を呼び出して、認証器へ渡します。

これは WebAuthn と呼ばれる、パスキーのようなパスワードレス認証を実施するためのブラウザに実装されている WebAPI の一つで、この API を使用して、認証器に秘密鍵と公開鍵のペアの作成を依頼します

step4 navigator.credentials.create()


navigator.credentials.create()のドキュメントを確認すると、
引数として options を渡すことが可能となっており、中でも、WebAuthn では、publicKeyというオブジェクトを渡すことができる、とあります。

個々のプロパティなどの詳細についてはここでは割愛します。
気になる方は以下のドキュメントの参照してみてください。

参考
CredentialsContainer: create() メソッド
CredentialsContainer: create() メソッド

step3 で Lambda から返却されたoptionsオブジェクトを、JS アプリケーションにて、publicKeyというオブジェクトに格納して、チャレンジ値・ユーザー情報などが渡しています。
ここで渡された情報を元に、認証器で鍵のペアの作成を行っていきます。

// フロントエンド
// https://github.com/aws-samples/amazon-cognito-passwordless-auth/blob/c6bd49cb0ed85d011b58518c1bb6abfa3ee4e349/client/fido2.ts#L50

export async function fido2CreateCredential({ friendlyName, }) {
    const { debug, fido2 } = configure();
    const publicKeyOptions = await fido2StartCreateCredential();
    const publicKey = {
        ...publicKeyOptions,
        rp: {
            name: fido2?.rp?.name ?? publicKeyOptions.rp.name,
            id: fido2?.rp?.id ?? publicKeyOptions.rp.id,
        },
        attestation: fido2?.attestation,
        authenticatorSelection: publicKeyOptions.authenticatorSelection ?? fido2?.authenticatorSelection,
        extensions: fido2?.extensions,
        timeout: publicKeyOptions.timeout ?? fido2?.timeout,
        challenge: bufferFromBase64Url(publicKeyOptions.challenge),
        user: {
            ...publicKeyOptions.user,
            id: Uint8Array.from(publicKeyOptions.user.id, (c) => c.charCodeAt(0)),
        },
        excludeCredentials: publicKeyOptions.excludeCredentials.map((credential) => ({
            ...credential,
            id: bufferFromBase64Url(credential.id),
        })),
    };
    debug?.("Assembled public key options:", publicKey);
    const credential = await navigator.credentials.create({
        publicKey,
    });

5. 認証器での本人確認

navigator.credentials.create()を呼び出すと、
認証器側では本人確認を実施して問題がなければ、秘密鍵と公開鍵のペアを作成します。

step5 認証器での本人確認

今回は、ブラウザに組み込まれている認証器を使用して、指紋認証による生体認証を使っています。
もちろん、ブラウザとは別の認証器などを使うことも可能です。
ただし、今回のハンズオンでは一部対応できていないものもあるようなので、適宜調整の上使用ください。

ここで生体認証が完了すると、認証器で秘密鍵と公開鍵のペアが作成されます。

パスキーの作成と生体認証

6. 認証器からのレスポンス

navigator.credential.create() が完了すると、認証器の情報などとともに、公開鍵がレスポンスとして渡されます

step6 認証器からのレスポンス

以下は、step5 のコードの続きで、navigator.credential.create()からのレスポンスを処理している部分です。

具体的に返り値として、navigator.credential.create()から、PublicKeyCredentialオブジェクトインスタンスが戻ってきます。

参考
PublicKeyCredential
PublicKeyCredential
// フロントエンド
// https://github.com/aws-samples/amazon-cognito-passwordless-auth/blob/c6bd49cb0ed85d011b58518c1bb6abfa3ee4e349/client/fido2.ts#L81

const credential = await navigator.credentials.create({
        publicKey,
    });
    if (!credential) {
        throw new Error("empty credential");
    }
    if (!(credential instanceof PublicKeyCredential) ||
        !(credential.response instanceof AuthenticatorAttestationResponse)) {
        throw new Error("credential.response is not an instance of AuthenticatorAttestationResponse");
    }
    const response = credential.response;
    debug?.("Created credential:", {
        credential,
        getTransports: response.getTransports?.(),
        getAuthenticatorData: response.getAuthenticatorData?.(),
        getPublicKey: response.getPublicKey?.(),
        getPublicKeyAlgorithm: response.getPublicKeyAlgorithm?.(),
    });
    const resolvedFriendlyName = typeof friendlyName === "string" ? friendlyName : await friendlyName();
    return fido2CompleteCreateCredential({
        credential: credential,
        friendlyName: resolvedFriendlyName,
    });
}


このオブジェクトの中には、AuthenticatorAttestationResponseが入っており、大きく以下2つのデータから構成されます。
ここは仕様の説明が非常にわかりやすいため、参考として記載します。

  • clientDataJSON

navigator.credential.create()へ渡された値が格納されているデータです。
つまり、ここに/register-authenticator/start で生成したチャレンジ値が入っています

The clientDataJSON property of the AuthenticatorResponse interface stores a JSON string in an ArrayBuffer, representing the client data that was passed to navigator.credentials.create() or navigator.credentials.get()

参考:https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorResponse/clientDataJSON

  • attestationObject

公開鍵を含むデータであり、秘密鍵によって署名されています。

The attestationObject property of the AuthenticatorAttestationResponse interface returns an ArrayBuffer containing the new public key, as well as signature over the entire attestationObject with a private key that is stored in the authenticator when it is manufactured.

参考:https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAttestationResponse/attestationObject

特に重要となるのは、2つ目のattestationObjectであり、
公開鍵のデータ自体を秘密鍵で署名することで、
公開鍵のデータが改竄されていないこととデータの送信者が秘密鍵の所有者であることが担保されます

鍵情報が生成された後は、画面上に、
ユーザーが識別しやすいようにフレンドリーネームをつけるダイアログが表示されます。

フレンドリーネームの設定
貼り付けた画像_2023_11_17_16_40.png (105.5 kB)

7. 公開鍵の登録


step6 でフロントエンドへ届いた公開鍵を含むデータ(AuthenticatorAttestationResponse)は、次に API Gateway の/register-authenticator/complete へ投げられます。

/register-authenticator/completeと紐づく Lambda では、
送信された公開鍵のデータを検証した上で実際にDBへ登録する処理を行います。

補足

今回デプロイされるリソースでは、/register-authenticator/complete /register-authenticator/start の裏側で実行されるコードは、同じ一つの Lambda 上に存在します。
リクエストのパスを見て、条件分岐をして処理を分けているようです。

この公開鍵を含むデータは、先述の通りチャレンジ値が含まれている(clientDataJSON)ので、DynamoDB に格納したチャレンジ値と同じ値であるか、をチェックすることが可能です。
このチャレンジ値のチェックによって、一連の登録フローが同一のユーザーによって処理されていることを担保することができます

チャレンジ値の照合と合わせて、attestationObjectの中身の確認を行い、
認証器での処理が適切に行われているのかについてもチェックをします。

補足

attestationObjectの中に存在するauthDataプロパティの中に、認証器での鍵の生成や認証においての処理に関する情報が格納されており、適切に処理が行われているのかを確認することができます。(認証器での本人確認が完了しているかを表すUser Verificationなど)


step7 公開鍵の登録


諸々のチェックが問題なければ、最後に DynamoDB に公開鍵の情報を格納して終わりです。

参考までに、DynamoDB に格納されているデータは、以下のようになっています。
ユーザーIDや、認証器のID(aaguid)と一緒に、公開鍵(jwk)が保存されていますね。
※こちらも個々の値の詳細な説明については、ここでは取り扱いません。

DynamoDBに格納された公開鍵データ
すでに削除されたリソースですので、わかりやすさを重視し、
特に懸念のないシークレット情報についてはそのまま公開しています

これで無事に、認証器で生成した秘密鍵のペアとなる公開鍵を、データベースへ格納できました。
以降、DynamoDB に登録した公開鍵と、認証器で管理している秘密鍵を使用して、公開鍵暗号方式による認証をすることで、パスキーによるログインが可能となります。

認証のフローについて

次は、認証のフローを実際に見ていきたいと思いますが、
長くなりましたので、認証フローについては後編にてご紹介させていただければと思います。

後編が完成しましたら、こちらへリンクを掲載しますのでしばらくお待ちください。
後編が完成しました↓

参考
Amazon Cognito でパスキー認証の裏側を見てみよう【後編】
Amazon Cognito でパスキー認証の裏側を見てみよう【後編】
AUTHOR
tak
tak
記事URLをコピーしました