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 optionalenvelopeAadparameter 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.2 —
encryptV0(Phase 1 migration writer) accepts a caller- suppliednoncePrefixparameter and trusts it. If a caller ever passes the same(contentKey, noncePrefix)pair to two distinctencryptV0invocations (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 viacombineKeys(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:line | Op | Key source | Nonce/IV source | AAD | Purpose |
|---|---|---|---|---|---|
aes-gcm-v0/api.ts:110 | AES-GCM decrypt | contentKey (32, per-file, caller-supplied) | prefix(4) ‖ uint64_be(i) | ∅ | v0 chunk decrypt |
v0-bridge.ts:166 | AES-GCM encrypt | contentKey (32, per-file, caller-supplied) | prefix(4) ‖ uint64_be(i); prefix random per call if omitted, else caller-supplied | ∅ | v0 chunk encrypt (Phase 1 writer) |
v0-bridge.ts:297 | AES-GCM encrypt | state.key (32, init-time importKey) | prefix(4) ‖ uint64_be(index) | ∅ | v0 worker encrypt path |
v0-bridge.ts:318 | AES-GCM decrypt | state.key (32, init-time importKey) | prefix(4) ‖ uint64_be(index) | ∅ | v0 worker decrypt path |
Crypto library — v1 chunk AEAD
| File:line | Op | Key source | Nonce/IV source | AAD | Purpose |
|---|---|---|---|---|---|
aes-gcm-v1/index.ts:147 | AES-GCM encrypt | chunkKey = 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:168 | AES-GCM decrypt | same | same | same | v1 AES-GCM chunk decrypt |
xchacha-v1/index.ts:124 | XChaCha20-Poly1305 encrypt | chunkKey = 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:142 | XChaCha20-Poly1305 decrypt | same | same | same | v1 XChaCha chunk decrypt |
pq-hybrid-v1/index.ts:196 | XChaCha20-Poly1305 encrypt | chunkKey = 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:214 | XChaCha20-Poly1305 decrypt | same | same | same | PQ-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:line | Op | Key source | Nonce/IV source | AAD | Purpose |
|---|---|---|---|---|---|
aes-gcm-v1/index.ts:201 | AES-GCM encrypt | envelopeKey (32, caller) | randomBytes(12) (wrapIv) | envelopeAad ?? ∅ (no caller supplies) | CSK wrap in 0x01 suite_payload |
aes-gcm-v1/index.ts:243 | AES-GCM decrypt | envelopeKey (32, caller) | wrapIv from suite_payload | envelopeAad ?? ∅ | CSK unwrap in 0x01 suite_payload |
xchacha-v1/index.ts:166 | XSalsa20-Poly1305 secretbox seal | envelopeKey (32, caller) | randomBytes(24) (wrapNonce) | n/a (secretbox has no AAD) | CSK wrap in 0x02 suite_payload |
xchacha-v1/index.ts:198 | secretbox open | envelopeKey (32, caller) | wrapNonce from suite_payload | n/a | CSK unwrap in 0x02 suite_payload |
pq-hybrid-v1/index.ts:260 | secretbox seal | envelopeKey (32, caller) | randomBytes(24) (classicalNonce) | n/a | classical-share wrap in 0x03 suite_payload |
pq-hybrid-v1/index.ts:316 | secretbox open | envelopeKey (32, caller) | classicalNonce from suite_payload | n/a | classical-share unwrap in 0x03 suite_payload |
Web app — envelope-key wraps, vault key, metadata
| File:line | Op | Key source | Nonce/IV source | AAD | Purpose |
|---|---|---|---|---|---|
keyCrypto.ts:61 | AES-GCM encrypt | wrappingKeyB64 (caller; folder/parent key) | randomBytes(12) | ∅ (no AAD param) | folder/file-key envelope wrap |
keyCrypto.ts:79 | AES-GCM decrypt | same | ivB64 from storage | ∅ | envelope unwrap |
vaultKeyClient.ts:192 | AES-GCM encrypt | PBKDF2-SHA-256(password, salt, 600 000) | randomBytes(12) | ∅ | vault-key wrap (legacy PBKDF2 path) |
vaultKeyClient.ts:211 | AES-GCM encrypt | Argon2id(password, salt, MODERATE/SENSITIVE) | randomBytes(12) | ∅ | vault-key wrap (Argon2id path, current default) |
vaultKeyClient.ts:254 | AES-GCM decrypt | PBKDF2 or Argon2id, dispatched by stored kdf field | iv from stored record | ∅ | vault-key unwrap |
metadataClient.ts:526 | AES-GCM encrypt | Argon2id(secret, per-record salt, INTERACTIVE/MODERATE) | crypto.getRandomValues(new Uint8Array(12)) | ∅ | metadata v4 encrypt (per-record key) |
metadataClient.ts:342 | AES-GCM decrypt | Argon2id, salt from record | iv from record | ∅ | metadata v4 decrypt (batch helper) |
metadataClient.ts:576, :612 | AES-GCM decrypt | Argon2id, salt from record | iv from record | ∅ | metadata v4 decrypt (single-record path) |
metadataClient.ts:357, :646 | AES-GCM decrypt | SHA-256(secret) (legacy constant key) | iv from record | ∅ | metadata v3 decrypt (legacy lazy-upgrade path) |
keyring.ts:162 | AES-GCM encrypt | non-extractable IDB key from createWrappingKey() | randomBytes(12) | ∅ | session record V1: wrap rootKey under per-session IDB key |
keyring.ts:206 | AES-GCM encrypt | randomBytes(32) (stored alongside ciphertext) | randomBytes(12) | ∅ | session record V2: Safari fallback (effectively obfuscation, see Finding 4.4) |
keyring.ts:264 | AES-GCM decrypt | session wrap key (V1 from IDB, V2 from sessionStorage) | iv from record | ∅ | restore rootKey from session |
keyring.ts:289 | AES-GCM encrypt | recoveryKey (32, per-user, displayed once) | randomBytes(12) | ∅ | wrap rootKey under recovery key |
keyring.ts:303 | AES-GCM decrypt | recoveryKey (32) | recIv from /api/vault-key | ∅ | unwrap 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/unwrapContentKeyexpose anenvelopeAad?: Uint8Arrayparameter (aes-gcm-v1/index.ts:190, 228) that defaults tonew Uint8Array(0). A repo-wide grep forenvelopeAadfinds only the four lines inside that one file — no caller ever passes a non-default value.xchacha-v1.wrapContentKey/unwrapContentKeyusecrypto_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/decapsulateFromHeaderwrap 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), andkeyring.wrapRootKeyWithRecovery(keyring.ts:289) all callcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext)with noadditionalData. 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:
- 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). - 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). - 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:
- For
aes-gcm-v1.wrapContentKey, expose and useenvelopeAad. The v1 in-file CSK wrap can bind"shieldfive/v1/csk-wrap" || file_id || suite_byte. The web app'skeyCrypto.wrapKeyB64should grow an optionalaad: Uint8Arrayparameter and start binding the folder/ file identifier at every call site. - 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. - For
metadataClient.encryptMetadataClient, bind"shieldfive/metadata/v4" || subject_table || subject_id. The subject context defends against same-user cross-record substitution. - For
keyring.wrapRootKeyWithRecovery, bind"shieldfive/recovery/v1" || user_id. - For the secretbox-based wraps in
xchacha-v1andpq-hybrid-v1, see Finding 4.3 — switch tocrypto_aead_xchacha20poly1305_ietf_encryptto 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:
- Tighten the API surface. Either remove the optional
noncePrefixfromV0EncryptOptionsand 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 firstinit. - Add a defensive in-memory guard in the worker: at
init, hash the(key, prefix)pair and refuse a secondinitwith 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 isfile_id. A substituted wrap (even one that unwraps cleanly) yields a differentcombinedKeyfor 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:
- 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.
- 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:
- (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.
- 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:
- 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.
- 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-43and the encrypt mirror inv0-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 enforcetotalChunks ≤ 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 fromcombinedKey(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 atformat/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 useoptions.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 issubtle.decrypt({ name: 'AES-GCM', iv }, key, combined)— noadditionalDatafield 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_decryptthrows on tag failure and returns the full plaintext on success. Same property. - Secretbox via libsodium:
crypto_secretbox_open_easyreturnsnullon 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) awaitsdecryptChunk(ctx, chunkIndex, ctBytes)and only then callscontroller.enqueue(pt). A tag failure throws insidedecryptChunk, propagates out oftryEmitChunk, and is caught by thetransformouter try/catch which callscontroller.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 awaitsdecryptChunkand then pushes intoplaintextParts. The function only returns aBlobafter 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:211runs beforecreateChunkContextand before any chunk is decrypted. Wrong content key →header_mac_mismatchthrown before chunk iteration. The pre-MACparseAesGcmV1SuitePayloadcall (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
tryParseHeaderatstreams/aes-gcm-v1.ts:311-312, which runs once and beforephaseadvances to 1.tryEmitChunkrefuses to run whileparsedHeaderis null. - Web app v1 worker (
sfCryptoWorkerImpl.ts:392-440, per Task 1 § "Web app — v1 worker"): probe →parseHeader→verifyHeaderMac→ caller-fileId cross-check → only then are chunks fed in. - Crypto-lib key wraps (
wrapContentKey/unwrapContentKeyin 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.decryptcall 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) andisFinal(1 byte), so:- Truncation: removing the last chunk from a v1 file changes the
decryptor's
totalChunks-iteration loop and is also detected by thebytesEmitted !== plaintextSizecheck (aes-gcm-v1/api.ts:266) — but if an attacker rewrotetotalChunksin the header, the header MAC would fail. If they did neither and just chopped bytes, the trailing-bytes check catches it. AADis_finalmakes 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.
- Truncation: removing the last chunk from a v1 file changes the
decryptor's
- 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
@shieldfive/crypto1.0.0-alpha.3, repo HEADa6705c7crypto/src/suites/aes-gcm-v0/api.tscrypto/src/suites/aes-gcm-v1/index.tscrypto/src/suites/aes-gcm-v1/api.tscrypto/src/suites/xchacha-v1/index.tscrypto/src/suites/xchacha-v1/api.tscrypto/src/suites/pq-hybrid-v1/index.tscrypto/src/suites/pq-hybrid-v1/api.tscrypto/src/streams/aes-gcm-v1.tscrypto/src/migration/v0-bridge.tscrypto/src/format/header.ts
- web app, repo HEAD
c4a58782web/utils/keyCrypto.tsweb/utils/vaultKeyClient.tsweb/utils/metadataClient.tsweb/utils/sharePassword.ts(no AEAD content)web/utils/keyring.ts