Skip to content

Box-of-Code/webauthn-prf-demo

 
 

Repository files navigation

Passkeys for end-to-end encryption

This is a demo end-to-end encrypted notepad app using Passkeys.

Details

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),
);

Running locally

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

Cryptography warning

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.

About

Passkeys for end-to-end encryption

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • TypeScript 52.4%
  • Go 43.5%
  • JavaScript 1.8%
  • CSS 1.4%
  • HTML 0.9%