Internal review

Task 4 — AEAD usage audit

AEAD discipline across v0 and v1: nonce-reuse prevention, AAD non-emptiness, tag-check-before-plaintext, wrong-key fail-fast.

Task 4 — AEAD usage audit

Date: 2026-05-11 Reviewer: Cho García Scope: every AEAD encryption and decryption call site in @shieldfive/crypto (vendored at 1.0.0-alpha.3, repo HEAD a6705c7) and the web app's higher-level wrappers (web HEAD c4a58782). For each call site, four properties were checked: (a) nonce uniqueness per key, (b) AAD non-emptiness and encrypt/decrypt parity, (c) tag-before- plaintext semantics, and (d) wrong-key fail-fast. KEM-specific properties of ML-KEM-1024 and the wire format itself are out of scope per Tasks 2 and 1 respectively. Methodology: every subtle.encrypt, subtle.decrypt, crypto_aead_xchacha20poly1305_ietf_*, and crypto_secretbox_* call in the in-scope files was tabulated (Step 1 below), classified by key source / nonce source / AAD, then audited against the four properties. The streaming decryptor was traced from controller.enqueue upward to confirm plaintext is only emitted after AEAD tag verification. The table forms the audit's spine — every reader can re-derive the audit by walking the table.

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 AEAD layer holds up. No nonce reuse exists in any path: per-file content keys are fresh, chunk nonces are bound to file_id via HKDF plus a monotonic counter, and every key-wrap uses a fresh random IV under a key whose use count is far below the random-IV birthday bound. Tag-before-plaintext is preserved everywhere — subtle.decrypt and libsodium's crypto_aead_xchacha20poly1305_ietf_decrypt both throw on tag failure before returning bytes, and the streaming decryptor only calls controller.enqueue after awaiting decryptChunk. Wrong-key fail-fast is preserved on every entry point: v1 paths catch the wrong content key at the header MAC (or, for PQ-hybrid, at the post- decapsulation header MAC); v0 catches it on chunk 0's AEAD tag; every key-wrap catches a wrong wrapping key on the wrap's own tag.

Highest-impact findings:

  • 4.1 — every key-wrap AEAD in the system uses empty AAD. The crypto-library wraps (wrapContentKey) offer an optional envelopeAad parameter that no caller supplies; the web app's envelope wraps (keyCrypto.wrapKeyB64, vaultKeyClient.wrapVaultKey, metadataClient.encryptMetadataClient, keyring.wrapRootKeyWithRecovery, session record wraps) do not accept AAD at all. Substitution-of-wraps is structurally caught by downstream operations failing (header MAC mismatch, downstream-AEAD tag mismatch), but no AAD-level binding is asserted at the wrap point itself. Defense-in-depth gap, not a break.
  • 4.2encryptV0 (Phase 1 migration writer) accepts a caller- supplied noncePrefix parameter and trusts it. If a caller ever passes the same (contentKey, noncePrefix) pair to two distinct encryptV0 invocations (e.g., a misimplemented resumable-upload flow), AES-GCM nonces collide and confidentiality breaks catastrophically. The current production worker generates a fresh prefix per file so the bug is not reachable today; the surface remains.
  • 4.3 — the PQ-hybrid classical-share wrap uses crypto_secretbox_easy, which has no AAD primitive at all. Cross- file binding survives via combineKeys (HKDF salt = file_id), so this is documentation/hygiene, not a break.

None reach Critical/High. Confidentiality and authenticity hold across every path under both adversarial and accidental misuse considered here. Findings are defense-in-depth and API-surface hygiene issues.

Step 1 — AEAD call-site inventory

The table below lists every AEAD operation in scope. "Key source" names the key material; "Nonce source" describes how the IV/nonce is generated; "AAD" lists what is bound to the AEAD.

Crypto library — v0 (legacy)

File:lineOpKey sourceNonce/IV sourceAADPurpose
aes-gcm-v0/api.ts:110AES-GCM decryptcontentKey (32, per-file, caller-supplied)prefix(4) ‖ uint64_be(i)v0 chunk decrypt
v0-bridge.ts:166AES-GCM encryptcontentKey (32, per-file, caller-supplied)prefix(4) ‖ uint64_be(i); prefix random per call if omitted, else caller-suppliedv0 chunk encrypt (Phase 1 writer)
v0-bridge.ts:297AES-GCM encryptstate.key (32, init-time importKey)prefix(4) ‖ uint64_be(index)v0 worker encrypt path
v0-bridge.ts:318AES-GCM decryptstate.key (32, init-time importKey)prefix(4) ‖ uint64_be(index)v0 worker decrypt path

