Pendaftaran Kunci Sandi

Panduan ini menjelaskan cara mengizinkan pengguna mendaftarkan kunci sandi untuk akun mereka, sehingga memberikan alternatif login tanpa sandi yang sangat aman dan tahan terhadap phishing.

Persyaratan Database

Untuk mendukung pendaftaran kunci sandi, tabel kredensial database Anda harus menyimpan kolom berikut:

export interface StoredPasskeyCredential {
  id: string; // Base64URL-encoded credential ID (unique lookup key)
  passkeyUserId: string; // Associated application user ID
  credentialPublicKey: string; // Base64URL-encoded public key used to verify assertion signatures
  credentialType: "public-key";
  credentialDeviceType: "singleDevice" | "multiDevice"; // Helps distinguish device-bound vs cloud-synced passkeys
  credentialBackedUp: boolean; // Boolean backup state reported by the authenticator
  aaguid: string; // Authenticator Attestation GUID
  providerIcon?: string; // Provider icon derived from the AAGUID registry (dark or light theme URLs)
  name: string; // Provider name derived from AAGUID registry
  transports: string[]; // Array of transport names (e.g. 'internal', 'hybrid') necessary for exclusion options
  lastUsedAt?: number; // Optional epoch timestamp of last sign-in
  registeredAt: number; // Registration epoch timestamp
  counter: number; // Authenticator sign-in signature counter used to prevent replay attacks
}

Sisi Server

Pembuatan Opsi

Buat endpoint yang menghasilkan parameter pembuatan WebAuthn. Mengandalkan library yang telah diperiksa sesuai standar kategori, bukan kriptografi yang dibuat sendiri.

  1. Gunakan RP ID yang telah ditetapkan: Gunakan RP ID yang tepat dan telah ditetapkan sebagai string konstan.
  2. Buat Tantangan yang aman: Buat buffer acak yang aman secara kriptografis dan memiliki entropi tinggi di server, simpan dengan aman di sesi pengguna, dan lakukan enkode sebagai Base64URL untuk pengiriman opsi.
  3. Hindari Kunci Sandi Duplikat: Petakan ID kredensial pengguna yang sudah terdaftar sebelumnya ke array opsi excludeCredentials. Tindakan ini mencegah pengautentikasi mendaftarkan kredensial duplikat di akun penyedia kunci sandi yang sama.
  4. Terapkan Kredensial yang Dapat Ditemukan: Tetapkan requireResidentKey: true dan residentKey: "required" di opsi authenticatorSelection untuk meminta kredensial yang dapat ditemukan, yang diperlukan untuk login yang dapat ditemukan.
  5. Konfigurasi Verifikasi Pengguna: Tentukan userVerification: "preferred" atau userVerification: "required". Banyak kasus penggunaan kepatuhan (misalnya, keuangan, layanan kesehatan) memerlukan 'required' untuk menerapkan verifikasi pengguna saat pembuatan.
  6. Menentukan Cakupan Lampiran:
    • Alur Promosi: Saat mengusulkan pembuatan kunci sandi tepat setelah login dengan sandi standar atau promosi setelah pendaftaran, tetapkan authenticatorAttachment: "platform" untuk menerapkan pengautentikasi platform dan mengabaikan perintah kunci keamanan eksternal.
    • Alur Pengelolaan: Saat dipanggil dari panel setelan atau keamanan khusus yang mendukung kunci keamanan eksternal selain pengautentikasi platform, hapus properti authenticatorAttachment sepenuhnya.
    • Tips: Terima flag permintaan promotion: boolean untuk menangani kedua alur secara bersyarat dengan satu endpoint.
// Options generation example
const options = {
  challenge: serverGeneratedBase64UrlChallenge, // Cryptographically random challenge
  rp: { id: "example.com", name: "Secure Application" },
  user: {
    id: userBase64UrlId, // Unique base64url string identifying the account
    name: "user@example.com",
    displayName: "Jane Doe",
  },
  pubKeyCredParams: [
    {
      type: "public-key",
      alg: -7,
    },
    {
      type: "public-key",
      alg: -257,
    },
  ],
  excludeCredentials: userExistingCredentials.map((cred) => ({
    type: "public-key",
    id: cred.id,
    transports: cred.transports,
  })),
  authenticatorSelection: {
    residentKey: "required",
    requireResidentKey: true,
    userVerification: "preferred",
    ...(isPromotionFlow && { authenticatorAttachment: "platform" }),
  },
};

Verifikasi

  1. Verifikasi Tantangan: Verifikasi tantangan dengan aman terhadap tantangan terikat sesi yang diharapkan.
  2. Memverifikasi Kehadiran Pengguna:
    • Pastikan flag User Present (UP) yang ditampilkan dalam data pengautentikasi yang diurai adalah true untuk mengonfirmasi kehadiran fisik pengguna pada saat pembuatan.
  3. Melonggarkan Verifikasi untuk 'preferred':
    • Jika opsi pembuatan menentukan userVerification: "preferred", panggilan verifikasi sisi server HARUS dikonfigurasi dengan requireUserVerification: false. Jika tidak, pengautentikasi yang mendaftar tanpa verifikasi pengguna (misalnya, kunci layar dinonaktifkan) akan memicu kegagalan verifikasi server palsu.

