This is a demo end-to-end encrypted notepad app using Passkeys.
This app leverages the WebAuthn PRF extension to derive cryptographic material for client-side encryption. Credential pseudo-random functions consume a "salt" and combine this with the private key to produce a deterministic, but cryptographically "strong" value.
During registration, the app provides a salt to credential creation. For this app, the salt is "well-known" but could be chosen at random for more advanced setups:
// Register a passkey with the server.
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, // For this app, a static value is chosen.
},
},
},
},
});The returned credential then contain a pseudo-random value:
const pubKey = cred as PublicKeyCredential;
const resp = pubKey.response as AuthenticatorAssertionResponse;
const ext = pubKey.getClientExtensionResults();
// Pseudo random derived from the private key and salt.
const { first } = ext.prf.results;The same value is available on login by providing the same salt during the authentication phase:
// Challenge the user's passkey to sign a challenge.
const cred = await navigator.credentials.get({
publicKey: {
challenge, // Provided by the server.
rpId,
extensions: {
prf: {
eval: {
first: SALT, // Same static value as above.
},
},
},
},
});
// const ... = cred.response.getClientExtensionResults().prf.results.first;The app then uses that output to derive encryption keys for client-side cryptography:
const extractable = false;
const prfKey = await crypto.subtle.importKey(
"raw",
prfFirst,
"HKDF",
extractable,
["deriveKey"],
);
const aesKey = crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt, // Random public value.
info: new TextEncoder().encode("note-encryption-key"),
},
prfKey,
{ name: "AES-GCM", length: 256 },
extractable,
["encrypt", "decrypt"],
);
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Encrypt a plain text.
const cipherText = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv, // Another random public value.
},
aesKey,
new TextEncoder().encode(plainText),
);The app requires pnpm and go. Use pnpm start
to run both the frontend React app and backend Go server:
pnpm start
Then visit: http://localhost:9811
To reset the local database:
pnpm clean
This app uses established primitives, but the "protocol" is bare-bones. For example, there's no way to add a second passkey, rotate key material, or encrypt more than one note. This is intended for demonstration, and not representative of best practices for modern end-to-end encrypted services.