Skip to content

fix(sdk): DSPX-4590 zip64 conformance and per-segment size defaults - #3967

Draft
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4590-zip64-conformance
Draft

fix(sdk): DSPX-4590 zip64 conformance and per-segment size defaults#3967
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4590-zip64-conformance

Conversation

@dmihalcik-virtru

Copy link
Copy Markdown
Member

Jira: https://virtru.atlassian.net/browse/DSPX-4590

go-sdk's zip layer disagrees with the other SDKs in a handful of places, and its
manifest reader treats optional per-segment sizes as mandatory. This addresses
all seven findings from the DSPX-4590 investigation.

Finding 1 (interop): writer switched to ZIP64 at 4 GiB instead of 2 GiB

Finalize compared against ^uint32(0), so a payload between 2 GiB and 4 GiB
was written as a zip32 archive with a value in the top half of the unsigned
32-bit range. java-sdk widens those central-directory fields signed, so
deployed Java clients read the size/offset back as a negative number and cannot
open the container. web-sdk always writes ZIP64, java-sdk (since java-sdk#393)
switches at Integer.MAX_VALUE.

  • New maxNonZip64Value = math.MaxInt32 in zip_primitives.go, mirroring
    java-sdk's MAX_NON_ZIP64_VALUE.
  • The rule is applied to the uncompressed size, the compressed size and
    entry.Offset (the local-header offset), which was previously not checked at
    all — an archive under 2 GiB of payload could still place a later entry's
    header past the boundary.
  • The threshold is injectable: Config.MaxNonZip64Value plus a
    WithMaxNonZip64Value option (clamped to (0, maxNonZip64Value], so it can
    only ever make the writer more eager to use ZIP64). This lets the tests
    drive the ZIP64 path with a 1 KiB threshold instead of allocating gigabytes.

Finding 2: ZIP64 extra field parsed positionally

The reader assumed the ZIP64 extended-information field was the first entry in
the extra-field area and that all three values were always present. Per APPNOTE
4.5.3 the values appear in the order original size, compressed size, local
header offset
, and each is present only when its central-directory
counterpart holds the 0xFFFFFFFF sentinel. A container whose extra area leads
with, say, an extended-timestamp field (tag 0x5455) was misparsed.

parseZip64ExtraField now walks the whole extra area, skips foreign tags,
reads values in spec order gated on the sentinels, and rejects a field that
claims to run past the end of the area.

Finding 3: ZIP64 detected from the CD offset alone

NewReader only looked at CentralDirectoryOffset == 0xFFFFFFFF. An archive
that overflows the entry count (0xFFFF) or the central-directory size but not
the offset was read as zip32. eocdNeedsZip64 now checks all three EOCD
sentinel fields.

Finding 4: per-entry ZIP64 extra field ignored without a ZIP64 EOCD

A writer may put a ZIP64 extra field on an individual entry while leaving the
EOCD in zip32 form. The reader now consults the extra field whenever the
central-directory header carries a sentinel, independent of the EOCD form.

Finding 5: central-directory cursor could wrap at uint16

nextCD was advanced with uint16 arithmetic and did not include the file
comment. A 65000-byte filename plus a 600-byte extra field wraps to 110 and
the reader walks into the middle of a header. The advance is now done in
uint64 and includes FileCommentLength.

Finding 6: silent truncation when a value does not fit 32 bits

The zip32 paths narrowed with a bare cast. checkFitsInCentralDirectory now
returns a new ErrFieldOverflow for the compressed size, uncompressed size,
local-header offset, central-directory size/offset and entry count instead of
writing a corrupt archive. (With finding 1 in place this is unreachable in
normal operation; it is a backstop against future callers.)

Finding 7 (correctness): per-segment sizes are optional, and go-sdk read them as zero

manifest.schema.json marks segmentSizeDefault and
encryptedSegmentSizeDefault required on integrityInformation but declares no
required list on segments/items — a writer may omit a per-segment size
whenever it equals the default. web-sdk does exactly that for every full-sized
segment, so every web-sdk container larger than one segment (1 MiB) failed to
decrypt in go-sdk
: the missing keys unmarshalled to 0.

IntegrityInformation.resolveSegmentSizes now substitutes the manifest-level
defaults, and is used in all three places that consumed the raw values
independently:

  • the payloadSize accumulation in NewReader/LoadTDF (a wrong total
    truncates Seek and the ReadAt bounds check),
  • the WriteTo loop,
  • the ReadAt loop, including the skip-ahead arithmetic.

A segment that resolves to zero or negative is now rejected with a new
ErrSegSizeUnresolved. Previously the len(readBuf) != seg.EncryptedSize
guard passed vacuously for a zero-length segment and the read failed several
frames later inside the GMAC calculation, with a message that said nothing
about the manifest. ReadAt also guards segmentSizeDefault <= 0 before using
it as a divisor.

Tests

  • sdk/internal/zipstream/zip64_conformance_test.go — hand-assembles raw zip
    archives (buildRawZip) so the reader can be pointed at containers no Go
    writer would produce: extra field not first, differing compressed and
    uncompressed sizes so the APPNOTE 4.5.3 ordering is actually asserted (a
    fixture with equal sizes passes either way), per-entry ZIP64 under a zip32
    EOCD, a central-directory file comment, the 65646-byte name+extra case that
    wraps to 110 at uint16, ZIP64 implied by the entry count, and a malformed
    extra field. Writer side: TestWriterSwitchesToZip64AtInjectedThreshold uses
    the injected 1 KiB threshold, TestEntryNeedsZip64AtTwoGiB pins
    maxNonZip64Value == math.MaxInt32 and covers size / compressed size /
    offset, TestCentralDirectoryNarrowingGuard covers finding 6.
  • sdk/internal/zipstream/fuzz_test.go — two new seeds for the nextCD
    overflow and the file-comment case.
  • sdk/tdf_segment_defaults_test.go — round-trips a 2 MiB payload through a
    manifest rewritten to omit the per-segment sizes (the root signature covers
    the segment hashes, not the JSON encoding, so the rewritten container is
    still internally consistent), asserting both the WriteTo and the ReadAt
    path plus payloadSize. Verified by fault injection: with the fallback
    removed the test fails with payloadSize 4242 instead of 2101394 and with
    [tamper detected] tdf: fail to read payload from tdf, matching the symptoms
    in the ticket. TestResolveSegmentSizes covers the resolution rules directly.

Follow-up required in opentdf/tests (NOT covered by this PR)

The xtest cell test_tdfs.py::test_chunky_roundtrip currently SKIPS for
go, because xtest/sdk/go/cli.sh answers no to supports chunky. That shim
lives in the opentdf/tests repo, so merging this PR does not flip it —
the go column will stay skipped and the interop regression will stay
invisible in CI. When this fix ships in a release, someone needs to
version-gate the chunky) case in xtest/sdk/go/cli.sh so it reports
support at or above that version.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added comp:sdk A software development kit, including library, for client applications and inter-service communicati size/m labels Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 242.868569ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 144.536763ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 435.408375ms
Throughput 229.67 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 1m1.032224813s
Average Latency 608.994998ms
Throughput 81.92 requests/second