Logika Sisi Klien

  1. Membatasi UI saat pemuatan halaman:
    • Saat pemuatan halaman, panggil PublicKeyCredential.getClientCapabilities() dan nonaktifkan tombol "Buat kunci sandi" jika conditionalGet atau passkeyPlatformAuthenticator tidak tersedia.
  2. Panggil pembuatan & Serialisasi: Dekode opsi server dengan PublicKeyCredential.parseCreationOptionsFromJSON() dan teruskan konfigurasi yang dihasilkan ke navigator.credentials.create().
    • Panggil credential.toJSON() untuk mengenkode AuthenticatorAttestationResponse menjadi objek yang dapat diserialisasi JSON dan valid sebelum mengambil endpoint verifikasi.
  3. Menangani Pengecualian WebAuthn:
    • InvalidStateError: Kunci sandi yang cocok sudah ada (dicocokkan oleh excludeCredentials).
    • NotAllowedError: Pengguna membatalkan atau kehabisan waktu dialog kunci sandi autentikasi.
    • AbortError: Operasi telah dibatalkan.
    • SecurityError: Error asal aman (HTTPS) atau ketidakcocokan RP ID (masalah konfigurasi).
  4. Pemisahan Try/Catch untuk Signal API:
    • Gabungkan panggilan fetch() verifikasi server dalam blok try/catch. Panggil signalUnknownCredential() saat pengambilan verifikasi server gagal (status response.ok === false atau jaringan menampilkan error).
// optionsFetch and registerVerifyFetch are app-defined HTTP methods
import { optionsFetch, registerVerifyFetch } from "./api.js";

async function registerPasskey(isPromotion = false) {
  // Verify passkey capability and conditional UI are available
  const capabilities = await PublicKeyCredential.getClientCapabilities();
  if (
    !capabilities.passkeyPlatformAuthenticator ||
    !capabilities.conditionalGet
  ) {
    // Hide "Create passkey" buttons and fall back to password flows instead
    showStandardPasswordFallbackUI();
    return;
  }

  const creationOptionsJSON = await optionsFetch({ promotion: isPromotion });
  const publicKey =
    PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJSON);

  let credential;
  try {
    // passkey prompt execution
    credential = await navigator.credentials.create({ publicKey });
  } catch (err) {
    if (err.name === "InvalidStateError") {
      console.log("A passkey already exists for this account.");
      alert("A passkey already exists for this account.");
    } else if (err.name === "SecurityError") {
      console.error("Configuration RP ID or Secure Context error.");
      alert("Configuration RP ID or Secure Context error.");
    } else if (err.name === "NotAllowedError") {
      console.log("User cancelled the passkey dialog.");
    } else if (err.name === "AbortError") {
      console.log("The creation operation has been aborted.");
    }
    return; // Safe API exit, do not signal unknown for standard WebAuthn cancels
  }

  // Server Verification phase (Segregated Try/Catch)
  let encodedResponse = credential.toJSON();
  try {
    const response = await registerVerifyFetch(encodedResponse);
    if (!response.ok) {
      // Server verification failed to verify/authenticate the credential (orphaned)
      await PublicKeyCredential.signalUnknownCredential({
        rpId, // RP ID must match the one defined on the server
        credentialId: encodedResponse.id, // Base64URL-encoded credential ID
      });
    }
  } catch (serverErr) {
    console.error("Server verification network failure:", serverErr);
    await publickeycredential.signalunknowncredential({
      rpId, // RP ID must match the one defined on the server
      credentialid: encodedresponse.id, // base64url-encoded credential id
    });
  }
}

Strategi Penggantian

Penggantian Sinkronisasi Signal API

WebAuthn Signal API (webauthn-signals) adalah pengoptimalan progresif yang digunakan untuk menjaga pengelola sandi tetap sinkron dengan status kredensial server.

  • Pengalaman Penggantian: Jika PublicKeyCredential.signalUnknownCredential tidak didukung oleh browser, panggilan HARUS dilewati dengan aman melalui gerbang deteksi fitur (if (PublicKeyCredential.signalUnknownCredential)), dan verifikasi sisi server hanya mencatat kegagalan tanpa memicu pembaruan pengelola.

Penggantian Serialisasi JSON yang Mudah

Selalu instal 'webauthn-polyfills' dan impor dalam konteks. Pertimbangkan bahwa selama PublicKeyCredential didukung, PublicKeyCredential.parseCreationOptionsFromJSON dan PublicKeyCredential.prototype.toJSON juga didukung.

import 'webauthn-polyfills';