This article presents a live demonstration of the draft WebAuthn PRF Extension. Matthew Miller introduced this experimental extension in Encrypting Data in the Browser Using WebAuthn and I thought it was such an exciting development that I had to share it too. Put simply, the PRF Extension offloads sourcing and storage of symmetric key material to an authenticator.
Keep in mind, we're using cryptography in a web browser. Two things to consider: first, as mentioned in End-to-End Encryption in Web Apps, the web application might be compromised at the source (after gaining trust with users, It could exfiltrate secret data easily with an update); second, as mentioned in New Personal Blog, Content Security Policies from the source cannot stop extensions from modifying browser APIs like navigator.credentials.get
and can otherwise store and exfiltrate data.
The problem is that you download a new version of the application each time you use it. This means that you need to trust that the application isn't compromised each time you use it. ... A compromised application would completely break the security model of E2E encryption. - Lúcás in End-to-End Encryption in Web Apps
PRF Extension is fairly new. To access this feature: you must use Chrome Canary and set Experimental Web Platform features to Enabled
on Chrome flags. If your browser, authenticator, or device do not support the PRF Extension or the underlying HMAC Secret Extension, this demonstration will simulate a successful PRF response for you.
This demonstration does not send or store any information. The JS source is released as public domain and without warranty. You may reference it or copy from it in any way you wish. However, any and all cryptography you add to your application should be carefully reviewed by multiple experts and is your responsibility.
What is a PRF?
The acronym stands for "Pseudo-Random Function". PRFs are functions with a secret key and some content as inputs. Its output is a deterministic binary string that is indistinguishable from random. In practice, these outputs are used as MAC tags or symmetric key material.
Have you have used HMAC to "sign" content with a symmetric secret, and later "verify" the content and tag with the same symmetric secret? If so, then you have used a PRF! As mentioned earlier, PRFs may be used for creating suitable symmetric key material too, which is exactly what this demonstration does.
Unlike Client to Authenticator Protocol (CTAP) which names the HMAC Secret Extension by its internal construction, WebAuthn names the PRF Extension by what it provides: a Pseudo-Random Function.
Note that A PRF can be used to derive keys, but may not have what is called Key Derivation Function (KDF) security. If you need KDF-security in addition to PRF-security, check out HMAC-based Key Derivation Function (HKDF), which is available in most cryptographic libraries.
Lastly, PRF Extension implementations are expected to wrap the CTAP HMAC Secret Extension with some domain separation. Authenticators which do not use CTAP, such as passkeys, may elect to implement this extension differently. At this time, passkeys and most platform authenticators do not implement the PRF Extension.
Registration
An essential part to any WebAuthn registration is a username. Without it, security keys may not respond to a registration request.
As hinted in the HMAC Secret Extension, an authenticator will only create PRF key material if requested during registration. Therefore, to use the PRF Extension which may be built upon the HMAC Secret Extension, we request PRF support during registration.
Credential Raw ID:
Credential ID:
Credential Extension Results:
Actual | Expected |
---|---|
|
|
If all is well, there will be a "prf"
entry inside the extension results. Otherwise, this authenticator and browser combination does not support the PRF Extension. A simulation will take place in this demonstration if it is not available.
const state = {
challenge: crypto.getRandomValues(new Uint8Array(32)),
username: 'example',
// userId to be populated in a moment
userId: null,
// salt won't matter for registration
// it just needs the right type
salt: new Uint8Array(new Array(32).fill(1)),
credential: null,
prfSupported: false
}
// Crypto Subtle Digest is async
state.userId = await crypto.subtle.digest('sha-256',
encoder.encode("username:" + state.username)
);
// Registration options
const options = {
publicKey: {
// Attestation none means we will not be able to verify
// a signed challenge
challenge: state.challenge,
// Relying party
rp: {
name: "Levi Schuck's Blog",
id: "levischuck.com"
},
// User information
user: {
// Must not be all zeros or empty
id: state.userId,
// Must not be empty
name: state.username,
displayName: state.username,
},
// What algorithms are supported
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' }, // RS256
],
// Authenticator qualifications
authenticatorSelection: {
// I'm choosing UV: discouraged for convenient
// demonstration.
userVerification: 'discouraged',
// Attestation none since we care not what device
// was registered
attestation: 'none'
},
// Important part: the extensions!
extensions: {
// WebAuthn uses the same structure for registration
// as authentication.
// While it looks like we are asking the authenticator
// to evaluate a PRF over the salt, this is more
// of a not equals null, not empty check that sets
// the authenticator up for future PRF use.
prf: {
eval: {
first: state.salt,
},
},
},
},
};
// Ask the browser to create / register a credential
const regCredential = await navigator.credentials.create(options);
state.credential = regCredential;
// See if PRF is supported or not
const extensionResults = regCredential.getClientExtensionResults();
if (extensionResults?.prf?.enabled) {
state.prfSupported = true;
} else {
state.prfSupported = false;
}
The PRF value is not available at registration, only during authentication. Therefore, we must do another WebAuthn interaction.
Authenticate
The application will not have a PRF value until we authenticate again with the Client ID received during registration. If you are curious as to why the Client ID can be so long, see Credential Storage Modality mode 2. It is likely that the PRF material is stored inside the Client ID.
We cannot access the PRF key in the authenticator. Instead, the PRF is used to create new key material with some contextual information. It is up to the application to provide this contextual information while a WebAuthn authentication flow is happening. If we need additional key material later, either we ask the authenticator for more (and incur another WebAuthn authentication flow), or we use HMAC-based Extract-and-Expand Key Derivation Function (HKDF) to derive new key material from the start. Matthew Miller shows how to use HKDF in Encrypting Data in the Browser Using WebAuthn.
For convenience, I hash the "context" input below and use the result as a salt
input to the extension. While the PRF Extension calls this input "first"
, the underlying HMAC Secret Extension calls it a salt
which the PRF operates on.
Salt:
PRF Value:
PRF is unsupported, so we made up our own!
const state = {
challenge: crypto.getRandomValues(new Uint8Array(32)),
username: 'example',
// userId to be populated in a moment
userId: null,
// salt won't matter for registration
// it just needs the right type
salt: new Uint8Array(new Array(32).fill(1)),
credential: null,
prfSupported: true,
// The following is a synthetic example of the
// application state, it will not actually work.
credential: {
rawId: new Uint8Array(new Array(64).fill(1)),
response: {
getTransports: () => {['usb']}
}
}
}
// Options for authentication
// A lot less here than registration
const options = {
publicKey: {
// If we actually wanted to verify a signature
// this would be important to reference
challenge: state.challenge,
// We should present the same relying party
// information to the authenticator when it
// returns for authentication
rpId: "levischuck.com",
// Here we tell the browser which authenticators
// we are looking for
allowCredentials: [
// Only one authenticator is desired since
// the PRF secret is tied to one and only one
// authenticator.
{
// Here you'd likely base64 decode the id field
// from a server and use that
id: state.credential.rawId,
// This field is optional, we can restrict the
// transports to the types the authenticator
// reported upon registration
transports: state.credential.response.getTransports(),
// There's only one choice: public-key
type: "public-key",
},
],
// The PRF is bound to which UV mode is used
// This may either be discouraged or required
userVerification: 'discouraged',
// And finally the extensions again
// This time, the salt value matters
extensions: {
prf: {
eval: {
// Input the contextual information
first: state.salt,
// There is a "second" optional field too
// Though it is intended for key rotation.
},
},
},
},
};
// Ask the browser to find and authenticate with the selected credential
const authCredential = await navigator.credentials.get(options);
state.authCredential = authCredential;
// Separate from the authentication, we have to ask for the extension results
let extensions = state.authCredential.getClientExtensionResults();
if (extensions.prf?.results?.first) {
// Import the key material directly into an encryption key
state.prf = new Uint8Array(extensions.prf.results.first);
state.prfKey = await crypto.subtle.importKey(
'raw',
state.prf,
// We'll be using AES-GCM with a 256bit key
{name: 'AES-GCM', length: 256},
// This key does not need to be exportable
false,
// This key may encrypt or decrypt
['encrypt', 'decrypt']);
} else {
state.prf = null;
state.prfKey = null;
}
The PRF output "value"
is uniformly random, this makes it useful as raw key material. For brevity, this demonstration will directly us it as an encryption key.
Making secrets
The PRF value was loaded into an AES-256 GCM key. Try to put something witty into the text box below! No associated data is used in this demonstration.
The format here is not particularly novel. The first 12 bytes are a nonce, encoded as hex. Then a dot to split the nonce from the encrypted content. And finally the encrypted content and tag, encoded as hex.
Encrypted:
const encoder = new TextEncoder();
const state = {
// An example key
prfKey: await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']),
// User input
dataIn: 'Testing',
// GCM often uses 96 bit nonces in practice
dataInNonce: crypto.getRandomValues(new Uint8Array(12)),
output: ''
}
// Encrypt the things
state.encrypted = await crypto.subtle.encrypt(
// When encrypting, we must provide the operation and
// required parameters like the nonce, named "iv" in
// the Subtle Crypto API.
{
name: "AES-GCM",
length: 256,
iv: state.dataInNonce
},
// This takes a loaded CryptoKey
state.prfKey,
// And the data to encrypt
encoder.encode(state.dataIn)
);
// A helpful function
function encodeHex(array) {
return Array.from(new Uint8Array(array))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
state.output = encodeHex(state.dataInNonce) + '.' + encodeHex(state.encrypted);
Select the text above and enter it below. If it is unmodified, it will decrypt successfully. If you refresh and try it again, it will most likely not work anymore since the Client ID is not remembered on this website.
Decrypted:
// A useful function
function decodeHex(text) {
// Get rid of all punctuation and spacing.
text = text.replace(/[^0-9a-zA-Z]+/g, '');
const match = text.match(/[0-9a-fA-F]{1,2}/g);
if (text.match(/^[0-9a-fA-F]+$/) && match && match.length) {
return Uint8Array.from(match.map(byte => parseInt(byte, 16)));
}
throw new Error('Bad input to decodeHex');
}
const state = {
prfKey: await crypto.subtle.importKey(
'raw',
// an example key
decodeHex('f4be65a4263a08be91aa9d3067512a4db79aaea42102073a5f65606bc0459519'),
{name: 'AES-GCM', length: 256},
false, ['encrypt', 'decrypt']
),
// Example encrypted content that decrypts to 'Testing'
dataOut: '9cadc00c861a0bd33854fdd8.f0a909354b34400c5005cd3777a4e8f219fd0b8d75ac4a'
}
let [nonceHex, dataHex] = state.dataOut.split('.');
let nonce = decodeHex(nonceHex);
let data = decodeHex(dataHex);
let decrypted = await crypto.subtle.decrypt({
name: "AES-GCM",
length: 256,
iv: nonce
}, state.prfKey, data);
state.decrypted = new TextDecoder().decode(decrypted)
See? Pretty neat.
Conclusion
The HMAC Secret Extension and PRF Extension bring web applications closer to supporting Hardware Security Module (HSM)-like use cases. They provide a deterministic means to generate symmetric key material while offloading state from the client. Security keys are also far more affordable than HSMs.
Here is an example of stateless security: DNSSEC Root KSK Ceremony 48 used a laptop with a Live DVD and an HSM. A ceremony step specifically checks that the laptop does not have a hard drive to store key material (skip to 48:30 for this particular moment) and that the signing key material never leaves a protected domain. While this is an abnormal scenario for a typical person to encounter, it demonstrates the kind of care that some security processes require. These extensions enable careful handling of client-stateless key material on web platforms.
These extensions expand what is possible on web platforms. That said, the web security model is not strong enough to safely use these features yet — unless it runs on an entirely offline chromebook. Two things need to change for this extension to be used safely.
First, we need a solution to version web applications with integrity on the complete source code shipped the client. One such solution in development was Web Packages. However as of February 2nd, the Wpack working group was closed with undelivered work.
Second, navigator.credentials
must be protected from local and extension scripts, else the PRF Extension evaluation may be exfiltrated.
PRF Extension is very interesting and sure got me excited. Until the security model has improved for the web, consider this to be an experimental feature and a toy, rather than a foundation for a new product or service.
Acknowledgements
Thank you to Matthew Miller for reviewing this article before publication.