Skip to content

feat(sdk): add ChunkedWriter for out-of-order segment TDF creation#3782

Open
ronelliott wants to merge 1 commit into
mainfrom
feat/DSPX-2604
Open

feat(sdk): add ChunkedWriter for out-of-order segment TDF creation#3782
ronelliott wants to merge 1 commit into
mainfrom
feat/DSPX-2604

Conversation

@ronelliott

@ronelliott ronelliott commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Proposed Changes

Adds ChunkedWriter, an SDK entry point that produces a TDF from segments
supplied one at a time. Callers encrypt and upload each segment
independently — off-thread or in parallel — then call Finalize to seal
the archive. Contrast with SDK.CreateTDF, which requires the full
plaintext buffered up front.

The writer exposes injection seams (Clock, SegmentCipher,
ArchiveWriterFactory, KeySplitter) so tests can substitute
deterministic or in-memory implementations without touching global
state. The default splitter is single-KAS only; callers with
attribute-based multi-KAS grants inject their own via
WithChunkedKeySplitter.

No reader is included — produced TDFs round-trip through the mainline
SDK.LoadTDF path.

New files (all under sdk/):

  • chunked_writer.go — ChunkedWriter interface + concrete writer
  • chunked_options.goWithChunked* options
  • clock.go — Clock interface + defaults
  • segment.go — SegmentCipher interface + AES-256-GCM default
  • archive_writer.go — ArchiveWriterFactory + ZIP64 default
  • key_splitter.go — KeySplitter interface + single-KAS default
  • chunked_test.go — round-trip, keep-segments, assertion-rejection

Jira: DSPX-2604

Checklist

  • Signed commits with DCO sign-off
  • Unit tests added
  • make fmt clean
  • sdk package tests pass with -race

Testing Instructions

cd sdk && go test -run TestChunked -race .

Verifies three scenarios end-to-end:

  1. TestChunkedRoundTrip — write 3 segments, decrypt via mainline SDK.LoadTDF against in-process RSA-2048 fake KAS.
  2. TestChunkedKeepSegments — write 3, keep prefix [0, 1], decrypt only retained.
  3. TestChunkedFinalizeRejectsAssertions — assertions option returns sentinel error (unsupported).

Summary by CodeRabbit

  • New Features
    • Added chunked writing for encrypting and finalizing TDF segments independently.
    • Added configurable options for encryption, integrity, key splitting, metadata, MIME type, timestamps, and retained segments.
    • Added support for custom archive writers, ciphers, clocks, randomness sources, and key splitters.
    • Added default AES-GCM encryption and single-KAS key splitting.
    • Added manifest generation, encrypted metadata, integrity protection, and ZIP64 archive output.
  • Bug Fixes
    • Added validation for duplicate, invalid, or post-finalization segment writes.

DSPX-2604

Introduces ChunkedWriter, an SDK entry point that produces a TDF from
segments supplied one at a time. Callers encrypt and upload each
segment independently — off-thread or in parallel — then call Finalize
to seal the archive. Contrast with SDK.CreateTDF, which requires the
full plaintext buffered up front.

The writer exposes injection seams (Clock, SegmentCipher,
ArchiveWriterFactory, KeySplitter) so tests can substitute
deterministic or in-memory implementations without touching global
state. The default splitter is single-KAS only; callers with
attribute-based multi-KAS grants inject their own via
WithChunkedKeySplitter.

No reader is included — produced TDFs round-trip through the mainline
SDK.LoadTDF path, verified end-to-end by TestChunkedRoundTrip.

Files:
  chunked_writer.go   — ChunkedWriter interface + concrete writer
  chunked_options.go  — WithChunked* options
  clock.go            — Clock interface + defaults
  segment.go          — SegmentCipher interface + AES-256-GCM default
  archive_writer.go   — ArchiveWriterFactory + ZIP64 default
  key_splitter.go     — KeySplitter interface + single-KAS default
  chunked_test.go     — round-trip, keep-segments, assertion-rejection
