From 6851ddc1e8c4f4d7fbdba46da2949608391f7032 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 6 Nov 2026 11:08:34 -0700 Subject: [PATCH] Web chunk(): copy the caller's bytes once, straight into the frame The call-time copy made for the buffer-reuse fix was copied again into the frame when the chunk's turn came. The frame is now built at call time with a blank signature, the data is hashed where it sits in it, and the signature is written in when the chunk is signed. --- src/web.js | 18 ++++++++++++++++-- test/hardening.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/web.js b/src/web.js index 405e5fb..973814e 100644 --- a/src/web.js +++ b/src/web.js @@ -280,6 +280,11 @@ const signWith = async (key, text) => toHex(await subtle.sign('HMAC', key, utf8. */ const copyOf = (data) => Uint8Array.prototype.slice.call(data); +/** @param {unknown} data */ +const isBytes = (data) => + ArrayBuffer.isView(data) && /** @type {any} */ (data).BYTES_PER_ELEMENT === 1; // a Uint8Array of any realm or a Node Buffer; not a DataView or a wider array +const blankSignature = '0'.repeat(64); // a signature is 64 hex digits + /** * Runs the signing steps of a stream one after the other. A step that throws rejects its own call only: the next one still runs, as on the * synchronous entries, where a refused call throws and the signer stays usable. A step moves the chain only once it has succeeded. @@ -326,8 +331,17 @@ export function chunkSigner(authHeaders, credentials) { if (data.length === 0) { return data; } - const own = copyOf(data); // the signature and the frame are made when the chunk's turn comes; the caller may reuse its buffer once this returns - return frameChunk(await sign(own), own); + // The one copy of the bytes is the frame itself, made now, so the caller may reuse its buffer once this returns. The data is hashed where it sits in the + // frame and the signature is written into the blank the frame has for it, when the chunk's turn comes. + if (!isBytes(data)) { + throw new TypeError('data must be a Uint8Array'); + } + const frame = frameChunk(blankSignature, data); + const body = frame.length - 2 - data.length; // the data ends the frame, followed by CR LF; the signature ends the line before it + const at = body - 2 - blankSignature.length; + const signature = await sign(frame.subarray(body, frame.length - 2)); + utf8.encodeInto(signature, frame.subarray(at, at + signature.length)); + return frame; }, async end(trailers) { const text = trailers === undefined ? undefined : canonicalTrailers(trailers); // before sign(), so a refused trailer leaves the chain where it was diff --git a/test/hardening.test.js b/test/hardening.test.js index 71c90ea..dd471a3 100644 --- a/test/hardening.test.js +++ b/test/hardening.test.js @@ -233,6 +233,30 @@ for (const [ assert.deepEqual(await pending, expected); }); + test(`${name}: a chunk that is a view into a larger buffer is signed as its own bytes`, async () => { + const whole = new Uint8Array(40).map((_, i) => i); + const part = whole.subarray(10, 30); + const expected = await chunkSigner(await chunkedHeaders(), credentials).chunk(part.slice()); + assert.deepEqual(await chunkSigner(await chunkedHeaders(), credentials).chunk(part), expected); + assert.deepEqual( + await chunkSigner(await chunkedHeaders(), credentials).chunk(Buffer.from(part)), + expected, + ); + }); + + // the synchronous entries hash whatever node:crypto or Bun accepts (a string, a wider typed array); only the WebCrypto entry checks the type before framing + test( + `${name}: a chunk that is not bytes is refused, even one that claims to be a Uint8Array`, + { skip: name !== 'web' }, + async () => { + const claims = { [Symbol.toStringTag]: 'Uint8Array', length: 3 }; + const signer = chunkSigner(await chunkedHeaders(), credentials); + for (const data of [claims, new DataView(new ArrayBuffer(3)), new Uint16Array(3), 'abc']) { + await rejects(() => signer.chunk(/** @type {any} */ (data)), TypeError, /./); + } + }, + ); + test(`${name}: an event is signed as the caller passed it, even if the buffer is reused straight away`, async () => { const buffer = new Uint8Array([1, 2, 3]); const seed = () => getAuthHeaders({ ...request, service: 'transcribe' }, credentials);