Passkey PRFs for end-to-end encryption
CTO / Founder
End-to-end encrypted apps have always had to reckon with users losing their encryption keys. If you brick your phone, you just assume you're never getting your Signal chats back. But as E2EE has found its way into consumer services, there are more seamless backup and syncs of encrypted data between devices and even into clients like web browsers.
The problem is humans are still bad at picking strong passwords, or memorizing 256 bits of random data.
This coming weekend, I’ll be speaking at BSidesSeattle on how WhatsApp, Signal, and X (Twitter) leverage hardware security modules to encrypt data with a relatively weak passphrase or PIN (Saturday at 3:30pm in Track 4!). What didn't make the cut for that talk is the next generation of schemes of deriving encryption keys from passkeys.
Passkeys are a newish technology based on the security key protocols in browsers and mobile apps. As opposed to a physical hardware token, they are magically synced by your OS credential store or password manager (iCloud Keychain, Google Password Manager, Windows Hello, 1Password, etc.), and replace your password rather than act as a second factor.
As passkey syncing has dramatically improved over the last few years, services like 1Password, Bitwarden, and Confer are leveraging an extension for deriving cryptographic material from passkeys. Today, if you enable WhatsApp encrypted chat backups, by default your backups are protected with a passkey, instead of a password that you have to memorize:
This post covers passkey pseudo-random functions (PRFs), and their use in end-to-end encryption.
Passkey PRFs
Passkeys are extremely limited in the primitives they expose. Through the browser, you can only request a signature over a structured challenge. There's no API for signing arbitrary data or decryption, much less key derivation.
Using an underlying API originally intended for disk decryption, WebAuthn supports a “prf” extension where the client combines a seed (called a “salt”) with a per-credential private value. This produces a random-looking but deterministic result that can be used to derive other cryptographic material like encryption keys.
To enable this extension, pass a salt to the “extensions.prf.eval.first” argument for a registration or authentication request:
// Register a passkey with the server and generate a PRF output.
const cred = await navigator.credentials.create({
publicKey: {
challenge, // Provided by the server.
rp: {
id: rpId,
name: rpName,
},
user: {
id: userId, // Provided by the server.
displayName: username,
name: username,
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -257 },
],
extensions: {
prf: {
eval: {
first: salt, // Provide a salt for a deterministic output
},
},
},
},
});The salt can be chosen based on the needs of an application. A static value is fine since the PRF is unique per-credential, so two users (or the same user on different sites) will always produce different outputs. Per-user salts can be useful for supporting rotation of key material for more advanced needs.
One benefit of a static salt is that it can be provided during the login challenge, before you know which user is authenticating:
// Use a static salt value.
const salt = new TextEncoder().encode("my-sites-static-salt").buffer;
// Login the user and generate a PRF output all in one go.
const cred = await navigator.credentials.get({
publicKey: {
challenge, // Provided by the server.
rpId,
extensions: {
prf: {
eval: {
first: salt,
},
},
},
},
});A second salt can also be passed to the extension as a credential rotation primitive:
// From the Mozilla docs
//
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/WebAuthn_extensions#prf
({
extensions: {
prf: {
eval: {
first: currentSessionKey, // salt for current session
second: nextSessionKey, // salt for next session
},
},
},
});The PRF output
The credential returned by the registration or authentication phase will contain a PRF result field.
const pubKey = cred as PublicKeyCredential;
const resp = pubKey.response as AuthenticatorAssertionResponse;
const ext = pubKey.getClientExtensionResults();
// Pseudo-random value
const { first } = ext.prf.results;The client can then use the output to derive key material as input to an envelope encryption scheme or Double Ratchet algorithm. In a more straightforward case, the client can feed the result into the Web Crypto API to encrypt data directly:
const prfKey = await crypto.subtle.importKey(
"raw",
ext.prf.results.first,
"HKDF",
false, // Not extractable
["deriveKey"],
);
// Derive an encryption key with a standard key derivation function.
// Salt for the HKDF. NOT the passkey PRF.
const hkdfSalt = crypto.getRandomValues(new Uint8Array(12));
const secretKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: hkdfSalt.buffer,
info: new TextEncoder().encode("note-encryption-key"),
},
prfKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
// An "initialization vector" is a public value that MUST be unique per
// encryption event with a given symmetric AES key. Best practice is to
// generate it randomly for every call to "encrypt".
const iv = crypto.getRandomValues(new Uint8Array(16));
// Encrypt with AES-GCM.
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
secretKey,
new TextEncoder().encode(data), // Data to encrypt.
);
// Send encrypted data to the server to store as well as public metadata
// (IV and HKDF salt).
const encryptedData = new Uint8Array([
...iv,
...hkdfSalt,
...new Uint8Array(ciphertext)]).buffer;Later, the client can decrypt the encrypted data held by the server using the same PRF output:
// Receive the "encryptedData" back from the server.
// Extract the IV, HKDF salt, and ciphertext.
const iv = encryptedData.slice(0, 16);
const hkdfSalt = encryptedData.slice(16, 16 + 12);
const ciphertext = encryptedData.slice(16 + 12);
const secretKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: hkdfSalt.buffer,
info: new TextEncoder().encode("note-encryption-key"),
},
prfKey, // Output from PRF.
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
secretKey,
ciphertext,
);Putting it together
As part of this post, we open-sourced a full demo with a React frontend and Go backend to run locally. You can find the code on GitHub:
https://github.com/oblique-security/webauthn-prf-demo
Passkey PRFs are an incredibly interesting primitive for cryptographic operations on a mobile app, or in a browser. This isn’t just limited to symmetric encryption. You could imagine seeding asymmetric key generation for SSH connections, or signing application prompts for audit logs. If you’re willing to put up with a little bit of JavaScript cryptography, there’s now a robust means to drive these schemes with key material that magically syncs between devices.