Passkey Implementierung

In diesem Artikel zeige ich euch wie ihr die Erstellung und Anmeldung mit Passkeys in einem Java-Backend mit einer nativen Android-App umsetzen könnt. Der Schwerpunkt liegt dabei auf einer möglichst lose gekoppelten Integration im Backend mit der Verwendung der Library webauthn4j.

Einführung ins Thema

Hier eine nützliche Einführung in das Thema, mit der ich gestartet habe. Ist zwar für Android – erklärt es aber auch generell ganz gut:

Der Lösungsansatz im Backend

Während man im Frontend einen ziemlich klaren Weg vorgegeben bekommt, wie die Passkey Implementierung mit dem Credential Manager funktioniert => Google Developer Guide ist die Backend Seite viel weniger klar geregelt. Es gibt die Möglichtkeit 3rd Party Plattformen (Okta, Firebase,..) einzubinden. Wenn man diese ohnehin schon verwendet ist der Weg wohl klar.
Will man eine solche Plattform nicht einbinden dann muss man selbst Hand anlegen. Hier findet man schon einige nützliche Ressourcen.

In der Java Welt kommt man relativ schnell zur Erkenntnis dass die Lib webauthn4j die weit verbreitetste ist, welche auch von vielen großen Anbietern verwendet wird. Hier ist dann noch die Entscheidung zu treffen, ob man die Spring Security Erweiterung verwendet oder nur die Core Lib verwendet. Da ich in mein bestehendes Spring Security Setup nicht eingreifen wollte, und das Risiko dass hier meine bestehenden Mechanismen beeinflusst werden nicht eingehen wollte, habe ich mich dafür entschieden nur die Core Lib für die Umsetzung zu verwenden.

Entscheidungshilfe

Hier ein paar Links zur Enscheidungshilfe, welche Libraries es gibt:

Developer Guides for Backend
Github Repo mit vielen nützlichen Links

Webauthn(4j) in a nutshell

Ich werde hier nicht auf den kompletten Fido/Webauthn Standard eingehen, da dass eine komplexe Thematik ist. Ich verweise hier lieber auf den Deep-Dive. Vielmehr will ich nur kurz skizzieren was am Backend umgesetzt werden muss um Passkeys zu unterstützen.
Prinzipiell sind zwei Szenarien zu unterscheiden:

  1. Registrierung / Erstellung eines Passkeys (mit eingeloggtem User)
  2. Login mit einem Passkey

Ich gehe beim Erstellungs-Szenario davon aus, dass der Passkey zusätzlich zur klassischen Passwort Anmeldung angeboten wird und der User ihn nur erstellen kann, wenn er schon in seinem Konto angemeldet ist.

Beide Szenarien unterteilen sich wiederum in zwei Phasen:

  1. Initialisierung der Aktion (Request Challenge)
  2. Abschluss der Aktion

Diese Schritte erfolgen immer in Zusammenspiel des Backends/der API und der SDK am Frontend. Im Fall von Android dem CredentialManager.

Folgender Ablauf ist also zu Implementieren:

  1. Request an die API –> Initialisierung
  2. Weitergabe des JSONs aus dem Response an den CredentialManager
  3. Warten auf Ergebnis des CredentialManagers
  4. Forwarden des Results an das Backend/ die API um den Vorgang abzuschließen –> Finish

Erstellung eines Passkeys Walkthrough

Wir definiert zwei Endpunkte auf der Server-Seite die nur im eingloggten Zustand abrufbar sind. Wir starten mit dem init call:

@RequestMapping(value = "/registration/init", method = RequestMethod.POST)
public CredentialCreationOptions start(@Context HttpServletRequest request) {
    return webAuthnService.startRegistration(getLoggedInUser(), getClientType());
}

Dieser wird vom Client ohne Parameter aufgerufen sobald ein Passkey erstellt werden soll. Folgendes Objekt wird von diesem Call erzeugt und kann am Client 1:1 an den CredentialManager weitergegeben werden.

public class CredentialCreationOptions {

    private String origin;
    private String challenge;
    private RpEntity rp;
    private UserEntity user;
    private List<PubKeyCredParam> pubKeyCredParams;
    private long timeout;
    private String attestation;
    private List<PublicKeyCredentialDescriptor> excludeCredentials;
    private AuthenticatorSelection authenticatorSelection;

}
originDie FacetID des APKs (siehe weiter unten)
challengeEin zufällig generierter einmaliger generierter Wert
RpEntityDie Origin der API (hier muss auch das .well-known File zur Verfügung stehen)
UserEntityID des eingeloggten Users
pubKeyCredParamsEine Liste der erlaubten Algorithmen
timeoutWie lange die Aktion am Client dauern darf
attestationVerschiedene Modi werden unterstützt. Z.Bsp „direct“
excludeCredentialsBereits erstellte Credentials
authenticatorSelectionParameter für die Erstellung des Passkeys
 public CredentialCreationOptions startRegistration(User user, ClientType clientType) {

   
        // should be login or identification instead
        FidoRegistration fidoRegistration = fidoRegistrationRepository.findByUserId(kmUser.getId());

        List<FidoCredential> fidoCredentials = kmUserFidoCredentialRepository.findAllByFidoRegistrationId(fidoRegistration.getId());

        List<PublicKeyCredentialDescriptor> excludeCredentials = fidoCredentials.stream()
                .map(c -> PublicKeyCredentialDescriptor.builder().type(PublicKeyCredentialType.PUBLIC_KEY.toString())
                        .id(c.getId().toString()).build()).collect(Collectors.toList());

        RpEntity relyingParty = RpEntity.builder()
                .name(clientType.equals(ClientType.IOS) ? iosRpId : androidRpId)
                .id(clientType.equals(ClientType.IOS) ? iosRpId : androidRpId)
                .build();

        UserEntity user = UserEntity.builder()
                .displayName(user.getMail())
                .name(user.getName())
           .id(Base64.getUrlEncoder().withoutPadding().encodeToString(fidoRegistration.getFidoId().getBytes()))
                .build();

        List<PubKeyCredParam> pubkey = Arrays.asList(
                PubKeyCredParam.builder()
                        .type(PublicKeyCredentialType.PUBLIC_KEY.toString()).alg(-7).build(),
                PubKeyCredParam.builder()
                        .type(PublicKeyCredentialType.PUBLIC_KEY.toString()).alg(-257).build()
        );


        byte[] challengeRaw = challenge(32);
        String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeRaw);

        AuthenticatorSelection authenticatorSelection = new AuthenticatorSelection();
        authenticatorSelection.setAuthenticatorAttachment("platform");
        authenticatorSelection.setRequireResidentKey(true);
        authenticatorSelection.setResidentKey("required");
        authenticatorSelection.setUserVerification("required");

        // create response to send back
        CredentialCreationOptions publicKeyCredentialCreationOptions = CredentialCreationOptions
                .builder()
                .origin(clientType.equals(ClientType.IOS) ? iosOrigin : androidOrigin)
                .attestation("direct")
                .challenge(challenge)
                .rp(relyingParty)
                .pubKeyCredParams(pubkey)
                .user(user)
                .excludeCredentials(excludeCredentials)
                .authenticatorSelection(authenticatorSelection)
                .timeout(60000)
                .build();


        log.info("Returning credential creation options {}", publicKeyCredentialCreationOptions);

        WebAuthnSession session = new WebAuthnSession(challenge, fidoRegistration);

        redisWebauthnSessionService.addRegistrationSession(session);
        return publicKeyCredentialCreationOptions;
    }

Hier sieht man wie webauthn4j genutzt werden kann um die Create-Options für die Passkey-Erstellung zu erzeugen. Dieses Objekt wird an den Client zurückgegeben um den CredentialManager zu starten:

webauthnRestService.initRegistration().subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Observer<>() {
                    @Override
                    public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) {

                    }

                    @Override
                    public void onNext(@io.reactivex.annotations.NonNull CredentialCreationOptions credentialCreationOptions) {
                        String publicKeyCredentialRequestJson = gson.toJson(credentialCreationOptions);
                        CreatePublicKeyCredentialRequest createPublicKeyCredentialRequest = new CreatePublicKeyCredentialRequest(publicKeyCredentialRequestJson);

                        // Sende die Anforderung
                        credentialManager.createCredentialAsync(context,
                                createPublicKeyCredentialRequest,
                                null,
                                executor,
                                new CredentialManagerCallback<>() {
                                    @Override
                                    public void onResult(CreateCredentialResponse createCredentialResponse) {


                                           // CONTINUE HERE TO SEND SUCCESSFUL RESPONSE TO API
                                     
                                    }

                                    @Override
                                    public void onError(@NotNull androidx.credentials.exceptions.CreateCredentialException e) {
                                        webauthnRegisterCallback.onError(e);
                                    }
                                });
                    }


                    @Override
                    public void onError(@io.reactivex.annotations.NonNull Throwable e) {
                        webauthnRegisterCallback.onError(e);
                    }

                    @Override
                    public void onComplete() {

                    }
                });

To be continued

FacetId in Android herausfinden

In Android muss die FacetId des APKs für die Festlegung der erlaubten Origin verwendet werden.
Diese hat folgende Form

android:apk-key-hash:<base64_encoded_sha256_hash-of-apk-signing-cert>

Der hintere Teil errechnet sich aus dem SHA-256 Hash des verwendeten Signing Keys mit entferntem Padding:

keytool -list -v -keystore <your.keystore> | grep "SHA256: " | cut -d " " -f 3 | xxd -r -p | openssl base64 | sed 's/=//g' | tr '/+' '_-'

Damit wird sichergestellt, das man im Besitz des Signing Keys ist um Passkey Requests des Clients zu beantworten.