Registro de llaves de acceso

En esta guía, se detalla cómo permitir que los usuarios registren una llave de acceso para su cuenta, lo que proporciona una alternativa de acceso sin contraseña altamente segura y resistente al phishing.

Requisitos de la base de datos

Para admitir los registros de llaves de acceso, la tabla de credenciales de tu base de datos debe almacenar los siguientes campos:

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
}

Del servidor

Generación de opciones

Crea un extremo que genere parámetros de creación de WebAuthn. Confía en una biblioteca verificada según los estándares de la categoría en lugar de implementar la criptografía de forma manual.

  1. Usa el ID de RP predefinido: Usa el ID de RP adecuado predefinido como una cadena constante.
  2. Crea un desafío seguro: Genera un búfer aleatorio criptográficamente seguro y de alta entropía en el servidor, almacénalo de forma segura en la sesión del usuario y codifícalo como Base64URL para la entrega de opciones.
  3. Evita las llaves de acceso duplicadas: Asigna los IDs de credenciales preexistentes del usuario al array de opciones excludeCredentials. Esto evita que el autenticador registre credenciales duplicadas en la misma cuenta del proveedor de llaves de acceso.
  4. Aplicar credenciales detectables: Establece requireResidentKey: true y residentKey: "required" en las opciones de authenticatorSelection para solicitar una credencial detectable, que es necesaria para los accesos detectables.
  5. Configura la verificación del usuario: Especifica userVerification: "preferred" o userVerification: "required". Muchos casos de uso de cumplimiento (p.ej., finanzas, atención médica) requieren que 'required' aplique la verificación del usuario en el momento de la creación.
  6. Determina el alcance del adjunto:
    • Flujo de promoción: Cuando propongas la creación de llaves de acceso justo después de los accesos con contraseña estándar o las promociones posteriores al registro, establece authenticatorAttachment: "platform" para aplicar el autenticador de la plataforma y omitir las solicitudes de llaves de seguridad externas.
    • Flujo de administración: Cuando se llama desde un panel de seguridad o configuración dedicado en el que se admiten llaves de seguridad externas además del autenticador de la plataforma, omite la propiedad authenticatorAttachment por completo.
    • Sugerencia: Acepta un parámetro de solicitud promotion: boolean para controlar ambos flujos de forma condicional con un solo extremo.
// 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" }),
  },
};

Verificación

  1. Verificación del desafío: Verifica de forma segura el desafío con el desafío vinculado a la sesión esperado.
  2. Verifica la presencia del usuario:
    • Asegúrate de que la marca de usuario presente (UP) que se muestra en los datos analizados del autenticador sea true para confirmar la presencia física del usuario en el momento de la creación.
  3. Flexibilización de la verificación para "preferred":
    • Cuando las opciones de creación especificaron userVerification: "preferred", la llamada de verificación del servidor DEBE configurarse con requireUserVerification: false. De lo contrario, los autenticadores que se registren sin verificación del usuario (p.ej., con los bloqueos de pantalla inhabilitados) activarán fallas de verificación del servidor falsas.

Lógica del cliente

  1. Restringe la IU durante la carga de página:
    • Cuando se cargue la página, llama a PublicKeyCredential.getClientCapabilities() y inhabilita el botón "Crear llave de acceso" si conditionalGet o passkeyPlatformAuthenticator no están disponibles.
  2. Invoca la creación y serializa: Decodifica las opciones del servidor con PublicKeyCredential.parseCreationOptionsFromJSON() y pasa la configuración resultante a navigator.credentials.create().
    • Llama a credential.toJSON() para codificar el AuthenticatorAttestationResponse en un objeto válido que se pueda serializar en JSON antes de recuperar el extremo de verificación.
  3. Controla las excepciones de WebAuthn:
    • InvalidStateError: Ya existe una llave de acceso coincidente (coincidencia por excludeCredentials).
    • NotAllowedError: El usuario canceló el diálogo de la llave de acceso de autenticación o se agotó el tiempo de espera.
    • AbortError: Se anuló la operación.
    • SecurityError: Orígenes seguros (HTTPS) o errores de discrepancia de ID de RP (problemas de configuración).
  4. Separación de bloques try/catch para la API de Signal:
    • Une la llamada de verificación del servidor fetch() en un bloque try/catch. Llama a signalUnknownCredential() cuando falle la recuperación de la verificación del servidor (cualquier estado response.ok === false o error de red).
// 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
    });
  }
}

Estrategias de resguardo

Resguardo de sincronización de la API de Signal

La API de Signal de WebAuthn (webauthn-signals) es una optimización progresiva que se usa para mantener los administradores de contraseñas sincronizados con el estado de las credenciales del servidor.

  • Experiencia de resguardo: Si el navegador no admite PublicKeyCredential.signalUnknownCredential, la llamada DEBE omitirse de forma segura a través de la detección de funciones (if (PublicKeyCredential.signalUnknownCredential)), y la verificación del servidor simplemente registra la falla sin activar las actualizaciones del administrador.

Resguardo de serialización de JSON sencillo

Instala siempre "webauthn-polyfills" y, luego, impórtalo en el contexto. Ten en cuenta que, siempre que se admita PublicKeyCredential, también se admitirán PublicKeyCredential.parseCreationOptionsFromJSON y PublicKeyCredential.prototype.toJSON.

import 'webauthn-polyfills';