Internal review

Task 5 — Streaming worker invariants

The single-frame-in-flight invariant under every encrypt/decrypt path, and content-key zeroing on completion and abort.

Task 5 — Streaming worker invariants review

Date: 2026-05-11 Reviewer: Cho García Scope: web/utils/sfCryptoWorkerImpl.ts, web/utils/sfCryptoWorker.ts, web/utils/sf-crypto-worker.ts, web/utils/sfCryptoDecrypt.ts, and the library streaming surfaces they drive (crypto/src/streams/aes-gcm-v1.ts, crypto/src/migration/v0-bridge.ts). @shieldfive/[email protected] (repo HEAD a6705c7); web HEAD c4a58782. Methodology: state-machine audit. The single-frame-in-flight invariant (web/docs/phase2-design.md §3, restated in sfCryptoWorkerImpl.ts:8-26) was treated as the load-bearing claim; each happy, error, and clear path was walked against that claim with attention to "where does state live, what is in flight, what is cleaned up on failure?". Cross-referenced against the empirical back-pressure characterization in web/audit/phase-2/streaming-memory-results.md and the Phase 2 §0 gate verdict. Wire-format alignment and AEAD-level tag-before-plaintext properties were treated as out of scope (Tasks 1 and 4, both done).

Status note. This document records the review as performed on 2026-05-11. For current finding-level status (Fixed / Open / Accepted) including PR links for shipped fixes, see triage.md. The per-task status indicators in this document reflect state at review time and are not maintained after publication.

Summary

The single-frame-in-flight invariant holds under every code path examined — including the AEAD-mid-stream-failure path, the clear-during-process race, and the v0/v1 dispatcher boundary. The deferred-write/read-back-the-frame/await-write pattern at sfCryptoWorkerImpl.ts:294-349 correctly composes with WHATWG TransformStream HWM-0 back-pressure and matches the library's actual emission shape. No plaintext-leak path was identified, and no realistic error condition was found that violates the "at most one chunk plaintext + one ciphertext frame in flight" claim.

Highest-impact findings are documentation-style hardening opportunities and one robustness gap that costs a hang (not data loss):

  • 5.1initDecryptV1 does not validate parsed.suite against the suite the worker actually supports. A wrong-suite (XChaCha, PQ-hybrid) file passes init and surfaces the error only when the library's stream rejects it at chunk 0 — late, but recoverable.
  • 5.4processEncryptV1(0) on a totalChunks === 0 init hangs the worker on reader.read() because the library emits nothing for empty input and close() is never issued (no chunk is "final" when there are no chunks). Empty-file uploads are guarded at the call site (encryptModal.tsx) by totalChunks branching, but the worker itself has no defensive index check.
  • 5.2 — A second init message without a prior clear overwrites v1State without canceling the previous writer/reader or zeroing the previous contentKey. The current call sites always create a fresh worker per session, so this is unreachable in production; defense-in-depth only.

None reach Critical/High. Single-frame-in-flight is by construction of the worker's linear process(i) body, and AEAD tag-before-enqueue inside the library (already validated in Task 4 §"Tag-before- plaintext") closes the only path by which a partial plaintext could reach the readable side.

Findings

Finding 5.1 — initDecryptV1 does not validate parsed.suite

Severity: Low Affects: v1 decrypt Component: implementation

Description. sfCryptoWorkerImpl.ts:378-440 runs parseHeader(probeBytes) and then verifyHeaderMac(parsed, contentKey) against the on-disk header. Both checks are suite-agnostic — parseHeader accepts any of {0x01, 0x02, 0x03}, and the header-MAC key derivation (HKDF(content_key, salt=file_id, info="shieldfive/v1/header-mac", L=32)) is the same across all three suites. The worker then unconditionally creates an AES-GCM-v1-only decrypt stream:

const stream = createAesGcmV1DecryptStream({ contentKey })

If the on-disk header carries suite=0x02 (XChaCha) or 0x03 (PQ-hybrid) but the caller has the matching content key, init succeeds and posts inited{ totalChunks } to the main thread. The suite-mismatch error only surfaces at processDecryptV1(0), when the library's tryParseHeader (streams/aes-gcm-v1.ts:304-308) re-parses the header and throws decrypt stream: this stream only supports aes-256-gcm-v1; got suite 0x....

