Passkey: AAL3 Strong Security with a Customizable Interface

Huan Liu
5 min readJul 12, 2023

Apple introduced Passkey a year ago in WWDC2022. It is ground breaking in that it allows the private key in a FIDO credential to be stored in the iCloud keychain, and facilitates it to be propagated from device to device. While it makes the FIDO credential easy to use, and it solves the bootstrapping problem (someone logging into a website/app from a new device), it comes with a security downside that the private key is no longer stored in the hardware TPM module. Because of this downside, according to NIST, passkey can only be classified as providing an AAL2 level assurance.

The following picture shows a security strength tradeoff among common authentication factors.

Spectrum of security guarantee

To get to the highest security level, we need to leverage a hardware device. Unfortunately, the current Passkey implementations by Apple and Google do not give developers an option to choose a TPM module.

What if you really want a high security guarantee? You can obviously use a physical security key such as a Yubikey, but that is both costly and hard to use. This post shows you how to leverage the built-in TPM (also called Secure Enclave) using the raw primitives provided by the iOS platform to build a FIDO solution from scratch.

The concept behind FIDO is simple. It leverages private and public key cryptography, where a user uses the private key to sign a nonce from the server, and prove that it possess the private key. In this Post, we will demonstrate how you can achieve the same authentication routine with native iOS APIs. This is not an exact implementation of the complete FIDO protocol, but it focuses only on the private public key portion due to page limit. But you could expand on the example if you choose.

We will use the Local Authentication framework in iOS, which will generate a LAPublicKey LAPrivateKey pair to be stored in the TPM module. Then we will demonstrate how to sign a signature with the LAPrivateKey, and how to validate the signature with the LAPublicKey.

Credential Enrollment during Registration

First, we demonstrate how to generate a key pair during Registration. We leverage LARightStore, which stores a LAPersistedRight backed by a unique key in the Secure Enclave. We create a function generateClientKeys() to capture the full logic. First, we initiate a LARight(), which is “a grouped set of requirements that gate access to a resource or operation”. When we call LARightStore.shared.saveRight(), a key pair is generated, and the keys and the right are persisted, and a LAPersistedRight is returned. We can get a reference to the public key for the newly generated key by calling persistedRight.key.publicKey. This public key is returned so that the caller can persist the public key on the server side for future verification.

// generate a key pair
func generateClientKeys() async throws -> Data {
let right = LARight()
// in case there were key generated before, clean up before generate a new key pair
try await LARightStore.shared.removeRight(forIdentifier: "fido-key")
// generate a new key pair
let persistedRight = try await LARightStore.shared.saveRight(right, identifier: keyIdentifier)
return try await persistedRight.key.publicKey.bytes
}

Note that, we also call LARightStore.shared.removeRight() right before saveRight(). This is to remove any old key if one was saved under the same identifier before.

Authentication

After registration, when a user comes back to your app again and needs to login, we need to go through the authentication ceremony to verify the user. The following code is a simplified FIDO flow. First, following the FIDO protocol, we need to call the server to retrieve a nonce. A nonce is used to prevent a replay attack. Then, we call the following function to sign the nonce.

func signServerChallenge(nonce: Data) async throws -> Data {
let persistedRight = try await LARightStore.shared.right(forIdentifier: "fido-key")
try await persistedRight.authorize(localizedReason: "Authenticating...")

// verify we can sign
guard persistedRight.key.canSign(using: .ecdsaSignatureMessageX962SHA256) else {
throw NSError(domain: "SampleErrorDomain", code: -1, userInfo: [:])
}

return try await persistedRight.key.sign(nonce, algorithm: .ecdsaSignatureMessageX962SHA256)
}

This function first look up the LAPersistedRight under the same identifier. If found, it asks the user for authorization to use the key, then it uses the private key to sign the nonce. The nonce and its signature should be sent to the server for verification.

Sample code and demo

The FIDO in TPM project is a demo project incorporating the code snippet above to demonstrate the user interface for both the registration and authentication ceremonies. You can also watch this video demo to see the registration and authentication user experience.

When to use this solution

Why would not you use the native WebAuthn APIs provided by the platform (Authentication Services API on iOS or the Credential Manager API on Android)? There are several reasons to use a home grown solution as outlined in this post:

Strong security (up to AAL3 level)

This solution would allow you to provide a strong security assurance using the built-in platform authenticators (TouchID or FaceID) without the need to buy a separate security key (such as a Yubikey). It is both secure and easy to use, because you do not have to carry an extra piece of hardware.

More customizable UX and UI

As shown in the demo video, the registration experience is much simplified, where it could even be done silently without a user interaction. The authentication experience is also simpler, with a lot of room to customize the experience. In particular, you could choose to mention “Biometric” or some other more familiar terminology to the end user, since most users have not heard of passkey. This gives you the flexibility if you do not want to market the feature as passkey.

Not required to be tied to a website.

WebAuthn is a web API following the FIDO standard. To avoid phishing attacks, WebAuthn requires the credential to be bounded to a web domain. When iOS and Android introduced the equivalent native API, they followed this design by requiring your app to be bounded to a web domain through a universal link. But your app may not have a web presence. This solution saves you the hassle of setting up a website and configuring for universal links.

Conclusion

Unlike inside a web browser when you are limited to the WebAuthn API, you have a full range of APIs to leverage in a native app. If you do not want to use the FIDO API given by the platform, you actually can implement the FIDO protocol yourself. Hopefully, this post shows you the basis if you want to venture into more customizations and a stronger security assurance.

Note: this article is also posted at Passkey: AAL3 Strong Security with a Customizable Interface

--

--