The zip writer only switched to ZIP64 once a value exceeded 4 GiB, but
java-sdk reads the 32-bit central-directory fields as signed, so any
container in the 2-4 GiB band was written as zip32 with a value that
deployed Java clients read back as negative. Switch at 2 GiB
(math.MaxInt32) to match java-sdk's MAX_NON_ZIP64_VALUE, apply the same
rule to the local-header offset, and make the threshold injectable so
the ZIP64 path can be exercised in a unit test without allocating
gigabytes.

The reader's ZIP64 extra-field parser assumed the field was first in the
extra area and that all three values were always present. APPNOTE 4.5.3
says each value appears only when its central-directory counterpart
holds the 0xFFFFFFFF sentinel, in the order original size, compressed
size, local header offset. Walk the whole extra area, skip foreign tags,
and read only the values the sentinels advertise.

Also: detect ZIP64 from any of the three EOCD sentinel fields rather
than the offset alone, honour a per-entry ZIP64 extra field in a zip32
EOCD archive, widen the central-directory cursor arithmetic to uint64 so
a long name plus extra plus comment cannot wrap at uint16, include the
file-comment length in that cursor, and return an explicit error instead
of silently truncating when a value will not fit a 32-bit field.

Finally, segmentSize and encryptedSegmentSize are optional per-segment
overrides: manifest.schema.json requires only the integrityInformation
defaults, and web-sdk omits the per-segment keys whenever they equal the
default, so every web-sdk container over one segment failed to decrypt
in go-sdk. Fall back to the manifest defaults in the payload-size
computation, WriteTo and ReadAt, and reject a segment that resolves to
zero rather than letting it slip past the read-length check and fail
later inside the GMAC calculation.
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4590-zip64-conformance branch from 2e371da to 01d583c Compare September 3, 2026 20:12
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 153.324467ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 80.230409ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 285.707129ms
Throughput 350.01 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 38.015976429s
Average Latency 379.547187ms
Throughput 131.52 requests/second

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⚠️ Govulncheck found vulnerabilities ⚠️

