Internal review

Task 6 — Upload proof system review

Upload-proof shapes, the three-way client/server/B2 cross-check, proof-key uniqueness, timing-safe MAC, and version confusion.

Task 6 — Upload proof system review

Date: 2026-05-11 Reviewer: Cho García Scope: web/utils/uploadProof.ts, web/utils/uploadProofClient.ts, web/utils/uploadProofFormat.ts, web/app/api/files/upload-part/route.ts (server proof verification surface), web/app/api/files/complete-upload/route.ts (final proof check), web/app/api/files/create-upload-session/route.ts (proof key/payload issuance), web/supabase/migrations/20260220172000_fix_finalize_upload_function_ambiguity.sql, web/supabase/migrations/20260510211958_finalize_upload_accepts_v1_proof.sql. Web HEAD c4a58782. Methodology: cross-layer audit using the Stage 2c "three independent sources of truth" model — submitted proof prefix bytes, on-disk header bytes, DB row cipher_version. Each verification predicate was traced to which independent source it asserts against, and edge cases (concurrent sessions, resumed uploads, empty files, v0/v1 confusion, post-finalize re-submission) were enumerated.

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 upload-proof system holds across the three properties this review exists to certify:

  • Per-session uniqueness. Each create-upload-session call mints a fresh crypto.randomUUID() fileId AND a fresh crypto.randomUUID() nonce inside the proof_payload, then derives proofKey = HMAC-SHA256(UPLOAD_PROOF_SECRET, "proof:" || payload). Two sessions never share a proof key, even for the same user re-uploading the same plaintext.
  • Wrong-proof-for-wrong-file is unreachable. Both v0 and v1 paths re-derive the proof key from files.proof_payload looked up by (id = fileId, user_id = current user). A proof MAC'd under proofKey_A cannot pass HMAC verification under proofKey_B. For v1 the binding is additionally anchored by including the on-disk header bytes (which carry file_id) in the MAC payload.
  • Version confusion fails fast. v0 proofs (64-char hex, ^[0-9a-f]{64}$) and v1 proofs (34-byte framed base64) are structurally distinct, and verifyUploadProof routes by cipher_version before either format-specific verifier is entered. A v0 proof submitted to a v1 row dies at the proofBytes.length !== V1_PROOF_FRAME_LENGTH length check; a v1 proof submitted to a v0 row dies at PROOF_HEX_RE.test(proof).
  • Timing-safe verification. Both verifyAesGcmProof and verifyAesGcmV1Proof compare the MAC with crypto.timingSafeEqual after length pre-equality. verifySecretstreamProof (legacy) does the same.

Findings below are all Low/Informational. No path that accepts a wrong proof, no timing leak in MAC verification, no proof-key collision across sessions.

The Stage 2c hotfix #2 that motivated the explicit cross-layer audit (the finalize_file_upload_with_quota regex ^[0-9a-f]{64}$ rejecting every v1 proof at the database) is fixed in 20260510211958_finalize_upload_accepts_v1_proof.sql. The fix drops the regex entirely (Option B). This is documented and safe given that cryptographic verification runs in TypeScript before the RPC is invoked, but it does mean the database-layer defense in depth is now weaker than it was for v0 — flagged below as Finding 6.3.

Three-way cross-check map

For v1 (cipher_version=2) the three independent sources of truth and which predicate compares which pair are:

#PredicateSource ASource BCatches
1uploadProof.ts:239 cipherVersion !== 2DB row cipher_version(server constant 0x02)Dispatcher bug or service-role caller routing v0 row through v1 verifier
2uploadProof.ts:288 parsed.suite !== V1_AES_GCM_SUITE_BYTEOn-disk header byte 5 (suite ID)(server constant 0x01)B2 object rewritten with a different suite payload (e.g. XChaCha bytes)
3uploadProof.ts:293 proofBytes[0] !== UPLOAD_PROOF_VERSION_V1Submitted proof byte 0(server constant 0x02)Client built a proof with the wrong version prefix
4uploadProof.ts:296 proofBytes[1] !== AES_GCM_V1_CIPHER_VERSIONSubmitted proof byte 1(server constant 0x02)Client built a proof claiming a different cipher_version
5uploadProof.ts:339-342 timing-safe HMAC compareHMAC(proofKey, [0x02][0x02] ‖ headerBytes ‖ chunkZeroFrame)Submitted proofBytes2..34)Anything else — wrong proof key, wrong header bytes, wrong chunk 0

The three "independent sources" are: the DB row (cipher_version, proof_payload), the on-disk bytes (suite byte, header bytes, chunk 0 frame), and the submitted proof (prefix bytes, MAC). Each predicate compares values from at most two of these, so any pairwise divergence surfaces independently with a distinct error message. Predicate 5 is the ground-truth cryptographic check; predicates 1-4 are defense-in-depth fail-fast rails that produce log-actionable error messages instead of a uniform "MAC mismatch."

For v0 (cipher_version=1 or NULL) the equivalent map is smaller because there is no on-disk header to compare against:

#PredicateSource ASource BCatches
1[uploadProof.ts:100 PROOF_HEX_RE.test(proof)Submitted proof shaperegex ^[0-9a-f]{64}$v1 proof submitted to v0 row
2uploadProof.ts:103-108 cipherChunkSize > 0, cipherNoncePrefix presentDB row(non-null/non-zero)v1 row mis-routed through v0 verifier (chunk_size/nonce_prefix are NULL on v1)
3uploadProof.ts:146-148 timing-safe HMAC compareHMAC(proofKey, prefix ‖ chunkZero), where prefix is built from DB-side cipherVersion, chunkSize, noncePrefixBytesSubmitted hexAnything else

Note the v0 prefix is derived from DB-side bytes (not on-disk bytes) — v0 has no on-disk header to read those bytes from. The chunkSize and noncePrefix written into the prefix come from files.cipher_chunk_size and files.cipher_nonce_prefix. The MAC payload is prefix ‖ chunkZero where chunkZero comes from the B2 read.

Findings

Finding 6.1 — Buffer.from(_, 'base64') does not throw; try/catch around it is dead code

Severity: Informational Affects: v1 proof verification only Component: implementation (web/utils/uploadProof.ts:226-234)

Description. verifyAesGcmV1Proof wraps the base64 decode in a try/catch:

let proofBytes: Buffer
try {
  proofBytes = Buffer.from(proof, 'base64')
} catch {
  throw new Error('Invalid encryption proof format')
}
if (proofBytes.length !== V1_PROOF_FRAME_LENGTH) {
  throw new Error('Invalid encryption proof format')
}

Buffer.from(string, 'base64') in Node never throws on invalid input — non-base64 characters are silently ignored and the decode returns whatever bytes the valid characters produced. The catch block is unreachable. Behavior is still correct because the length check at proofBytes.length !== V1_PROOF_FRAME_LENGTH (34) catches every malformed input that the catch was nominally guarding against: a shorter result fails on length; a longer result fails on length; a result of accidentally-the-right-length (improbable but possible for a crafted hex string of certain shapes) will fail at the HMAC compare.

Reproduction or evidence. Buffer.from('@@@', 'base64') returns an empty Buffer (Buffer.alloc(0)). No throw. Same for Buffer.from('!!!!', 'base64'). The Node base64 decoder is lenient by design.

Recommendation. Drop the try/catch and rely on the length check alone. This is purely a cosmetic / readability fix; no security impact. If you want a stronger shape check, use Buffer.byteLength(s, 'base64') to detect non-base64 bytes before decoding, but the existing length check is sufficient.


Finding 6.2 — chunk_0 length lower bound not enforced; a too-small frame would pass proof verification

Severity: Low Affects: v1 proof verification Component: implementation (web/utils/uploadProof.ts:305-311)

Description. The chunk_0 length-prefix check enforces only the upper bound and a zero-reject:

const chunkZeroLength = ciphertextHead.readUInt32BE(chunkZeroLengthOffset)
if (chunkZeroLength === 0) {
  throw new Error('UploadProofV1: chunk_0 length is zero')
}
if (chunkZeroLength > parsed.chunkSize + AES_GCM_TAG_BYTES) {
  throw new Error('UploadProofV1: chunk_0 length exceeds chunkSize + tag')
}

The v1 spec requires every chunk to carry at least one plaintext byte plus the 16-byte AES-GCM tag — minimum cipher length is 17 (final chunk; final-only because non-final chunks are exactly chunkSize + 16). The lower-bound check here permits chunk_0 lengths of 1 through 16, which are structurally invalid: they can't represent a non-empty plaintext at all because there's no room for the tag.

There is no security impact:

  1. A length 1-16 chunk would still need a valid HMAC under the per-session proof key, and the proof MAC payload includes the on-disk bytes of the truncated frame verbatim, so the proof check doesn't blindly accept the malformed file.
  2. On download, crypto/src/streams/aes-gcm-v1.ts:340-348 enforces both bounds and would refuse to decrypt — so the file is un-readable.

It is, however, an integrity defense-in-depth gap: a client that uploads a 17-byte frame later truncated to 16 bytes by some intermediate (or a buggy client that emits length=16 for an empty plaintext) would have its complete-upload accepted only to find the file un-decryptable. Failing fast at upload-complete is more useful than failing at download.

Reproduction or evidence. Inspection of lines 305-311 against the v1 spec § "Chunks" final-chunk size rule.

Recommendation. Add the lower-bound check:

if (chunkZeroLength < 1 + AES_GCM_TAG_BYTES) {
  throw new Error('UploadProofV1: chunk_0 length below 1 + tag')
}

Pairs cleanly with the existing upper-bound and zero-reject. Also consider asserting chunkZeroLength === parsed.chunkSize + AES_GCM_TAG_BYTES when parsed.totalChunks > 1 (only the final chunk may be shorter), matching the constraint enforced downstream in the decrypt stream.


Finding 6.3 — finalize_file_upload_with_quota no longer enforces any proof-shape regex

Severity: Low Affects: v0 + v1 Component: SQL (intentional Stage 2c hotfix #2; flagged here for defense-in-depth) (web/supabase/migrations/20260510211958_finalize_upload_accepts_v1_proof.sql, header comments and lines 98-100)

Description. The 20260220172000 version of finalize_file_upload_with_quota enforced p_encryption_proof !~ '^[0-9a-f]{64}$' (matching the v0 hex shape). v1 proofs are 34-byte base64 (about 48 chars) and trip this regex — every legitimate v1 upload was rejected at the database, surfacing as 500 "Failed to finalize upload" via complete-upload/route.ts:412-419. The 20260510211958 hotfix drops the regex entirely (Option B in the migration header).

This is correct and safe for the reasons stated in the migration's header comment (cryptographic verification runs in verifyUploadProof BEFORE the RPC is called; coupling the DB to a specific wire-format string is what the bug demonstrated to be fragile). It does mean the function now performs only NULL/empty checks on p_encryption_proof:

IF p_encryption_proof IS NULL OR length(trim(p_encryption_proof)) = 0 THEN
  RAISE EXCEPTION 'Missing encryption proof';
END IF;

If a future code change ever called this RPC bypassing verifyUploadProof (e.g. a parallel route added for batch finalization that re-used the RPC without re-running TS verification), an arbitrary non-empty string would be persisted as encryption_proof. There is no current path that does this — the only caller is complete-upload/route.ts:391-401 and it runs verifyUploadProof immediately before. But the DB-level "belt" that used to exist for v0 is now gone.

Reproduction or evidence. Read the diff between 20260220172000_fix_finalize_upload_function_ambiguity.sql and 20260510211958_finalize_upload_accepts_v1_proof.sql: the only deleted clause is the regex check at lines 43-45 of the older file. The ciphertext-hash regex is left in place (line 102-105 of the newer file) because SHA-1/SHA-256 hex shapes have not changed across wire formats.

Recommendation. Two options, neither blocking:

  1. Re-add a permissive, wire-format-agnostic shape regex such as ^[A-Za-z0-9+/=]{32,512}$. This rejects nonsense (newlines, NULs, SQL fragments) without coupling to a specific cipher_version. Wide enough that Phase 3 PQ-hybrid proofs (whatever shape they take) will pass.
  2. Or document the deliberate gap in web/docs/phase2-design.md §6 alongside the cross-layer audit table, so a future contributor thinking "I'll add a batch-finalize endpoint that reuses this RPC" sees the rationale immediately.

I lean toward (1) — the regex is cheap, the failure mode it screens against is a future code-organization bug, not present-day attacks.


Finding 6.4 — v0 proof MAC payload omits file_id from the explicit prefix; fileId binding is via proof_key only

Severity: Informational Affects: v0 Component: implementation (web/utils/uploadProofFormat.ts:24-44, web/utils/uploadProof.ts:111-116)

Description. The v0 proof prefix is exactly 10 bytes:

prefix[0]       = UPLOAD_PROOF_VERSION_V0 (= 1)
prefix[1]       = cipher_version          (= 1)
prefix2..6)    = chunk_size              (big-endian uint32, from DB row)
prefix[6..10)   = nonce_prefix            (4 bytes, from DB row)

The MAC payload is prefix ‖ chunkZeroCiphertext. No file_id byte appears anywhere in the payload. The fileId binding is provided entirely by the proof_key derivation:

payload  = "{userId}:{fileId}:{nonce}:{issuedAt}"
proofKey = HMAC-SHA256(UPLOAD_PROOF_SECRET, "proof:" || payload)

The proof_payload is stored in files.proof_payload keyed by file row, so as long as proof_payload is populated correctly per row, each file has a unique proof_key and cross-file proof reuse is infeasible.

This is a structural argument exactly analogous to v1's chunk-AAD omission (see Task 1 finding 1.1): the property holds, but via a different mechanism than a casual reader would expect. v1 fixes this explicitly by including the on-disk header bytes (which carry file_id) in the MAC payload. v0 does not — it relies on proof_key uniqueness end to end.

In practice this is fine because:

  1. files.proof_payload is written once at session creation ([create-upload-session/route.ts:516) and is never updated by any route (grep across app/ and utils/ confirms no update({ proof_payload: ... })).
  2. The proof_payload column is set per file row, not shared across rows. Two file rows with the same proof_payload would only happen via a code bug, and randomUUID() collisions are negligible.
  3. v0 is legacy in the codebase. New writes default to v1 once NEXT_PUBLIC_CRYPTO_V1_ENABLED=true ships to prod.

Reproduction or evidence. Inspect buildAesGcmProofPrefix in uploadProofFormat.ts:24-44. The prefix is 1 + 1 + 4 + 4 = 10 bytes; no slot for file_id.

Recommendation. No code change required. If v0 is going to stay indefinitely (rather than being phased out after Phase 3), consider a forward-looking v0.1 prefix variant that includes 16 bytes of file_id between the version byte and the cipher_version byte; that change would make the binding explicit (matching v1) and remove the structural argument from the security story. Until v0 retires, document the proof_key-mediated binding in web/docs/phase2-design.md §6 so future reviewers don't have to re-derive it.


Finding 6.5 — Three-way cross-check redundancy is intentional and load-bearing; do not collapse

Severity: Informational (positive finding) Affects: v1 Component: implementation (web/utils/uploadProof.ts:273-298)

Description. A future contributor reading verifyAesGcmV1Proof will likely notice that predicates 1-4 in the table above are formally redundant against predicate 5 (the timing-safe HMAC compare). Each of the four pre-checks compares values that, if they disagreed silently, would also cause the final HMAC to mismatch — so collapsing them into a single MAC check would not change the accept/reject semantics.

The redundancy is deliberately preserved:

  1. Log triage. A "v0 proof prefix submitted to v1 row" failure and an "on-disk bytes rewritten between upload and complete-upload" failure look the same to the user (HTTP 400 "Invalid encryption proof") but have very different operational meaning. Distinct error strings let operators triage from the structured log (complete-upload/route.ts:306).
  2. Fail-fast. Predicate 5 requires fetching ~5 MB from B2 (uploadProof.ts:253-255 reads MAX_HEADER + 4 + chunkSize + tag bytes). Predicates 1, 3, 4 are constant-time and run before that fetch (predicate 2 runs after the fetch but before the HMAC).
  3. Code-readability defense. The source comment at lines 273-285 explicitly tells future contributors not to collapse this.

The implementation correctly preserves the intent.

Reproduction or evidence. Read the comment block at uploadProof.ts:273-285 — "the redundancy below is INTENTIONAL"; the per-predicate error strings at lines 240, 289, 294, 297 are all distinct.

Recommendation. None. Worth calling out in web/docs/phase2-design.md §6 so the table of independent-source-of- truth predicates is canonical.


Finding 6.6 — Empty/zero-chunk files are correctly rejected at proof verification

Severity: Informational (positive finding) Affects: v0 + v1 Component: implementation

Description. An empty file (plaintextSize=0, totalChunks=0) has no chunk_0 to bind a proof to. The current code rejects both v0 and v1 empty uploads:

  • v0 (uploadProof.ts:128-135): expectedCipherLen = min(totalSize, chunkSize + tag). For an empty file, totalSize === 0 so expectedCipherLen === 0 and the check if (!expectedCipherLen) throw fires. Reject.
  • v1 (uploadProof.ts:300-304): chunkZeroLengthOffset = parsed.headerLength (149 for AES-GCM-v1). An empty v1 file is just the 149-byte header with no chunks. The check ciphertextHead.length < chunkZeroLengthOffset + 4 evaluates to 149 < 153 → reject "Encrypted blob too small for verification."

Additionally, both wire-format parsers reject the totalChunks=0 case upstream — parseHeader (crypto/src/format/header.ts:156-158) requires plaintextSize === 0 when totalChunks === 0, and the v0 trailing-bytes check (crypto/src/suites/aes-gcm-v0/api.ts:79-83) rejects tag-only payloads.

So there is no path where an empty-file proof would be accepted as "valid." Whether to allow empty files at all is an upload-session-creation question, not a proof question; the proof system itself fails closed.

Reproduction or evidence. Trace the size variables in both verifiers for the case ciphertextSize = 0 and confirm the throw paths.

Recommendation. None for the proof system. If empty-file uploads need to be supported as a product feature, the right place to add support is create-upload-session (which currently does not validate sizeBytes === 0) plus a separate "no-proof-required" code path at complete-upload. That would be a larger change.


Finding 6.7 — Proof-key derivation cannot collide across sessions, even same-user same-fileId-string

Severity: Informational (positive finding) Affects: v0 + v1 Component: implementation

Description. Two upload sessions for the "same file" (same user, same plaintext) cannot share a proof_key because:

  1. fileId is generated server-side at crypto.randomUUID() for every create-upload-session call (create-upload-session/route.ts:476), not derived from any client-supplied bytes. There is no "deterministic from sessionID" derivation — every session gets a fresh UUID.
  2. Even if two calls did somehow land on the same fileId (which would also collide the primary key on files.id), the proofPayload includes a separate nonce = crypto.randomUUID() (uploadProof.ts:54) AND issuedAt = Date.now() (uploadProof.ts:55), so the HMAC input differs.

Collision probability per pair of sessions is ≈ 2⁻¹²² (UUID v4 random bits) on the fileId alone, ≈ 2⁻¹²² again on the nonce, plus a ≈ 1-millisecond issuedAt granularity. Cumulative birthday-bound collision: not reachable at any plausible upload rate.

Reproduction or evidence. Inspection of generateUploadProofPayload and the call site.

Recommendation. None. Worth a positive note in web/docs/phase2-design.md so the property is documented.


Finding 6.8 — There is no in-place upload-resume path; every retry creates a new session with a new proof_key

Severity: Informational (positive finding) Affects: v0 + v1 Component: implementation

Description. The original review brief flagged "Resumed uploads: proof key derivation deterministic from session ID?" as a concern. Empirically there is no resume path:

  • uploadSessionClient.ts exposes createUploadSession and finalizeUpload. No resumeUploadSession or similar.
  • complete-upload/route.ts early-returns {ok: true, alreadyCompleted: true} for upload_status === 'ready' rows (complete-upload/route.ts:148-150) — re-finalizing an already-finalized row is a no-op, not a resume.
  • On verifyUploadProof failure the route deletes the B2 object, marks the row upload_status = 'failed', and returns 400 (complete-upload/route.ts:307-324). The row remains in the DB with failed status; there is no path to re-upload to the same row.

If a client retries after failed, it must call create-upload-session again, which mints a new files.id, a new proof_payload, and a new proof_key. No proof-key reuse is possible.

Reproduction or evidence. Grep for resume / resumable / upload_status = 'pending' returned no in-place-resume call sites:

$ grep -rn "resume\|resumable" /Users/cho/sites/shieldfive/web/app/api/files/ /Users/cho/sites/shieldfive/web/utils/storage/
(no matches in production code)

Recommendation. None. If a resumable-upload feature is added later (e.g. to recover a half-completed multipart B2 session without re-encrypting), the design MUST keep the existing per-session proof_key invariant — i.e., re-issue a new proof_key/proof_payload on resume rather than re-using the original. Worth noting in web/docs/phase2-design.md §6 as a forward-looking constraint.


Finding 6.9 — verifyAesGcmV1Proof re-checks cipherVersion !== 2 after the dispatcher already routed on it

Severity: Informational Affects: v1 Component: implementation (web/utils/uploadProof.ts:239-241)

Description. The dispatcher at uploadProof.ts:350-352 routes to verifyAesGcmV1Proof only when input.cipherVersion === AES_GCM_V1_CIPHER_VERSION (= 2). Inside the verifier, the same check is repeated as predicate (1/3) of the three-way cross-check:

if (cipherVersion !== AES_GCM_V1_CIPHER_VERSION) {
  throw new Error('UploadProofV1: file row cipher_version mismatch')
}

This is intentional defense-in-depth — if a future caller bypasses the dispatcher and calls verifyAesGcmV1Proof directly (it is exported at line 214), the row cipher_version is still validated. It is also load-bearing in the documented "three-way cross-check" rationale: each predicate compares an independent source of truth, and pulling this one would reduce the cross-check to two-way.

The "Informational" classification is because the check is correct and the defense-in-depth value is real. Flagged here for completeness of the cross-layer audit so a future contributor doesn't "simplify" it.

Reproduction or evidence. Both call sites read identically.

Recommendation. None. If the function is ever refactored to be internal-only (renamed to _verifyAesGcmV1Proof and dropped from the public exports), the cipher_version recheck could plausibly move to the dispatcher — but that's a larger refactor and the cost of keeping the recheck is one branch.


Finding 6.10 — Proof key returned to client over HTTPS; if proof_payload is exfiltrated, proof forgery is possible

Severity: Informational Affects: v0 + v1 Component: design

Description. The proof key is:

proofKey = HMAC-SHA256(UPLOAD_PROOF_SECRET, "proof:" || proof_payload)

UPLOAD_PROOF_SECRET is a server-side environment variable. The proof_payload is stored in files.proof_payload (DB), AND returned to the client in the create-upload-session response body (as proofKey — already-derived). The two routes that exist:

  1. Compromise of UPLOAD_PROOF_SECRET → universal forgery (attacker can derive any session's proof_key by re-running the HMAC with the known payload format). Mitigation: keep the secret out of source control, rotate on suspicion, ≥32 bytes (already enforced at uploadProof.ts:41-44).
  2. Compromise of files.proof_payload for some row → forgery limited to that specific upload session. Caveat: an attacker who reads files.proof_payload typically also has DB access, at which point they can directly flip files.upload_status = 'ready' and skip the proof entirely. So this path doesn't add real attack surface beyond DB compromise.

The proof_payload is not particularly sensitive on its own ({userId}:{fileId}:{nonce}:{issuedAt} — userId and fileId leak through other routes too), but combined with the secret it's the input to the proof_key. RLS coverage of files.proof_payload is therefore a prerequisite for the proof system's security argument. The RLS audit is out of crypto-layer scope and covered by the separate server-side review (kept private) — but flagged so that review explicitly covers files.proof_payload.

Reproduction or evidence. Inspection of deriveProofKey (uploadProof.ts:61-67) and the response shape at create-upload-session/route.ts:549-557, 575-584.

Recommendation. Cross-reference this dependency from the separate server-side review (RLS), kept private. Specifically, that review should confirm that files.proof_payload is unreadable to non-owner authenticated users and unreadable to anonymous/share-page sessions. The column is in 20250119000000_initial_schema.sql:137; the RLS policies on files must cover it. If a future "select files.* fields needed for X" helper is added, proof_payload should be on the do-not-leak list.


What I checked but did not find issues with

Proof key derivation

  • UPLOAD_PROOF_SECRET length check (≥ 32 chars) is enforced at every derive (uploadProof.ts:41-44). Missing env throws synchronously, not silently.
  • Proof key is HMAC-SHA256 with a domain-separation prefix ("proof:") on the input. Reduces risk of accidentally interpreting the proof_key derivation as some other HMAC (e.g., a hypothetical signing key).
  • Proof key is 32 bytes (HMAC-SHA256 output), hex-encoded as 64 chars for transport. Client and server both decode via the same hex path.

Proof MAC construction

  • Both computeAesGcmUploadProof (client) and computeProof (server) build the same MAC input layout (prefix ‖ chunkZeroBytes for v0, [0x02][0x02] ‖ headerBytes ‖ chunkZeroFrame for v1). Both use HMAC-SHA256.
  • v1 client and server build the leading 2-byte version/cipher_version prefix from constants, not from any external input (uploadProofClient.ts:134-149, uploadProof.ts:326-330).
  • v0 prefix includes chunkSize and noncePrefix from the DB row (uploadProof.ts:111-115), not from the on-disk bytes (which v0 has no header for) — the binding to the file's nonce-prefix is therefore implicit via proof_key + prefix.

Timing-safe comparison

  • v0 (verifyAesGcmProof): length pre-equality then crypto.timingSafeEqual (uploadProof.ts:146-148).
  • v1 (verifyAesGcmV1Proof): same pattern at (uploadProof.ts:339-342).
  • Secretstream (legacy): same pattern at (uploadProof.ts:205-207).
  • All three reject zero-length or length-mismatched inputs explicitly rather than letting timingSafeEqual throw.

Cross-version dispatch

  • verifyUploadProof dispatches v1 first (early return), then v0, then secretstream (uploadProof.ts:347-360).
  • v0 proof shape (^[0-9a-f]{64}$) and v1 proof shape (34-byte framed base64) are structurally distinct. v0 → v1 row and v1 → v0 row both fail at format-check before the HMAC is computed.
  • cipherVersion ?? AES_GCM_CIPHER_VERSION semantics (uploadProof.ts:112): NULL is treated as v0=1, matching the complete-upload route's interpretation (complete-upload/route.ts:185).
  • Dispatcher comments at the v0 isAesGcm gate (uploadProof.ts:353-356) correctly note that v1 rows have cipherNoncePrefix === null and cipherChunkSize === null, so they fall through to the cipherVersion branch first (already handled at line 350-352) and never reach the AES-GCM-v0 verifier with NULL row fields.

B2 read for verification

  • v0 reads 0, expectedCipherLen) from B2, where expectedCipherLen = min(ciphertextSize, chunkSize + 16). The upper bound is the only valid size for non-final chunks plus the tag. No over-read beyond what's required.
  • v1 reads [0, MAX_HEADER + 4 + chunkSize + 16) from B2 (about 5.25 MB for a 5-MiB chunkSize). The over-fetch by MAX_HEADER bytes saves a round-trip vs. parsing the header to learn its true length first. Documented at [uploadProof.ts:247-254.
  • Both paths refuse to compute a proof if the B2 read returns empty bytes (uploadProof.ts:139-141, 257-259).
  • Both paths read with readObjectRange which is a signed B2 range read, not an open-stream — no append-after-finalize attack window.

Concurrency

  • The dispatcher's three-way cross-check (v1) reads cipher_version from the row in the same transaction as the finalize_file_upload_with_quota RPC call. The RPC takes a pg_advisory_xact_lock keyed on user_id (20260510211958_finalize_upload_accepts_v1_proof.sql:107) before re-reading the row FOR UPDATE, so concurrent complete-upload calls for the same row serialize at the DB.
  • The TS-side proof verification is not under the lock — it can run concurrently with another verify for the same file. That is fine because the only side effect of TS verify is the read of proof_payload, which doesn't change. The first to reach the RPC wins; the second sees upload_status === 'ready' and short-circuits to alreadyCompleted: true (complete-upload/route.ts:148-150).
  • Concurrent create-upload-session calls for the same user generate different fileIds (UUIDs are random), so two sessions never share proof_payload / proof_key.

upload-part route gating

  • For v1 (cipher_version === 2), the proxy returns 400 immediately (upload-part/route.ts:161-169) rather than falling through with NULL chunk_size and 409-ing on the size check. Reasoning is documented in the surrounding comment: v1 parts use a variable-frame layout that the proxy's uniform-chunk assumption can't represent. Web clients upload v1 parts direct to B2.
  • For v0, the proxy verifies part size as cipherSize === chunkSize + 16 (non-final) or chunkSize - remainder + 16 (final), and rejects on mismatch (upload-part/route.ts:218-220).
  • The proxy also enforces per-part SHA-1 if provided by the client, catching tampering between the encrypt worker and the B2 PUT (upload-part/route.ts:229-241).
  • B2's own SHA-1 validation on the part body is the final integrity rail; if the body is corrupted in flight or the proxy mis-frames it, the part SHA-1 in partSha1Array (returned by B2 or computed by the client) won't reconcile with what B2 stored, and finishLargeFile (line 203 of complete-upload) will fail.

Wire-format-vs-row consistency

  • For v1 rows, cipher_chunk_size and cipher_nonce_prefix are always NULL by design (create-upload-session/route.ts:510-513). complete-upload's row validation only checks these for cipher_version === 1 (complete-upload/route.ts:185-193).
  • For v0 rows, those columns are populated from the request body, rejected when v1 is requested (create-upload-session/route.ts:124-142).
  • create-upload-session rejects cipherVersion ∉ {1, 2} (create-upload-session/route.ts:110-115), so the row's cipher_version is always 1 or 2; the dispatcher's fallthrough to verifySecretstreamProof is unreachable from a newly-created row.

Out of scope (deferred to other tasks)

  • Wire format details (suite payloads, AAD construction, header MAC derivation, chunk MAC) — Task 1, done.
  • Content-key derivation, envelope-key derivation, password-derived-key unwrap chain — Task 2, done.
  • AEAD chunk-level nonce uniqueness audit — Task 4.
  • Streaming invariants (back-pressure, deferred-header-bytes deadlock prevention) — Task 5.
  • RLS coverage of files.proof_payload — out of crypto-layer scope and covered by the separate server-side review (kept private). Finding 6.10 explicitly cross-references this dependency.
  • Threat model coverage and the position of the upload-proof check in the broader threat tree — Task 3, done.

References

  • web/utils/uploadProof.ts
  • web/utils/uploadProofClient.ts
  • web/utils/uploadProofFormat.ts
  • web/app/api/files/create-upload-session/route.ts
  • web/app/api/files/upload-part/route.ts
  • web/app/api/files/complete-upload/route.ts
  • web/utils/storage/uploadSessionClient.ts
  • web/app/files/components/modals/encryptModal.tsx
  • web/supabase/migrations/20260220172000_fix_finalize_upload_function_ambiguity.sql
  • web/supabase/migrations/20260510211958_finalize_upload_accepts_v1_proof.sql
  • web/supabase/migrations/20250119000000_initial_schema.sql (files.proof_payload, files.encryption_proof, initial RPC)
  • Stage 2a design rationale: web/docs/phase2-design.md §6
  • Stage 2c audit log: web/docs/phase2-stage-2c-audit.md