Pourquoi une passkey résiste au phishing?
Un utilisateur qui saisit son mot de passe et son code OTP sur un faux portail de connexion donne à l’attaquant tout ce qu’il faut pour rejouer l’authentification. Est-ce qu’il se passe la même chose s’il valide sa passkey sur ce même faux portail? Non. La signature produite par l’authentificateur est liée au nom de domaine du service légitime et à un challenge à usage unique, et c’est le navigateur qui renseigne l’origine, pas la page.
Pour comprendre d’où vient cette propriété, il faut regarder ce qui circule pendant les deux cérémonies WebAuthn, l’enregistrement puis l’authentification, ce qu’Entra ID conserve de son côté et ce qui reste enfermé dans l’authentificateur.
Trois acteurs participent à la cérémonie: la Relying Party (RP), ici Entra ID, identifiée par un RP ID qui est un nom de domaine (login.microsoft.com pour Entra), le client (le navigateur ou l’OS, qui expose l’API navigator.credentials) et l’authentificateur (clé de sécurité USB/NFC, Windows Hello, Microsoft Authenticator, gestionnaire de passkeys synchronisées, etc…).
Le point de départ de la résistance au phishing est simple: une paire de clés est générée pour chaque couple RP ID / compte, et le navigateur refuse qu’une page demande un RP ID qui ne correspond pas à son propre domaine. Une page hébergée sur login-micros0ft.com ne peut donc pas demander une signature pour login.microsoft.com. Si l’attaquant monte un proxy de type adversary-in-the-middle qui relaie la page légitime, l’origine que le navigateur inscrit dans le clientDataJSON est celle du proxy, elle est couverte par la signature, et Entra ID la rejette.
Comparons avec une réponse NTLM: comme nous l’avions vu dans l’article sur les protocoles NBT-NS, LLMNR et l’exploitation des failles, rien dans cette réponse ne dit à qui elle était destinée, d’où la possibilité de la relayer. WebAuthn ferme cette porte au niveau du protocole.
Enregistrement: ce que le serveur stocke
À l’enregistrement, Entra ID envoie au navigateur des options de création: un challenge aléatoire, le RP ID, l’identifiant de l’utilisateur, les algorithmes acceptés, la préférence d’attestation et la liste des credentials déjà enregistrés pour éviter les doublons. Le navigateur construit alors un clientDataJSON qui contient le type (webauthn.create), le challenge et l’origine qu’il a lui-même constatée, puis transmet à l’authentificateur le hash SHA-256 de cet objet.
L’authentificateur vérifie la présence de l’utilisateur (un appui sur la clé), et généralement sa vérification (PIN ou biométrie), génère une nouvelle paire de clés et renvoie un attestationObject encodé en CBOR. Il contient trois éléments:
fmt: le format de l’attestation (packed,tpm,android-key,apple,fido-u2founone),attStmt: la déclaration d’attestation, c’est à dire une signature produite avec une clé d’attestation du fabricant et sa chaîne de certificats,authData: les données de l’authentificateur, dont la structure est détaillée ci dessous.
Le premier champ est le hash SHA-256 du RP ID: le serveur vérifie qu’il correspond au sien. L’octet de flags porte les bits UP (bit 0, présence), UV (bit 2, vérification), BE et BS (bits 3 et 4, credential éligible à la sauvegarde et effectivement sauvegardé, autrement dit synchronisable), AT (bit 6, données de credential présentes) et ED (bit 7, extensions). Le signCount est un compteur de signatures. À l’enregistrement viennent ensuite l’AAGUID, un identifiant de 128 bits qui désigne le modèle d’authentificateur, le credential ID et la clé publique.
À l’issue de la cérémonie, Entra ID conserve pour ce compte le credential ID, la clé publique, l’AAGUID, le dernier compteur et les informations tirées de l’attestation (modèle, niveau d’attestation). Nous pouvons consulter une partie de ces données avec Microsoft Graph:
GET https://graph.microsoft.com/v1.0/users/alice@test.onmicrosoft.com/authentication/fido2Methods
{
"id": "Y2Ek...",
"displayName": "Clé USB bureau",
"aaguid": "de1e552d-db1d-4423-a619-566b625cdc84",
"model": "Microsoft Authenticator for Android",
"attestationLevel": "attested",
"createdDateTime": "2026-10-12T08:41:03Z"
}Aucun secret dans cette liste. La clé privée ne quitte jamais l’authentificateur, et le PIN ou l’empreinte non plus: le serveur ne voit que le bit UV. Une fuite de la base côté RP livre des clés publiques, rien de rejouable, alors qu’une fuite d’un annuaire livre des hashs exploitables, même lorsque ce sont des hashs de hashs comme nous l’avions vu dans l’article sur Password Hash Sync et Seamless SSO.
Authentification: signature et compteur
La seconde cérémonie suit la même logique, sans création de clé. Entra ID envoie un nouveau challenge, le navigateur construit un clientDataJSON de type webauthn.get, et l’authentificateur signe la concaténation de l’authenticatorData (cette fois sans le bloc attestedCredentialData) et du hash SHA-256 du clientDataJSON.
Côté serveur, Entra ID retrouve la clé publique grâce au credential ID, vérifie la signature, puis contrôle le type, le challenge (il doit être celui qu’il vient d’émettre), l’origine, le rpIdHash, les bits UP et UV et le compteur. Si le compteur reçu n’est pas supérieur au dernier stocké alors qu’il n’est pas nul, cela peut trahir un clone de l’authentificateur.
Attention: les passkeys synchronisées (iCloud Keychain, Google Password Manager…) renvoient en général un compteur à 0, puisque la même clé vit sur plusieurs appareils. La détection de clonage par compteur ne vaut donc que pour les authentificateurs liés à un appareil.
Pour observer ces objets sur un compte de test, le plus simple est d’intercepter la réponse de l’API dans la console du navigateur, sur la page de connexion, avant de lancer la connexion par passkey:
const b = x => btoa(String.fromCharCode(...new Uint8Array(x)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const get = navigator.credentials.get.bind(navigator.credentials);
navigator.credentials.get = async o => {
const c = await get(o);
console.log(c.id, b(c.response.clientDataJSON), b(c.response.authenticatorData));
return c;
};Pour l’enregistrement, la même technique s’applique à navigator.credentials.create, et la méthode getAuthenticatorData() de la réponse évite d’avoir à décoder le CBOR de l’attestationObject. Attention, ce hook disparaît au moindre rechargement de la page: il faut le coller une fois la page de choix de la méthode affichée. L’onglet « WebAuthn » des outils de développement de Chrome permet aussi de simuler un authentificateur virtuel. Une capture Fiddler fonctionne également, comme nous l’avions fait dans l’article sur les entêtes WWW-Authenticate et Authorization, mais il faut alors retrouver les champs dans le corps du POST.
Le script Python ci dessous décode les deux objets récupérés (encodés en base64url):
import base64, hashlib, json, struct, sys, uuid
def b64u_decode(s):
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
FLAGS = {0: "UP", 2: "UV", 3: "BE", 4: "BS", 6: "AT", 7: "ED"}
def parse_client_data(b64):
cd = json.loads(b64u_decode(b64))
for k, v in cd.items():
print(f"clientDataJSON.{k:<12}: {v}")
return cd
def parse_auth_data(raw, rp_id="login.microsoft.com"):
rp_hash, flags, count = raw[:32], raw[32], struct.unpack(">I", raw[33:37])[0]
attendu = hashlib.sha256(rp_id.encode()).digest()
print(f"rpIdHash : {rp_hash.hex()[:16]}... ({'OK' if rp_hash == attendu else 'DIFFERENT'} pour {rp_id})")
print(f"flags : 0x{flags:02x} -> {' '.join(n for b, n in FLAGS.items() if flags >> b & 1)}")
print(f"signCount : {count}")
if flags >> 6 & 1:
aaguid = uuid.UUID(bytes=raw[37:53])
l = struct.unpack(">H", raw[53:55])[0]
cred_id = raw[55:55 + l]
print(f"aaguid : {aaguid}")
print(f"credentialId : {base64.urlsafe_b64encode(cred_id).rstrip(b'=').decode()[:24]}... ({l} octets)")
print(f"credentialPublicKey : {len(raw) - 55 - l} octets (COSE_Key)")
if __name__ == "__main__":
parse_client_data(sys.argv[1])
parse_auth_data(b64u_decode(sys.argv[2]))Voici la sortie pour une assertion d’exemple:
python3 webauthn_decode.py eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Iiwi... NWye1KCTIblpXx6vkYID8bVfaJ2mH7yWGEwVfdpoDIEFAAAAKg
clientDataJSON.type : webauthn.get
clientDataJSON.challenge : Qm9uam91ciB0ZXN0LmxvY2Fs
clientDataJSON.origin : https://login.microsoft.com
clientDataJSON.crossOrigin : False
rpIdHash : 356c9ed4a09321b9... (OK pour login.microsoft.com)
flags : 0x05 -> UP UV
signCount : 42-> origin est écrite par le navigateur et couverte par la signature via le hash du clientDataJSON: le proxy d’un attaquant ne peut pas la modifier.
-> 0x05: présence et vérification de l’utilisateur, pas de bit BE/BS, nous sommes sur un authentificateur lié à l’appareil, et le compteur est incrémenté.
Et pour l’enregistrement d’une passkey dans Microsoft Authenticator sur Android:
clientDataJSON.type : webauthn.create
clientDataJSON.challenge : Qm9uam91ciB0ZXN0LmxvY2Fs
clientDataJSON.origin : https://login.microsoft.com
clientDataJSON.crossOrigin : False
rpIdHash : 356c9ed4a09321b9... (OK pour login.microsoft.com)
flags : 0x45 -> UP UV AT
signCount : 0
aaguid : de1e552d-db1d-4423-a619-566b625cdc84
credentialId : -cfkJ7NsbAtA4siGwhrxE0eM... (32 octets)
credentialPublicKey : 77 octets (COSE_Key)Le bit AT est levé et l’AAGUID apparaît. C’est cette valeur qu’Entra ID utilise pour ses restrictions de clés.
AAGUID et attestation dans Entra ID
Entra ID regroupe désormais ces réglages dans des profils de passkeys, que l’on attribue à des groupes. La documentation Microsoft présente des exigences spécifiques comme l’attestation, le type de passkey (liée à l’appareil ou synchronisée) ou des restrictions par AAGUID, avec des profils distincts selon les populations, par exemple les administrateurs d’un côté et les opérateurs de terrain de l’autre.
Les deux réglages ne s’appliquent pas au même moment, et ce point est très important. L’application de l’attestation ne détermine si une passkey est acceptée qu’au moment de l’enregistrement. Une passkey enregistrée sans attestation continue donc de fonctionner si nous activons l’option plus tard. Les restrictions de clés, elles, conditionnent l’utilisation des modèles ou fournisseurs à la fois pour l’enregistrement et pour l’authentification: retirer un AAGUID de la liste autorisée coupe immédiatement les connexions des passkeys de ce modèle déjà enregistrées. Il vaut mieux le savoir avant de « nettoyer » une liste en production.
Le décodage plus haut explique le reste. L’AAGUID se trouve dans l’authData, que l’authentificateur remplit lui-même. Sans attestation, rien ne prouve que cette valeur est vraie: un authentificateur logiciel peut y écrire l’AAGUID d’une clé YubiKey. Avec l’attestation, la déclaration attStmt est signée par une clé du fabricant dont la chaîne de certificats peut être vérifiée dans les métadonnées de confiance. Microsoft le dit sans détour: sans attestation imposée, Entra ID ne peut garantir aucun attribut d’une passkey, y compris son caractère synchronisé ou lié à un appareil. Une liste d’AAGUID sans attestation reste utile pour orienter les utilisateurs vers les bons modèles, pas pour bloquer un attaquant.
Le cas de Microsoft Authenticator illustre la combinaison des deux. La page dédiée donne les AAGUID de l’application, dont de1e552d-db1d-4423-a619-566b625cdc84 pour Android, et précise une contrainte: l’enregistrement de passkeys attestées n’est possible que directement dans l’application Authenticator, les parcours inter-appareils ne le supportent pas.
Un dernier niveau de contrôle se place dans l’accès conditionnel: les options avancées des niveaux d’authentification acceptent elles aussi une liste d’AAGUID. Nous pouvons ainsi laisser tous les utilisateurs enregistrer une passkey, puis n’exiger un modèle attesté que pour les rôles d’administration ou les applications sensibles.
Attention, la passkey protège uniquement la cérémonie d’authentification: les jetons et cookies de session émis ensuite restent exposés comme avec n’importe quelle autre méthode. Un poste compromis par un voleur de cookies reste un poste compromis. Pour les comptes à privilèges, la combinaison que je retiens est un profil limité aux passkeys liées à l’appareil, avec attestation imposée et une liste courte d’AAGUID de clés matérielles validées, complétée par un niveau d’authentification qui reprend cette même liste.
Rémi Vernier
Responsable des opérations cybersécurité dans un grand groupe international. Identité, Active Directory, Entra ID, cloud hybride, et désormais SAP, IA et modèles en local.