Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- In Cloudflare Workers and browsers (the WebCrypto entry), a refused `eventSigner` `sign` call, for example one with a `now` of `NaN` or a `null` message, no longer makes every later call on that signer reject. It now leaves the signer where it was, as the Node, Bun and Deno entries do.
- On the same entry, `chunk(data)` and `sign(message)` now read the buffer when they are called, not when their turn in the queue comes. A caller that reused its buffer straight after the call got a signature for the new bytes; it now gets the one the other entries give.

## 1.0.4 - 2026-11-01

Documentation only: the code is unchanged since 1.0.2 (`src/` is the same tree and the built files are byte-identical).
Expand Down
2 changes: 2 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ A request that cannot be signed correctly throws instead of returning a signatur

`chunk(data)` with an empty `data` returns it unchanged (zero bytes) and signs nothing, because a zero-length chunk ends the upload; call `end()` for that. Trailer names are lowercased before they are signed.

A call that is refused (a trailer with a line break, a `now` that is not a time, a message that is not a `Uint8Array`) throws, or on the promise-returning entry rejects, and leaves the signer where it was: the next call is signed with the signature before the refused one. The bytes you pass are read when you call, so you can reuse the buffer straight away.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Honor the new promise to refuse non-Uint8Array messages · ref f00c4b

The guide lists a message that is not a Uint8Array as a refused call, but the new copy uses the generic typed-array slice method and accepts other typed-array types.

Failure: Passing new Uint16Array([0x0102]) to either event signer succeeds: hashing reads two bytes while framing writes one byte, yielding a signature the service rejects instead of the documented error.

Suggested fix: Validate the message type before signing on all entries, or narrow the new refusal promise to the inputs actually rejected and state that callers must supply a Uint8Array.


### How it reads the URL

