]> git.ardis.lu - envpass/commitdiff
Pass PRF as raw bytes instead of string
authorArdis Lu <ardisglu@gmail.com>
Mon, 25 May 2026 00:21:14 +0000 (17:21 -0700)
committerArdis Lu <ardisglu@gmail.com>
Mon, 25 May 2026 00:21:14 +0000 (17:21 -0700)
Avoiding string serialization is more performant and simpler, and more importantly reduces the places where this sensitive value is written to memory. By keeping the value in buffer we can immediately zero it out manually instead of waiting for garbage collection.

The value is still "leaked" via DevTools inspection and other points in the network stack, but that is out of the control of JS.

src/getPrf.html
src/getPrf.js
src/utils.js
test/getPrf.test.js
test/utils.test.js

index 1965a30471f19d812600a3a0b0ab2c05498ade80..9aac142bf1bad4779e0cca49e76f5a8249f0735c 100644 (file)
       }).then(c => c.getClientExtensionResults().prf.results.first);
     }
     async function broadcastPrf(controller) {
-      let prfBuffer;
-      try { prfBuffer = await getPrf(controller.signal); }
-      catch { prfBuffer = await createPrf(controller.signal); }
-      const prf = new Uint8Array(prfBuffer).toHex();
+      const prf = new Uint8Array(32);
+      try { prf.set(await getPrf(controller.signal)); }
+      catch { prf.set(await createPrf(controller.signal)); }
       const response = await fetch(`http://localhost:${location.port}`, { // Intentionally hardcoding localhost, do not broadcast anywhere else
         method: 'POST',
         headers: {
-          'Content-Type': 'application/json',
+          'Content-Type': 'application/octet-stream',
           'Envpass-Challenge': challenge
         },
-        body: JSON.stringify({ prf })
+        body: prf
       });
+      prf.fill(0);
       if (response.status === 200) {
         p.textContent = 'Successfully sent passkey. You may close this window now.';
         clearTimeout(timeoutId);
index be19216bf0b80446c4a69903727323f729d13b75..1f2e1479ae2331102b0a8cc07a3fcab1cddd120a 100644 (file)
@@ -4,7 +4,6 @@ import { readFile } from 'node:fs/promises';
 
 import open from 'open';
 
-import { parsePrf } from '#src/utils.js';
 /** @import { Bytes32 } from '#src/utils.js'; */
 
 /**
@@ -121,7 +120,7 @@ export async function getPrf(options = {}) {
         response.writeHead(403).end();
         return;
       }
-      if (request.headers['content-type'] !== 'application/json') {
+      if (request.headers['content-type'] !== 'application/octet-stream') {
         response.writeHead(400).end();
         return;
       }
@@ -129,12 +128,20 @@ export async function getPrf(options = {}) {
         response.writeHead(401).end();
         return;
       }
-      let body = '';
-      request.on('data', chunk => body += chunk);
+      const prf = new Uint8Array(32); // PRF is always 32 bytes, see https://w3c.github.io/webauthn/#prf-extension
+      let offset = 0;
+      request.on('data', chunk => {
+        try { prf.set(chunk, offset); }
+        catch { // Invalid chunk or too many bytes, stop processing immediately.
+          response.writeHead(400).end();
+          request.destroy();
+          return;
+        }
+        chunk.fill(0);
+        offset += chunk.byteLength;
+      });
       request.on('end', () => {
-        const payload = JSON.parse(body);
-        const prf = parsePrf(payload.prf);
-        if (prf === null) {
+        if (offset !== 32) { // Not all 32 PRF bytes were from passkey
           response.writeHead(400).end();
           return;
         }
index 921415f43da435dd841ddc360c6ac75fb0f9acd8..d0d38044a95e5b63657983e6ab62d4c743807499 100644 (file)
@@ -20,34 +20,6 @@ export function toEnvString(obj) {
  * @typedef {Uint8Array<ArrayBuffer>&{byteLength:32}} Bytes32
  */
 
-/**
- * Returns whether `value` is `Bytes32` or not. This function is NOT intended to be directly called.
- * Use `parsePrf` instead ("parse, don't validate").
- * 
- * This function is only defined separately to make the `value is Bytes32` assertion for TypeScript.
- * @param {any} value A value that may be a `Bytes32`.
- * @returns {value is Bytes32}
- */
-function isBytes32(value) {
-  return value instanceof Uint8Array && value.byteLength === 32;
-}
-
-/**
- * Parse a string that may be an output from a passkey's pseudo random function encoded in hexadecimal.
- * @param {string} value A string that may be a value generated from a passkey's pseudo random function.
- * @returns {Bytes32|null} A 32 byte `Uint8Array`, or `null` if `value` is not a 64 character hexadecimal
- * value.
- */
-export function parsePrf(value) {
-  if (/^(0x)?[0-9a-f]{64}$/i.test(value)) {
-    const arr = Uint8Array.fromHex(value.replace(/^0x/i, ''));
-    if (isBytes32(arr)) {
-      return arr;
-    }
-  }
-  return null;
-}
-
 const ENVPASS_VERSIONS = /** @type {const} */(['v1']);
 /**
  * @typedef {typeof ENVPASS_VERSIONS[number]} EnvpassVersion A valid version identifier for envpass.
index a582431addcdc1449ea795ea47f8f34cee8e56f8..a8916e3f87b3fd535cca85cd5667c80e57ee9b31 100644 (file)
@@ -22,7 +22,7 @@ function filterNullValues(obj) {
  * @typedef {Object} MakePostRequestOptions
  * @property {Record<string,string|null>} [headers] Headers to set on the request. Set a property to
  * `null` to *delete* that header.
- * @property {string|null} [prf] `prf` string to pass on the request body.
+ * @property {BufferSource|null} [prf] `prf` buffer to pass on the request body.
  */
 
 /**
@@ -34,10 +34,10 @@ function makePostRequest(url, options = {}) {
   const { origin, searchParams } = new URL(url);
   const {
     headers = {},
-    prf = 'deadbeef000000000000000000000000000000000000000000000000cafebabe'
+    prf = Uint8Array.fromHex('deadbeef000000000000000000000000000000000000000000000000cafebabe')
   } = options;
   const mergedHeaders = {
-    'Content-Type': 'application/json',
+    'Content-Type': 'application/octet-stream',
     'Origin': origin,
     'Sec-Fetch-Site': 'same-origin',
     'Sec-Fetch-Mode': 'cors',
@@ -46,11 +46,10 @@ function makePostRequest(url, options = {}) {
     ...headers
   }
   const filteredHeaders = filterNullValues(mergedHeaders);
-  const body = filterNullValues({ prf });
   return new Request(origin, {
     method: 'POST',
     headers: filteredHeaders,
-    body: JSON.stringify(body)
+    body: prf
   });
 }
 
@@ -87,17 +86,17 @@ suite('getPrf.js (server)', () => {
     // See https://github.com/nodejs/undici/issues/1305
     const { url } = await setupServer(t);
     const { origin, searchParams } = new URL(url);
-    const prf = 'deadbeef000000000000000000000000000000000000000000000000cafebabe';
+    const prf = Uint8Array.fromHex('deadbeef000000000000000000000000000000000000000000000000cafebabe');
     const options = {
       method: 'POST',
       headers: /** @type {{[key:string]:string}} */({
-        'Content-Type': 'application/json',
+        'Content-Type': 'application/octet-stream',
         'Origin': origin,
         'Sec-Fetch-Site': 'same-origin',
         'Sec-Fetch-Dest': 'empty',
         'Envpass-Challenge': searchParams.get('challenge')
       }),
-      body: JSON.stringify({ prf })
+      body: prf
     }
     deepStrictEqual((await request(origin, options)).statusCode, 403); // Tests the unset case
     options.headers['Sec-Fetch-Mode'] = 'navigate';
@@ -133,7 +132,11 @@ suite('getPrf.js (server)', () => {
   test('validates prf on POST', async (t) => {
     const { url } = await setupServer(t);
     deepStrictEqual((await fetch(makePostRequest(url, { prf: null }))).status, 400);
-    deepStrictEqual((await fetch(makePostRequest(url, { prf: 'INVALID' }))).status, 400);
+    deepStrictEqual((await fetch(makePostRequest(url, { prf: new Uint8Array() }))).status, 400);
+    deepStrictEqual((await fetch(makePostRequest(url, { prf: Uint8Array.fromHex('11') }))).status, 400);
+    deepStrictEqual((await fetch(makePostRequest(url, { prf: Uint8Array.fromHex('11'.repeat(31)) }))).status, 400);
+    deepStrictEqual((await fetch(makePostRequest(url, { prf: Uint8Array.fromHex('11'.repeat(33)) }))).status, 400);
+    deepStrictEqual((await fetch(makePostRequest(url, { prf: Uint8Array.fromHex('11'.repeat(32)) }))).status, 200);
   });
   test('window expiration works', async (t) => {
     t.mock.timers.enable({ apis: ['setTimeout'] }); // MUST be called BEFORE setupServer()
index acf545b43aa672d1b4eb4729a65d8d0673328e9a..fd8ea415959250b78f95d29257c7ccf5d4e07cd3 100644 (file)
@@ -1,7 +1,7 @@
 import { suite, test } from 'node:test';
 import { ok, deepStrictEqual, throws } from 'node:assert/strict';
 
-import { toEnvString, parsePrf, parseEnvpassEncrypted, InputError } from '#src/utils.js';
+import { toEnvString, parseEnvpassEncrypted, InputError } from '#src/utils.js';
 import { parseEnv } from 'node:util';
 
 // Useful reference: https://dotenvx.com/spec/
@@ -25,35 +25,6 @@ suite('toEnvString', { concurrency: true }, () => {
   }
 });
 
-suite('parsePrf', { concurrency: true }, () => {
-  const rejects = [
-    { name: 'rejects empty', input: '' },
-    { name: 'rejects 1 character', input: 'a' },
-    { name: 'rejects 2 characters', input: 'aa' },
-    { name: 'rejects 62 characters', input: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
-    { name: 'rejects 66 characters', input: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
-    { name: 'rejects non-hex', input: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' },
-    { name: 'rejects non-hex with "0x" prefix', input: '0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' },
-  ];
-  for (const { name, input } of rejects) {
-    test(name, { concurrency: true }, () => {
-      deepStrictEqual(parsePrf(input), null);
-    });
-  }
-  const accepts = [
-    { name: 'accepts all lowercase', input: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
-    { name: 'accepts all uppercase', input: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' },
-    { name: 'accepts mixed case', input: 'AaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' },
-    { name: 'accepts with "0x" prefix', input: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' },
-    { name: 'accepts with "0X" prefix', input: '0XAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' },
-  ];
-  for (const { name, input } of accepts) {
-    test(name, { concurrency: true }, () => {
-      deepStrictEqual(parsePrf(input), Uint8Array.fromHex(input.replace(/0x/i, '')));
-    });
-  }
-});
-
 suite('parseEnvpassEncrypted', { concurrency: true }, () => {
   const rejects = [
     { name: 'rejects empty', input: '' },
This page took 0.052818 seconds and 4 git commands to generate.