zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core パスキーを別の端末に安全に追加する方法

ASP.NET Coreで、ログイン済みの端末を使用して別の端末にパスキーを追加する方法を紹介します。

新しい端末はまだログインできないため、新しい端末だけでパスキーの追加を完了させることはできません。

登録用URLを新しい端末で開いてパスキーを作成し、最後にログイン済みの端末で承認します。

パスキー追加の流れ

以下の順番でパスキーを追加します。

  1. ログイン済みの端末で登録用トークンを発行する
  2. 新しい端末で登録用URLを開く
  3. 新しい端末でパスキーを作成する
  4. ログイン済みの端末に登録内容を表示する
  5. 既存のパスキーで署名して追加を承認する

新しい端末でパスキーを作成した時点では、アカウントへ追加しないことがポイントです。

登録用トークンを発行する

ログイン済みの端末で、短い有効期限を持つ交換トークンを作成します。

登録用トークンの発行
var flowId = Guid.NewGuid();
var exchangeToken = GenerateSecureToken(32);
var verificationCode = GenerateAlphanumericCode(8);

await repository.InsertAsync(new DeviceRegistrationToken
{
    TokenHash = Hash(exchangeToken),
    VerificationCodeHash = Hash(verificationCode),
    UserId = currentUser.UserId,
    FlowId = flowId,
    ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
}, cancellationToken);

交換トークンはURLに含め、確認コードは別に表示します。

URLと確認コードを分けることで、URLだけを取得した第三者が登録処理を進めるのを防ぎます。

同じ利用者が新しいトークンを発行した場合は、以前の未使用トークンを無効にします。

新しい端末でトークンを交換する

新しい端末から、交換トークンと確認コードを送信します。

交換トークンの検証
if (currentUser.IsAuthenticated)
{
    throw new UnauthorizedAccessException();
}

var token = await repository.FindByHashForUpdateAsync(
    Hash(request.ExchangeToken),
    cancellationToken
);

if (token is null ||
    token.ExpiresAt < DateTimeOffset.UtcNow ||
    token.ConsumedAt is not null ||
    !Verify(request.VerificationCode, token.VerificationCodeHash))
{
    throw new UnauthorizedAccessException();
}

token.ConsumedAt = DateTimeOffset.UtcNow;

交換処理は、未ログインの状態だけで許可します。

すでに別の利用者としてログインしている場合は、そのセッションを利用して処理を続けないようにします。

検証に成功したら、対象の利用者IDとFlowIdを持つ短命な一時セッションを作成します。

新しい端末でパスキーを作成する

一時セッションを使用してWebAuthnの登録用challengeを発行します。

登録challengeの作成
var options = new CredentialCreateOptions
{
    Challenge = RandomNumberGenerator.GetBytes(32),
    User = new PublicKeyCredentialUserEntity
    {
        Id = user.SubjectHandle,
        Name = user.LoginId,
        DisplayName = user.DisplayName,
    },
};

ブラウザでは、受け取ったchallengeを使用してパスキーを作成します。

ブラウザでパスキーを作成
const credential = await navigator.credentials.create({
  publicKey: options,
});

サーバーではclientDataJSONattestationObject、challenge、origin、RP IDなどを検証します。

検証に成功した公開鍵は、承認待ちの登録リクエストとして保存します。

承認待ちデータの保存
await requestRepository.InsertAsync(new PasskeyRegistrationRequest
{
    UserId = temporarySession.UserId,
    FlowId = temporarySession.FlowId,
    CredentialId = result.CredentialId,
    PublicKey = result.PublicKey,
    SignCount = result.SignCount,
    ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
}, cancellationToken);

この時点では、認証に使用するパスキーのテーブルへ保存しません。

既存端末で登録を承認する

ログイン済みの端末は、最初に発行したFlowIdを使用して承認待ちのデータを取得します。

承認画面には、新しいパスキーの名前や確認コードを表示します。

利用者が内容を確認したあと、既存のパスキーによる署名を要求します。

既存パスキーによる承認
const assertion = await navigator.credentials.get({
  publicKey: approvalOptions,
});

サーバーで署名を検証できた場合だけ、承認待ちの公開鍵をアカウントへ追加します。

これにより、登録用URLと確認コードが第三者に渡った場合でも、既存端末で承認されない限りパスキーは追加されません。

ポーリングで登録完了を検知する

既存端末では、FlowIdを指定して承認待ちデータが作成されたか確認します。

短い間隔で何度もデータベースへ問い合わせる代わりに、登録リクエストが作成されたときに待機中の処理を起こすこともできます。

ただし、通知は処理開始のきっかけとして使用し、最終的な状態はデータベースから取得します。

画面の再読み込みや通知の取りこぼしが発生しても、登録処理を継続できるようにします。

注意点

交換トークン、一時セッション、challenge、承認待ちリクエストには、それぞれ短い有効期限を設定します。

使用済みまたは期限切れのデータは一定期間後に削除します。

交換トークンと確認コードの試行回数を制限し、総当たりによる確認コードの特定も防ぐ必要があります。


関連記事