Passkeys in der Praxis – Teil 2: Registrierung und Authentifizierung

vor 1 Stunde 1

Passkeys bestehen aus einem kryptografischen Schlüsselpaar, das bei der Registrierung erzeugt und bei jeder späteren Anmeldung wiederverwendet wird. Die genauen Abläufe – bei WebAuthn heißen sie Ceremonies – Registration und Authentication folgen dabei demselben Grundmuster: Der Server generiert eine Challenge, der Client ruft die WebAuthn API auf, der Authenticator signiert, und der Server validiert. Was sich unterscheidet, ist die Richtung: Registration (navigator.credentials.create()) erzeugt ein neues Credential und speichert den Public Key. Authentication (navigator.credentials.get()) beweist den Besitz des zugehörigen Private Key. Wer dieses Muster einmal wirklich versteht, kann alle Validierungsschritte direkt einordnen und erkennt sofort, wo eine Implementierung sicherheitskritische Abkürzungen nimmt.

Martina Kraus

Martina Kraus beschäftigt sich schon seit frühen Jahren mit der Webentwicklung. Das Umsetzen großer Softwarelösungen in Node.js und Angular hat sie schon immer begeistert. Als selbstständige Softwareentwicklerin arbeitet sie vornehmlich mit Angular mit Schwerpunkt auf Sicherheit in Webanwendungen.

Die Registration Ceremony nach §7.1 der W3C-WebAuthn-Level-2-Spezifikation beginnt auf dem Server. Bevor navigator.credentials.create() im Browser aufgerufen werden kann, muss der Server ein PublicKeyCredentialCreationOptions-Objekt zusammenstellen und an den Client senden. Dieses Objekt ist mehr als eine Konfigurationsdatei, denn jedes seiner Felder trifft eine Entscheidung, die sich auf Sicherheit und Nutzererfahrung auswirkt.

Bevor die einzelnen Felder im Detail besprochen werden, zeigt das folgende Listing, wie ein vollständiges PublicKeyCredentialCreationOptions-Objekt in der Praxis aussieht:

const creationOptions: PublicKeyCredentialCreationOptions = { challenge: crypto.getRandomValues(new Uint8Array(32)), // serverseitig generiert rp: { id: "example.com", name: "Example App", }, user: { id: crypto.getRandomValues(new Uint8Array(16)), // keine PII! name: "martina@example.com", // nur für UI displayName: "Martina", }, pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256 { type: "public-key", alg: -257 }, // RS256 ], excludeCredentials: existingCredentialIds.map(id => ({ type: "public-key", id, })), authenticatorSelection: { residentKey: "required", userVerification: "required", }, attestation: "none", timeout: 60000, };

Listing 1: PublicKeyCredentialCreationOptions

Das wichtigste Feld ist challenge. Diese ist ein Puffer aus kryptografisch zufälligen Bytes, den der Server generiert – mindestens 16 Bytes müssen es laut Spezifikation sein. In der Praxis haben sich allerdings 32 Bytes bewährt, weil sie einen deutlichen Sicherheitspuffer über dem Minimum bieten, ohne die Nutzlast spürbar zu vergrößern. Die Challenge wird serverseitig gespeichert, bis die Validierungsantwort eintrifft, und darf nur einmal verwendet werden. Ihr Zweck ist es, Replay-Angriffe zu verhindern: Ohne eine frische, serverseitig verifizierte Challenge könnte ein Angreifer eine abgefangene Authenticator-Antwort erneut einspielen.

Das rp-Objekt identifiziert die Relying Party (RP). Aus Teil 1 bekannt ist die Regel: Die id muss eine registrierbare Domain-Teilmenge der aktuellen Origin sein. In der Praxis bedeutet das, dass id entweder die exakte Domain oder eine übergeordnete Domain ist. shop.example.com kann demnach example.com als rpId nutzen, aber nicht umgekehrt. Der name ist ausschließlich für die Authenticator-UI relevant, nicht für die Sicherheit.

Das user-Objekt enthält Informationen über den registrierenden Nutzer. Hier lauert ein häufiger Fehler: Die user.id ist ein opaker – also für Client und Nutzer inhaltlich bedeutungsloser – Byte-Puffer, der keine personenbezogenen Daten enthalten soll, insbesondere keine E-Mail-Adresse und keinen Benutzernamen. Der Authenticator verknüpft das Credential intern mit dieser ID, und sie wird bei der Authentication im userHandle zurückgegeben. Eine zufällig generierte UUID ist die richtige Wahl. Die Spec schreibt außerdem vor, dass user.id zwischen 1 und 64 Bytes groß sein muss. Wer einen zu langen Puffer übergibt, bekommt einen TypeError. name und displayName werden nur in der Authenticator-UI angezeigt.

pubKeyCredParams ist ein Array, das angibt, welche Algorithmen der Server akzeptiert, in Prioritätsreihenfolge von oben nach unten. Die gängigen Einträge sind -7 für ES256 (ECDSA über P-256 mit SHA-256), -257 für RS256 und -8 für EdDSA. Ein moderner Server sollte mindestens ES256 und RS256 beherrschen, um eine breite Authenticator-Kompatibilität zu gewährleisten.

Mit excludeCredentials kann der Server verhindern, dass derselbe Authenticator mehrfach für dasselbe Konto registriert wird. Das Feld enthält eine Liste von credentialId-Werten, die dem Nutzer bereits gehören. Ist der Authenticator schon registriert, bricht der Browser die Anfrage ab. Das Feld ist optional, aber es ist Best Practice, es zu befüllen. Ohne diese Prüfung kann ein Nutzer denselben Passkey mehrfach anlegen, was die Credential-Verwaltung unnötig verkompliziert.

authenticatorSelection ist das Feld, mit dem die RP Einfluss auf den Authenticator-Typ nimmt. authenticatorAttachment mit dem Wert "platform" schränkt auf eingebettete Authenticators ein, "cross-platform" auf externe Geräte. Wichtiger ist residentKey: Mit dem Wert "required" wird ein Discoverable Credential erzeugt – eines, das der Authenticator selbst findet, ohne dass der Server eine credentialId mitschickt. Das ist die technische Voraussetzung für ein Log-in ohne Nutzernamen. "preferred" ist ein sinnvoller Kompromiss, wenn Kompatibilität wichtiger als garantierte Discoverable Credentials ist. Das dritte relevante Unterfeld ist userVerification: "required" bedeutet, dass der Authenticator zwingend Biometrie oder PIN verlangen muss. Diese Einstellung hat direkte Auswirkungen auf die spätere Assertion-Validierung – der Server muss bei jeder Authentication prüfen, ob das UV-Flag (User Verification) in der authData dem hier konfigurierten Wert entspricht.

Das attestation-Feld gibt an, welche Art von Attestationsdaten der Server erwartet. "none" ist für die meisten Consumer-Anwendungen die richtige Wahl: Der Server interessiert sich nicht dafür, welches Gerät das Credential erzeugt hat. "direct" liefert eine vollständige Zertifikatskette, mit der der Server das Authenticator-Modell verifizieren kann. Das ist relevant für Enterprise-Szenarien oder wenn nur zertifizierte Hardware akzeptiert werden soll. Wichtig dabei: Das attestation-Feld ist ein Hinweis, keine harte Vorgabe. Ein Authenticator kann trotz "none" eine vollständige Attestation zurückliefern – der Server entscheidet dann, ob er sie validiert oder ignoriert.

Das nächste Listing demonstriert eine rpIdHash-Prüfung in TypeScript:

// rpIdHash-Prüfung const expectedRpIdHash = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(rpId) ); const actualRpIdHash = authData.slice(0, 32); if (!bufferEqual(expectedRpIdHash, actualRpIdHash)) { throw new Error("rpIdHash mismatch – Credential für falsche RP"); }

Listing 2: rpIdHash-Prüfung in TypeScript

Nachdem navigator.credentials.create() aufgerufen wurde und die Nutzerin oder der Nutzer den Authenticator bedient hat, gibt der Browser ein PublicKeyCredential zurück, dessen response eine AuthenticatorAttestationResponse enthält. Das Herzstück davon ist das attestationObject, ein CBOR-kodiertes Objekt mit drei Feldern.

fmt gibt das Attestation-Format an: "packed", "tpm", "android-key", "fido-u2f", "apple" oder "none". Es bestimmt, wie das zweite Feld attStmt zu interpretieren ist. Bei "none" ist attStmt leer – der Authenticator macht keine Aussage über seine Herkunft. Bei "packed" enthält attStmt einen Algorithmus-Identifier (alg), eine Signatur (sig) und optional eine X.509-Zertifikatskette (x5c), mit der die Signatur verifiziert werden kann.

Das dritte Feld, authData, ist aus Teil 1 bekannt: der SHA-256-Hash der rpId, das flags-Byte, der signCount und – bei der Registration, wenn das AT-Flag (Attested Credential Data) gesetzt ist – die attestedCredentialData mit aaguid, credentialId und credentialPublicKey. Es sind genau diese letzten drei Werte, die der Server dauerhaft in der Datenbank speichern muss.

Die Validierung beginnt mit clientDataJSON. Der Server dekodiert den ArrayBuffer als UTF-8 und prüft drei Dinge: type muss "webauthn.create" sein, challenge muss mit der serverseitig gespeicherten Challenge übereinstimmen (Base64url-dekodiert), und origin muss der erwarteten Origin der Anwendung entsprechen. Diese drei Prüfungen sind nicht optional: Die type-Prüfung verhindert, dass eine Authentication-Antwort fälschlich als Registration akzeptiert wird, die challenge-Prüfung schützt vor Replay-Angriffen mit abgefangenen Antworten, und die origin-Prüfung verhindert Phishing, indem sie sicherstellt, dass die Antwort tatsächlich für die eigene Domain erzeugt wurde.

Anschließend wird das attestationObject CBOR-dekodiert, und der Server extrahiert die authData. Der erste sicherheitskritische Check: Der rpIdHash in authData[0..31] muss mit SHA-256(rpId) übereinstimmen. Ist das nicht der Fall, wurde das Credential für eine andere Relying Party erzeugt – der Server lehnt es ab, ohne Fallback. Danach folgen die Flag-Prüfungen: Das UP-Flag (User Presence) muss gesetzt sein. Das UV-Flag muss gegen die konfigurierte userVerification-Policy geprüft werden – wenn der Server "required" gesetzt hat und das Flag fehlt, ist die Registration ungültig.

Bevor das Credential gespeichert wird, muss der Server prüfen, ob die credentialId bereits in der Datenbank existiert. Doppelte IDs dürfen nicht entstehen. Was am Ende in der Datenbank landet, sind mindestens fünf Felder: credentialId, credentialPublicKey (im COSE-Format, CBOR Object Signing and Encryption), signCount (initial 0), aaguid und die Verknüpfung zur userId. Für Produktionssysteme, die Passkeys und Hardware-Keys unterschiedlich behandeln wollen, sind zusätzlich das BE-Flag (backupEligible) und das BS-Flag (backupState) aus den authData-Flags essenziell. Sie signalisieren, ob das Credential synchronisiert werden kann und ob es aktuell synchronisiert ist. Optional, aber ebenfalls empfehlenswert ist die transports-Angabe aus der Response, um den Browser bei zukünftigen Authentifizierungen besser zu steuern.

Gesamten Artikel lesen