- **Paths.** For S3, percent-escapes are decoded and every byte except `A-Z a-z 0-9 - . _ ~ /` is encoded again, once. A raw `+` in the path is signed as a space (`%20`). That is this library's reading of how S3 treats it: the test corpus follows the same rule, botocore does not check it independently, and it has not been tried against AWS S3. Cloudflare R2 agrees: it accepted a raw `+` signed as `%20` and answered `SignatureDoesNotMatch` to the same URL signed as `%2B` (four read-only `GET` requests to a bucket that no longer exists, so each ended in an error after the signature check; which object a raw `+` then names was not tested). A local `s3s-fs` 0.17.0 server reads a raw `+` as a plus sign and rejected sigflint's signature for it. To mean a literal plus, write `%2B`: it is signed as a plus, and that server accepted it from sigflint and from botocore. `%20` is a space. An escape that is not valid (`%zz`, a lone `%`) is signed as a literal `%`. For every other service the path is normalised (empty segments dropped, `.` and `..` resolved) and encoded a second time, as the specification says.
Expand Down
58 changes: 40 additions & 18 deletions src/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,28 @@ export async function presignV4a(request, credentials) {
*/
const signWith = async (key, text) => toHex(await subtle.sign('HMAC', key, utf8.encode(text)));

/**
* A copy of the bytes as they are now. TypedArray's slice copies even for a Node Buffer, whose own slice returns a view, and it throws for
* anything that is not a typed array, as hashing it would.
* @param {Uint8Array} data
*/
const copyOf = (data) => Uint8Array.prototype.slice.call(data);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Copy bytes without invoking the input's species constructor · ref b8639c

copyOf uses Uint8Array.prototype.slice.call(data), which constructs its result through the input's Symbol.species.

Failure: A valid Uint8Array subclass whose species is Uint16Array produces a 16-bit copy. The signer hashes six bytes for input elements [1, 2, 255] but frames three bytes, so the service rejects the signature. The same input worked on the parent revision and synchronous entry.

Suggested fix: Copy validated input into a fresh ordinary Uint8Array without using the input's species constructor.


/**
* 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.
* @returns {<T>(step: () => Promise<T>) => Promise<T>}
*/
function inOrder() {
/** @type {Promise<unknown>} */
let turn = Promise.resolve();
return (step) => {
const result = turn.then(step);
turn = result.catch(() => {});
return result;
};
}

/**
* Signs an aws-chunked upload chunk by chunk; the same shape as the other entries, with promises.
* @param {import('./base.js').AuthHeaders} authHeaders - the headers getAuthHeaders returned for the request
Expand All @@ -289,19 +311,24 @@ export function chunkSigner(authHeaders, credentials) {
seed.service,
);
// chunks are signed one after the other: a call waits for the one before it
/** @type {Promise<any>} */
let turn = Promise.resolve();
const inTurn = inOrder();
/** @param {Uint8Array} data */
const sign = (data) =>
(turn = turn.then(
inTurn(
async () =>
(previous = await signWith(
await keyPromise,
chunkStringToSign(seed.datetime, seed.scope, previous, await hexHash(data)),
)),
));
);
return {
chunk: async (data) => (data.length === 0 ? data : frameChunk(await sign(data), data)),
async chunk(data) {
if (data.length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Keep an empty chunk empty when its buffer grows · ref d4c1ed

The empty-input branch returns the caller's view directly: if (data.length === 0) { return data; }.

Failure: If a length-tracking Uint8Array is empty when passed to chunk(), then its resizable buffer grows and is filled before the promise is awaited, the result contains the new raw bytes instead of zero bytes. Forwarding them corrupts the chunked stream, despite the new call-time buffer-reuse guarantee.

Suggested fix: Return a stable zero-length result for empty views whose backing buffer can grow, and apply the same guarantee to the synchronous entry.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Measure the new copy cost before merging · ref 7d2d4e

chunk() now copies each input before queueing it. CONTRIBUTING.md requires a bench/ measurement for a change that makes a workload slower, and the pull request says no benchmark was run.

Failure: In a local reproduction, queueing eight calls with the same unchanged 4 MiB buffer allocated 32 MiB of snapshots immediately; the parent allocated no extra snapshots. This can exhaust a memory-limited Worker, and the required before-and-after cost evidence is missing.

Suggested fix: Measure WebCrypto chunk and event throughput and memory for representative sizes under the repository's benchmark protocol, then record the tradeoff before merging.

return frameChunk(await sign(own), own);
},
async end(trailers) {
const text = trailers === undefined ? undefined : canonicalTrailers(trailers); // before sign(), so a refused trailer leaves the chain where it was
const signature = await sign(new Uint8Array(0));
Expand Down Expand Up @@ -329,24 +356,19 @@ export function chunkSigner(authHeaders, credentials) {
export function eventSigner(authHeaders, credentials) {
const seed = seedOf(authHeaders);
let previous = seed.signature;
/** @type {Promise<any>} */
let turn = Promise.resolve();
const inTurn = inOrder();
return {
sign: (message, now = Date.now()) =>
(turn = turn.then(async () => {
async sign(message, now = Date.now()) {
const own = copyOf(message); // the caller may reuse its buffer once this returns, as on the synchronous entries
return inTurn(async () => {
const { datetime, scope, date } = eventScope(now, seed.region, seed.service);
const header = dateHeader(now);
previous = await signWith(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Commit the chain only after framing succeeds · ref f43d43

The event step assigns previous = await signWith(...) before return frameEvent(header, previous, own), while inOrder permits the next step to run after a rejection.

Failure: If framing throws after signing, such as from a recoverable output-allocation failure, the call rejects but previous already contains the signature of a frame the caller never received. A later event is accepted locally but is chained to that missing frame, so the service rejects it. The chunk path also commits before frameChunk succeeds.

Suggested fix: Build the frame using a temporary signature, then update previous only after framing succeeds; keep the complete operation inside the queue for chunks and trailers as well.

await getSigningKey(credentials.secretAccessKey, date, seed.region, seed.service),
eventStringToSign(
datetime,
scope,
previous,
await hexHash(header),
await hexHash(message),
),
eventStringToSign(datetime, scope, previous, await hexHash(header), await hexHash(own)),
);
return frameEvent(header, previous, message);
})),
return frameEvent(header, previous, own);
});
},
};
}
56 changes: 56 additions & 0 deletions test/hardening.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,62 @@ for (const [
assert.deepEqual(await signer.chunk(new Uint8Array([1, 2, 3])), reference);
});

test(`${name}: a refused chunk leaves the chunk signer usable and the chain where it was`, async () => {
const part = new Uint8Array([1, 2, 3]);
const fresh = await chunkSigner(await chunkedHeaders(), credentials).chunk(part);
const signer = chunkSigner(await chunkedHeaders(), credentials);
const refused = /** @type {Uint8Array} */ (/** @type {unknown} */ ({ length: 3 }));
await rejects(() => signer.chunk(refused), TypeError, /./);
assert.deepEqual(await signer.chunk(part), fresh);
// a call made straight after a refused one, without waiting for it, is not refused with it
const second = chunkSigner(await chunkedHeaders(), credentials);
const [first, next] = await Promise.allSettled([
Promise.resolve().then(() => second.chunk(refused)),
Promise.resolve().then(() => second.chunk(part)),
]);
assert.equal(first.status, 'rejected');
assert.deepEqual(next.status === 'fulfilled' && next.value, fresh);
});

test(`${name}: a refused event leaves the event signer usable and the chain where it was`, async () => {
const message = new Uint8Array([1, 2, 3]);
const seed = () => getAuthHeaders({ ...request, service: 'transcribe' }, credentials);
const fresh = await eventSigner(await seed(), credentials).sign(message, now);
for (const [refusedMessage, at] of /** @type {[Uint8Array, number][]} */ ([
[message, NaN],
[/** @type {Uint8Array} */ (/** @type {unknown} */ (null)), now],
])) {
const signer = eventSigner(await seed(), credentials);
await rejects(() => signer.sign(refusedMessage, at), Error, /./);
assert.deepEqual(await signer.sign(message, now), fresh);
}
// a refused event takes its turn in the queue: the call made straight after it, without waiting, is not refused with it
const signer = eventSigner(await seed(), credentials);
const [first, next] = await Promise.allSettled([
Promise.resolve().then(() => signer.sign(message, NaN)),
Promise.resolve().then(() => signer.sign(message, now)),
]);
assert.equal(first.status, 'rejected');
assert.deepEqual(next.status === 'fulfilled' && next.value, fresh);
});

test(`${name}: a chunk is signed as the caller passed it, even if the buffer is reused straight away`, async () => {
const buffer = new Uint8Array([1, 2, 3]);
const expected = await chunkSigner(await chunkedHeaders(), credentials).chunk(buffer.slice());
const pending = chunkSigner(await chunkedHeaders(), credentials).chunk(buffer);
buffer.fill(9);
assert.deepEqual(await pending, expected);
});

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);
const expected = await eventSigner(await seed(), credentials).sign(buffer.slice(), now);
const pending = eventSigner(await seed(), credentials).sign(buffer, now);
buffer.fill(9);
assert.deepEqual(await pending, expected);
});

test(`${name}: an event signed at a fractional millisecond time is the same as at the whole millisecond`, async () => {
const message = new Uint8Array([1, 2, 3]);
const a = await eventSigner(
Expand Down
Loading