From: Ardis Lu Date: Mon, 25 May 2026 00:21:14 +0000 (-0700) Subject: Pass PRF as raw bytes instead of string X-Git-Tag: v0.1.2~4 X-Git-Url: https://git.ardis.lu/?a=commitdiff_plain;h=989ca71ecd3413e43bfdcd685a163f3123a83f9a;p=envpass Pass PRF as raw bytes instead of string 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. --- diff --git a/src/getPrf.html b/src/getPrf.html index 1965a30..9aac142 100644 --- a/src/getPrf.html +++ b/src/getPrf.html @@ -88,18 +88,18 @@ }).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); diff --git a/src/getPrf.js b/src/getPrf.js index be19216..1f2e147 100644 --- a/src/getPrf.js +++ b/src/getPrf.js @@ -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; } diff --git a/src/utils.js b/src/utils.js index 921415f..d0d3804 100644 --- a/src/utils.js +++ b/src/utils.js @@ -20,34 +20,6 @@ export function toEnvString(obj) { * @typedef {Uint8Array&{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. diff --git a/test/getPrf.test.js b/test/getPrf.test.js index a582431..a8916e3 100644 --- a/test/getPrf.test.js +++ b/test/getPrf.test.js @@ -22,7 +22,7 @@ function filterNullValues(obj) { * @typedef {Object} MakePostRequestOptions * @property {Record} [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() diff --git a/test/utils.test.js b/test/utils.test.js index acf545b..fd8ea41 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -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: '' },