This is not exploitable — the dispatcher (sfCryptoDecrypt.ts:30-36) only routes cipher_version === 2 to the v1 path, and the web app never produces non-0x01 files today (Task 3, see task-03-threat-model-review.md:206-226). But the deferred error is a UX hazard: the user has already started the decrypt flow (a progress bar, possibly a Blob URL placeholder) before the failure shows up.

Reproduction or evidence. Craft a synthetic header with suite=0x02 and a matching header_mac under some content_key. Pass it to initDecryptV1 with that content key; init returns inited. Send process(0); the worker posts error: 'decrypt stream: this stream only supports aes-256-gcm-v1; got suite 0x02'.

Recommendation. Add an explicit suite check at init time immediately after verifyHeaderMac:

if (parsed.suite !== SUITE.AES_256_GCM_V1) {
  throw new Error(
    `crypto worker v1: unsupported suite 0x${parsed.suite.toString(16).padStart(2, '0')}`,
  )
}

When the PQ-hybrid (0x03) suite is added to the worker in Phase 3, this check becomes the dispatching point: route to the appropriate stream factory based on parsed.suite. Importing SUITE from @shieldfive/crypto keeps the constant in sync with the library.


Finding 5.2 — initEncryptV1 / initDecryptV1 re-init clobbers prior state without cancel

Severity: Low Affects: v1 encrypt + decrypt Component: implementation

Description. Both init handlers (sfCryptoWorkerImpl.ts:232-275, sfCryptoWorkerImpl.ts:378-440) overwrite v1State unconditionally. If a v1State already exists (prior session of the same worker), its writer, reader, and contentKey are simply orphaned — the new state object replaces them, and the only references the worker held are dropped.

Consequences:

  • The prior stream's writer is not abort()ed and the reader is not cancel()ed. The underlying TransformStream remains live until GC reclaims it. In a back-pressured-at-HWM-0 state, the writable may have pending writes that never resolve.
  • The prior contentKey Uint8Array is dropped without state.contentKey.fill(0), so the bytes linger in worker heap until GC. By contrast, the explicit clear path does zero it (sfCryptoWorkerImpl.ts:543).

The v0 init path (sfCryptoWorkerImpl.ts:568-570) already handles this — it awaits clearV1() before falling through to the v0 bridge. The v1→v1 path does not.

This is unreachable in current production code: every caller (sfCryptoDecrypt.ts v1 path, encryptModal.tsx) creates a fresh Worker per session via createSfCryptoWorker() and terminates it in finally. Filing as defense-in-depth.

Reproduction or evidence. Within a single worker instance, post two init messages with mode: 'encrypt-v1' (or 'decrypt-v1') without an intervening clear. The second init succeeds and replaces state; the first stream's resources are orphaned and the first content key bytes are not zeroed.

Recommendation. Mirror the v0 path: clear before reinitializing.

if (msg.type === 'init') {
  if (msg.mode === 'encrypt-v1' || msg.mode === 'decrypt-v1') {
    if (v1State) await clearV1().catch(() => null)
    try {
      /* existing init body */
    } catch (err) {
      postError(err)
    }
    return
  }
  // ... v0 path (already calls clearV1)
}

The clearV1 zeroization and stream-teardown work happens regardless of whether the previous mode was encrypt-v1 or decrypt-v1, so a single call covers both.


Finding 5.3 — processDecryptV1 does not bound ctLength against parsedHeader.chunkSize before reading from the blob

Severity: Low Affects: v1 decrypt Component: implementation

Description. sfCryptoWorkerImpl.ts:456-468 reads the 4-byte length prefix from the ciphertext blob, decodes it as ctLength = readUint32BE(lenBytes), and then unconditionally slices ctLength bytes off the blob:

const ctLength = readUint32BE(lenBytes)
const ctBytes = new Uint8Array(
  await blob.slice(off + 4, off + 4 + ctLength).arrayBuffer(),
)
if (ctBytes.length !== ctLength) {
  throw new Error('crypto worker v1: truncated ciphertext frame')
}

The library's stream then range-checks (streams/aes-gcm-v1.ts:340-348):

const maxCipher = parsedHeader.chunkSize + AES_GCM_V1_TAG_BYTES
if (nextCipherLen < minCipher || nextCipherLen > maxCipher) {
  throw new HeaderError('chunk_length_out_of_range')
}

— but only after the worker has already allocated up to ctLength bytes from blob.slice().arrayBuffer(). With ctLength a 4-byte unsigned, the worker's read is capped only by the blob's remaining size, not by the authenticated parsedHeader.chunkSize.

Practical impact is limited. The header MAC binds chunk_size, so an attacker who can flip the length-prefix bytes still cannot exceed the blob the user already downloaded. The amplification ceiling is min(2^32 − 1, blob.size − off − 4), i.e. it is dominated by the blob size in every realistic scenario. The worker fails cleanly via the size-mismatch check, and no plaintext is emitted.

What's defective is the layering — the worker pre-allocates based on attacker-controlled bytes and only post-validates via the library. A defensive bound at the worker layer keeps the single-frame-in-flight invariant tight regardless of length-prefix tampering, and avoids reading multi-MiB blob slices when chunkSize is, say, 64 KiB.

Reproduction or evidence. Take a valid v1 file, flip the first four bytes of chunk 0 (immediately after parsed.headerLength) from 0x00 0x50 0x00 0x10 (5 MiB + 16) to 0xFF 0xFF 0xFF 0xFF. Feed it to processDecryptV1(0). The worker allocates min(2^32 − 1, blob.size − headerLength − 4) bytes before the size-mismatch or library range-check fires.

Recommendation. Pre-validate against parsedHeader.chunkSize + AES_GCM_V1_TAG_BYTES:

const ctLength = readUint32BE(lenBytes)
const maxCt = state.parsedHeader.chunkSize + 16 /* AES_GCM tag */
const minCt = 1 + 16
if (ctLength < minCt || ctLength > maxCt) {
  throw new Error(
    `crypto worker v1: chunk ${index} length ${ctLength} out of range [${minCt}, ${maxCt}]`,
  )
}
// then slice

Constant 16 can be replaced with AES_GCM_V1_TAG_BYTES exported from @shieldfive/crypto/streams/aes-gcm-v1. The check duplicates the library's check by design — same rationale as the existing header-MAC double-check at init: the worker layer should reject junk early without forwarding to the library.


Finding 5.4 — processEncryptV1(0) on a zero-chunk init hangs

Severity: Low Affects: v1 encrypt Component: implementation

Description. initEncryptV1 computes (sfCryptoWorkerImpl.ts:249-250):

const totalChunks =
  msg.plaintextSize === 0 ? 0 : Math.ceil(msg.plaintextSize / msg.chunkSize)

totalChunks === 0 is a valid state. The init posts back inited{ totalChunks: 0 }. If the caller then sends {type: 'process', index: 0}, the worker's order check passes (0 === state.nextExpectedIndex), the worker slices zero plaintext bytes from the blob, and issues writer.write(new Uint8Array(0)).

In the library's transform (streams/aes-gcm-v1.ts:161-185), ensureHeader runs and enqueues the 149-byte header, bytesConsumed += 0, the merge into pending leaves pending empty, and the drain loop's chunkIndex < totalChunks - 1 is 0 < -1, which is false. No chunk frame is emitted.

The worker's isFinal test is 0 === totalChunks - 1 which is 0 === -1, false. writer.close() is therefore never issued, so flush() is never invoked.

The worker then enters fr.ensure(4) (the length-prefix read) on a stream that has emitted the header and will never emit anything else. reader.read() awaits indefinitely.

The hang is contained: clear still works (it nulls v1State, cancels the reader, aborts the writer). But a misbehaving caller can wedge the worker without recovering — and the only recovery is a clear that the caller hasn't decided to send because they're waiting on the chunk response.

In production this is unreachable: encryptModal.tsx early-returns on empty files (a file with fs.file.size === 0 would not reach the encrypt session). But the worker accepts the message and quietly hangs rather than rejecting it.

Reproduction or evidence. Programmatically post {type: 'init', mode: 'encrypt-v1', key, fileId, chunkSize: 5_242_880, blob: new Blob([]), plaintextSize: 0} followed by {type: 'process', index: 0}. Inspect the worker — no chunk reply arrives. clear still works.

Recommendation. Add an index-bound check in processEncryptV1 parallel to the order check:

if (state.totalChunks === 0 || index >= state.totalChunks) {
  throw new Error(
    `crypto worker v1: process index ${index} out of range (totalChunks=${state.totalChunks})`,
  )
}

The same check would be appropriate in processDecryptV1 — there the read from blob.slice() would fail with truncated ciphertext length prefix past the final chunk, so the failure is loud, but an explicit bound is clearer and matches the encrypt side.


Finding 5.5 — Worker postMessage transfers chunk data by structured clone instead of transfer

Severity: Low Affects: v1 encrypt + decrypt (and v0 in the bridge, same shape) Component: implementation

