Passkeys

Passkeys sustituyen las contraseñas por un par de claves creado por el autenticador del usuario: Windows Hello, iCloud Keychain, Google Password Manager, un gestor de contraseñas o una llave de seguridad FIDO2. La clave privada nunca sale del autenticador, el servidor solo almacena la clave pública, y la firma está vinculada al dominio del sitio, de modo que las passkeys no pueden sufrir phishing, reutilizarse ni filtrarse desde la base de datos del servidor.

Técnicamente una passkey es una credencial WebAuthn discoverable (una resident key): el autenticador guarda el user handle junto a la clave privada, de modo que el usuario puede iniciar sesión sin escribir un nombre de usuario, y el navegador puede ofrecer las passkeys del sitio en la lista de autofill del campo de nombre de usuario. El servidor TsgcWSAPIServer_WebAuthn admite passkeys con los ajustes y eventos descritos a continuación.

Availability

El servidor WebAuthn se incluye en sgcWebSockets Enterprise y en el pack sgcAuth. WebAuthn necesita un contexto seguro: sirva las páginas por https (o localhost) y establezca WebAuthnOptions.RelyingParty con el nombre de host que muestra el navegador.

Registrar credenciales discoverable

La propiedad WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential establece el residentKey solicitado en las opciones de registro:

ValorresidentKeyMeaning
waundcPreferred (default)preferredEl autenticador crea una credencial discoverable cuando puede.
waundcRequiredrequiredSolo se aceptan credenciales discoverable. Úselo para passkeys e inicio de sesión sin nombre de usuario.
waundcDiscourageddiscouragedEl autenticador debe crear una credencial del lado del servidor, que necesita el nombre de usuario para iniciar sesión.

Una solicitud de registro puede sobrescribir el valor predeterminado con el campo discoverable_credential ("required", "preferred" o "discouraged"). El ajuste solo se aplica cuando la propia solicitud no pide un residentKey específico.


WebAuthn.WebAuthnOptions.RelyingParty := 'www.example.com';
// ... passkeys are discoverable credentials: require a resident key
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential :=
  waundcRequired;
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.UserVerification :=
  waunuvPreferred;
WebAuthn.WebAuthnOptions.DefaultOptions.Authentication.UserVerification :=
  waunuvPreferred;

Varias passkeys por usuario

Un usuario suele tener varias passkeys, por ejemplo una en el ordenador y otra en el teléfono. Registre cada una con el mismo nombre de usuario: cuando el nombre de usuario ya tiene credenciales conocidas por el servidor, el registro reutiliza su user handle (user.id), de modo que todas las passkeys de la cuenta comparten un único user handle. El servidor conoce las credenciales registradas mientras se ejecuta y las añadidas con AddCredential, así que cuando sus credenciales residen en una base de datos, añádalas al arrancar para mantener un único user handle por cuenta. Con un nombre de usuario, las opciones de autenticación incluyen todas las passkeys del usuario en allowCredentials.

Inicio de sesión sin nombre de usuario

Solicite las opciones de autenticación con un nombre de usuario vacío. Las opciones entonces no llevan allowCredentials, el navegador muestra las passkeys que tiene para el relying party y el usuario elige una. El servidor exige el userHandle de la respuesta y comprueba que pertenece a la credencial usada, y una credencial que la aplicación no conoce se rechaza.


// ... "Sign in with a passkey" button: the browser lists the passkeys of the site
const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
const assertion = await SimpleWebAuthnBrowser.startAuthentication(options);
const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);

Autofill de passkeys

Con conditional mediation, las passkeys aparecen en la lista de autofill del campo de nombre de usuario, junto a las contraseñas guardadas. Añada autocomplete="username webauthn" al input, cargue /sgcWebAuthn.js (servido por el componente, endpoint EndpointsOptions.WebAuthn) y llame a startAuthentication(options, true) con opciones sin nombre de usuario cuando se cargue la página. La promesa se resuelve cuando el usuario elige una passkey.


<script src="/sgcWebAuthn.js"></script>
<input id="username" name="username" autocomplete="username webauthn">
<script>
async function post(url, body) {
  const r = await fetch(url, {method: "POST",
    headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
  return r.json();
}
async function passkeyAutofill() {
  if (!(await SimpleWebAuthnBrowser.browserSupportsWebAuthnAutofill())) return;
  // ... no user name: the options carry no allowCredentials
  const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
  // ... true: conditional mediation, the passkeys appear in the autofill list
  const assertion = await SimpleWebAuthnBrowser.startAuthentication(options, true);
  const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);
  if (result.verified) { location.href = "/"; }
}
passkeyAutofill();
</script>

El autofill necesita un navegador que admita conditional mediation (versiones actuales de Chrome, Edge y Safari). browserSupportsWebAuthnAutofill() devuelve False en los demás navegadores, mantenga para ellos un botón "Iniciar sesión con una passkey".

Almacenar las passkeys en su propia base de datos

El componente no persiste las credenciales. Cuatro eventos lo conectan a su almacenamiento:

EventoQué hacer
OnWebAuthnRegistrationSuccessfulGuarde aCredentialRecord, por ejemplo aCredentialRecord.AsJSON, junto con su CredentialId y Username.
OnWebAuthnAuthenticationOptionsRequestCon un nombre de usuario, añada las passkeys de ese usuario a CredentialRecords. Sin un nombre de usuario, no añada nada.
OnWebAuthnAuthenticationGetCredentialSe dispara cuando la credencial usada por el navegador no está en la lista de credenciales de la ceremonia, lo que ocurre en un inicio de sesión sin nombre de usuario o con autofill. Busque el credential id, rellene aCredentialRecord y establezca Found en True.
OnWebAuthnAuthenticationSuccessfulGuarde el SignCount y el BackupState actualizados de aAuthentication.Credential.CredentialRecord y cree la sesión.

// ... 1. registration: store the new passkey (the whole record as JSON)
procedure TForm1.WebAuthnWebAuthnRegistrationSuccessful(Sender: TObject;
  const aRegistration: TsgcWebAuthn_Registration;
  const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Accept: Boolean);
begin
  DBInsertPasskey(aCredentialRecord.CredentialId, aCredentialRecord.Username,
    aCredentialRecord.AsJSON);
end;

// ... 2. sign in with a user name: the passkeys of that user
procedure TForm1.WebAuthnWebAuthnAuthenticationOptionsRequest(Sender: TObject;
  const aRequest: TsgcWebAuthn_AuthenticationOptions_Request;
  var CredentialRecords: TsgcWebAuthn_CredentialRecords; var Accept: Boolean);
var
  i: Integer;
  oList: TStringList;
begin
  if aRequest.Username = '' then
    Exit; // ... usernameless or autofill: no allowCredentials
  oList := TStringList.Create;
  try
    DBGetPasskeysOfUser(aRequest.Username, oList); // ... one JSON per line
    for i := 0 to oList.Count - 1 do
      CredentialRecords.AddCredentialRecordFromJSON(oList[i]);
    Accept := oList.Count > 0;
  finally
    oList.Free;
  end;
end;

// ... 3. usernameless and autofill: find the passkey by its credential id
procedure TForm1.WebAuthnWebAuthnAuthenticationGetCredential(Sender: TObject;
  const aCredentialId: string;
  const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Found: Boolean);
var
  vJSON: string;
begin
  Found := DBFindPasskey(aCredentialId, vJSON);
  if Found then
    aCredentialRecord.ReadJSON(vJSON);
end;

// ... 4. signed in: save the new signature counter and backup state
procedure TForm1.WebAuthnWebAuthnAuthenticationSuccessful(Sender: TObject;
  const aRequest: TsgcWebAuthn_AuthenticationVerify_Request;
  const aAuthentication: TsgcWebAuthn_Authentication; var Accept: Boolean);
var
  oRecord: TsgcWebAuthn_CredentialRecord;
begin
  oRecord := aAuthentication.Credential.CredentialRecord;
  DBUpdatePasskey(oRecord.CredentialId, oRecord.AsJSON);
  CreateUserSession(oRecord.Username);
end;

El registro devuelto por OnWebAuthnAuthenticationGetCredential debe tener el CredentialId solicitado, y el mismo Username cuando la ceremonia se inició con un nombre de usuario, en caso contrario el inicio de sesión falla. Mantenga el UserId (user handle) en el registro almacenado, se compara con el userHandle enviado por el autenticador. Los eventos se ejecutan en los threads de conexión del servidor, proteja los recursos compartidos en consecuencia.

Sincronizada o vinculada al dispositivo

Los flags de datos del autenticador indican qué tipo de passkey recibió el servidor. Se almacenan en el registro de la credencial como BackupEligible (flag BE) y BackupState (flag BS):

BackupEligibleBackupStateMeaning
TrueTruePasskey sincronizada, con copia de seguridad de la plataforma o del gestor de contraseñas, y disponible en los demás dispositivos del usuario.
TrueFalsePasskey multidispositivo que todavía no tiene copia de seguridad.
FalseFalsePasskey vinculada al dispositivo, por ejemplo una llave de seguridad o una clave TPM que nunca sale del dispositivo. Recomiende al usuario registrar una segunda passkey.

function GetPasskeyKind(aRecord: TsgcWebAuthn_CredentialRecord): string;
begin
  if aRecord.BackupEligible and aRecord.BackupState then
    Result := 'synced passkey'
  else if aRecord.BackupEligible then
    Result := 'multi-device passkey, not backed up yet'
  else
    Result := 'device-bound passkey';
end;

En cada inicio de sesión el servidor rechaza una respuesta con el flag BS activado y el flag BE desactivado, y una respuesta cuyo flag BE difiera del BackupEligible almacenado, porque la elegibilidad de una credencial nunca cambia. El nuevo flag BS se copia en BackupState, guárdelo en OnWebAuthnAuthenticationSuccessful.

Contador de firma y autenticadores clonados

Algunos autenticadores incrementan un contador de firma en cada inicio de sesión. Cuando el contador de la respuesta o el SignCount almacenado no es cero, el contador de la respuesta debe ser mayor que el valor almacenado, en caso contrario el inicio de sesión se rechaza porque el autenticador podría haber sido clonado. El valor almacenado solo avanza, y debe guardarse después de cada inicio de sesión para que la detección funcione. Las passkeys sincronizadas normalmente siempre informan 0, lo que desactiva la comprobación para ellas. Establezca WebAuthnOptions.AllowSignCountLessOrEqualStoredValue en True para aceptar un contador que no aumenta (el valor almacenado nunca se reduce).

Demo

La demo Demos\26.Authentication\01.Passkeys registra varias passkeys por usuario, inicia sesión sin nombre de usuario y con autofill, muestra si cada passkey está sincronizada o vinculada al dispositivo, y almacena las credenciales en un archivo JSON de la aplicación mediante los eventos anteriores. Consulte también WebAuthn y el cliente JavaScript.