The following modules have known vulnerabilities:

  • otdfctl
  • service
  • tests-bdd

See the workflow run for details.

dmihalcik-virtru added a commit that referenced this pull request Sep 4, 2026
The zip writer only switched to ZIP64 once a value exceeded 4 GiB, but
java-sdk reads the 32-bit central-directory fields as signed, so any
container in the 2-4 GiB band was written as zip32 with a value that
deployed Java clients read back as negative. Switch at 2 GiB
(math.MaxInt32) to match java-sdk's MAX_NON_ZIP64_VALUE, apply the same
rule to the local-header offset, and make the threshold injectable so
the ZIP64 path can be exercised in a unit test without allocating
gigabytes.

The reader's ZIP64 extra-field parser assumed the field was first in the
extra area and that all three values were always present. APPNOTE 4.5.3
says each value appears only when its central-directory counterpart
holds the 0xFFFFFFFF sentinel, in the order original size, compressed
size, local header offset. Walk the whole extra area, skip foreign tags,
and read only the values the sentinels advertise.

Also: detect ZIP64 from any of the three EOCD sentinel fields rather
than the offset alone, honour a per-entry ZIP64 extra field in a zip32
EOCD archive, widen the central-directory cursor arithmetic to uint64 so
a long name plus extra plus comment cannot wrap at uint16, include the
file-comment length in that cursor, and return an explicit error instead
of silently truncating when a value will not fit a 32-bit field.

segmentSize and encryptedSegmentSize are optional per-segment overrides:
manifest.schema.json requires only the integrityInformation defaults,
and web-sdk omits the per-segment keys whenever they equal the default,
so every web-sdk container over one segment failed to decrypt in
go-sdk. Fall back to the manifest defaults in the payload-size
computation, WriteTo and ReadAt.

Rebased onto #3933 (map ReadAt plaintext offsets from cumulative segment
sizes), which rewrote ReadAt's segment lookup from a uniform
DefaultSegmentSize stride to a walk over each segment's actual
plaintext/ciphertext size -- necessary for the non-uniform segments
sdk/experimental/tdf can emit, and something this change's original
ReadAt fix did not attempt. Reconciling the two surfaced a further bug:
resolveSegmentSizes treated a per-segment size of 0 as "omitted, use the
default" independently for Size and EncryptedSize, but JSON can't
distinguish an omitted key from an explicit 0, and go-sdk's own
CreateTDF already writes segmentSize: 0 for the sole segment of an
empty-payload TDF (no omitempty on the field). That made an empty TDF
round-trip to the wrong payloadSize.

resolveSegmentSizes now resolves EncryptedSize first -- its zero value is
never ambiguous, since ciphertext can never legitimately be zero bytes --
and disambiguates a zero Size by comparing the resolved EncryptedSize
against DefaultEncryptedSegSize rather than assuming Size and
EncryptedSize are only ever omitted together. Checking go-sdk, java-sdk
and web-sdk's actual manifest-writing source confirmed go-sdk and
java-sdk always set both fields together (so the joint-zero assumption
happened to hold for them), but web-sdk's lib/tdf3/src/tdf.ts decides
whether to omit segmentSize and encryptedSegmentSize with two independent
equals-the-default comparisons, not one joint check -- so the
joint-zero-only version would have mis-resolved a segment where only one
of the two happened to be omitted. The corrected comparison needs no
assumption about the cipher's per-segment overhead (nonce/tag size
stays out of manifest.go entirely): the overhead is constant across
every segment in one manifest, so if the resolved EncryptedSize equals
its default, the plaintext size must too, regardless of what that
overhead number actually is.

Also gives calculateSignature's too-short-ciphertext-for-GMAC error
(previously a bare, unclassified error) a proper ErrTampered-wrapped
sentinel, consistent with the rest of this file's integrity failures.

Verified against opentdf/tests' DSPX-4592-java-underflow branch (adds
test_tdfs.py::test_chunky_roundtrip, a 5 MiB round-trip that forces a
full-default-sized segment): with platform-ref and otdfctl-ref both
pointed at this branch and XT_FORCE_SUPPORTS=chunky, js-encrypt ->
go-decrypt passes (js omits per-segment sizes on the full-sized segment;
go now defaults them back). The one remaining failure in that run,
js-encrypt -> java-decrypt, is java-sdk's own pre-existing GMAC-on-empty-
segment bug (DSPX-4589), unrelated to this change.

Supersedes #3967, which is left open, unmodified, for reference.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp:sdk A software development kit, including library, for client applications and inter-service communicati size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant