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.1 —
initDecryptV1does not validateparsed.suiteagainst 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.4 —
processEncryptV1(0)on atotalChunks === 0init hangs the worker onreader.read()because the library emits nothing for empty input andclose()is never issued (no chunk is "final" when there are no chunks). Empty-file uploads are guarded at the call site (encryptModal.tsx) bytotalChunksbranching, but the worker itself has no defensive index check. - 5.2 — A second
initmessage without a priorclearoverwritesv1Statewithout canceling the previous writer/reader or zeroing the previouscontentKey. 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
writeris notabort()ed and thereaderis notcancel()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
contentKeyUint8Array is dropped withoutstate.contentKey.fill(0), so the bytes linger in worker heap until GC. By contrast, the explicitclearpath 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:
state.contentKey— the raw 32 bytes the worker received in the init message. Zeroed.- The HKDF-derived chunk key inside
createChunkContext— derived fromstate.contentKeyandfileId. Stored as aCryptoKeyviasubtle.importKey. Cannot be zeroed from JS becauseCryptoKeyexposes no raw-byte accessor; the implementation (browser WebCrypto or Node WebCrypto) owns the lifecycle of the underlying bytes. - The header-MAC key, also HKDF-derived from
state.contentKeyand imported as aCryptoKey. 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. Replycleared. —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
plaintextis one chunk worth (min(chunkSize, plaintextSize − i*chunkSize)). - Local
lenBytes(4 B),ciphertext(≤ chunkSize+16 B), andpart(header for i=0 + lenBytes + ciphertext) are bounded. FrameReader.bufpeaks at the largest single read (≤ ciphertext length).- Library's internal
pendingis empty after each drain (eachtransformcall processes exactly one chunk's worth of input, drains exactly one frame, thenpending = 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,blobreference,parsedHeaderreference (decrypt only). No multi-frame queue.
Decrypt-v1 happy path. Same shape:
lenBytes+ctBytesare bounded (with the caveat in 5.3).writeBytesis bounded.plaintextread out of the library is bounded byexpectedPlaintextSize, which is at mostparsedHeader.chunkSize.pendingHeaderBytesis held from init to the chunk-0 process and thennull-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) andsfCryptoWorkerImpl.ts:488-516(decrypt) composes correctly with WHATWG TransformStream HWM=0 back-pressure. ThewritePromise.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 iswhile (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
pendingbuffer is copied to a fresh Uint8Array after each drain (streams/aes-gcm-v1.ts:181-184):pending = pending.subarray(chunkSize); pending = pending.slice(0). Theslice(0)is essential — without itpendingwould 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
bufferis similarly copied after everyconsume()(streams/aes-gcm-v1.ts:261-265). Same rationale. - The worker's
FrameReader.buffollows the samesubarray().slice(0)pattern atsfCryptoWorkerImpl.ts:170-174. FrameReader.appendallocates a fresh merged buffer per inboundreader.read()chunk (sfCryptoWorkerImpl.ts:152-158). Worst case is O(N²) bytes copied across N reads, but N perprocess()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
initDecryptV1runsparseHeader → verifyHeaderMac → fileId checkbefore constructing the decrypt stream (sfCryptoWorkerImpl.ts:396-416). The library's stream then re-verifies internally insidetryParseHeader(streams/aes-gcm-v1.ts:301-312). This is the deliberate redundancy noted atsfCryptoWorkerImpl.ts:398-402.tryParseHeadercallsverifyHeaderMacBEFOREcreateChunkContext, 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).tryEmitChunkawaitsdecryptChunkand only then callscontroller.enqueue(pt). Tag failure throws insidedecryptChunk, the catch intransformcallscontroller.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)andclosePromise.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 ofprocessbefore the trailing await), the pre-attached.catchsuppresses the unhandled-rejection that would otherwise fire. The error is still surfaced via theonmessageouter try/catch postingerror.
Clear path cleanup
clearV1(sfCryptoWorkerImpl.ts:529-544) nullsv1StateSYNCHRONOUSLY before any await. A concurrent re-entry on theonmessagehandler seesv1State === nulland takes the v0 fallback path (or no-ops forprocess).reader.cancel()andwriter.abort()are best-effort with empty catch blocks. Their failure does not preventcontentKey.fill(0).contentKey.fill(0)runs unconditionally at the end. The state object goes out of scope afterclearV1returns; the writer/ reader handles are dropped with it.
Caller-side cleanup
decryptV0PathanddecryptV1PathinsfCryptoDecrypt.ts:75-78, 121-124wrap the chunk loop intry { ... } finally { await clearSfCryptoWorker(worker).catch(() => null); worker.terminate() }. Even on an exception (mid-stream AEAD failure, network error),clearV1runs to zero the content key, thenterminate()tears down the worker context.encryptModal.tsxalso usesclearSfCryptoWorkerbeforeworker.terminate()(encryptModal.tsx:738).
Page-navigation cleanup (browser limitation)
- If the user navigates mid-decrypt before the
finallyruns,worker.terminate()is implicit when the tab is destroyed. The worker's heap is freed butcontentKeybytes 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
processEncryptV1andprocessDecryptV1rejectindex !== state.nextExpectedIndexat the top of the function (sfCryptoWorkerImpl.ts:282-286, 447-451). Rejection postserror; 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:
clearV1is awaited first (sfCryptoWorkerImpl.ts:568-570). Then the message falls through tov0Scope.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 internalstatevariable is not visible to the v1 worker; it persists in the closure ofinstallV0WorkerHandleruntil either a v0clearor a v0 re-init. Subsequentprocessmessages route to v1 becausev1Stateis 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 asCryptoKey, 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_requestwithv1Stateset 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 exactlyfullHeaderLenbytes from the readable side). Theparsed.headerLengthaccessor 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 declaredplaintextSize. If the worker were to be passed aplaintextSizethat doesn't match the blob (a caller bug), the encrypt would error on the final chunk'sclose(). 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)atsfCryptoWorkerImpl.ts:341but 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/encryptChunkand 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 ininstallV0WorkerHandlerbecause the v0 path stores keys asCryptoKeynot 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
@shieldfive/crypto1.0.0-alpha.3, repo HEADa6705c7- web app, repo HEAD
c4a58782web/utils/sfCryptoWorkerImpl.tsweb/utils/sfCryptoWorker.tsweb/utils/sf-crypto-worker.tsweb/utils/sfCryptoDecrypt.tsweb/app/files/components/modals/encryptModal.tsxweb/docs/phase2-design.md§3 "Worker internals (encrypt mode, v1)"web/audit/phase-2/streaming-memory-results.md
- Cross-task references
task-01-wire-format-spec-review.md— wire format alignment (in scope: not here)task-03-threat-model-review.md— worker suite scopetask-04-aead-usage-audit.md— tag-before-plaintext property