Crypto library — v1 chunk AEAD

File:lineOpKey sourceNonce/IV sourceAADPurpose
aes-gcm-v1/index.ts:147AES-GCM encryptchunkKey = HKDF(content_key, salt=file_id, info=AES_GCM_CHUNK_KEY)prefix(4) ‖ uint64_be(i); prefix = HKDF(file_id, info=AES_GCM_NONCE_PREFIX, L=4)buildChunkAad(i, total, isFinal) (36 B)v1 AES-GCM chunk encrypt
aes-gcm-v1/index.ts:168AES-GCM decryptsamesamesamev1 AES-GCM chunk decrypt
xchacha-v1/index.ts:124XChaCha20-Poly1305 encryptchunkKey = HKDF(content_key, salt=file_id, info=XCHACHA_CHUNK_KEY)prefix(16) ‖ uint64_be(i); prefix = HKDF(file_id, info=XCHACHA_NONCE_PREFIX, L=16)buildChunkAad(i, total, isFinal) (36 B)v1 XChaCha chunk encrypt
xchacha-v1/index.ts:142XChaCha20-Poly1305 decryptsamesamesamev1 XChaCha chunk decrypt
pq-hybrid-v1/index.ts:196XChaCha20-Poly1305 encryptchunkKey = HKDF(combinedKey, salt=file_id, info=XCHACHA_CHUNK_KEY)prefix(16) ‖ uint64_be(i); prefix = HKDF(file_id, info=XCHACHA_NONCE_PREFIX, L=16)buildChunkAad(i, total, isFinal) (36 B)PQ-hybrid chunk encrypt
pq-hybrid-v1/index.ts:214XChaCha20-Poly1305 decryptsamesamesamePQ-hybrid chunk decrypt
streams/aes-gcm-v1.ts:155(delegates to encryptChunk)streaming AES-GCM chunk encrypt
streams/aes-gcm-v1.ts:357(delegates to decryptChunk)streaming AES-GCM chunk decrypt

Crypto library — key wraps embedded in v1 suite_payload

File:lineOpKey sourceNonce/IV sourceAADPurpose
aes-gcm-v1/index.ts:201AES-GCM encryptenvelopeKey (32, caller)randomBytes(12) (wrapIv)envelopeAad ?? ∅ (no caller supplies)CSK wrap in 0x01 suite_payload
aes-gcm-v1/index.ts:243AES-GCM decryptenvelopeKey (32, caller)wrapIv from suite_payloadenvelopeAad ?? ∅CSK unwrap in 0x01 suite_payload
xchacha-v1/index.ts:166XSalsa20-Poly1305 secretbox sealenvelopeKey (32, caller)randomBytes(24) (wrapNonce)n/a (secretbox has no AAD)CSK wrap in 0x02 suite_payload
xchacha-v1/index.ts:198secretbox openenvelopeKey (32, caller)wrapNonce from suite_payloadn/aCSK unwrap in 0x02 suite_payload
pq-hybrid-v1/index.ts:260secretbox sealenvelopeKey (32, caller)randomBytes(24) (classicalNonce)n/aclassical-share wrap in 0x03 suite_payload
pq-hybrid-v1/index.ts:316secretbox openenvelopeKey (32, caller)classicalNonce from suite_payloadn/aclassical-share unwrap in 0x03 suite_payload

Web app — envelope-key wraps, vault key, metadata

File:lineOpKey sourceNonce/IV sourceAADPurpose
keyCrypto.ts:61AES-GCM encryptwrappingKeyB64 (caller; folder/parent key)randomBytes(12)∅ (no AAD param)folder/file-key envelope wrap
keyCrypto.ts:79AES-GCM decryptsameivB64 from storageenvelope unwrap
vaultKeyClient.ts:192AES-GCM encryptPBKDF2-SHA-256(password, salt, 600 000)randomBytes(12)vault-key wrap (legacy PBKDF2 path)
vaultKeyClient.ts:211AES-GCM encryptArgon2id(password, salt, MODERATE/SENSITIVE)randomBytes(12)vault-key wrap (Argon2id path, current default)
vaultKeyClient.ts:254AES-GCM decryptPBKDF2 or Argon2id, dispatched by stored kdf fieldiv from stored recordvault-key unwrap
metadataClient.ts:526AES-GCM encryptArgon2id(secret, per-record salt, INTERACTIVE/MODERATE)crypto.getRandomValues(new Uint8Array(12))metadata v4 encrypt (per-record key)
metadataClient.ts:342AES-GCM decryptArgon2id, salt from recordiv from recordmetadata v4 decrypt (batch helper)
metadataClient.ts:576, :612AES-GCM decryptArgon2id, salt from recordiv from recordmetadata v4 decrypt (single-record path)
metadataClient.ts:357, :646AES-GCM decryptSHA-256(secret) (legacy constant key)iv from recordmetadata v3 decrypt (legacy lazy-upgrade path)
keyring.ts:162AES-GCM encryptnon-extractable IDB key from createWrappingKey()randomBytes(12)session record V1: wrap rootKey under per-session IDB key
keyring.ts:206AES-GCM encryptrandomBytes(32) (stored alongside ciphertext)randomBytes(12)session record V2: Safari fallback (effectively obfuscation, see Finding 4.4)
keyring.ts:264AES-GCM decryptsession wrap key (V1 from IDB, V2 from sessionStorage)iv from recordrestore rootKey from session
keyring.ts:289AES-GCM encryptrecoveryKey (32, per-user, displayed once)randomBytes(12)wrap rootKey under recovery key
keyring.ts:303AES-GCM decryptrecoveryKey (32)recIv from /api/vault-keyunwrap rootKey via recovery

sharePassword.ts was listed as in scope for "share password wrap" but contains no AEAD operations: deriveAutoSharePassword is a single SHA-256 over "shieldfive:auto-share:v1:" || rootKeyB64, used as a derived password for bcrypt server-side. Confirmed by inspection; no AEAD call to audit on this file.

Findings

Finding 4.1 — Every key-wrap AEAD uses empty AAD; structural binding only

Severity: Medium Affects: v1 (library wraps) and the web app envelope/vault/metadata wraps Component: spec + implementation

Description. No key-wrap AEAD in the system supplies application context as AAD. Concretely:

  • aes-gcm-v1.wrapContentKey / unwrapContentKey expose an envelopeAad?: Uint8Array parameter (aes-gcm-v1/index.ts:190, 228) that defaults to new Uint8Array(0). A repo-wide grep for envelopeAad finds only the four lines inside that one file — no caller ever passes a non-default value.
  • xchacha-v1.wrapContentKey / unwrapContentKey use crypto_secretbox_easy (xchacha-v1/index.ts:166, 198), which has no AAD parameter at the primitive level (see also Finding 4.3).
  • pq-hybrid-v1.encapsulateForRecipient / decapsulateFromHeader wrap the classical share with secretbox (pq-hybrid-v1/index.ts:260, 316), same constraint.
  • Web app: keyCrypto.wrapKeyB64 (keyCrypto.ts:61), vaultKeyClient.wrapVaultKey (vaultKeyClient.ts:192, 211), metadataClient.encryptMetadataClient (metadataClient.ts:526), keyring.persistRootKeyInSession (keyring.ts:162, 206), and keyring.wrapRootKeyWithRecovery (keyring.ts:289) all call crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext) with no additionalData. The dispatcher signature is identical on decrypt.

What this means in practice. An AEAD with empty AAD authenticates only (key, iv, ciphertext) — it makes no claim about which slot or which file or which role the wrap belongs to. Substitution attacks at the wrap layer (e.g., swap the wrapped-key bytes for folder A into the database row for folder B, where both rows are encrypted under the same parent wrapping key) cannot be caught at the AEAD layer.

Why this does not break confidentiality or authenticity. Structural binding is provided downstream:

  1. For envelope wraps of content keys (folder keys, CSKs), the unwrapped key is fed into a v1 decrypt that begins with header MAC verification keyed by that very content key. A wrong substitution yields a wrong content key → header MAC fails (Finding 1.4 path, format/header.ts:201-214).
  2. For metadata wraps, the per-record salt is part of the encrypted payload, so a substituted record forces the recipient to re-derive Argon2id over the substituted salt — the wrong derived key fails the AEAD tag check (metadataClient.ts:597-625).
  3. For vault-key and recovery wraps, the path is single-step: a wrong wrapping key fails the AEAD tag, no plaintext returns.

The DB schema (and RLS, separately covered by the server-side review) enforces slot identity — i.e., folder A's wrap is read only when answering queries about folder A. A successful substitution at the AEAD layer would still require the substituted wrapping key bytes to be valid under the same (parent_key, iv) pair and the unwrapped key to be useful when applied to A's downstream data, which it would not be.

So this is a defense-in-depth gap, not a break. The mitigation chain is "wrap succeeds → downstream operation fails because the recovered key is wrong." Adding AAD at the wrap layer would let the wrap itself catch the substitution, surfacing the failure earlier and in a more diagnostic place.

Reproduction or evidence. Inspect the table in Step 1 — the AAD column is empty for every key-wrap row. Repo-wide grep for additionalData and envelopeAad confirms no AAD is supplied by any caller in the codebase.

Recommendation. Add AAD-level binding where feasible:

  1. For aes-gcm-v1.wrapContentKey, expose and use envelopeAad. The v1 in-file CSK wrap can bind "shieldfive/v1/csk-wrap" || file_id || suite_byte. The web app's keyCrypto.wrapKeyB64 should grow an optional aad: Uint8Array parameter and start binding the folder/ file identifier at every call site.
  2. For vaultKeyClient.wrapVaultKey, bind a domain-separating string like "shieldfive/vault-key/v1" || user_id. This pins a vault-key wrap to a single user, so a wrap stolen from user A's record cannot be installed in user B's row.
  3. For metadataClient.encryptMetadataClient, bind "shieldfive/metadata/v4" || subject_table || subject_id. The subject context defends against same-user cross-record substitution.
  4. For keyring.wrapRootKeyWithRecovery, bind "shieldfive/recovery/v1" || user_id.
  5. For the secretbox-based wraps in xchacha-v1 and pq-hybrid-v1, see Finding 4.3 — switch to crypto_aead_xchacha20poly1305_ietf_encrypt to get AAD support.

Roll out in coordination with on-disk migration: any new AAD becomes part of the verification contract, and existing records were written without it. Strategy: version each wrap (e.g., wrap_aad_version), read AAD by version, and rewrap on next write. Land alongside test vectors that pin the AAD bytes for at least one wrap per category.


Finding 4.2 — encryptV0 accepts caller-supplied noncePrefix; reuse is catastrophic

Severity: Low Affects: v0 (Phase 1 migration writer) Component: implementation

Description. crypto/src/migration/v0-bridge.ts:114-132:

export async function encryptV0(
  options: V0EncryptOptions,
): Promise<V0EncryptResult> {
  const { blob, contentKey, chunkSize, onProgress } = options
  ...
  const noncePrefix = options.noncePrefix ?? randomBytes(V0_NONCE_PREFIX_BYTES)

v0 uses iv = noncePrefix(4) || uint64_be(counter) for AES-GCM. The 4-byte prefix is intended to be sampled randomly once per file; the counter is monotonic per chunk within the file. If a caller passes the same (contentKey, noncePrefix) to two distinct encryptV0 invocations — e.g., a resumable-upload that restarts mid-stream from chunk 0 with the persisted prefix and key — every chunk's IV is reused under the same content key. AES-GCM nonce reuse leaks plaintext-XOR and forges authentication keys; it is the canonical AEAD catastrophic failure.

The same surface exists in the bundled worker handler (v0-bridge.ts:236-281): init accepts a caller-supplied noncePrefix and reuses it across every process chunk message. If a single browser session inits the worker twice with the same key+prefix and asks for chunk 0 each time, the second encryption reuses the IV.

This is not exploitable today. The production worker that calls encryptV0 generates a fresh noncePrefix for each upload and stores it in the DB before any chunk is encrypted. The library itself randomizes if noncePrefix is omitted. The risk lives in the API shape: an optional parameter that is catastrophic to misuse, with no in-code guardrail to catch a misuse.

Note this risk is bounded by the v0 sunset path. Phase 2 ships with v1 as the default writer; once all v0 files are migrated, both encryptV0 and installV0WorkerHandler are slated for deletion (v0-bridge.ts:1-23). The risk is bounded by that timeline.

Reproduction or evidence. Call encryptV0({ blob: blobA, contentKey: K, noncePrefix: P, chunkSize }) twice with the same (K, P). Both outputs share identical chunk-0 IVs. Decrypting both ciphertexts and XOR'ing them yields pt_A_chunk0 XOR pt_B_chunk0 (recoverable to either plaintext given the other). I did not write this proof out in code because the result is textbook AES-GCM nonce-reuse behavior.

Recommendation. Two complementary mitigations:

  1. Tighten the API surface. Either remove the optional noncePrefix from V0EncryptOptions and always randomize internally, or rename it to something visibly hazardous (UNSAFE_noncePrefixForTestVector) and document that production callers MUST omit it. The worker handler should likewise refuse to honor a caller-supplied prefix outside its first init.
  2. Add a defensive in-memory guard in the worker: at init, hash the (key, prefix) pair and refuse a second init with the same pair within the worker's lifetime. This catches the most likely misuse path (a UI-triggered upload retry that reinits the worker).

Both are low-effort and bounded by the v0 sunset timeline. Once the v0 writer is deleted entirely (post-migration), the surface disappears.


Finding 4.3 — secretbox classical-share wrap in PQ-hybrid has no AAD by construction

Severity: Low Affects: v1 (suites 0x02 and 0x03) Component: implementation (hygiene; spec choice is deliberate)

Description. Both the XChaCha-v1 CSK wrap (xchacha-v1/index.ts:166) and the PQ-hybrid classical-share wrap (pq-hybrid-v1/index.ts:260) use libsodium's crypto_secretbox_easy, which is XSalsa20-Poly1305 without the AEAD construction's AAD parameter. There is no way to bind file_id or any other context at the wrap step.

This is benign because:

  • For the XChaCha-v1 0x02 suite_payload wrap, the wrapped key is the content key for that file. A successful unwrap that produces the wrong key fails the file's header MAC three lines later (xchacha-v1/api.ts:180).
  • For the PQ-hybrid 0x03 classical share wrap, the unwrapped value feeds into combineKeys (pq-hybrid-v1/index.ts:138-152), whose HKDF salt is file_id. A substituted wrap (even one that unwraps cleanly) yields a different combinedKey for that file_id, which then fails the header MAC.

So the structural binding to file_id is preserved — but it lives in the KDF, not in the AEAD primitive.

Reproduction or evidence. Inspect either secretbox call site; libsodium's crypto_secretbox_easy(message, nonce, key) has no AAD parameter in its signature.

Recommendation. Bundle with Finding 4.1. If wrap-layer AAD is adopted system-wide, switch the two secretbox calls to crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, aad, nsec=null, npub=nonce, key) so the same aad string (e.g., "shieldfive/v1/classical-wrap" || file_id) can be supplied. The wire-level change is small: secretbox output is 16-byte tag + 32-byte ciphertext = 48 bytes; the AEAD output is the same shape and the existing 72-byte zero-padded slot accommodates it without a format bump. Cover with a deterministic test vector for the classical-share wrap once the AAD is bound.

If this is deferred, document the structural-binding argument in spec/format-v1.md § "0x03 — pq-hybrid" so a future reader doesn't spend a session re-deriving why the secretbox wrap is safe without AAD.


Finding 4.4 — Keyring session V2 fallback stores wrapping key alongside ciphertext (informational)

Severity: Informational Affects: web app (Safari/IndexedDB-unavailable fallback only) Component: implementation (documented Safari workaround)

Description. web/utils/keyring.ts:189-219 generates a 32-byte random AES-GCM key (keyring.ts:197), wraps the rootKey with it, and then stores both wrapKey (base64) and the wrapped ct/iv in the same sessionStorage record (keyring.ts:213-218). The same sessionStorage entry contains both the lock and the key to it.

This is not an AEAD-level bug — the AEAD itself is correctly used, with fresh IV and fresh key per session — but the resulting record provides no confidentiality against any reader who has access to window.sessionStorage. It is effectively obfuscation: the rootKey is recoverable in one base64 decode plus one AES-GCM decrypt with locally- available material.

This is intentional and documented: V1 stores the wrap key in IndexedDB (non-extractable), which Safari fails on intermittently (keyring.ts:179-186). V2 keeps the session restorable across reloads even when IDB is broken, at the cost of confidentiality-against-local-process. The same-origin same- session attacker that could read sessionStorage on V2 can also read the in-memory rootKeyB64 on V1, so the security delta is bounded.

Reproduction or evidence. Read createEmergencyRecoveryRecord and the V2 record format (EmergencyRecoverySession) at lines 44-50. Renamed from createSessionFallbackRecord / SessionRecordV2 in web#157; record version (v: 2) and wire shape are unchanged.

Recommendation. None for the current release. Two notes for the audit trail:

  1. Worth a brief comment in the V2 branch saying "this is obfuscation, not encryption — the wrap key lives next to the ciphertext to survive IDB-broken browsers." A future contributor reading the code in isolation might over-trust the AES-GCM call.
  2. The separate server-side review (kept private) covers master-key in-memory handling end-to-end across the V1/V2 lifecycle, including BFCache and session expiry. This file is in that review's scope and is filed here as informational so it shows up at AEAD-audit time too.