Signed-off-by: Ron Elliott <ron.elliott@virtru.com>
@ronelliott
ronelliott requested review from a team as code owners July 23, 2026 15:06
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added comp:sdk A software development kit, including library, for client applications and inter-service communicati size/xl labels Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a configurable chunked TDF writer that encrypts indexed segments, streams ZIP output, builds manifests with KAS-wrapped keys, supports finalize-time controls, and includes end-to-end tests using a fake KAS.

Changes

Chunked TDF writing

Layer / File(s) Summary
Writer contracts and factories
sdk/chunked_writer.go, sdk/chunked_options.go, sdk/archive_writer.go, sdk/segment.go, sdk/clock.go, sdk/key_splitter.go
Defines the chunked writer API, functional options, injectable factories and clocks, segment cipher construction, archive creation, and default single-KAS key splitting.
Segment encryption and streaming
sdk/chunked_writer.go
Generates a DEK, encrypts indexed segments, computes integrity metadata, writes ZIP segment data, and returns segment output readers.
Manifest finalization and key wrapping
sdk/chunked_writer.go
Builds ordered manifests, supports segment-prefix selection and metadata options, creates policy bindings, encrypts metadata, wraps key shares, and closes the archive.
End-to-end validation
sdk/chunked_test.go
Tests chunked round trips, retained segment prefixes, unsupported assertions, and fake-KAS rewrap handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested labels: comp:kas

Poem

I’m a rabbit with chunks in a ZIP-lined burrow,
Encrypting each hop with a cryptographic furrow.
Keys split and wrapped, manifests bloom,
Fake KAS friends make tests safely zoom.
Three segments return, then prefixes stay—
Hop-hop, the TDF finds its way!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a ChunkedWriter for chunked, out-of-order TDF creation in the SDK.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/DSPX-2604

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

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 196.403395ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

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

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 444.631348ms
Throughput 224.91 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 49.690655338s
Average Latency 495.034588ms
Throughput 100.62 requests/second

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Govulncheck found vulnerabilities ⚠️

The following modules have known vulnerabilities:

  • examples
  • otdfctl
  • sdk
  • service
  • lib/fixtures
  • tests-bdd

See the workflow run for details.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdk/chunked_test.go`:
- Around line 116-124: Extend the chunked-segment tests around
writeChunkedSegments to add a round-trip case that calls WriteSegment with
indices in out-of-order sequence such as 2, 0, 1, appends each returned TDFData
in that write order, and verifies LoadTDF reconstructs plaintext ordered by
segment index. Keep the existing in-order helper behavior unchanged.

In `@sdk/chunked_writer.go`:
- Around line 438-455: Refactor buildManifest and its Finalize/GetManifest
callers so the long-running splitter.Split and related KAO construction do not
execute while w.mu is held. Snapshot segment metadata, dek, and attributes under
the appropriate lock, release the lock for splitting and wrapping, then
reacquire it only to commit w.manifest and w.finalized while preserving
consistency and existing finalize behavior.
- Around line 747-762: Update chunkedWrapKeyWithPublicKey so empty or
unrecognized pk.Algorithm values return an explicit unsupported-algorithm error
instead of falling through to chunkedWrapKeyWithRSA. Preserve the existing KEM
and EC dispatch, and only invoke RSA wrapping when the algorithm is explicitly
recognized as an RSA type.
- Around line 41-65: Align ChunkedWriter with sdk/experimental/tdf.Writer
instead of maintaining a smaller duplicate API: either delegate to or deprecate
the experimental implementation, or document ChunkedWriter as its intended
replacement. Add assertion handling to ChunkedFinalize and GetManifest using the
existing ChunkedFinalizeConfig assertion fields, and preserve attribute-based
K/O configuration plus contiguous-prefix trimming through WithSegments rather
than returning ErrChunkedAssertionsUnsupported.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f266eba3-88cf-43d2-994a-43ac5a66402a

📥 Commits

Reviewing files that changed from the base of the PR and between 88e21dc and 3fab053.

📒 Files selected for processing (7)
  • sdk/archive_writer.go
  • sdk/chunked_options.go
  • sdk/chunked_test.go
  • sdk/chunked_writer.go
  • sdk/clock.go
  • sdk/key_splitter.go
  • sdk/segment.go

Comment thread sdk/chunked_test.go
Comment on lines +116 to +124
func writeChunkedSegments(ctx context.Context, t *testing.T, w ChunkedWriter, segments [][]byte) []byte {
t.Helper()
var body bytes.Buffer
for i, chunk := range segments {
seg, err := w.WriteSegment(ctx, i, chunk)
require.NoError(t, err)
_, err = io.Copy(&body, seg.TDFData)
require.NoError(t, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover out-of-order segment arrival.

This helper hardcodes index order to write order, so the suite never verifies the documented WriteSegment contract for independently supplied, out-of-order segments. Add a round-trip case that writes indices such as 2, 0, 1, appends each returned TDFData in write order, then verifies LoadTDF returns plaintext ordered by segment index. Based on provided upstream contract, indices need not arrive in order and the PR promises independently supplied segments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/chunked_test.go` around lines 116 - 124, Extend the chunked-segment tests
around writeChunkedSegments to add a round-trip case that calls WriteSegment
with indices in out-of-order sequence such as 2, 0, 1, appends each returned
TDFData in that write order, and verifies LoadTDF reconstructs plaintext ordered
by segment index. Keep the existing in-order helper behavior unchanged.

