Read up on Passkeys adoption trends in Top 50 websites.
Or take a look at an example app using SlashID to implement Passkeys and WebAuthn on GitHub or a live demo.
How do passkeys work?
The Fast IDentity Online (FIDO) alliance together with a number of tech companies and the World Wide Web Consortium (W3C) have been developing ways to add public-key cryptography to browsers for several years. From that effort, the WebAuthn standard was born.
As we discussed extensively in this blog, WebAuthn allows users to authenticate to a remote server by proving ownership of a private key stored in a Roaming Authenticator (e.g., a Yubikey, a Titan key) or a Platform Authenticator (e.g., the built-in keychain on Apple devices). Multi-device FIDO credentials, also known as passkeys, extend the Platform Authenticator concept to allow the import/export of private keys from one device to another.
How that operation is performed is highly dependent on the vendor; in this post we’ll focus on Apple.
Passkeys According To Apple
The good
The obvious advantage of passkeys is the improved user experience.
Besides UX, passkeys also preserve the anti-phishing properties of WebAuthn which from a secuirty standpoint is the most important feature of the standard.
The WebAuthn standard is by far the most credible attempt to make passwords obsolete and passkeys are a key step in that direction. In fact, the main obstacle to widespread WebAuthn adoption has been the issue of porting credentials from one device to another and passkeys are a solution to that.
The bad
The primary disadvantage of passkeys is that their security profile is significantly weaker than a traditional, hardware-bound, Platform Authenticator- or Roaming Authenticator-generated keypair.
If we take an iOS device as an example, before passkeys, a platform authenticator key would normally be stored in Apple’s Secure Enclave. The Secure Enclave is a dedicated secure subsystem that is isolated from the main processor and is designed to keep sensitive data secure even when the kernel becomes compromised. When the platform authenticator key is stored in the Secure Enclave, two things happen:
- The private key is tied to that device and cannot be exported or recovered if the device is lost
- Access to the private key is gated by biometric verification, such as TouchID or FaceID.
These two security safeguards are lost with passkeys, because, despite being stored in the Secure Enclave, the private key is exported to iCloud and hence its strength is only as good as the iCloud recovery process.
The ugly
iOS 16 makes it impossible to use device-bound WebAuthn keys, effectively downgrading WebAuthn security on Apple devices to an AppleID reset flow.
Further, it is not possible to perform attestation of the authenticator. This, among other things, makes device verification harder and prevents novel applications of WebAuthn such as getting rid of CAPTCHAs.
Apple’s stance on the topic seems to be that since the key can be moved to other devices, it doesn’t make sense to provide an AAGUID or an attestation statement.
While the statement is technically accurate, the lack of an attestation statement means that you can’t prove the key has been stored, or even generated, safely because you can’t infer properties/provenance of the authenticator.
The technical details
What do passkeys look like compared to traditional Platform Authenticator keys?
Let’s examine what happens when a new credential is created through navigator.credentials.create()
and the browser returns a PublicKeyCredential
object.
This object contains one important structure: the attestationObject
.
The attestationObject
is a CBOR-encoded blob containing data about the attestation. This data is what allows a relying party to verify the chain of trust of the authenticator. Whether this data is present or not depends on whether the navigator.credentials.create()
function was called with an attestation
parameter.
To compare Apple changes versus the standard, let’s first look at Chrome.
This is what an attestationObject
looks like for a key generated via Chrome:
b'{"type":"webauthn.create","challenge":"mKuoQhqah7SLIxbinNzgu1jgzRGa0V0i_0Bw3WnSi3U","origin":"http://localhost:5000","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}'
format: packed
attStmt: { 'alg': -7,
'sig': b'0F\x02!\x00\xed6\xea\x12\xbao\x80\xf8\xd9Z\xae@\x1bu\xb0`\xb2O\x81'
b'\x94H\x0bcv\xd9\xe3\x8c\x8cKR\x1a\xbc\x02!\x00\xc2\xa4\xbd!'
b'\xbc=\xab\xa0\x1c\xac\xfd`\xeb\xf4\xcb\n\xc2}\x84\xb8\x1d$oE'
b'\xf3\xc3\t\xd4\xffJu\xd3'}
{ 'attestedCredData': {
'aaguid': b'\xad\xce\x00\x025\xbc\xc6\nd\x8b\x0b%'
b'\xf1\xf0U\x03',
'credentialId': b'\x8c\xdf\x99\xb6b\x13I\xd1'
b'\x9e\x83\x04lT\x04\x84=_)|\x96'
b'I\xf5\xb5\\8r\xeb\xb8\x9f=\xc6N'
b'\xa7Lz-\xb4\xe7\xca\x8c'
b'\xaa\xc7\xa5{]\x06i\x9f'
b'{\xb6\xe1\x9d\xf0\xa0]\t'
b'>G\xbc\x900\xcca(\x86\xbc\x07\x97',
'credentialIdLength': 68,
'credentialPublicKey': {
'alg': 'ecc',
'eccurve': 'P256',
'key': <cryptography.hazmat.backends.openssl.ec._EllipticCurvePublicKey object at 0x104b3fbe0>,
'kty': 'ECP',
'x': b'\xa1\x97\xb4\xd3'
b'd\x87&\xbf'
b'\x8di\xb0\xfa'
b'{\xb9\x0b\x13'
b':\xc8b\xf3'
b'\xfa3G\xcd'
b'\xdf\x08Z\xd7'
b'\x00\xd0-\xda',
'y': b'<\xd6\x93\x0c'
b']\x1f\x8f\xc0'
b'\x80\xcb\x16\x0f'
b'c\x19EN'
b'y\xae\x18\x87{plw'
b'X\xd59^iK\xa4\xc0'}},
'flags': { 'attestedCredDataIncluded': True,
'extensionDataIncluded': False,
'userPresent': True,
'userVerified': True},
'flagsRaw': 69,
'rpIdHash': b'I\x96\r\xe5\x88\x0e\x8cht4\x17\x0fdv`[\x8f\xe4\xae\xb9'
b'\xa2\x862\xc7\x99\\\xf3\xba\x83\x1d\x97',
'signCount': 0}
This is the attestationObject
on newer versions of Safari instead:
b'{"type":"webauthn.create","challenge":"mKuoQhqah7SLIxbinNzgu1jgzRGa0V0i_0Bw3WnSi3U","origin":"http://localhost:5000"}'
format: none
attStmt: {}
{ 'attestedCredData': {
'aaguid': b'\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00',
'credentialId': b'\xf6(\xdb5\x97^\xf9|T\x1c:\x14'
b'\xce\x95\x844\x19v+\x97',
'credentialIdLength': 20,
'credentialPublicKey': {
'alg': 'ecc',
'eccurve': 'P256',
'key': <cryptography.hazmat.backends.openssl.ec._EllipticCurvePublicKey object at 0x104bef250>,
'kty': 'ECP',
'x': b'h\xf8\x07)'
b'\x8c\xa4\xcc#'
b'\xc4\x80^\xda'
b'\x19#O\xcf'
b'\xc1\xc5o\xb7'
b"\x90'TL)\x05r\xc6"
b'\xa2[\x9a[',
'y': b'5\xb9\xfa\x87'
b'O*\xf92\x91F\xa9~'
b'A\xf0tt'
b'\xb5\x16i\x04'
b'\xded\x06\xf6k0O3'
b'\xa8\x9a\xaa\x9a'}},
'flags': { 'attestedCredDataIncluded': True,
'extensionDataIncluded': False,
'userPresent': True,
'userVerified': True},
'flagsRaw': 69,
'rpIdHash': b'I\x96\r\xe5\x88\x0e\x8cht4\x17\x0fdv`[\x8f\xe4\xae\xb9'
b'\xa2\x862\xc7\x99\\\xf3\xba\x83\x1d\x97',
'signCount': 0}
There are two main differences between the attestationObject
created by Chrome and Safari:
- The Authenticator Attestation Global Unique Identifier (AAGUID) for Safari is zero’ed out. An AAGUID is a 128-bit identifier indicating the authenticator type. The manufacturer must ensure that the AAGUID is the same across all substantially identical keys made by that manufacturer, and different (with high probability) from the AAGUIDs of all other types of keys. Apple, by not sharing its AAGUID, makes it impossible to recognize a public key generated by an Apple device.
- The attStmt field is empty for the public keys generated with Safari. This field contains a cryptographically verifiable statement. For most vendors that means an array of X.509 certificates forming a chain up to Root CA for that manufacturer. Apple, starting from iOS 16 and Mac OS Ventura, makes it impossible to verify the authenticity of the attestation generated via Safari, because the authenticator doesn’t generate any attestation statement.
These two changes are meaningful because it is now impossible to verify the device type used to generate a key, and hence the trustworthiness of the key and its metadata. In fact, the lack of an attestation statement means that you can’t prove the key has been stored, or even generated, safely because you can’t infer properties/provenance of the authenticator.
How are passkeys stored vs Platform authenticator keys
Passkeys and traditional platform authenticator keys are generated and stored in an Apple-specific trusted secure subsystem called Secure Enclave Processor (SEP), which is separate from the main processor and which is capable of preserving the integrity of the data even in the event of a device compromise.
The bar to compromise the Secure Enclave is extremely high, and therefore the most sensitive data types such as Apple Pay and FaceID data are stored there. This presentation has a great in-depth explanation of how SEP works.
Unlocking access to a WebAuthn credential requires users to confirm their identity either through FaceID or TouchID (depending on the device model). In both cases, the communication between the biometric sensor and the Secure Enclave happens over a physical serial peripheral bus over an encrypted channel, and the data never leaves the Secure Enclave, where it is verified against a 2D representation of the original biometric data. This guide from Apple has further details on SEP-based creation and storage of keys.
This is an example of how Chrome creates WebAuthn keys through SEP:
base::ScopedCFTypeRef<SecAccessControlRef>
TouchIdCredentialStore::DefaultAccessControl() {
return base::ScopedCFTypeRef<SecAccessControlRef>(
SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
// Credential can only be used when the device is unlocked.
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
// Private key is available for signing after user authorization with
// biometrics or password.
kSecAccessControlPrivateKeyUsage | kSecAccessControlUserPresence,
nullptr));
}
absl::optional<std::pair<Credential, base::ScopedCFTypeRef<SecKeyRef>>>
TouchIdCredentialStore::CreateCredential(
const std::string& rp_id,
const PublicKeyCredentialUserEntity& user,
Discoverable discoverable) const {
…
CFDictionarySetValue(params, kSecAttrSynchronizable, @NO);
CFDictionarySetValue(params, kSecAttrTokenID, kSecAttrTokenIDSecureEnclave);
…
CFDictionarySetValue(private_key_params, kSecAttrIsPermanent, @YES);
CFDictionarySetValue(private_key_params, kSecAttrAccessControl,
DefaultAccessControl());
…
base::ScopedCFTypeRef<CFErrorRef> cferr;
base::ScopedCFTypeRef<SecKeyRef> private_key =
Keychain::GetInstance().KeyCreateRandomKey(params,
cferr.InitializeInto());
…
}
Crucially, note how Chrome uses three parameters:
- kSecAttrSynchronizable is set to NO. In other words, this key cannot be synchronized through iCloud.
- kSecAttrTokenIDSecureEnclave calls KeyCreateRandomKey to generate the key directly in SEP.
- kSecAttrAccessibleWhenUnlockedThisDeviceOnly is specified, which forces the key to be encrypted with a resident device key that can’t be migrated to iCloud. Per Apple, “This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present.”
So what about Safari? Apple clarifies in their documentation that Safari keychain items are synchronized.
As a result, every item that will sync must be explicitly marked with the
kSecAttrSynchronizable
attribute. Apple sets this attribute for Safari user data (including user names, passwords, and credit card numbers), as well as for Wi-Fi passwords, HomeKit encryption keys, and other keychain items supporting end-to-end iCloud encryption.
The Safari codebase is confusing with respect to key creation. On the surface, it looks like kSecAttrSynchronizable
is not specified anywhere:
RetainPtr<SecKeyRef> LocalConnection::createCredentialPrivateKey(LAContext *context, SecAccessControlRef accessControlRef, const String& secAttrLabel, NSData *secAttrApplicationTag) const
{
RetainPtr privateKeyAttributes = @{
(id)kSecAttrAccessControl: (id)accessControlRef,
(id)kSecAttrIsPermanent: @YES,
(id)kSecAttrAccessGroup: @(LocalAuthenticatorAccessGroup),
(id)kSecAttrLabel: secAttrLabel,
(id)kSecAttrApplicationTag: secAttrApplicationTag,
};
if (context) {
auto mutableCopy = adoptNS([privateKeyAttributes mutableCopy]);
mutableCopy.get()[(id)kSecUseAuthenticationContext] = context;
privateKeyAttributes = WTFMove(mutableCopy);
}
NSDictionary *attributes = @{
(id)kSecAttrTokenID: (id)kSecAttrTokenIDSecureEnclave,
(id)kSecAttrKeyType: (id)kSecAttrKeyTypeECSECPrimeRandom,
(id)kSecAttrKeySizeInBits: @256,
(id)kSecPrivateKeyAttrs: privateKeyAttributes.get(),
};
LOCAL_CONNECTION_ADDITIONS
CFErrorRef errorRef = nullptr;
auto credentialPrivateKey = adoptCF(SecKeyCreateRandomKey((__bridge CFDictionaryRef)attributes, &errorRef));
auto retainError = adoptCF(errorRef);
if (errorRef) {
LOG_ERROR("Couldn't create private key: %@", (NSError *)errorRef);
return nullptr;
}
return credentialPrivateKey;
}
However, paying closer attention, you’ll notice a macro in the code called LOCAL_CONNECTION_ADDITIONS
. This cryptic macro is defined as:
#if USE(APPLE_INTERNAL_SDK)
#import <WebKitAdditions/LocalConnectionAdditions.h>
#else
#define LOCAL_CONNECTION_ADDITIONS
#endif
Looking at the disassembled function generated through Ghidra, we can see what actually happens:
void __ZNK6WebKit15LocalConnection26createCredentialPrivateKeyEP9LAContextP18__SecAccessControlRKN3W TF6StringEP6NSData
(undefined8 param_1,long param_2,undefined8 param_3,long *param_4,undefined8 param_5)
{
…
(&_OBJC_CLASS_$_NSString,"stringWithUTF8String:","com.apple.webkit.webauthn");
...
uVar7 = *(undefined8 *)__got::_kSecAttrLabel;
…
local_180 = *(undefined8 *)__got::_kSecAttrTokenID;
local_160 = *(undefined8 *)__got::_kSecAttrTokenIDSecureEnclave;
uVar9 = *(undefined8 *)__got::_kSecAttrKeyType;
uVar8 = *(undefined8 *)__got::_kSecAttrKeyTypeECSECPrimeRandom;
uVar13 = *(undefined8 *)__got::_kSecAttrKeySizeInBits;
uVar11 = *(undefined8 *)__got::_kSecPrivateKeyAttrs;
local_150 = &PTR__OBJC_CLASS_$_NSConstantIntegerNumber_00c3a9f0;
uStack376 = uVar9;
local_170 = uVar13;
uStack360 = uVar11;
uStack344 = uVar8;
lStack328 = lVar3;
uVar4 = __auth_stubs::_objc_msgSend
(&_OBJC_CLASS_$_NSDictionary,"dictionaryWithObjects:forKeys:count:",&local_160,
&local_180,4);
lVar2 = (*(code *)__ZN6WebKit27getASCWebKitSPISupportClassE)();
if (lVar2 != 0) {
uVar5 = (*(code *)__ZN6WebKit27getASCWebKitSPISupportClassE)();
iVar1 = __auth_stubs::_objc_msgSend(uVar5,"shouldUseAlternateCredentialStore");
if (iVar1 != 0) {
local_b0 = *(undefined8 *)__got::_kSecAttrSynchronizable;
local_90 = __got::___kCFBooleanTrue;
local_80 = &PTR__OBJC_CLASS_$_NSConstantIntegerNumber_00c3a9f0;
local_d0 = __got::___kCFBooleanTrue;
local_f0 = uVar10;
uStack232 = uVar6;
uStack168 = uVar9;
local_a0 = uVar13;
uStack152 = uVar11;
uStack136 = uVar8;
local_c8 = __auth_stubs::_objc_msgSend
(&_OBJC_CLASS_$_NSString,"stringWithUTF8String:",
"com.apple.webkit.webauthn");
local_e0 = uVar7;
if (*param_4 == 0) {
local_c0 = &cf_"";
}
else {
local_c0 = (cfstringStruct *)__auth_stubs::__ZN3WTF10StringImplcvP8NSStringEv();
}
local_d8 = uVar12;
uStack184 = param_5;
local_78 = __auth_stubs::_objc_msgSend
(&_OBJC_CLASS_$_NSDictionary,"dictionaryWithObjects:forKeys:count:",
&local_d0,&local_f0,4);
uVar4 = __auth_stubs::_objc_msgSend
(&_OBJC_CLASS_$_NSDictionary,"dictionaryWithObjects:forKeys:count:",
&local_90,&local_b0,4);
}
}
local_90 = (undefined *)0x0;
lVar2 = __auth_stubs::_SecKeyCreateRandomKey(uVar4,&local_90);
In other words, when Safari is built with the internal APPLE_INTERNAL_SDK
the dictionary passed to SecKeyCreateRandomKey
is modified to include kSecAttrSynchronizable
as a parameter, making the key exportable.
You might have noticed that in both code snippets a private key object is returned, even though it’s created by the Secure Enclave. The private key is logically part of the keychain, and you can later obtain a reference to it in the usual way, but the key data is encoded and not available in clear-text, and only the Secure Enclave can use the key.
How are keys exported from the Secure Enclave?
As mentioned earlier, the purpose of storing sensitive data in the Secure Enclave is to avoid exposing private keys outside of the secure processor, so a reasonable question is: how exactly are keys exported from the SEP to iCloud?
As mentioned above, SecKeyCreateRandomKey
returns a reference to the private key but, when the key is generated in the Secure Enclave, SecKeyCreateRandomKey
returns an encoded key instead of a clear-text private key.
Normally these keys are encrypted with a Secure Enclave keypair that never leaves the device. However, if an item is marked as kSecAttrSynchronizable
, the Secure Enclave will use a different keypair to encrypt the key.
Quoting Apple:
When a user enables iCloud Keychain for the first time, the device establishes a circle of trust and creates a syncing identity for itself. The syncing identity consists of a private key and a public key, and is stored in the device’s keychain. The public key of the syncing identity is put in the circle, and the circle is signed twice: first by the private key of the syncing identity, and then again with an asymmetric elliptical key (using P-256) derived from the user’s iCloud account password. Also stored with the circle are the parameters (random salt and iterations) used to create the key that’s based on the user’s iCloud password.
In other words, the private key used to encrypt kSecAttrSynchronizable
keys in the Secure Enclave is backed in iCloud. As such, when a new device needs to restore the keychain it can reconstruct the private key and thus decrypt the keypair needed to access all the private keys marked as kSecAttrSynchronizable
, which include passkeys.
How does iCloud Keychain keep data safe?
As we’ve seen, the security of passkeys relies on the security of the iCloud Keychain. Apple does a great job at explaining how iCloud Keychain data can be accessed and its recovery process.
The brief summary of the storage model is that the iCloud Keychain data is encrypted with an hardware-bound keypair, stored in an hardware security module (HSM). The key is inaccessible to Apple. The encrypted keypair is then stored with Apple. To quote the documentation
The iOS, iPadOS, or macOS device first exports a copy of the user’s keychain and then encrypts it wrapped with keys in an asymmetric keybag and places it in the user’s iCloud key-value storage area. The keybag is wrapped with the user’s iCloud security code and with the public key of the hardware security module (HSM) cluster that stores the escrow record. This becomes the user’s iCloud escrow record. For two-factor authentication accounts, the keychain is also stored in CloudKit and wrapped to intermediate keys that are recoverable only with the contents of the iCloud escrow record, thereby providing the same level of protection.
The recovery process requires the user to have access to:
- Authenticate to their iCloud account
- Receive an SMS code on the user phone number saved for recovery
- The passcode of one of the devices
Apple implements various other mechanisms to reduce the risk of credential stuffing attacks.
More information here, here and here.
Conclusion
Passkeys are a significant step forward in the journey to go passwordless at scale. Even though their security profile is weaker than hardware-bound WebAuthn keys, Apple has a strong recovery process for iCloud Keychain, which partially mitigates the associated risks and the main security benefit of WebAuthn, phishing protection, is preserved with passkeys.
However, the changes introduced by Apple to implement passkeys make both verifying the provenance and storage of the key and the creation of hardware-bound iOS keys impossible, significantly reducing the scope of WebAuthn security and integrity guarantees.