🎱

Androidアプリにパスキーを実装する

に公開

はじめに

この記事では、
•Android(Kotlin)
•バックエンド(Node.js)
で最小構成の動く実装をまとめます。

パスキー概略

パスキーは 公開鍵暗号方式(WebAuthn / FIDO2) に基づく次世代の認証方式で、
•パスワードの入力が不要
•生体認証だけでログイン
•端末やクラウド間で同期
•フィッシング耐性が非常に高い
という特徴があります。

Android では Credential Manager API に統合されており、
「パスワード」「IDプロバイダ」「パスキー」を単一の API で扱えるようになっています。

バックエンドの実装

パスキー(WebAuthn)処理は @simplewebauthn/serverを使用します。
npm install express cors cookie-parser @simplewebauthn/server @simplewebauthn/types

登録

app.post('/webauthn/register/options', async (req, res) => {
  const { username } = req.body;

  const options = await generateRegistrationOptions({
    rpName: 'My App',
    rpID: 'example.com',
    userID: username,
    userName: username,
    attestationType: 'none',
  });

  user.currentChallenge = options.challenge;
  res.json(options);
});

検証

app.post('/webauthn/register/verify', async (req, res) => {
  const { username, credential } = req.body;
  const user = userStore[username];

  const verification = await verifyRegistrationResponse({
    response: credential,
    expectedChallenge: user.currentChallenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
  });

  const { registrationInfo } = verification;

  user.credentials.push({
    credentialID: registrationInfo.credentialID,
    credentialPublicKey: registrationInfo.credentialPublicKey,
    counter: registrationInfo.counter,
  });

  res.json({ ok: true });
});

ログインフロー(パスキー認証)

app.post('/webauthn/login/options', async (req, res) => {
  const user = userStore[req.body.username];

  const options = await generateAuthenticationOptions({
    rpID: 'example.com',
    allowCredentials: user.credentials.map(c => ({
      id: c.credentialID,
      type: 'public-key',
    })),
  });

  user.currentChallenge = options.challenge;
  res.json(options);
});

ログインの検証

app.post('/webauthn/login/verify', async (req, res) => {
  const { username, credential } = req.body;
  const user = userStore[username];

  const authenticator = user.credentials.find(
    c => c.credentialID.toString('base64url') === credential.rawId
  );

  const verification = await verifyAuthenticationResponse({
    response: credential,
    expectedChallenge: user.currentChallenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
    authenticator,
  });

  authenticator.counter = verification.authenticationInfo.newCounter;

  res.json({ ok: true });
});

Androidの実装

必要最低限を抜粋して記載します。

パスキー登録

  1. Node の register/options を叩く
  2. 取得した JSON を CreatePublicKeyCredentialRequest にセット
  3. Credential Manager を呼ぶ
  4. 返ってきた credential を register/verify に送信
suspend fun createPasskey(context: Context, username: String) {
    val credentialManager = CredentialManager.create(context)

    val options = api.registerOptions(username)
    val requestJson = JSONObject(options.publicKey).toString()

    val createRequest = CreatePublicKeyCredentialRequest(requestJson)
    val result = credentialManager.createCredential(context, createRequest)

    val cred = result.credential as PublicKeyCredential
    val credentialMap = JSONObject(cred.authenticationResponseJson).toMap()

    api.verifyRegister(username, credentialMap)
}

パスキー認証

  1. Node の login/options を叩く
  2. GetPublicKeyCredentialOption を作成
  3. Credential Manager の getCredential() を呼ぶ
  4. 結果を login/verify へ送る
suspend fun loginWithPasskey(context: Context, username: String) {
    val credentialManager = CredentialManager.create(context)

    val options = api.loginOptions(username)
    val requestJson = JSONObject(options.publicKey).toString()

    val option = GetPublicKeyCredentialOption(requestJson)
    val getRequest = GetCredentialRequest(listOf(option))

    val result = credentialManager.getCredential(context, getRequest)
    val cred = result.credential as PublicKeyCredential

    val credentialMap = JSONObject(cred.authenticationResponseJson).toMap()
    api.verifyLogin(username, credentialMap)
}

注意点

  1. RP ID と origin を正しく揃える
  2. 開発環境では HTTPS が必須
  3. credential の保存は DB で

最後に

今回は簡易ではありますが実装方法の概略をまとめました。
近年パスキーの導入が必須みたいなところがあるのでこれを機にしっかりと学んでおきたいですね...。

Discussion