The chunked writer has been the sole implementation of TDF construction since
CreateTDF was rewritten on top of it. Every TDF the SDK produces already goes
through this code; the "Experimental: not part of the stable SDK API; may
change or be removed" markers on it stopped being true at that point. Drop
them, and give the writer the constructor it was missing.
SDK.NewChunkedWriter resolves key access against the platform the SDK is
connected to, running attributes through the same autoconfigure path
CreateTDF uses. Until now a chunked caller who wanted multi-KAS attribute
grants had to implement a KeySplitter to get them, reimplementing
autoconfigure by hand; DefaultKeySplitter is single-KAS and attribute-blind.
The TDFOptions that shape key access -- WithDataAttributes,
WithKasInformation, WithWrappingKeyAlg -- go in through
WithChunkedTDFOptions.
Resolution happens at Finalize, not at construction. That is the difference
from CreateTDF and the reason this is a distinct resolver rather than a shared
one: a chunked caller may still be adding attributes while segments are in
flight, so the attribute set is not known until Finalize is called. The cost
is that an unreachable KAS surfaces at Finalize, after segments have already
been handed back. Passing WithChunkedKeySplitter opts out entirely and the
given splitter is used as-is -- the config tracks whether the option was
supplied so that "left at the default" and "deliberately overridden" are
distinguishable.
The test seams -- archiveFactory, cipherFactory, clock -- stay unexported.
archiveFactory returns a zipstream.SegmentWriter, and zipstream lives under
internal/, so there is no form of these that could be exported and supported.
Graduating the package does not graduate them.
sdk/experimental/tdf is deprecated at the package level rather than deleted.
It has forwarded to this implementation since the Writer delegation commit and
its manifest and assertion types are already aliases of the sdk types, so
values move across the boundary without conversion and callers can migrate a
call site at a time. The package doc carries the full option-by-option
mapping.
The experimental-writer benchmark example moves with it:
benchmark-experimental-writer becomes benchmark-chunked-writer and builds on
the package-level NewChunkedWriter, since it talks to one KAS whose key it has
already fetched and has no reason to pay for platform resolution inside the
timed section. Two bugs in it are fixed in passing: the segment goroutines
read the loop variable to compute their own offsets, and shared a single err.
Testing: the chunked suite and the classic TDFSuite round trips both pass
unchanged. Two new cases cover the constructor -- one asserting that
attributes resolve through the platform to the right KAS URL and KID and that
the result round-trips through LoadTDF, one asserting that an explicit
splitter is honored, using a KAS URL that would fail loudly if platform
resolution ran anyway. The fake KAS gained a PublicKey handler so the first
exercises the real key fetch rather than being handed a PEM. Also verified
against cross-module builds of examples, otdfctl, service, and tests-bdd.
Graduating also settles what a nil default KAS means, which the experimental
options never did. WithChunkedDefaultKAS and WithChunkedDefaultKASForFinalize
now reject nil, like every other option in the file that takes a pointer or an
interface. Storing a nil is not recoverable later -- the field is
indistinguishable from "not set" -- and for this particular field the failure
is silent rather than a panic: resolution falls through to the platform base
key, and the caller finds out their data went somewhere they never named only
when a reader cannot unwrap it. That is the shape of the base-key contention
that broke the streaming e2e file. SDK.NewChunkedWriter's doc comment now
spells the fallback out, including that WithKasInformation does not suppress
it, since it fills kasInfoList without clearing autoconfigure.
sdk/experimental/tdf keeps taking nil as "unset" -- WithDefaultKAS and
WithDefaultKASForWriter ship on main and are not this stack's to tighten -- so
the delegation appends the stable option only when a KAS is actually set. A
nil there still surfaces as the splitter's ErrNoDefaultKAS at Finalize, which
is what callers already handle, and a test pins that so a future change cannot
leak the option error through.
TestChunkedOptionsRejectNil covers both new guards and
TestSDKChunkedWriterFallsBackToBaseKey pins the fallback itself, so the
behavior the doc comment describes is asserted rather than just written down.
Both were mutation-checked: removing either guard fails them.
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Proposed Changes
The chunked writer has been the sole implementation of TDF construction since
CreateTDF was rewritten on top of it. Every TDF the SDK produces already goes
through this code; the "Experimental: not part of the stable SDK API; may
change or be removed" markers on it stopped being true at that point. Drop
them, and give the writer the constructor it was missing.
SDK.NewChunkedWriter resolves key access against the platform the SDK is
connected to, running attributes through the same autoconfigure path
CreateTDF uses. Until now a chunked caller who wanted multi-KAS attribute
grants had to implement a KeySplitter to get them, reimplementing
autoconfigure by hand; DefaultKeySplitter is single-KAS and attribute-blind.
The TDFOptions that shape key access -- WithDataAttributes,
WithKasInformation, WithWrappingKeyAlg -- go in through
WithChunkedTDFOptions.
Resolution happens at Finalize, not at construction. That is the difference
from CreateTDF and the reason this is a distinct resolver rather than a shared
one: a chunked caller may still be adding attributes while segments are in
flight, so the attribute set is not known until Finalize is called. The cost
is that an unreachable KAS surfaces at Finalize, after segments have already
been handed back. Passing WithChunkedKeySplitter opts out entirely and the
given splitter is used as-is -- the config tracks whether the option was
supplied so that "left at the default" and "deliberately overridden" are
distinguishable.
The test seams -- archiveFactory, cipherFactory, clock -- stay unexported.
archiveFactory returns a zipstream.SegmentWriter, and zipstream lives under
internal/, so there is no form of these that could be exported and supported.
Graduating the package does not graduate them.
sdk/experimental/tdf is deprecated at the package level rather than deleted.
It has forwarded to this implementation since the Writer delegation commit and
its manifest and assertion types are already aliases of the sdk types, so
values move across the boundary without conversion and callers can migrate a
call site at a time. The package doc carries the full option-by-option
mapping.
The experimental-writer benchmark example moves with it:
benchmark-experimental-writer becomes benchmark-chunked-writer and builds on
the package-level NewChunkedWriter, since it talks to one KAS whose key it has
already fetched and has no reason to pay for platform resolution inside the
timed section. Two bugs in it are fixed in passing: the segment goroutines
read the loop variable to compute their own offsets, and shared a single err.
Testing: the chunked suite and the classic TDFSuite round trips both pass
unchanged. Two new cases cover the constructor -- one asserting that
attributes resolve through the platform to the right KAS URL and KID and that
the result round-trips through LoadTDF, one asserting that an explicit
splitter is honored, using a KAS URL that would fail loudly if platform
resolution ran anyway. The fake KAS gained a PublicKey handler so the first
exercises the real key fetch rather than being handed a PEM. Also verified
against cross-module builds of examples, otdfctl, service, and tests-bdd.
Graduating also settles what a nil default KAS means, which the experimental
options never did. WithChunkedDefaultKAS and WithChunkedDefaultKASForFinalize
now reject nil, like every other option in the file that takes a pointer or an
interface. Storing a nil is not recoverable later -- the field is
indistinguishable from "not set" -- and for this particular field the failure
is silent rather than a panic: resolution falls through to the platform base
key, and the caller finds out their data went somewhere they never named only
when a reader cannot unwrap it. That is the shape of the base-key contention
that broke the streaming e2e file. SDK.NewChunkedWriter's doc comment now
spells the fallback out, including that WithKasInformation does not suppress
it, since it fills kasInfoList without clearing autoconfigure.
sdk/experimental/tdf keeps taking nil as "unset" -- WithDefaultKAS and
WithDefaultKASForWriter ship on main and are not this stack's to tighten -- so
the delegation appends the stable option only when a KAS is actually set. A
nil there still surfaces as the splitter's ErrNoDefaultKAS at Finalize, which
is what callers already handle, and a test pins that so a future change cannot
leak the option error through.
TestChunkedOptionsRejectNil covers both new guards and
TestSDKChunkedWriterFallsBackToBaseKey pins the fallback itself, so the
behavior the doc comment describes is asserted rather than just written down.
Both were mutation-checked: removing either guard fails them.
Checklist
Testing Instructions
TestSDKChunkedWriterResolvesKeyAccessandTestSDKChunkedWriterKeepsAnExplicitSplitterare the new coverage for theconstructor. The first goes through the fake KAS's new
PublicKeyhandler, soit exercises the real key fetch rather than being handed a PEM.
For the nil-KAS half, the cases to look at are
TestChunkedOptionsRejectNil/default_KAS{,_for_finalize},TestSDKChunkedWriterFallsBackToBaseKey, andTestWriterEndToEnd/ErrorConditions/ExplicitNilKASStaysUnset. Both directionswere mutation-checked: dropping either guard in
chunked_options.gofails thefirst two, and forwarding the KAS unconditionally from
experimental/tdf/writer.goagain makes plainNewWriter(ctx)fail withchunked: default KAS must not be nil— which is the coupling between the twochanges.
The full DSPX-2604 stack — 20 PRs
mainmainmainmainmainmainmaindspx-2604-base-11= #3932 + #3934 + #3935dspx-2604-base-17= #3944 + #3945dspx-2604-base-19= #3947 + #3939Reviewable in parallel right now, since they sit directly on
mainand depend onnothing else: 01, 02, 04, 05, 06, 07, 08.
Why three PRs have a
dspx-2604-base-*base. A GitHub PR takes one base branch,but 11, 17 and 19 each build on more than one parent. The
base-*branches are emptymerge commits that exist only to join those parents so the PR diff shows exactly its
own change and nothing else. They contain no code, have no PR of their own, and go
away once their parents land — retarget the child onto
mainat that point.Wants a cross-SDK xtest run before merge: 15, 17 (and therefore 20). They touch
the KAS wire format.
Red checks you may see are network flakes, not this stack. Four distinct ones hit
this batch and all clear on re-run:
golangci-lint config verifytiming out onhttps://golangci-lint.run/.../golangci.v2.8.jsonschema.json(fails the wholego (<module>)job and fail-fast cancels its siblings), the bats installer getting a 403,Docker Hub timing out on
keycloak/keycloak:26.4, andbufreporting "the serverhosted at that remote is unavailable" while the Java SDK generates sources. The
govulncheckstep also emits##[error]annotations against the go1.25.11 stdlib, butit is
continue-on-error: trueand never fails a job — 01 bumps the toolchain andclears those annotations.