Bullet-proof encryption for web apps
The modern web, tested and explained in plain English
Modern Web Weekly #71
If you want to use encryption in your web app to encrypt and decrypt sensitive data, you can use the Web Crypto API to generate an public and private key:
const { publicKey, privateKey } = await window.crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["encrypt", "decrypt"],
);You can now send the public key to anyone you want to share your encrypted data with and keep your private key safely stored so you can decrypt data. But where exactly would you store your private key?
The only options you have are localStorage, sessionStorage, IndexedDB, and OPFS. While these are relatively safe, there is always the possibility that your web app is compromised and your private key is stolen. For very sensitive data, that can be un unacceptable risk.
Luckily, there’s now a solution.
The WebAuthn PRF extension (Pseudo-Random Function) enables web apps to derive encryption keys using the user’s passkey, without ever touching the private key itself.
Whenever you register a passkey or authenticate with it, you provide a text label for the key and you get a secret back that you can use to derive the encryption key. If you specify the same label, you get the same encryption key back.
So your web app only needs to store the label that you can use to get the key. No need to store the key anywhere else so it can never be stolen.
Here’s how it works.
When registering a passkey
Whenever you register a passkey, you add a prf key to the extensions key of the credential creation options like this:
extensions: {
prf: {
eval: {
first: new TextEncoder().encode("prf-key-v1") // the text label to derive the key
}
}
}In this example, “prf-key-1” is the text label we use for the key. The complete example to register the passkey would look something like this:
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array([
21, 31, 105 /* 29 more random bytes generated by the server */,
]),
rp: { name: "Secure Notes" },
user: {
id: new Uint8Array(16),
name: "pwa@whatpwacando.today",
displayName: "PWA"
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
userVerification: "required"
},
extensions: {
prf: {
eval: {
first: new TextEncoder().encode("prf-key-v1") // the text label to derive the key
}
}
}
}
}); We can now access the secret that we can use to derive the encryption key:
const prfResult = credential.getClientExtensionResults().prf.results.first;prfResult is an ArrayBuffer that we can use to create a CryptoKey object using importKey():
const keyMaterial = await crypto.subtle.importKey(
"raw",
prfResult,
"HKDF",
false,
["deriveKey"]
);
And then we can use deriveKey() to get the actual encryption key:
const encryptionKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array([]),
info: new TextEncoder().encode("prf-demo"),
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);Don’t worry if you don’t fully understand these last two steps. The important part is that you know how to get the actual encryption key and use it to encrypt and decrypt your data.
Here’s how you can encrypt text:
const textToEncrypt = 'My super secret data';
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedText = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
encryptionKey,
new TextEncoder().encode(textToEncrypt)
);And here’s how you can decrypt it:
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
encryptionKey,
textToDecrypt
);
const decryptedText = new TextDecoder().decode(decryptedText);Notice that the variable iv (Initialisation Vector) needs to be the same for encryption and decryption, so if you want to store the decrypted text on a server, you need to store iv along with it.
If you want to decrypt the text, you simply get the decrypted text and iv from the server, authenticate with your passkey and pass in the correct text label when you get the passkey using navigator.credentials.get():
const assertion = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array([
21, 31, 105 /* 29 more random bytes generated by the server */,
]),
extensions: {
prf: {
eval: {
first: new TextEncoder().encode("prf-key-v1") // same text label to derive the key
}
}
}
}
});Because you use the same text label for the key (“pro-key-1“) you get the exact same key back so you can decrypt your data.
The beauty of this is that you can only get the key after you are authenticated using your passkey. There’s no key stored in the browser storage (localStorage, cookies, IndexedDB etc.) so even when your app is hacked, your private key can’t be stolen.
The WebAuthn PRF extension is supported in all major browsers.
Check out the demo on https://whatpwacando.today/encryption
Do you need help with your web app?
Book a 1-on-1 call with me and I will do my absolute best to answer all your questions and solve your problems.
€100 for one hour, money-back guarantee if you’re not satisfied.