Description. Each chunk reply posts the data buffer without a transfer list:

// encrypt — sfCryptoWorkerImpl.ts:369-375
scope.postMessage({
  type: 'chunk',
  mode: 'encrypt',
  index,
  data: part.buffer,
  sha1,
})
// decrypt — sfCryptoWorkerImpl.ts:521-526
scope.postMessage({
  type: 'chunk',
  mode: 'decrypt',
  index,
  data: plaintext.buffer,
})

The v0 bridge does the same (migration/v0-bridge.ts:304-310, 323-328). By contrast, the wrapper's INCOMING messages from main thread to worker DO use transfer (sfCryptoWorker.ts:188): worker.postMessage(init, transfers).

The structured-clone semantics make every chunk reply a copy: at the moment of postMessage, the worker holds the original Uint8Array and the runtime is also serializing an identical-bytes clone for the main thread. For a 5 MiB chunk size, this is a transient ~10 MiB blip in worker memory per process(i) — modest but multiplied by chunk count and inconsistent with the wrapper's own transfer discipline on the inbound side.

This is a memory/perf issue, not a confidentiality issue: the "recipient" of the clone is the same user's main thread, in the same browser tab. No principal boundary is crossed by the extra copy. But the single-frame-in-flight invariant claims peak working memory is "one chunk plaintext + one ciphertext frame"; the clone makes the encrypt-side peak briefly "two ciphertext frames" and the decrypt-side peak briefly "two plaintext chunks".

The Phase 2 §0 memory characterization (1 GiB encrypt on Node) did not exercise the postMessage path — the script consumed the readable directly. So the clone overhead is not visible in the recorded 7.57 MiB heap peak; in the actual worker, the peak is moderately higher than the §0 number suggests.

Reproduction or evidence. Read the postMessage call sites above. The MDN/WHATWG semantics: ArrayBuffers passed in the message body without inclusion in transfer are structurally cloned (a byte-for-byte copy).

Recommendation. Include the buffer in the transfer list. The buffer is then neutered in the worker (length becomes 0), but the worker doesn't reference it after postMessage, so neutering is fine:

scope.postMessage(
  { type: 'chunk', mode: 'encrypt', index, data: part.buffer, sha1 },
  [part.buffer],
)

— and analogously for decrypt and for the v0 bridge handler. The WorkerLikeScope.postMessage signature in sfCryptoWorkerImpl.ts:42 currently takes only (msg); widen it to (msg, transfer?: Transferable[]) so the implementation can pass the list through.

