Repository navigation
Web chunk and event signers recover after a refused call and copy the buffer at call time #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium: Copy bytes without invoking the input's species constructor ·
Failure: A valid Suggested fix: Copy validated input into a fresh ordinary |
||
|
|
||
| /** | ||
| * 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 | ||
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium: Keep an empty chunk empty when its buffer grows · The empty-input branch returns the caller's view directly: Failure: If a length-tracking 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium: Measure the new copy cost before merging ·
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)); | ||
|
|
@@ -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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium: Commit the chain only after framing succeeds · The event step assigns Failure: If framing throws after signing, such as from a recoverable output-allocation failure, the call rejects but Suggested fix: Build the frame using a temporary signature, then update |
||
| 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); | ||
| }); | ||
| }, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
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 f00c4bThe guide lists
a message that is not a Uint8Arrayas a refused call, but the new copy uses the generic typed-arrayslicemethod 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.