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.
}).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);
import open from 'open';
-import { parsePrf } from '#src/utils.js';
/** @import { Bytes32 } from '#src/utils.js'; */
/**
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;
}
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;
}
* @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.
* @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.
*/
/**
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',
...headers
}
const filteredHeaders = filterNullValues(mergedHeaders);
- const body = filterNullValues({ prf });
return new Request(origin, {
method: 'POST',
headers: filteredHeaders,
- body: JSON.stringify(body)
+ body: prf
});
}
// 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';
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()
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/
}
});
-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: '' },