Worth coordinating with the v0-bridge change — migrationV0.installV0WorkerHandler lives in @shieldfive/crypto and would need its own minor modification. Filing the v0 side separately (or accepting v0 stays on structured clone, since it's deprecated post-migration).


Finding 5.6 — clearV1 zeros the worker's contentKey copy but not the WebCrypto-imported CryptoKey

Severity: Informational Affects: v1 encrypt + decrypt Component: implementation (documentation of limitation)

Description. clearV1 (sfCryptoWorkerImpl.ts:529-544) explicitly zeros state.contentKey via state.contentKey.fill(0). That's one of (at least) three copies of the key material the worker has indirectly produced:

  1. state.contentKey — the raw 32 bytes the worker received in the init message. Zeroed.
  2. The HKDF-derived chunk key inside createChunkContext — derived from state.contentKey and fileId. Stored as a CryptoKey via subtle.importKey. Cannot be zeroed from JS because CryptoKey exposes no raw-byte accessor; the implementation (browser WebCrypto or Node WebCrypto) owns the lifecycle of the underlying bytes.
  3. The header-MAC key, also HKDF-derived from state.contentKey and imported as a CryptoKey. Same lifecycle as #2.

clearV1's scope matches what the Phase 2 design specifies:

On {type:'clear'}: cancel the stream, zeroize the content key, drop holdover buffers. Reply cleared. — web/docs/phase2-design.md §3

So the implementation does not under-deliver. But "zeroize the content key" understates what the worker actually controls. "Zeroize the content key bytes still in our hands; CryptoKey internals are at the discretion of the runtime" is the accurate characterization.

For the AES-GCM-v1 streaming path, the chunk-key CryptoKey holds the most attack-relevant material — but it is derived deterministically from (contentKey, fileId). Once contentKey bytes are zeroed AND fileId is no longer reachable (both are released when v1State = null), an attacker scanning worker memory would need to find chunk-key bytes directly via the CryptoKey representation. Browsers typically store these in native heap controlled by the WebCrypto implementation; no JS API exposes them, and they are reclaimed when the CryptoKey is GC'd.

This is Informational rather than Low because the limitation is inherent to WebCrypto's CryptoKey API, not a worker-side bug.

Reproduction or evidence. Read clearV1 and trace the contentKey lifecycle. Compare to createChunkContext where the derived chunk key is subtle.importKey-imported and stored as a CryptoKey. There is no public Web API to overwrite CryptoKey backing memory.

Recommendation. No code change required, but the implementation comment block at sfCryptoWorkerImpl.ts:8-26 could be augmented with one paragraph stating: "Clearing the content key zeros the bytes in this worker's heap. HKDF-derived chunk keys held as CryptoKey are not zeroable from JS; they are reclaimed when the worker terminates or the CryptoKey is GC'd."

Tracked in the separate server-side review (master key in-memory handling, kept private) for the broader picture across the master-key → vault-key → content-key hierarchy.


Finding 5.7 — Single-frame-in-flight invariant holds (positive finding)

Severity: Informational Affects: v1 encrypt + decrypt Component: both

Description. The invariant claimed in sfCryptoWorkerImpl.ts:8-26 and web/docs/phase2-design.md §3 is:

"At any moment between calls, the worker holds at most one plaintext chunk + one ciphertext frame in flight."

I walked every state transition in the v1 worker against this claim. It holds under every path examined.

Encrypt-v1 happy path. Per process(i) call:

  • Local plaintext is one chunk worth (min(chunkSize, plaintextSize − i*chunkSize)).
  • Local lenBytes (4 B), ciphertext (≤ chunkSize+16 B), and part (header for i=0 + lenBytes + ciphertext) are bounded.
  • FrameReader.buf peaks at the largest single read (≤ ciphertext length).
  • Library's internal pending is empty after each drain (each transform call processes exactly one chunk's worth of input, drains exactly one frame, then pending = pending.subarray(...).slice(0)).
  • Between process() calls, ALL of the above are out of scope or drained. Persistent state is: contentKey (32 B), the writer/ reader handles (no buffered data — readable has been read empty, writable's previous write has been awaited), counters, blob reference, parsedHeader reference (decrypt only). No multi-frame queue.

Decrypt-v1 happy path. Same shape:

  • lenBytes + ctBytes are bounded (with the caveat in 5.3).
  • writeBytes is bounded.
  • plaintext read out of the library is bounded by expectedPlaintextSize, which is at most parsedHeader.chunkSize.
  • pendingHeaderBytes is held from init to the chunk-0 process and then null-ed.

AEAD-mid-stream failure (decrypt). When decryptChunk throws, the library's transform catch invokes controller.error() BEFORE any controller.enqueue(pt) for that chunk (see streams/aes-gcm-v1.ts:324-362, already validated in Task 4 §"Tag-before-plaintext"). The worker's fr.ensure() then sees reader.read() reject. The throw propagates, processDecryptV1 re-raises, onmessage catches it and posts error. No plaintext for the failed chunk reaches the readable side; no plaintext for the failed chunk reaches the main thread.

Clear during process (race). onmessage handlers interleave at await points. If clear arrives while process is awaiting a read, clearV1 runs concurrently: it nulls v1State (so the next incoming message sees no v1 state), cancels the reader (which makes the in-flight reader.read() reject), and aborts the writer (which rejects any pending writer.write). process then catches the rejected read, posts error. clear posts cleared. The wrapper's waitFor(... type === 'cleared') accepts either resolution (error or cleared) — see sfCryptoWorker.ts:75-103. The contentKey is zeroed regardless.

Encrypt happy-path final chunk. Writer.write issued without await, then writer.close issued without await, then reads drain the final frame, then awaits surface any errors. WHATWG semantics: close() resolves after pending writes; transform on the final write does not drain (chunkIndex === totalChunks − 1), so flush emits the final frame. The worker reads exactly that frame and completes. No frame is left behind on the readable side; the close ack signals done: true for any subsequent (incorrect) read.

Empirical cross-reference. The Phase 2 §0 streaming-memory characterization (1 GiB encrypt through createAesGcmV1EncryptStream, three runs) recorded peak heapUsed 7.57 MiB and a @25-to-@100 sample delta of 128 KiB on a 1 GiB stream (audit/phase-2/streaming-memory-results.md). That measurement bounds the LIBRARY's portion of the invariant. The worker's portion (one plaintext chunk + one ciphertext frame in flight, plus per-process scratch) is by-construction of the linear process(i) body. The composite invariant — O(chunkSize) peak, independent of file size — holds under static reading and is consistent with empirical library-only data.

Browser back-pressure verification (iOS Safari, real device) is deferred to Stage 3 per phase2-design.md §0 "Browser back-pressure verification"; that's a known gap, not a finding here.

Recommendation. None. Worth recording as the answer to the question Task 5 was set up to answer.


State machines (reference)

Encrypt-v1

init received
  │  validate key/fileId/chunkSize/plaintextSize lengths
  │  totalChunks = ceil(plaintextSize/chunkSize)  [or 0 if size=0]
  │  build encrypt TransformStream (HWM defaults; readable HWM=0)
  │  v1State = { writer, reader, contentKey, blob,
  │             nextExpectedIndex=0, ... }
  ▼
ready ──── process(i) where i === nextExpectedIndex ────►
                                                          │
                                                          │ slice plaintext[i*chunkSize..]
                                                          │ writer.write(plaintext)  ┐
                                                          │   ┌─ .catch(noop)        │ deferred
                                                          │ if isFinal: writer.close()
                                                          │   └─ .catch(noop)        │
                                                          │ for i=0: read header bytes
                                                          │ read 4 bytes (lenPrefix)
                                                          │ read ctLength bytes
                                                          │ await writePromise       ┘
                                                          │ if closePromise: await it
                                                          │ assemble part, sha1, post chunk{}
                                                          │ nextExpectedIndex += 1
                                                          ▼
                                                       ready (or done if i=totalChunks-1)
clear received at any state
  │  v1State = null  (synchronous)
  │  await reader.cancel()   (best effort)
  │  await writer.abort()    (best effort)
  │  contentKey.fill(0)
  ▼
empty

Persistent memory between process() calls: contentKey (32 B) + writer/reader handles (with empty internal queues) + blob reference

  • four small counters. No multi-frame buffer.

Decrypt-v1

init received
  │  validate key/fileId lengths
  │  probe = first min(blob.size, 256) bytes
  │  parsed = parseHeader(probe)
  │  verifyHeaderMac(parsed, contentKey)        [init-time defense in depth]
  │  byte-equality check parsed.fileId == expected
  │  build decrypt TransformStream
  │  pendingHeaderBytes = probe.slice(0, parsed.headerLength)
  │  v1State = { writer, reader, contentKey, parsedHeader, blob,
  │             nextFrameOffset=parsed.headerLength,
  │             nextExpectedIndex=0, pendingHeaderBytes }
  ▼
ready ──── process(i) where i === nextExpectedIndex ────►
                                                          │
                                                          │ read 4 bytes (lenPrefix) from blob
                                                          │ read ctLength bytes from blob
                                                          │ writeBytes = (i=0 ? header || lenPrefix || ct
                                                          │              : lenPrefix || ct)
                                                          │ pendingHeaderBytes = null  (one-shot)
                                                          │ writer.write(writeBytes)  ┐
                                                          │ if isFinal: writer.close()│ deferred
                                                          │ read expectedPlaintextSize bytes
                                                          │ await writePromise        ┘
                                                          │ if closePromise: await it
                                                          │ post chunk{}
                                                          │ nextFrameOffset += 4 + ctLength
                                                          │ nextExpectedIndex += 1
                                                          ▼
                                                       ready (or done if i=totalChunks-1)

After init but before chunk 0: the library's stream has nothing queued (no transform has run; only pendingHeaderBytes exists in the worker's state). At chunk 0, pendingHeaderBytes is concatenated into writeBytes so the library's first transform call sees header || lenPrefix || ct(0) and runs tryParseHeader once.

v0 dispatcher boundary

init received
  ├── mode = 'encrypt-v1'  ──► initEncryptV1 (above)
  ├── mode = 'decrypt-v1'  ──► initDecryptV1 (above)
  └── any other mode       ──► (if v1State exists) await clearV1
                                 ──► forward to migrationV0 bridge

process/chunk_request received
  ├── (v1State set)        ──► route to processEncryptV1 or processDecryptV1
  └── (no v1State)         ──► forward to migrationV0 bridge

clear received
  ├── (v1State set)        ──► clearV1; post 'cleared'
  └── (no v1State)         ──► forward to migrationV0 bridge

This is the only path that crosses v0/v1 — and it has the right shape: v1 state is torn down (with clearV1 running, which zeros contentKey) before v0 takes over. Going the other direction (v0 running, then v1 init) does not need cleanup because the v0 bridge has no v1State to clobber; v0 has its own opaque state inside installV0WorkerHandler that is overwritten on re-init.


What I checked but did not find issues with

Single-frame invariant under all v1 paths

  • The "deferred write, then read, then await write" pattern at sfCryptoWorkerImpl.ts:294-349 (encrypt) and sfCryptoWorkerImpl.ts:488-516 (decrypt) composes correctly with WHATWG TransformStream HWM=0 back-pressure. The writePromise.catch(noop) pre-attach prevents unhandled-rejection between the write and the trailing await.
  • Each process(i) reads exactly the bytes the library emits for this call (header on i=0, then one length-prefixed frame). The library does not emit multi-frame batches — transform's drain loop is while (pending.length >= chunkSize && chunkIndex < totalChunks - 1), and the worker writes exactly one chunk's worth per call, so the loop drains exactly once on non-final calls and not at all on the final call (flush handles the final).
  • The library's encrypt-stream pending buffer is copied to a fresh Uint8Array after each drain (streams/aes-gcm-v1.ts:181-184): pending = pending.subarray(chunkSize); pending = pending.slice(0). The slice(0) is essential — without it pending would keep a reference to the original (chunkSize) buffer via subarray, preventing GC. The slice copies the leftover bytes into a fresh buffer of exactly that size; the original buffer becomes collectible.
  • The library's decrypt-stream buffer is similarly copied after every consume() (streams/aes-gcm-v1.ts:261-265). Same rationale.
  • The worker's FrameReader.buf follows the same subarray().slice(0) pattern at sfCryptoWorkerImpl.ts:170-174.
  • FrameReader.append allocates a fresh merged buffer per inbound reader.read() chunk (sfCryptoWorkerImpl.ts:152-158). Worst case is O(N²) bytes copied across N reads, but N per process() is small (3 reads for chunk 0: header, lenPrefix, ct; 2 reads for chunk i>0). Peak buf size = current frame.

