Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/cmd/benchmark_experimental.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func runExperimentalWriterBenchmark(_ *cobra.Command, _ []string) error {
}

attrs = append(attrs, &policy.Value{Fqn: testAttr, KasKeys: []*policy.SimpleKasKey{simpleyKey}, Attribute: &policy.Attribute{Namespace: &policy.Namespace{Name: "example.com"}, Fqn: testAttr}})
writer, err := tdf.NewWriter(context.Background(), tdf.WithDefaultKASForWriter(simpleyKey), tdf.WithInitialAttributes(attrs), tdf.WithSegmentIntegrityAlgorithm(tdf.SegmentHS256))
writer, err := tdf.NewWriter(context.Background(), tdf.WithDefaultKASForWriter(simpleyKey), tdf.WithInitialAttributes(attrs))
if err != nil {
return fmt.Errorf("failed to create writer: %w", err)
}
Expand Down
2 changes: 2 additions & 0 deletions sdk/experimental/tdf/assertion.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"github.com/opentdf/platform/sdk"
)

// These claim names have no caller here beyond the tests that assert
// against sdk's claim names.
const (
kAssertionSignature = "assertionSig"
kAssertionHash = "assertionHash"
Expand Down
105 changes: 75 additions & 30 deletions sdk/experimental/tdf/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,41 @@
// if err != nil {
// log.Fatal(err)
// }
// defer writer.Close()
//
// // Write data segments (can be out-of-order)
// // Write data segments (can be out-of-order). Keep every SegmentResult:
// // its TDFData carries the bytes that make up the payload.
// data1 := []byte("First segment")
// _, err = writer.WriteSegment(ctx, 0, data1)
// seg0, err := writer.WriteSegment(ctx, 0, data1)
// if err != nil {
// log.Fatal(err)
// }
//
// data2 := []byte("Second segment")
// _, err = writer.WriteSegment(ctx, 1, data2)
// seg1, err := writer.WriteSegment(ctx, 1, data2)
// if err != nil {
// log.Fatal(err)
// }
//
// // Finalize with attributes and options
// finalBytes, manifest, err := writer.Finalize(ctx,
// WithAttributeValues(attributes),
// WithPayloadMimeType("text/plain"),
// WithEncryptedMetadata("sensitive metadata"),
// result, err := writer.Finalize(ctx,
// tdf.WithAttributeValues(attributes),
// tdf.WithPayloadMimeType("text/plain"),
// tdf.WithEncryptedMetadata("sensitive metadata"),
// )
// if err != nil {
// log.Fatal(err)
// }
//
// // Assemble the TDF: each segment's TDFData in ascending index order,
// // then result.Data, which holds only the archive's closing bytes.
// var tdfFile bytes.Buffer
// for _, seg := range []*tdf.SegmentResult{seg0, seg1} {
// if _, err := io.Copy(&tdfFile, seg.TDFData); err != nil {
// log.Fatal(err)
// }
// }
// tdfFile.Write(result.Data)
//
// # Initial Attributes and Default KAS at Writer Creation
//
// Callers can provide initial attributes and a default KAS when constructing
Expand All @@ -70,53 +80,82 @@
// }
// // Later, Finalize without attributes/KAS uses the initial values.
//
// # Segment Overrides at Finalize (Contiguous Prefix)
// # Segment Overrides at Finalize
//
// By default Finalize describes every written segment, ordered by index.
// WithSegments narrows that to a chosen subset.
//
// You can restrict finalization to a contiguous prefix of written segments
// using `WithSegments([]int{0, 1, ..., K})`. Indices must start at 0 with no
// gaps or duplicates, and no segments may have been written beyond K.
// Indices need not be contiguous — a caller mapping S3 multipart uploads
// onto segments might write 0, 1, 5000, 5001 — but the list must name
// written segments in ascending index order and may drop only from the end.
// That is the order a reader concatenates the payload in, so dropping a
// segment from the middle would shift every later segment's offset and
// produce an unreadable TDF.
//
// Dropping a segment from the manifest does not shrink the archive: every
// segment that was actually written — including ones WithSegments excludes
// from the manifest — must still be appended when assembling the final
// file, in ascending index order. Skipping a dropped segment's bytes
// produces an archive whose central directory offsets overshoot, because
// the payload's recorded size and CRC already account for it.
//
// // Write segments 0 and 1
// _, _ = writer.WriteSegment(ctx, 0, []byte("part-0"))
// _, _ = writer.WriteSegment(ctx, 1, []byte("part-1"))
// seg0, _ := writer.WriteSegment(ctx, 0, []byte("part-0"))
// seg1, _ := writer.WriteSegment(ctx, 1, []byte("part-1"))
//
// // Finalize keeping the prefix [0,1]
// finalBytes, manifest, err := writer.Finalize(ctx,
// tdf.WithSegments([]int{0, 1}),
// // Finalize describing only segment 0 in the manifest -- both segments'
// // TDFData must still be assembled, in ascending index order.
// result, err := writer.Finalize(ctx,
// tdf.WithSegments([]int{0}),
// )
// if err != nil {
// log.Fatal(err)
// }
// var tdfFile bytes.Buffer
// io.Copy(&tdfFile, seg0.TDFData)
// io.Copy(&tdfFile, seg1.TDFData)
// tdfFile.Write(result.Data)
//
// If all segments should be kept, `WithSegments([0..N-1])` is equivalent to
// the default behavior and is optional.
// Keeping every written segment is the default, so passing the full list is
// optional.
//
// # Advanced Features
//
// The package supports advanced TDF features including:
//
// - Custom cryptographic assertions with JWT-based integrity
// - Encrypted metadata storage within key access objects
// - Segment integrity algorithm support (HS256, GMAC); the root signature is
// HS256 only
// - Integrity: GMAC segment hashes under an HS256 root signature
// - ZIP64 format support for large files
// - Memory-optimized segment processing
//
// # Architecture
//
// The TDF writer uses a two-layer architecture:
// The TDF writer uses a three-layer architecture:
//
// 1. TDF Layer (tdf.Writer): Handles encryption, assertions, and TDF protocol logic
// 2. Archive Layer (internal/zipstream): Manages ZIP file structure and segment assembly
// 1. Adapter Layer (tdf.Writer): Maps this package's options onto the stable
// writer and supplies multi-KAS ABAC key splitting
// 2. TDF Layer (sdk.ChunkedWriter): Handles encryption, assertions, and TDF
// protocol logic
// 3. Archive Layer (internal/zipstream): Manages ZIP file structure and
// segment assembly
//
// This separation enables independent optimization of cryptographic operations
// and file format handling.
// and file format handling. Callers who need only a single KAS can skip layer
// one and use [github.com/opentdf/platform/sdk.NewChunkedWriter] directly.
//
// # Thread Safety
//
// Writers are safe for concurrent use with proper external synchronization.
// Individual WriteSegment calls must be serialized, but multiple writers
// can operate independently.
// A Writer is safe for concurrent use. [Writer.WriteSegment] may be called
// from several goroutines at once so long as each targets a distinct segment
// index; two concurrent calls for the same index are not allowed, and one of
// them will fail rather than corrupt the archive.
//
// A successful Finalize is terminal and takes an exclusive lock, so it must
// not overlap with any in-flight WriteSegment call. A refusal before the
// archive is touched, such as ErrChunkedWriteInFlight, leaves the writer
// usable; resolve the cause and retry. An archive failure after mutation or
// a close failure leaves the writer unusable.
//
// # Performance Characteristics
//
Expand Down Expand Up @@ -158,6 +197,12 @@
// - ErrAlreadyFinalized: Writer has been finalized
// - ErrInvalidSegmentIndex: Invalid segment index provided
// - ErrSegmentAlreadyWritten: Duplicate segment index
//
// All errors include sufficient context for debugging and recovery.
// - ErrMissingSegmentZero: Finalize called without segment 0
// - ErrUnsupportedRootIntegrityAlgorithm, ErrUnsupportedSegmentIntegrityAlgorithm:
// NewWriter was asked for an algorithm other than the default
//
// All of the above are aliases of the stable SDK's error values, so
// errors.Is matches whether a caller compares against the experimental or
// the sdk-scoped name. All errors include sufficient context for debugging
// and recovery.
package tdf
6 changes: 4 additions & 2 deletions sdk/experimental/tdf/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,11 @@ func ExampleWriter_outOfOrder() {
func ExampleWriter_largeFile() {
ctx := context.Background()

// Configure for large file processing
// Configure for large file processing. GMAC is the default and the only
// supported segment algorithm; naming it costs nothing per segment because
// the tag is read out of ciphertext the cipher already produced.
writer, err := tdf.NewWriter(ctx,
tdf.WithSegmentIntegrityAlgorithm(tdf.SegmentGMAC), // Faster for many segments
tdf.WithSegmentIntegrityAlgorithm(tdf.SegmentGMAC),
)
if err != nil {
log.Println(err)
Expand Down
110 changes: 25 additions & 85 deletions sdk/experimental/tdf/integrity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
package tdf

import (
"crypto/rand"
"testing"

"github.com/opentdf/platform/lib/ocrypto"
"github.com/opentdf/platform/protocol/go/policy"
"github.com/opentdf/platform/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -39,61 +38,31 @@ func TestDeprecatedIntegrityAlgorithmConstants(t *testing.T) {
assert.Equal(t, SegmentGMAC, SegmentIntegrityAlg(GMAC))
}

// Both segment algorithms are real authenticators, because the input is data
// the cipher produced. An out-of-range value is not, and has to be refused
// rather than quietly treated as GMAC.
func TestSegmentIntegrity(t *testing.T) {
key := make([]byte, kKeySize)
_, err := rand.Read(key)
require.NoError(t, err)

data := make([]byte, kGMACPayloadLength*4)
_, err = rand.Read(data)
require.NoError(t, err)

hs256, err := segmentIntegrity(data, key, SegmentHS256)
require.NoError(t, err)
assert.Equal(t, string(ocrypto.CalculateSHA256Hmac(key, data)), hs256)

gmac, err := segmentIntegrity(data, key, SegmentGMAC)
require.NoError(t, err)
assert.Equal(t, string(data[len(data)-kGMACPayloadLength:]), gmac)

for _, alg := range []SegmentIntegrityAlg{SegmentIntegrityAlg(99), SegmentIntegrityAlg(-1)} {
_, err := segmentIntegrity(data, key, alg)
require.ErrorIs(t, err, ErrUnsupportedSegmentIntegrityAlgorithm, "alg %d", alg)
}
}

// The aggregate hash never passed through the AEAD, so tag extraction over it
// authenticates nothing -- it returns a copy of the last segment hash, which is
// manifest data an attacker already controls.
func TestRootIntegrityRejectsNonHS256(t *testing.T) {
key := make([]byte, kKeySize)
_, err := rand.Read(key)
require.NoError(t, err)

aggregate := make([]byte, kGMACPayloadLength*4)
_, err = rand.Read(aggregate)
require.NoError(t, err)
// Option cannot return an error, so NewWriter is where an algorithm the
// delegate cannot produce has to be caught -- silently substituting one would
// write a manifest that disagrees with what the caller asked for.
//
// ErrUnsupportedRootIntegrityAlgorithm and ErrUnsupportedSegmentIntegrityAlgorithm
// are aliases of the sdk-scoped errors (see manifest.go), so both names must
// match the same error.
func TestNewWriterRejectsUnproducibleAlgorithms(t *testing.T) {
ctx := t.Context()

for _, alg := range []RootIntegrityAlg{RootIntegrityAlg(GMAC), RootIntegrityAlg(99), RootIntegrityAlg(-1)} {
_, err := rootIntegrity(aggregate, key, alg)
require.ErrorIs(t, err, ErrUnsupportedRootIntegrityAlgorithm, "alg %d", alg)
}
_, err := NewWriter(ctx, WithIntegrityAlgorithm(RootIntegrityAlg(GMAC)))
require.ErrorIs(t, err, sdk.ErrUnsupportedRootIntegrityAlgorithm)
require.ErrorIs(t, err, ErrUnsupportedRootIntegrityAlgorithm)

sig, err := rootIntegrity(aggregate, key, RootHS256)
require.NoError(t, err)
assert.Equal(t, string(ocrypto.CalculateSHA256Hmac(key, aggregate)), sig)
_, err = NewWriter(ctx, WithSegmentIntegrityAlgorithm(SegmentHS256))
require.ErrorIs(t, err, sdk.ErrUnsupportedSegmentIntegrityAlgorithm)
require.ErrorIs(t, err, ErrUnsupportedSegmentIntegrityAlgorithm)
}

// Option cannot return an error, so Finalize is the only place an illegal root
// algorithm can be caught -- and it must be, or the writer produces a file no
// conforming reader will accept.
func TestFinalizeRejectsGMACRoot(t *testing.T) {
// The defaults are the only algorithms the writer emits, so a manifest names
// them whether or not the caller asked.
func TestFinalizeNamesWhatItSigned(t *testing.T) {
ctx := t.Context()

writer, err := NewWriter(ctx, WithIntegrityAlgorithm(RootIntegrityAlg(GMAC)))
writer, err := NewWriter(ctx)
require.NoError(t, err)

_, err = writer.WriteSegment(ctx, 0, []byte("Confidential business information"))
Expand All @@ -102,39 +71,10 @@ func TestFinalizeRejectsGMACRoot(t *testing.T) {
attributes := []*policy.Value{
createTestAttribute("https://example.com/attr/Category/value/Financial", testKAS1, "kid1"),
}
_, err = writer.Finalize(ctx, WithAttributeValues(attributes))
require.ErrorIs(t, err, ErrUnsupportedRootIntegrityAlgorithm)
}

// Both segment algorithms round-trip through Finalize, and neither disturbs the
// root, which stays HS256 whatever the segments declare.
func TestFinalizeSegmentAlgNamesWhatItSigned(t *testing.T) {
for _, tc := range []struct {
name string
alg SegmentIntegrityAlg
want string
}{
{"hs256", SegmentHS256, algHS256},
{"gmac", SegmentGMAC, algGMAC},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := t.Context()

writer, err := NewWriter(ctx, WithSegmentIntegrityAlgorithm(tc.alg))
require.NoError(t, err)

_, err = writer.WriteSegment(ctx, 0, []byte("Confidential business information"))
require.NoError(t, err)

attributes := []*policy.Value{
createTestAttribute("https://example.com/attr/Category/value/Financial", testKAS1, "kid1"),
}
result, err := writer.Finalize(ctx, WithAttributeValues(attributes))
require.NoError(t, err)
result, err := writer.Finalize(ctx, WithAttributeValues(attributes))
require.NoError(t, err)

intInfo := result.Manifest.IntegrityInformation
assert.Equal(t, tc.want, intInfo.SegmentHashAlgorithm)
assert.Equal(t, algHS256, intInfo.Algorithm, "root stays HS256 regardless of the segment algorithm")
})
}
intInfo := result.Manifest.IntegrityInformation
assert.Equal(t, algGMAC, intInfo.SegmentHashAlgorithm)
assert.Equal(t, algHS256, intInfo.Algorithm)
}
Loading
Loading