Finding 4.5 — Metadata v3 (legacy) uses a single AES-GCM key for every record of a user

Severity: Low Affects: web app metadata (legacy path, lazy-upgraded on write) Component: implementation (documented legacy path)

Description. web/utils/metadataClient.ts:315-329:

async function getLegacyKey(handle: MetadataKeyHandle) {
  if (handle.legacyKey) return handle.legacyKey
  const subtle = getSubtle()
  const encoder = new TextEncoder()
  const digest = await subtle.digest('SHA-256', encoder.encode(handle.secret))
  const legacy = await subtle.importKey('raw', digest, ...)
  ...
}

For metadata records still in v3 format (metadataClient.ts:39-44), the AES-GCM key is SHA-256(handle.secret) — i.e., one key per user, shared across every v3 record. Per-record IVs are random (metadataClient.ts:525, used for v4 — v3 IVs were written by an earlier writer and stored as parsed.iv).

Two consequences:

  1. (key, IV) collision probability follows the random-IV birthday bound: ~2^-32 risk after 2^32 records per user. Realistic user counts are eight orders of magnitude below this.
  2. A leaked v3 record reveals nothing about other v3 records under the same key directly — AES-GCM's confidentiality is preserved — but if any single v3 IV collides with another v3 IV under the same key, confidentiality fails for those two records' plaintexts.

v4 (metadataClient.ts:518-544) correctly derives a per-record key via Argon2id on a per-record salt, breaking the shared-key property. The v3 path is preserved for backward-compatible reads only; writes always emit v4 (metadataClient.ts:537, version constant VERSION = 4).

Reproduction or evidence. Read the legacy-key derivation at metadataClient.ts:315-329 and the v3 decrypt path at metadataClient.ts:637-655.

Recommendation. Two options, in order of preference:

  1. Migrate-then-remove. Add a background task that re-encrypts any v3 metadata record into v4 the next time it's read or touched. After a planned migration window, remove the v3 decrypt path entirely. The audit then has one canonical key derivation to reason about.
  2. Keep but instrument. If a migration window is impractical, log v3 reads to a metric so the long tail is visible; track v3 record count over time and revisit removal once the count drops below a threshold. This option leaves the (key, IV) birthday surface in place but bounds operational risk by visibility.

Cross-reference with the separate server-side review (logging) for the metric in option 2.


Finding 4.6 — v0 spec acknowledges no chunk-AAD truncation defense; flagged here for AEAD-audit completeness

Severity: Informational Affects: v0 Component: spec (already documented)

Description. v0 AEAD has AAD=∅ (aes-gcm-v0/api.ts:108-114). A chunk's ciphertext+tag does not bind chunk index, total chunks, or is-final. An attacker who removes the trailing N chunks from a v0 file produces a shorter v0 file that decrypts cleanly chunk-by-chunk — truncation is undetectable at the AEAD layer.

Mitigations live above AEAD:

  • v0 chunk reordering is caught by AEAD because the counter is in the IV. Swapping chunks N and M fails GCM auth.
  • v0 chunk substitution across files is caught because the content key and prefix differ per file.
  • v0 truncation is caught only by the out-of-band size record in the database — a v0 file with the last K chunks lopped off will decrypt successfully and produce a shorter plaintext. The application is responsible for refusing the result.

This is a documented v0 limitation (spec/format-v0.md § "Limitations") and a primary motivation for v1. Filed here so the AEAD audit's "what I checked" trail is complete; not an action item for this task.

Reproduction or evidence. Decrypt any v0 file truncated at a chunk boundary; no AEAD failure occurs.

Recommendation. None — this property is intrinsic to v0 and is fixed in v1 by buildChunkAad binding totalChunks and is_final (see format/header.ts:222-236). Track v0 retirement in the same engineering plan that retires v0-bridge.ts.


What I checked but did not find issues with

The audit traversed every AEAD call site in scope and verified the following properties hold.

Nonce uniqueness

  • v0 chunk IV (prefix(4) ‖ uint64_be(i), aes-gcm-v0/api.ts:38-43 and the encrypt mirror in v0-bridge.ts:83-88) — within a single file, the counter is monotonic and the prefix is fixed; across files, the content key is fresh per file so (key, IV) is unique regardless of prefix collisions. Caveat filed as Finding 4.2.
  • AES-GCM-v1 chunk IV (prefix(4) ‖ uint64_be(i), aes-gcm-v1/index.ts:88-93) — prefix = HKDF(file_id, info="...nonce-prefix", L=4); chunk key = HKDF(content_key, salt=file_id, info="...chunk-key"). The 4-byte prefix has a 2^32 birthday bound across file_ids, but the chunk key differs per file_id, so (key, IV) tuples never collide across files. Within a file, the counter is monotonic; encoders enforce totalChunks ≤ MAX_TOTAL_CHUNKS (well below 2^64) so counter wrap-around is impossible.
  • XChaCha-v1 chunk nonce (prefix(16) ‖ uint64_be(i), xchacha-v1/index.ts:82-87) — 16-byte HKDF-derived prefix means cross-file collision probability is 2^-128; chunk key is per-file via HKDF(content_key, salt=file_id); XChaCha's 24-byte nonce trivially supports random/derived prefixes.
  • PQ-hybrid chunk nonce (same as XChaCha, pq-hybrid-v1/index.ts:180-185) — same analysis; chunk key derives from combinedKey (HKDF salt = file_id), which is per-file unique because either share (PQ encapsulation result or fresh-random classical share) is fresh per encryption.
  • AES-GCM v1 wrapContentKey IV (random 12 bytes per wrap, aes-gcm-v1/index.ts:199) — random 96-bit IV with the same envelope key reused across files; birthday bound ≈ 2^32 wraps before collision risk emerges. Realistic envelope-key reuse (per-folder key wrapping every file in the folder) is 6+ orders of magnitude below this.
  • XChaCha-v1 secretbox wrap nonce (random 24 bytes, xchacha-v1/index.ts:164) — 192-bit nonce, 2^96 birthday bound. Trivially safe.
  • PQ-hybrid classical wrap nonce (random 24 bytes, pq-hybrid-v1/index.ts:259) — same analysis.
  • Web app vault-key wrap IV (random 12 bytes, vaultKeyClient.ts:183-184) — salt is also fresh per wrap, so the derived AES-GCM key is fresh per wrap; (key, IV) cannot collide because the key is fresh.
  • Web app metadata wrap IV (random 12 bytes, metadataClient.ts:525) — v4 per-record salt makes the key fresh per record. v3 (legacy) filed as Finding 4.5.
  • Web app keyCrypto envelope IV (random 12 bytes, keyCrypto.ts:57-58) — random 96-bit IV with potentially-reused wrapping key. Birthday bound 2^32; realistic per-folder wrap counts are far below.
  • Web app keyring session/recovery IVs (random 12 bytes, keyring.ts:154-155, 194, 285-286) — session record V1 uses a fresh per-session IDB wrap key (key fresh per session); V2 generates a fresh wrap key per record (key fresh per call); recovery key wrap is one-shot per regeneration. (key, IV) collisions are not reachable in any of these.

AAD parity (encrypt vs. decrypt)

  • v1 chunk AEAD: buildChunkAad(i, total, isFinal) is the only AAD-building helper in the library; all three suites (aes-gcm-v1, xchacha-v1, pq-hybrid-v1) import it from a single location at format/header.ts:222-236. Encrypt and decrypt in each suite both compute AAD identically (verified line-by-line: AES-GCM-v1 encrypt:145, decrypt:166; XChaCha-v1 encrypt:122, decrypt:140; PQ-hybrid encrypt:194, decrypt:212). Any AAD mismatch between sides would cause systematic decrypt failures — not present.
  • v0: AAD is ∅ on both sides by definition; mismatch is impossible.
  • aes-gcm-v1.wrapContentKey / unwrapContentKey: both sides use options.envelopeAad ?? new Uint8Array(0). Same default, same shape. Encrypt-decrypt parity holds regardless of caller.
  • Web app wraps: every encrypt is subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext) and every decrypt is subtle.decrypt({ name: 'AES-GCM', iv }, key, combined) — no additionalData field on either side. Parity by absence.

Tag-before-plaintext

  • AES-GCM via WebCrypto: subtle.decrypt({ name: 'AES-GCM', ... }) throws synchronously on tag failure and only returns an ArrayBuffer on success. No partial-plaintext return path exists in the spec or implementation.
  • XChaCha-Poly1305 via libsodium: crypto_aead_xchacha20poly1305_ietf_decrypt throws on tag failure and returns the full plaintext on success. Same property.
  • Secretbox via libsodium: crypto_secretbox_open_easy returns null on tag failure (the wrap helpers check this and throw, xchacha-v1/index.ts:203-205; pq-hybrid-v1/index.ts:321-323).
  • Streaming decrypt (createAesGcmV1DecryptStream, streams/aes-gcm-v1.ts:235-407): tryEmitChunk (streams/aes-gcm-v1.ts:324-362) awaits decryptChunk(ctx, chunkIndex, ctBytes) and only then calls controller.enqueue(pt). A tag failure throws inside decryptChunk, propagates out of tryEmitChunk, and is caught by the transform outer try/catch which calls controller.error(...). No bytes leak downstream before the chunk's tag verifies.
  • v1 whole-blob decryptors (aes-gcm-v1/api.ts:226-261; xchacha-v1/api.ts:193-227; pq-hybrid-v1/api.ts:198-232): each iteration awaits decryptChunk and then pushes into plaintextParts. The function only returns a Blob after the loop completes successfully. Throw propagates and the caller never sees partial bytes.

Wrong-key fail-fast

  • v0 (aes-gcm-v0/api.ts:109-120): wrong content key fails AES-GCM authentication on chunk 0; the call throws before returning any plaintext bytes.
  • v1 AES-GCM whole-blob (Finding 1.4a confirmed): header MAC verification at aes-gcm-v1/api.ts:211 runs before createChunkContext and before any chunk is decrypted. Wrong content key → header_mac_mismatch thrown before chunk iteration. The pre-MAC parseAesGcmV1SuitePayload call (Finding 1.4a) is a length-check only and does not decrypt anything.
  • v1 XChaCha whole-blob (Finding 1.4b): same property at xchacha-v1/api.ts:180.
  • v1 PQ-hybrid whole-blob (Finding 1.4c): header MAC runs after decapsulation (pq-hybrid-v1/api.ts:186) but before any chunk decrypt. The pre-MAC decapsulation is justified by ML-KEM-1024 IND-CCA2; see Task 1 Finding 1.4c for the full argument. Wrong PQ secret key + envelope key combination → wrong combined key → header MAC fails before chunk iteration begins.
  • v1 streaming AES-GCM decrypt: header MAC verified inside tryParseHeader at streams/aes-gcm-v1.ts:311-312, which runs once and before phase advances to 1. tryEmitChunk refuses to run while parsedHeader is null.
  • Web app v1 worker (sfCryptoWorkerImpl.ts:392-440, per Task 1 § "Web app — v1 worker"): probe → parseHeaderverifyHeaderMac → caller-fileId cross-check → only then are chunks fed in.
  • Crypto-lib key wraps (wrapContentKey / unwrapContentKey in both suites; classical-share secretbox in PQ-hybrid): wrong envelope key fails the wrap's own AEAD tag and throws before returning any bytes.
  • Web app envelope/vault/metadata wraps: every subtle.decrypt call throws on tag failure; no caller swallows the exception in a way that leaks partial plaintext (verified by inspecting each call site's try/catch).
  • Web app v3 legacy metadata decrypt (metadataClient.ts:637-655): on tag failure, the function returns an empty string and the caller treats it as "no metadata available". No plaintext leak.

Defense-in-depth properties

  • Every chunk AAD in v1 binds totalChunks (8 bytes) and isFinal (1 byte), so:
    • Truncation: removing the last chunk from a v1 file changes the decryptor's totalChunks-iteration loop and is also detected by the bytesEmitted !== plaintextSize check (aes-gcm-v1/api.ts:266) — but if an attacker rewrote totalChunks in the header, the header MAC would fail. If they did neither and just chopped bytes, the trailing-bytes check catches it. AAD is_final makes any attempt to demote a final chunk into a middle position trip GCM auth.
    • Reordering: chunk index in AAD is checked at AEAD layer; swapping two chunks' ciphertexts fails the AEAD on both.
  • v1 chunk keys are derived per-file via HKDF(salt=file_id), so chunks cannot be replayed across files even if file_id is rewritten (the new file_id derives a different chunk key → AEAD fails).

Out of scope (deferred to other tasks)

  • Streaming back-pressure invariants, single-frame-in-flight property, and content-key zeroing — Task 5.
  • ML-KEM-1024 IND-CCA2 argument (PQ-hybrid pre-MAC decapsulation safety) — Task 2 already, Task 3 threat-model coverage.
  • Upload-proof shapes and three-way cross-check — Task 6.
  • Server-side and web-application surfaces (master-key in-memory handling, RLS and DB-schema slot binding — the "downstream" half of the defense-in-depth chain in Finding 4.1 — and log/scrubbing surfaces that might capture AEAD intermediate state) are out of crypto-layer scope and covered by the separate server-side review, kept private.

References