Header MAC verification ordering

  • initDecryptV1 runs parseHeader → verifyHeaderMac → fileId check before constructing the decrypt stream (sfCryptoWorkerImpl.ts:396-416). The library's stream then re-verifies internally inside tryParseHeader (streams/aes-gcm-v1.ts:301-312). This is the deliberate redundancy noted at sfCryptoWorkerImpl.ts:398-402.
  • tryParseHeader calls verifyHeaderMac BEFORE createChunkContext, so a tampered header is rejected before any AEAD key is derived. The streaming decrypt path is not subject to Task 1 Finding 1.4a/b (which applied to whole-blob entry points only) — see Task 1 § "v1 — header" final bullet.

AEAD tag-before-plaintext (cross-reference Task 4)

  • Verified in Task 4 § "Tag-before-plaintext" (task-04-aead-usage-audit.md:614-622). tryEmitChunk awaits decryptChunk and only then calls controller.enqueue(pt). Tag failure throws inside decryptChunk, the catch in transform calls controller.error(...). No plaintext bytes reach the readable side for a chunk that fails AEAD authentication.

Error path: in-flight write/close cleanup

  • Both writePromise.catch(noop) and closePromise.catch(noop) pre-attach handlers BEFORE any await (sfCryptoWorkerImpl.ts:300-302, 312-314, 489-491, 502-504). If the read fails (and the throw propagates out of process before the trailing await), the pre-attached .catch suppresses the unhandled-rejection that would otherwise fire. The error is still surfaced via the onmessage outer try/catch posting error.

Clear path cleanup

  • clearV1 (sfCryptoWorkerImpl.ts:529-544) nulls v1State SYNCHRONOUSLY before any await. A concurrent re-entry on the onmessage handler sees v1State === null and takes the v0 fallback path (or no-ops for process).
  • reader.cancel() and writer.abort() are best-effort with empty catch blocks. Their failure does not prevent contentKey.fill(0).
  • contentKey.fill(0) runs unconditionally at the end. The state object goes out of scope after clearV1 returns; the writer/ reader handles are dropped with it.

Caller-side cleanup

  • decryptV0Path and decryptV1Path in sfCryptoDecrypt.ts:75-78, 121-124 wrap the chunk loop in try { ... } finally { await clearSfCryptoWorker(worker).catch(() => null); worker.terminate() }. Even on an exception (mid-stream AEAD failure, network error), clearV1 runs to zero the content key, then terminate() tears down the worker context.
  • encryptModal.tsx also uses clearSfCryptoWorker before worker.terminate() (encryptModal.tsx:738).

Page-navigation cleanup (browser limitation)

  • If the user navigates mid-decrypt before the finally runs, worker.terminate() is implicit when the tab is destroyed. The worker's heap is freed but contentKey bytes are not explicitly zeroed. This is a JS-runtime limitation: there is no reliable 'unload'-equivalent event for the worker to zero on its way out. In scope for the separate server-side review (master key in-memory handling, kept private), not here.

Out-of-order rejection

  • Both processEncryptV1 and processDecryptV1 reject index !== state.nextExpectedIndex at the top of the function (sfCryptoWorkerImpl.ts:282-286, 447-451). Rejection posts error; the state is not advanced. The next matching-index call still works. Out-of-order is treated as programmer error per the design contract (web/docs/phase2-design.md §3 "strict ascending order starting at i=0").

v0/v1 dispatcher cross-contamination

  • v0 init while v1State is set: clearV1 is awaited first (sfCryptoWorkerImpl.ts:568-570). Then the message falls through to v0Scope.onmessage. v1 state is fully torn down.
  • v1 init while v0 is "active" (a session in progress): the v1 init succeeds, sets v1State. The v0 bridge's internal state variable is not visible to the v1 worker; it persists in the closure of installV0WorkerHandler until either a v0 clear or a v0 re-init. Subsequent process messages route to v1 because v1State is set, so the v0 internal state is dormant. On worker termination, both are freed. The dormant v0 state is not a leak in the security sense (the keys are stored as CryptoKey, not exposed as raw bytes), but it does carry the previous v0 session's key handle. Filing as out-of-scope here; v0 session lifecycle belongs to the separate server-side review (kept private).
  • process / chunk_request with v1State set always routes to v1, never to v0. Inverse holds when v1State is null.

Wire-format invariants (cross-reference Task 1)

  • The header bytes the worker concatenates for chunk 0 on encrypt (sfCryptoWorkerImpl.ts:354-365) match what the library emits (the worker reads exactly fullHeaderLen bytes from the readable side). The parsed.headerLength accessor on decrypt does the inverse on the decode side.
  • The 72-byte zero suite_payload override (sfCryptoWorkerImpl.ts:252) matches the spec convention for vault-stored wrapped keys (Task 1 Finding 1.8).

Bonus: encrypt path's bytesConsumed === plaintextSize check

  • Inside the library's flush (streams/aes-gcm-v1.ts:186-211), flush refuses to complete if the bytes written do not match the declared plaintextSize. If the worker were to be passed a plaintextSize that doesn't match the blob (a caller bug), the encrypt would error on the final chunk's close(). This is the library's defense, not the worker's. Worker-level validation would be additive but not required.

Length-prefix range check duplication on encrypt

  • The encrypt path computes the ciphertext length from readUint32BE(lenBytes) at sfCryptoWorkerImpl.ts:341 but immediately consumes that many bytes from the reader. Since the bytes came from the library's own emission, the length is trustworthy. Decrypt's read from blob is the side that needs the bound check (Finding 5.3); encrypt does not.

Out of scope (deferred to other tasks)

  • Master-key memory lifecycle, BFCache, session-expiry zeroing — out of crypto-layer scope, covered by the separate server-side review (kept private).
  • Threat-model alignment of the worker against suite 0x03 (PQ-hybrid) when Phase 3 ships — Task 3 already records that the worker imports AES-GCM-v1 only; adding 0x03 is future work.
  • AEAD-level correctness inside decryptChunk/encryptChunk and the tag-check-before-enqueue semantic — Task 4.
  • Whole-blob library decrypt paths (whose MAC-ordering anti-pattern is Task 1 Finding 1.4a/b). The web app uses streaming for both encrypt and decrypt; whole-blob is not on the worker code path.
  • v0 worker bridge's session lifecycle (key zeroing on clear, no explicit raw-byte zeroization in installV0WorkerHandler because the v0 path stores keys as CryptoKey not Uint8Array) — out of crypto-layer scope, covered by the separate server-side review (kept private).
  • Browser back-pressure verification on iOS Safari and Chrome — a Stage 3 acceptance requirement per web/docs/phase2-design.md §0 "Browser back-pressure verification", not within this internal review's static analysis remit.

References