Les passkeys remplacent les mots de passe par une paire de clés créée par l'authentificateur de l'utilisateur : Windows Hello, iCloud Keychain, Google Password Manager, un gestionnaire de mots de passe ou une clé de sécurité FIDO2. La clé privée ne quitte jamais l'authentificateur, le serveur ne stocke que la clé publique, et la signature est liée au domaine du site, de sorte que les passkeys ne peuvent pas être hameçonnées, réutilisées ou exposées depuis la base de données du serveur.
Techniquement, une passkey est un identifiant WebAuthn découvrable (une clé résidente) : l'authentificateur conserve le user handle à côté de la clé privée, ce qui permet à l'utilisateur de se connecter sans saisir de nom d'utilisateur, et permet au navigateur de proposer les passkeys du site dans la liste d'autofill du champ de nom d'utilisateur. Le serveur TsgcWSAPIServer_WebAuthn prend en charge les passkeys avec les paramètres et événements décrits ci-dessous.
Le serveur WebAuthn est inclus dans sgcWebSockets Enterprise et dans le pack sgcAuth. WebAuthn nécessite un contexte sécurisé : servez les pages en https (ou localhost) et définissez WebAuthnOptions.RelyingParty sur le nom d'hôte affiché dans le navigateur.
La propriété WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential définit le residentKey demandé dans les options d'enregistrement :
| Valeur | residentKey | Meaning |
|---|---|---|
waundcPreferred (default) | preferred | L'authentificateur crée un identifiant découvrable lorsqu'il le peut. |
waundcRequired | required | Seuls les identifiants découvrables sont acceptés. Utilisez-le pour les passkeys et la connexion sans nom d'utilisateur. |
waundcDiscouraged | discouraged | L'authentificateur doit créer un identifiant côté serveur, qui nécessite le nom d'utilisateur pour se connecter. |
Une requête d'enregistrement peut remplacer la valeur par défaut avec le champ discoverable_credential ("required", "preferred" ou "discouraged"). Le paramètre n'est appliqué que lorsque la requête ne demande pas elle-même un residentKey spécifique.
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;
Un utilisateur possède généralement plusieurs passkeys, par exemple une sur l'ordinateur et une sur le téléphone. Enregistrez chacune d'elles avec le même nom d'utilisateur : lorsque le nom d'utilisateur possède déjà des identifiants connus du serveur, l'enregistrement réutilise leur user handle (user.id), de sorte que chaque passkey du compte partage un seul user handle. Le serveur connaît les identifiants enregistrés pendant son exécution ainsi que ceux ajoutés avec AddCredential, donc lorsque vos identifiants résident dans une base de données, ajoutez-les au démarrage pour conserver un seul user handle par compte. Avec un nom d'utilisateur, les options d'authentification contiennent toutes les passkeys de l'utilisateur dans allowCredentials.
Demandez les options d'authentification avec un nom d'utilisateur vide. Les options ne contiennent alors aucun allowCredentials, le navigateur affiche les passkeys qu'il possède pour la relying party et l'utilisateur en choisit une. Le serveur exige le userHandle de la réponse et vérifie qu'il appartient à l'identifiant utilisé, et un identifiant inconnu de l'application est rejeté.
// ... "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);
Avec la médiation conditionnelle, les passkeys apparaissent dans la liste d'autofill du champ de nom d'utilisateur, à côté des mots de passe enregistrés. Ajoutez autocomplete="username webauthn" au champ, chargez /sgcWebAuthn.js (servi par le composant, point de terminaison EndpointsOptions.WebAuthn) et appelez startAuthentication(options, true) avec des options sans nom d'utilisateur au chargement de la page. La promesse se résout lorsque l'utilisateur choisit une 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>
L'autofill nécessite un navigateur prenant en charge la médiation conditionnelle (Chrome, Edge et Safari actuels). browserSupportsWebAuthnAutofill() renvoie False dans les autres navigateurs, conservez pour eux un bouton « Se connecter avec une passkey ».
Le composant ne persiste pas les identifiants. Quatre événements le relient à votre stockage :
| Événement | Que faire |
|---|---|
| OnWebAuthnRegistrationSuccessful | Enregistrez aCredentialRecord, par exemple aCredentialRecord.AsJSON, avec son CredentialId et son Username. |
| OnWebAuthnAuthenticationOptionsRequest | Avec un nom d'utilisateur, ajoutez les passkeys de cet utilisateur à CredentialRecords. Sans nom d'utilisateur, n'ajoutez rien. |
| OnWebAuthnAuthenticationGetCredential | Se déclenche lorsque l'identifiant utilisé par le navigateur ne figure pas dans la liste des identifiants de la cérémonie, ce qui est le cas d'une connexion sans nom d'utilisateur ou par autofill. Recherchez l'id de l'identifiant, renseignez aCredentialRecord et définissez Found sur True. |
| OnWebAuthnAuthenticationSuccessful | Enregistrez le SignCount et le BackupState mis à jour de aAuthentication.Credential.CredentialRecord, puis créez la session. |
// ... 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;
L'enregistrement renvoyé par OnWebAuthnAuthenticationGetCredential doit avoir le CredentialId demandé, et le même Username lorsque la cérémonie a démarré avec un nom d'utilisateur, sinon la connexion échoue. Conservez l'UserId (user handle) dans l'enregistrement stocké, il est comparé au userHandle envoyé par l'authentificateur. Les événements s'exécutent dans les threads de connexion du serveur, protégez les ressources partagées en conséquence.
Les indicateurs des données de l'authentificateur indiquent le type de passkey reçu par le serveur. Ils sont stockés dans l'enregistrement de l'identifiant comme BackupEligible (indicateur BE) et BackupState (indicateur BS) :
| BackupEligible | BackupState | Meaning |
|---|---|---|
| True | True | Passkey synchronisée, sauvegardée par la plateforme ou le gestionnaire de mots de passe et disponible sur les autres appareils de l'utilisateur. |
| True | False | Passkey multi-appareil qui n'est pas encore sauvegardée. |
| False | False | Passkey liée à l'appareil, par exemple une clé de sécurité ou une clé TPM qui ne quitte jamais l'appareil. Recommandez à l'utilisateur d'enregistrer une deuxième 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;
À chaque connexion, le serveur rejette une réponse dont l'indicateur BS est défini sans l'indicateur BE, ainsi qu'une réponse dont l'indicateur BE diffère de BackupEligible stocké, car l'éligibilité d'un identifiant ne change jamais. Le nouvel indicateur BS est copié dans BackupState, enregistrez-le dans OnWebAuthnAuthenticationSuccessful.
Certains authentificateurs incrémentent un compteur de signature à chaque connexion. Lorsque le compteur de la réponse ou le SignCount stocké n'est pas zéro, le compteur de la réponse doit être supérieur à la valeur stockée, sinon la connexion est rejetée car l'authentificateur a peut-être été cloné. La valeur stockée n'avance que dans un sens, et elle doit être enregistrée après chaque connexion pour que la détection fonctionne. Les passkeys synchronisées renvoient généralement toujours 0, ce qui désactive la vérification pour elles. Définissez WebAuthnOptions.AllowSignCountLessOrEqualStoredValue sur True pour accepter un compteur qui n'augmente pas (la valeur stockée n'est cependant jamais abaissée).
La démo Demos\26.Authentication\01.Passkeys enregistre plusieurs passkeys par utilisateur, se connecte sans nom d'utilisateur et avec autofill, indique si chaque passkey est synchronisée ou liée à l'appareil, et stocke les identifiants dans un fichier JSON de l'application via les événements ci-dessus. Voir aussi WebAuthn et le client JavaScript.