Comment thread sdk/chunked_writer.go
Comment on lines +41 to +65
// ChunkedWriter creates a TDF from segments that may arrive in any
// order. Callers write each segment independently — typically
// off-thread or in parallel — then call Finalize to close the
// archive. Contrast with SDK.CreateTDF, which requires the full
// plaintext up front.
type ChunkedWriter interface {
// Finalize completes TDF creation. Every option applies only to
// this Finalize call; writer-level defaults set at NewChunked*
// remain otherwise. Returns the closing bytes (central directory
// + end-of-central-directory record) that must be appended after
// every segment's TDFData.
Finalize(ctx context.Context, opts ...ChunkedFinalizeOption) (*ChunkedFinalizeResult, error)

// GetManifest returns the manifest for the TDF. Before Finalize
// this is a snapshot built from currently-written segments; after
// Finalize it is the manifest that was written.
GetManifest(ctx context.Context, opts ...ChunkedFinalizeOption) (*Manifest, error)

// WriteSegment encrypts data as segment index and returns the
// ZIP bytes for that segment (local header + nonce + ciphertext).
// Callers upload or buffer those bytes; Finalize does not
// re-emit them. Indices need not arrive in order and need not be
// contiguous.
WriteSegment(ctx context.Context, index int, data []byte) (*ChunkedSegmentResult, error)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether sdk/experimental/tdf exists in this checkout and compare its writer API surface.
fd -t d experimental sdk
fd -t f '.go$' sdk/experimental/tdf 2>/dev/null | xargs -r ast-grep run --pattern 'func $NAME($$$) $$$' --lang go

Repository: opentdf/platform

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== chunked writer relevant symbols =="
ast-grep outline sdk/chunked_writer.go --view compact 2>/dev/null || true
rg -n "type Chunked|func New|func .*Chunked|Finalize|WriteSegment|ErrChunkedAssertionsUnsupported|Segment|Manifest" sdk/chunked_writer.go

echo
echo "== experimental tdf relevant symbols (named/exported and documentation snippets) =="
rg -n "type .*[Ww]riter|func .*[Ww]riter|NewWriter|WriteSegment|Finalize|Segment|LoadTDF|assert|Assert" sdk/experimental/tdf -g '*.go' | head -300

echo
echo "== file sizes =="
wc -l sdk/chunked_writer.go sdk/experimental/tdf/*.go 2>/dev/null | head -60

Repository: opentdf/platform

Length of output: 36816


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== chunked_final options and unsupported assertions path =="
sed -n '150,210p' sdk/chunked_writer.go
sed -n '280,305p' sdk/chunked_writer.go
sed -n '450,530p' sdk/chunked_writer.go
rg -n "ErrChunkedAssertionsUnsupported|FinalizeOption\\(.*Chunked|ChunkedAssertions|WithChunked" sdk/chunked_writer.go

echo
echo "== experimental writer New/Finalize/API and assertions path =="
sed -n '1,140p' sdk/experimental/tdf/writer.go
sed -n '230,350p' sdk/experimental/tdf/writer.go
sed -n '430,520p' sdk/experimental/tdf/options.go
sed -n '264,400p' sdk/experimental/tdf/writer_test.go
sed -n '400,550p' sdk/experimental/tdf/writer_test.go

Repository: opentdf/platform

Length of output: 28135


Coordinate this chunked writer with the existing experimental TDF writer.

sdk/experimental/tdf.Writer already supports streamed/out-of-order segment writing, NewWriter/WriteSegment/Finalize, attribute-based K/O configuration, and contiguous-prefix segment trimming via WithSegments. This stable ChunkedWriter duplicates that API while explicitly unsupported by ErrChunkedAssertionsUnsupported despite ChunkedFinalizeConfig containing assertions fields. Either build from/deprecate the experimental writer or clearly document the intended replacement path and add the missing assertion support so the stable API is not a smaller fork of the same capability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/chunked_writer.go` around lines 41 - 65, Align ChunkedWriter with
sdk/experimental/tdf.Writer instead of maintaining a smaller duplicate API:
either delegate to or deprecate the experimental implementation, or document
ChunkedWriter as its intended replacement. Add assertion handling to
ChunkedFinalize and GetManifest using the existing ChunkedFinalizeConfig
assertion fields, and preserve attribute-based K/O configuration plus
contiguous-prefix trimming through WithSegments rather than returning
ErrChunkedAssertionsUnsupported.

Comment thread sdk/chunked_writer.go
Comment on lines +438 to +455
func (w *chunkedWriter) buildManifest(ctx context.Context, cfg *ChunkedFinalizeConfig) (*Manifest, int64, int64, error) {
order, err := w.segmentOrderLocked(cfg.keepSegments)
if err != nil {
return nil, 0, 0, err
}

splits, err := w.splitter.Split(ctx, cfg.attributes, w.dek, cfg.defaultKAS)
if err != nil {
return nil, 0, 0, err
}
policyBytes, err := buildChunkedPolicy(cfg.attributes)
if err != nil {
return nil, 0, 0, err
}
kaos, err := buildChunkedKeyAccessObjects(splits, policyBytes, cfg.encryptedMetadata)
if err != nil {
return nil, 0, 0, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Holding w.mu across KeySplitter.Split serializes concurrent WriteSegment calls during Finalize/GetManifest.

buildManifest is invoked while Finalize holds w.mu.Lock() (full exclusive lock for the whole function) or GetManifest holds w.mu.RLock(), and it calls w.splitter.Split(ctx, ...) (line 444) inside that critical section. A real KeySplitter implementation resolving attribute-based multi-KAS grants will likely make network calls to the policy/KAS services. Holding the writer's lock for that duration blocks every concurrent WriteSegment call — undermining the stated goal of writing segments "off-thread or in parallel" (see the ChunkedWriter doc comment).

Consider snapshotting the needed state (segment metadata, dek, attributes) under the lock, releasing it, performing Split/KAO wrapping unlocked, then re-acquiring briefly only to commit w.manifest/w.finalized.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/chunked_writer.go` around lines 438 - 455, Refactor buildManifest and its
Finalize/GetManifest callers so the long-running splitter.Split and related KAO
construction do not execute while w.mu is held. Snapshot segment metadata, dek,
and attributes under the appropriate lock, release the lock for splitting and
wrapping, then reacquire it only to commit w.manifest and w.finalized while
preserving consistency and existing finalize behavior.

Comment thread sdk/chunked_writer.go
Comment on lines +747 to +762
// chunkedWrapKeyWithPublicKey dispatches to the right KAS wrapping
// scheme based on the algorithm advertised by the splitter.
func chunkedWrapKeyWithPublicKey(symKey []byte, pk KASPublicKey) (string, string, string, error) {
if pk.PEM == "" {
return "", "", "", fmt.Errorf("public key PEM is empty for kas %s", pk.URL)
}
ktype := ocrypto.KeyType(pk.Algorithm)
switch {
case ocrypto.IsKEMKeyType(ktype):
return chunkedWrapKeyWithKEM(ktype, pk.PEM, symKey)
case ocrypto.IsECKeyType(ktype):
return chunkedWrapKeyWithEC(ktype, pk.PEM, symKey)
default:
return chunkedWrapKeyWithRSA(pk.PEM, symKey)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Default branch silently attempts RSA wrap for unsupported/empty algorithm types.

key_splitter.go's algorithmPolicyToString comment states unknown algorithms return "" and "the caller treats that as 'unsupported algorithm'." But chunkedWrapKeyWithPublicKey's switch has no case for an empty/unrecognized ktype — it silently falls to chunkedWrapKeyWithRSA, which will attempt to parse a non-RSA PEM as an RSA key, producing a confusing low-level parse error instead of a clear "unsupported algorithm" error.

🐛 Proposed fix
 func chunkedWrapKeyWithPublicKey(symKey []byte, pk KASPublicKey) (string, string, string, error) {
 	if pk.PEM == "" {
 		return "", "", "", fmt.Errorf("public key PEM is empty for kas %s", pk.URL)
 	}
 	ktype := ocrypto.KeyType(pk.Algorithm)
 	switch {
+	case ktype == "":
+		return "", "", "", fmt.Errorf("unsupported or unrecognized algorithm for kas %s", pk.URL)
 	case ocrypto.IsKEMKeyType(ktype):
 		return chunkedWrapKeyWithKEM(ktype, pk.PEM, symKey)
 	case ocrypto.IsECKeyType(ktype):
 		return chunkedWrapKeyWithEC(ktype, pk.PEM, symKey)
 	default:
 		return chunkedWrapKeyWithRSA(pk.PEM, symKey)
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// chunkedWrapKeyWithPublicKey dispatches to the right KAS wrapping
// scheme based on the algorithm advertised by the splitter.
func chunkedWrapKeyWithPublicKey(symKey []byte, pk KASPublicKey) (string, string, string, error) {
if pk.PEM == "" {
return "", "", "", fmt.Errorf("public key PEM is empty for kas %s", pk.URL)
}
ktype := ocrypto.KeyType(pk.Algorithm)
switch {
case ocrypto.IsKEMKeyType(ktype):
return chunkedWrapKeyWithKEM(ktype, pk.PEM, symKey)
case ocrypto.IsECKeyType(ktype):
return chunkedWrapKeyWithEC(ktype, pk.PEM, symKey)
default:
return chunkedWrapKeyWithRSA(pk.PEM, symKey)
}
}
// chunkedWrapKeyWithPublicKey dispatches to the right KAS wrapping
// scheme based on the algorithm advertised by the splitter.
func chunkedWrapKeyWithPublicKey(symKey []byte, pk KASPublicKey) (string, string, string, error) {
if pk.PEM == "" {
return "", "", "", fmt.Errorf("public key PEM is empty for kas %s", pk.URL)
}
ktype := ocrypto.KeyType(pk.Algorithm)
switch {
case ktype == "":
return "", "", "", fmt.Errorf("unsupported or unrecognized algorithm for kas %s", pk.URL)
case ocrypto.IsKEMKeyType(ktype):
return chunkedWrapKeyWithKEM(ktype, pk.PEM, symKey)
case ocrypto.IsECKeyType(ktype):
return chunkedWrapKeyWithEC(ktype, pk.PEM, symKey)
default:
return chunkedWrapKeyWithRSA(pk.PEM, symKey)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/chunked_writer.go` around lines 747 - 762, Update
chunkedWrapKeyWithPublicKey so empty or unrecognized pk.Algorithm values return
an explicit unsupported-algorithm error instead of falling through to
chunkedWrapKeyWithRSA. Preserve the existing KEM and EC dispatch, and only
invoke RSA wrapping when the algorithm is explicitly recognized as an RSA type.

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/xl

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant