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-sessioncall mints a freshcrypto.randomUUID()fileIdAND a freshcrypto.randomUUID()nonceinside theproof_payload, then derivesproofKey = 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_payloadlooked up by(id = fileId, user_id = current user). A proof MAC'd underproofKey_Acannot pass HMAC verification underproofKey_B. For v1 the binding is additionally anchored by including the on-disk header bytes (which carryfile_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, andverifyUploadProofroutes bycipher_versionbefore either format-specific verifier is entered. A v0 proof submitted to a v1 row dies at theproofBytes.length !== V1_PROOF_FRAME_LENGTHlength check; a v1 proof submitted to a v0 row dies atPROOF_HEX_RE.test(proof). - Timing-safe verification. Both
verifyAesGcmProofandverifyAesGcmV1Proofcompare the MAC withcrypto.timingSafeEqualafter 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:
| # | Predicate | Source A | Source B | Catches |
|---|---|---|---|---|
| 1 | uploadProof.ts:239 cipherVersion !== 2 | DB row cipher_version | (server constant 0x02) | Dispatcher bug or service-role caller routing v0 row through v1 verifier |
| 2 | uploadProof.ts:288 parsed.suite !== V1_AES_GCM_SUITE_BYTE | On-disk header byte 5 (suite ID) | (server constant 0x01) | B2 object rewritten with a different suite payload (e.g. XChaCha bytes) |
| 3 | uploadProof.ts:293 proofBytes[0] !== UPLOAD_PROOF_VERSION_V1 | Submitted proof byte 0 | (server constant 0x02) | Client built a proof with the wrong version prefix |
| 4 | uploadProof.ts:296 proofBytes[1] !== AES_GCM_V1_CIPHER_VERSION | Submitted proof byte 1 | (server constant 0x02) | Client built a proof claiming a different cipher_version |
| 5 | uploadProof.ts:339-342 timing-safe HMAC compare | HMAC(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:
| # | Predicate | Source A | Source B | Catches |
|---|---|---|---|---|
| 1 | [uploadProof.ts:100 PROOF_HEX_RE.test(proof) | Submitted proof shape | regex ^[0-9a-f]{64}$ | v1 proof submitted to v0 row |
| 2 | uploadProof.ts:103-108 cipherChunkSize > 0, cipherNoncePrefix present | DB row | (non-null/non-zero) | v1 row mis-routed through v0 verifier (chunk_size/nonce_prefix are NULL on v1) |
| 3 | uploadProof.ts:146-148 timing-safe HMAC compare | HMAC(proofKey, prefix ‖ chunkZero), where prefix is built from DB-side cipherVersion, chunkSize, noncePrefixBytes | Submitted hex | Anything 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:
- 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.
- On download,
crypto/src/streams/aes-gcm-v1.ts:340-348enforces 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:
- 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. - Or document the deliberate gap in
web/docs/phase2-design.md §6alongside 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:
files.proof_payloadis written once at session creation ([create-upload-session/route.ts:516) and is never updated by any route (grep acrossapp/andutils/confirms noupdate({ proof_payload: ... })).- The proof_payload column is set per file row, not shared across
rows. Two file rows with the same
proof_payloadwould only happen via a code bug, andrandomUUID()collisions are negligible. - v0 is legacy in the codebase. New writes default to v1 once
NEXT_PUBLIC_CRYPTO_V1_ENABLED=trueships 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:
- 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). - Fail-fast. Predicate 5 requires fetching ~5 MB from B2
(
uploadProof.ts:253-255readsMAX_HEADER + 4 + chunkSize + tagbytes). Predicates 1, 3, 4 are constant-time and run before that fetch (predicate 2 runs after the fetch but before the HMAC). - 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 === 0soexpectedCipherLen === 0and the checkif (!expectedCipherLen) throwfires. 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 checkciphertextHead.length < chunkZeroLengthOffset + 4evaluates to149 < 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:
fileIdis generated server-side atcrypto.randomUUID()for everycreate-upload-sessioncall (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.- Even if two calls did somehow land on the same
fileId(which would also collide the primary key onfiles.id), theproofPayloadincludes a separatenonce = crypto.randomUUID()(uploadProof.ts:54) ANDissuedAt = 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.tsexposescreateUploadSessionandfinalizeUpload. NoresumeUploadSessionor similar.complete-upload/route.tsearly-returns{ok: true, alreadyCompleted: true}forupload_status === 'ready'rows (complete-upload/route.ts:148-150) — re-finalizing an already-finalized row is a no-op, not a resume.- On
verifyUploadProoffailure the route deletes the B2 object, marks the rowupload_status = 'failed', and returns 400 (complete-upload/route.ts:307-324). The row remains in the DB withfailedstatus; 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:
- 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). - Compromise of
files.proof_payloadfor some row → forgery limited to that specific upload session. Caveat: an attacker who readsfiles.proof_payloadtypically also has DB access, at which point they can directly flipfiles.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_SECRETlength 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) andcomputeProof(server) build the same MAC input layout (prefix ‖ chunkZeroBytesfor v0,[0x02][0x02] ‖ headerBytes ‖ chunkZeroFramefor 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
chunkSizeandnoncePrefixfrom 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 thencrypto.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
timingSafeEqualthrow.
Cross-version dispatch
verifyUploadProofdispatches 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_VERSIONsemantics (uploadProof.ts:112): NULL is treated as v0=1, matching thecomplete-uploadroute's interpretation (complete-upload/route.ts:185).- Dispatcher comments at the v0 isAesGcm gate
(
uploadProof.ts:353-356) correctly note that v1 rows havecipherNoncePrefix === nullandcipherChunkSize === 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, whereexpectedCipherLen = 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 byMAX_HEADERbytes 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
readObjectRangewhich 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_versionfrom the row in the same transaction as thefinalize_file_upload_with_quotaRPC call. The RPC takes apg_advisory_xact_lockkeyed on user_id (20260510211958_finalize_upload_accepts_v1_proof.sql:107) before re-reading the rowFOR 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 seesupload_status === 'ready'and short-circuits toalreadyCompleted: true(complete-upload/route.ts:148-150). - Concurrent
create-upload-sessioncalls for the same user generate differentfileIds (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) orchunkSize - 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, andfinishLargeFile(line 203 of complete-upload) will fail.
Wire-format-vs-row consistency
- For v1 rows,
cipher_chunk_sizeandcipher_nonce_prefixare 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-sessionrejectscipherVersion ∉ {1, 2}(create-upload-session/route.ts:110-115), so the row's cipher_version is always 1 or 2; the dispatcher's fallthrough toverifySecretstreamProofis 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.tsweb/utils/uploadProofClient.tsweb/utils/uploadProofFormat.tsweb/app/api/files/create-upload-session/route.tsweb/app/api/files/upload-part/route.tsweb/app/api/files/complete-upload/route.tsweb/utils/storage/uploadSessionClient.tsweb/app/files/components/modals/encryptModal.tsxweb/supabase/migrations/20260220172000_fix_finalize_upload_function_ambiguity.sqlweb/supabase/migrations/20260510211958_finalize_upload_accepts_v1_proof.sqlweb/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