From 1bdc0fd74b1dd9623d2ab09f6d67296bbdcf7274 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Wed, 14 Oct 2026 14:45:03 -0400 Subject: [PATCH 1/3] feat(sdk): add opt-in spec-compliant policy binding, re-encoded for legacy KAS The spec defines a key access object's policy binding hash as Base64(HMAC). Every SDK writes Base64(hex(HMAC)) instead, and no KAS released before the dual-accept fix can verify the spec's form: older ones always hex-decode it, later ones compare an untrimmed decode buffer. Flipping the writer outright breaks decryption against every deployed KAS. Keep the legacy form as the default and add WithSpecCompliantPolicyBinding (and chunked and experimental/tdf equivalents) to write the spec's form. A pre-4.3.0 target mode overrides it. On read, re-encode a 32-byte binding to the legacy form in the rewrap request. Every KAS verifies that form, so a TDF carrying the spec's binding decrypts through this reader against any KAS. Older readers forward the binding unchanged and still need a KAS with the fix, which is why the default stays legacy until fixed KAS are broadly deployed. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Paul Flynn --- sdk/chunked_options.go | 14 ++++++ sdk/chunked_test.go | 70 +++++++++++++++++++++++++++-- sdk/chunked_writer.go | 12 ++++- sdk/experimental/tdf/options.go | 19 ++++++++ sdk/experimental/tdf/writer.go | 8 +++- sdk/experimental/tdf/writer_test.go | 42 +++++++++++++++++ sdk/key_splitter.go | 17 ++++--- sdk/tdf.go | 35 ++++++++++++--- sdk/tdf_config.go | 17 +++++++ sdk/tdf_helpers_test.go | 56 +++++++++++++++++++---- sdk/tdf_test.go | 42 +++++++++++++++++ 11 files changed, 307 insertions(+), 25 deletions(-) diff --git a/sdk/chunked_options.go b/sdk/chunked_options.go index 9eee293a0c..a9dcd37cbb 100644 --- a/sdk/chunked_options.go +++ b/sdk/chunked_options.go @@ -213,6 +213,20 @@ func WithChunkedTargetMode(mode string) ChunkedWriterOption { } } +// WithChunkedSpecCompliantPolicyBinding writes each key access object's +// policy binding hash as Base64(HMAC), the form the TDF spec defines, +// instead of the legacy Base64(hex(HMAC)). See +// WithSpecCompliantPolicyBinding for the compatibility caveat. A +// pre-4.3.0 WithChunkedTargetMode overrides this option. +// +// Experimental: not part of the stable SDK API; may change or be removed. +func WithChunkedSpecCompliantPolicyBinding() ChunkedWriterOption { + return func(c *chunkedWriterConfig) error { + c.specCompliantPolicyBinding = true + return nil + } +} + // WithChunkedMimeType records the payload MIME type in the manifest. // // Experimental: not part of the stable SDK API; may change or be removed. diff --git a/sdk/chunked_test.go b/sdk/chunked_test.go index 76ed0118aa..3cf3b60f43 100644 --- a/sdk/chunked_test.go +++ b/sdk/chunked_test.go @@ -6,6 +6,7 @@ import ( "context" "crypto/ecdsa" "crypto/rand" + "crypto/sha256" "crypto/subtle" "crypto/x509" "encoding/hex" @@ -640,7 +641,9 @@ func (k *chunkedFakeKAS) Rewrap(_ context.Context, in *connect.Request[kaspb.Rew // verifyChunkedPolicyBinding recomputes the HMAC a KAS checks before it will // release a key share, and compares it constant-time to the one the KAO -// carries. +// carries. It accepts only the legacy Base64(hex(HMAC)) form, like every +// released KAS, so a reader that forwards a spec-compliant binding unchanged +// fails here as it would against a deployed KAS. func verifyChunkedPolicyBinding(binding *kaspb.PolicyBinding, share []byte, base64Policy string) error { if binding == nil { return errors.New("policy binding missing") @@ -746,7 +749,7 @@ func TestChunkedECKeyAccess(t *testing.T) { }}, }} - kaos, err := buildKeyAccessObjects(shares, `{"uuid":"test"}`, "") + kaos, err := buildKeyAccessObjects(shares, `{"uuid":"test"}`, "", false) require.NoError(t, err) require.Len(t, kaos, 1) @@ -812,7 +815,7 @@ func TestChunkedKeyAccessRejectsShareWithNoKAS(t *testing.T) { {id: "orphaned", data: dek}, } - _, err = buildKeyAccessObjects(shares, `{"uuid":"test"}`, "") + _, err = buildKeyAccessObjects(shares, `{"uuid":"test"}`, "", false) require.Error(t, err, "a share with no KAS to unwrap it makes the DEK unrecoverable") assert.Contains(t, err.Error(), "orphaned", "the error must name the split that cannot be recovered") } @@ -912,6 +915,67 @@ func TestChunkedCurrentTargetMode(t *testing.T) { assert.Equal(t, []byte("current"), plain) } +// TestChunkedPolicyBindingEncoding pins which policy binding form each +// configuration writes, and that every form decrypts through a KAS that only +// verifies the legacy one -- as every released KAS does. The spec-compliant +// case passes only because the reader re-encodes the binding in its rewrap +// request. +func TestChunkedPolicyBindingEncoding(t *testing.T) { + tests := []struct { + name string + opts []ChunkedWriterOption + // wantLen is the binding's length once base64-decoded: 64 for the + // legacy Base64(hex(HMAC)), 32 for the spec's Base64(HMAC). + wantLen int + }{ + { + name: "default stays legacy", + wantLen: 2 * sha256.Size, + }, + { + name: "spec-compliant opt-in", + opts: []ChunkedWriterOption{WithChunkedSpecCompliantPolicyBinding()}, + wantLen: sha256.Size, + }, + { + name: "legacy target mode overrides opt-in", + opts: []ChunkedWriterOption{WithChunkedSpecCompliantPolicyBinding(), WithChunkedTargetMode("4.2.2")}, + wantLen: 2 * sha256.Size, + }, + { + name: "legacy target mode overrides opt-in in either order", + opts: []ChunkedWriterOption{WithChunkedTargetMode("4.2.2"), WithChunkedSpecCompliantPolicyBinding()}, + wantLen: 2 * sha256.Size, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + s := newChunkedTestSDK(t) + writer, kasBundle := newChunkedWriterForTest(ctx, t, tt.opts...) + + body := writeChunkedSegments(ctx, t, writer, [][]byte{[]byte("bound")}) + fin, err := writer.Finalize(ctx) + require.NoError(t, err) + + require.Len(t, fin.Manifest.KeyAccessObjs, 1) + binding, ok := fin.Manifest.KeyAccessObjs[0].PolicyBinding.(PolicyBinding) + require.True(t, ok, "policy binding should be a PolicyBinding, got %T", fin.Manifest.KeyAccessObjs[0].PolicyBinding) + decoded, err := ocrypto.Base64Decode([]byte(binding.Hash)) + require.NoError(t, err) + assert.Len(t, decoded, tt.wantLen) + + reader, err := s.LoadTDF(bytes.NewReader(bytes.Join([][]byte{body, fin.Data}, nil)), + WithKasAllowlist([]string{kasBundle.url}), + ) + require.NoError(t, err) + plain, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, []byte("bound"), plain) + }) + } +} + // TestChunkedTargetModeInvalid rejects a non-semver target mode at // construction rather than at Finalize. func TestChunkedTargetModeInvalid(t *testing.T) { diff --git a/sdk/chunked_writer.go b/sdk/chunked_writer.go index 8599ac0486..c5868abe57 100644 --- a/sdk/chunked_writer.go +++ b/sdk/chunked_writer.go @@ -398,6 +398,12 @@ type chunkedWriterConfig struct { // which is right when every segment is the same length. segmentSize int64 + // specCompliantPolicyBinding writes policy bindings in the spec's + // Base64(HMAC) form rather than the legacy Base64(hex(HMAC)). Ignored + // when keyAccess is set or useHex is. Set by + // WithChunkedSpecCompliantPolicyBinding. + specCompliantPolicyBinding bool + // splitter maps attribute values to DEK splits at Finalize time. // Defaults to DefaultKeySplitter (single-KAS only). Ignored when // keyAccess is set. @@ -605,7 +611,11 @@ func newChunkedWriter(cfg chunkedWriterConfig) (*chunkedWriter, error) { } keyAccess := cfg.keyAccess if keyAccess == nil { - keyAccess = splitterKeyAccess{splitter: cfg.splitter} + keyAccess = splitterKeyAccess{ + splitter: cfg.splitter, + // A pre-4.3.0 target keeps the legacy binding, as on the CreateTDF path. + specCompliantPolicyBinding: cfg.specCompliantPolicyBinding && !cfg.useHex, + } } maxEncryptions := cfg.maxEncryptions if maxEncryptions == 0 { diff --git a/sdk/experimental/tdf/options.go b/sdk/experimental/tdf/options.go index d674e43536..3daf3ed253 100644 --- a/sdk/experimental/tdf/options.go +++ b/sdk/experimental/tdf/options.go @@ -139,6 +139,10 @@ type WriterConfig struct { // targetMode is the TDF spec version to write for, as semver. // Empty selects the current format. See WithTargetMode. targetMode string + + // specCompliantPolicyBinding writes policy bindings in the spec's + // Base64(HMAC) form. See WithSpecCompliantPolicyBinding. + specCompliantPolicyBinding bool } // ReaderConfig contains configuration options for TDF Reader creation. @@ -413,6 +417,21 @@ func WithTargetMode(mode string) Option[*WriterConfig] { } } +// WithSpecCompliantPolicyBinding writes each key access object's policy +// binding hash as Base64(HMAC), the form the TDF spec defines, instead of +// the legacy Base64(hex(HMAC)) written by default. +// +// Only a KAS carrying the policy binding dual-accept fix can verify the +// spec's form directly. The SDK's reader re-encodes it for older KAS, but +// other and older readers forward it unchanged, so a TDF written with this +// option may not decrypt through an older reader against an older KAS. A +// pre-4.3.0 [WithTargetMode] overrides this option. +func WithSpecCompliantPolicyBinding() Option[*WriterConfig] { + return func(c *WriterConfig) { + c.specCompliantPolicyBinding = true + } +} + // WithAssertions includes cryptographic assertions in the TDF. // // Assertions provide additional integrity verification and can include diff --git a/sdk/experimental/tdf/writer.go b/sdk/experimental/tdf/writer.go index eda10e5a9a..b46750c47b 100644 --- a/sdk/experimental/tdf/writer.go +++ b/sdk/experimental/tdf/writer.go @@ -137,12 +137,16 @@ func NewWriter(ctx context.Context, opts ...Option[*WriterConfig]) (*Writer, err return nil, fmt.Errorf("%w: %s", sdk.ErrUnsupportedSegmentIntegrityAlgorithm, config.segmentIntegrityAlg) } - inner, err := sdk.NewChunkedWriter(ctx, + chunkedOpts := []sdk.ChunkedWriterOption{ sdk.WithChunkedInitialAttributes(config.initialAttributes), sdk.WithChunkedDefaultKAS(config.initialDefaultKAS), sdk.WithChunkedKeySplitter(xorSplitter{}), sdk.WithChunkedTargetMode(config.targetMode), - ) + } + if config.specCompliantPolicyBinding { + chunkedOpts = append(chunkedOpts, sdk.WithChunkedSpecCompliantPolicyBinding()) + } + inner, err := sdk.NewChunkedWriter(ctx, chunkedOpts...) if err != nil { return nil, err } diff --git a/sdk/experimental/tdf/writer_test.go b/sdk/experimental/tdf/writer_test.go index 23da5cfcea..dc4e7ed745 100644 --- a/sdk/experimental/tdf/writer_test.go +++ b/sdk/experimental/tdf/writer_test.go @@ -5,6 +5,8 @@ package tdf import ( "bytes" "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "io" @@ -63,6 +65,7 @@ func TestWriterEndToEnd(t *testing.T) { {"HybridXWingFlow", testHybridXWingFlow}, {"HybridP256MLKEM768Flow", testHybridP256MLKEM768Flow}, {"HybridP384MLKEM1024Flow", testHybridP384MLKEM1024Flow}, + {"PolicyBindingEncoding", testPolicyBindingEncoding}, } for _, tc := range testCases { @@ -324,6 +327,45 @@ func testBasicTDFCreationFlow(t *testing.T) { assert.NotEmpty(t, finalizeResult.Manifest.Policy, "Policy should not be empty") } +// testPolicyBindingEncoding checks the policy binding stays the legacy +// Base64(hex(HMAC)) by default and becomes the spec's Base64(HMAC) on opt-in, +// unless a pre-4.3.0 target mode asks for legacy output. +func testPolicyBindingEncoding(t *testing.T) { + for _, tc := range []struct { + name string + opts []Option[*WriterConfig] + wantLen int + }{ + {name: "default stays legacy", wantLen: 2 * sha256.Size}, + {name: "spec-compliant opt-in", opts: []Option[*WriterConfig]{WithSpecCompliantPolicyBinding()}, wantLen: sha256.Size}, + { + name: "legacy target mode overrides opt-in", + opts: []Option[*WriterConfig]{WithSpecCompliantPolicyBinding(), WithTargetMode("4.2.2")}, + wantLen: 2 * sha256.Size, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + writer, err := NewWriter(ctx, tc.opts...) + require.NoError(t, err) + _, err = writer.WriteSegment(ctx, 0, []byte("bound")) + require.NoError(t, err) + + fin, err := writer.Finalize(ctx, WithAttributeValues([]*policy.Value{ + createTestAttribute("https://example.com/attr/Classification/value/Public", testKAS1, "kid1"), + })) + require.NoError(t, err) + + require.Len(t, fin.Manifest.KeyAccessObjs, 1) + binding, ok := fin.Manifest.KeyAccessObjs[0].PolicyBinding.(PolicyBinding) + require.True(t, ok, "policy binding should be a PolicyBinding, got %T", fin.Manifest.KeyAccessObjs[0].PolicyBinding) + decoded, err := base64.StdEncoding.DecodeString(binding.Hash) + require.NoError(t, err) + assert.Len(t, decoded, tc.wantLen) + }) + } +} + // testSingleSegmentWithAttributes tests TDF creation with attribute-based key splitting func testSingleSegmentWithAttributes(t *testing.T) { ctx := t.Context() diff --git a/sdk/key_splitter.go b/sdk/key_splitter.go index 232e4802ff..e104dd8890 100644 --- a/sdk/key_splitter.go +++ b/sdk/key_splitter.go @@ -354,6 +354,10 @@ func (r staticKeyAccess) resolve(_ context.Context, _ []byte, _ *chunkedFinalize type splitterKeyAccess struct { // splitter maps attributes plus the DEK onto KAS-addressed shares. splitter KeySplitter + + // specCompliantPolicyBinding writes policy bindings in the spec's + // Base64(HMAC) form; see createPolicyBinding. + specCompliantPolicyBinding bool } func (r splitterKeyAccess) resolve(ctx context.Context, dek []byte, cfg *chunkedFinalizeConfig) (string, []KeyAccess, error) { @@ -410,14 +414,14 @@ func (r splitterKeyAccess) resolve(ctx context.Context, dek []byte, cfg *chunked for _, v := range cfg.attributes { fqns = append(fqns, v.GetFqn()) } - return resolvePolicyAndKeyAccess(fqns, shares, cfg.encryptedMetadata) + return resolvePolicyAndKeyAccess(fqns, shares, cfg.encryptedMetadata, r.specCompliantPolicyBinding) } // resolvePolicyAndKeyAccess builds the policy document the DEK is bound to and wraps // every share to its KAS targets, returning the two manifest fields that bind a DEK to // policy. Shared by SDK.CreateTDF's KAO template path and the chunked writer's // KeySplitter path, so both emit byte-identical policy for the same attributes. -func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata string) (string, []KeyAccess, error) { +func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata string, specCompliantPolicyBinding bool) (string, []KeyAccess, error) { policyObj, err := createPolicyObjectFromFQNs(fqns) if err != nil { return "", nil, fmt.Errorf("fail to create policy object:%w", err) @@ -428,7 +432,7 @@ func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata stri } base64Policy := string(ocrypto.Base64Encode(policyObjectAsStr)) - kaos, err := buildKeyAccessObjects(shares, base64Policy, metadata) + kaos, err := buildKeyAccessObjects(shares, base64Policy, metadata, specCompliantPolicyBinding) if err != nil { return "", nil, err } @@ -436,8 +440,9 @@ func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata stri } // buildKeyAccessObjects wraps every share to each of its KAS targets, -// emitting the manifest's keyAccess array in share order. -func buildKeyAccessObjects(shares []splitShare, base64Policy, metadata string) ([]KeyAccess, error) { +// emitting the manifest's keyAccess array in share order. See +// createPolicyBinding for specCompliantPolicyBinding. +func buildKeyAccessObjects(shares []splitShare, base64Policy, metadata string, specCompliantPolicyBinding bool) ([]KeyAccess, error) { var kaos []KeyAccess for _, share := range shares { // A share with no KAS target contributes no key access object, @@ -455,7 +460,7 @@ func buildKeyAccessObjects(shares []splitShare, base64Policy, metadata string) ( // Policy binding and metadata are keyed on the split share, not // on the KAS, so compute them once per share rather than once // per KAS URL in an OR-group. - policyBinding := createPolicyBinding(share.data, base64Policy) + policyBinding := createPolicyBinding(share.data, base64Policy, specCompliantPolicyBinding) var encryptedMetadata string if metadata != "" { diff --git a/sdk/tdf.go b/sdk/tdf.go index 5d2003f585..833a554e5d 100644 --- a/sdk/tdf.go +++ b/sdk/tdf.go @@ -583,7 +583,10 @@ func (s SDK) resolveKeyAccess(ctx context.Context, tdfConfig *TDFConfig, dek []b for _, attribute := range tdfConfig.attributes { fqns = append(fqns, attribute.String()) } - return resolvePolicyAndKeyAccess(fqns, shares, tdfConfig.metaData) + // A pre-4.3.0 target keeps the legacy binding: it asks for output that legacy + // readers and KAS accept, whatever else was requested. + specCompliantPolicyBinding := tdfConfig.specCompliantPolicyBinding && !tdfConfig.useHex + return resolvePolicyAndKeyAccess(fqns, shares, tdfConfig.metaData, specCompliantPolicyBinding) } // templateSplitShares groups the KAO template by split ID and divides the DEK across @@ -654,11 +657,19 @@ func manifestSegmentIntegrityAlg(alg string) SegmentIntegrityAlg { // createPolicyBinding binds a key split to the policy it unlocks, keyed on the split // itself so that a KAS can verify the policy it is asked to enforce is the one the // creator bound. -func createPolicyBinding(symKey []byte, base64Policy string) PolicyBinding { - policyBindingHash := hex.EncodeToString(ocrypto.CalculateSHA256Hmac(symKey, []byte(base64Policy))) +// +// The spec defines the hash as Base64(HMAC), which specCompliant selects. Writers have +// always emitted Base64(hex(HMAC)) instead, and no KAS released before the dual-accept +// fix can verify the spec's form, so the legacy form stays the default until fixed KAS +// are broadly deployed. +func createPolicyBinding(symKey []byte, base64Policy string, specCompliant bool) PolicyBinding { + mac := ocrypto.CalculateSHA256Hmac(symKey, []byte(base64Policy)) + if !specCompliant { + mac = []byte(hex.EncodeToString(mac)) + } return PolicyBinding{ Alg: hmacIntegrityAlgorithm, - Hash: string(ocrypto.Base64Encode([]byte(policyBindingHash))), + Hash: string(ocrypto.Base64Encode(mac)), } } @@ -1324,6 +1335,20 @@ func (r *Reader) UnsafePayloadKeyRetrieval() ([]byte, error) { return r.payloadKey, nil } +// legacyPolicyBindingHash returns a policy binding hash as the legacy Base64(hex(HMAC)) +// for a rewrap request. Every KAS verifies that form, but none released before the +// dual-accept fix can verify the spec's Base64(HMAC): older ones always hex-decode it, +// later ones compare an untrimmed decode buffer. Re-encoding the spec's 32-byte form +// lets a TDF that carries it decrypt through any of them. Anything else is passed +// through unchanged for the KAS to judge. +func legacyPolicyBindingHash(hash string) string { + mac, err := ocrypto.Base64Decode([]byte(hash)) + if err != nil || len(mac) != sha256.Size { + return hash + } + return string(ocrypto.Base64Encode([]byte(hex.EncodeToString(mac)))) +} + func createRewrapRequest(_ context.Context, r *Reader) (map[string]*kas.UnsignedRewrapRequest_WithPolicyRequest, error) { kasReqs := make(map[string]*kas.UnsignedRewrapRequest_WithPolicyRequest) for i, kao := range r.manifest.KeyAccessObjs { @@ -1362,7 +1387,7 @@ func createRewrapRequest(_ context.Context, r *Reader) (map[string]*kas.Unsigned Kid: kao.KID, Protocol: kao.Protocol, PolicyBinding: &kas.PolicyBinding{ - Hash: hash, + Hash: legacyPolicyBindingHash(hash), Algorithm: alg, }, SplitId: kao.SplitID, diff --git a/sdk/tdf_config.go b/sdk/tdf_config.go index 7749c2ab91..019ab7abaa 100644 --- a/sdk/tdf_config.go +++ b/sdk/tdf_config.go @@ -154,6 +154,7 @@ type TDFConfig struct { preferredKeyWrapAlg ocrypto.KeyType useHex bool excludeVersionFromManifest bool + specCompliantPolicyBinding bool addDefaultAssertion bool // maxSegmentEncryptions overrides maxAESGCMSegmentsPerDEK when non-zero. // Only tests set it, through withMaxSegmentEncryptions. @@ -355,6 +356,22 @@ func WithTargetMode(mode string) TDFOption { } } +// WithSpecCompliantPolicyBinding writes each key access object's policy binding +// hash as Base64(HMAC), the form the TDF spec defines, instead of the legacy +// Base64(hex(HMAC)) written by default. +// +// Only a KAS carrying the policy binding dual-accept fix can verify the spec's +// form directly. This SDK's reader re-encodes it for older KAS, but other and +// older readers forward it unchanged, so a TDF written with this option may not +// decrypt through an older reader against an older KAS. A pre-4.3.0 +// WithTargetMode overrides this option and keeps the legacy form. +func WithSpecCompliantPolicyBinding() TDFOption { + return func(c *TDFConfig) error { + c.specCompliantPolicyBinding = true + return nil + } +} + // Schema Validation where 0 = none (skip), 1 = lax (allowing novel entries, 'falsy' values for unkowns), 2 = strict (rejecting novel entries, strict match to manifest schema) type SchemaValidationIntensity int diff --git a/sdk/tdf_helpers_test.go b/sdk/tdf_helpers_test.go index cdcd090b17..c93963bc20 100644 --- a/sdk/tdf_helpers_test.go +++ b/sdk/tdf_helpers_test.go @@ -123,24 +123,39 @@ func TestCreatePolicyBinding(t *testing.T) { policyJSON := `{"uuid":"test","body":{"dataAttributes":[{"attribute":"test"}],"dissem":[]}}` - // The wire format is base64(hex(hmac)), and KAS decodes in that order. The + // The default form is base64(hex(hmac)), and KAS decodes in that order. The // hex layer is easy to drop in a rewrite: every property below still holds - // without it, but every KAS would reject the result. Pin it to a vector. + // without it, but every released KAS would reject the result. Pin it to a + // vector. t.Run("known answer", func(t *testing.T) { fixedKey := make([]byte, kKeySize) for i := range fixedKey { fixedKey[i] = byte(i) } - binding := createPolicyBinding(fixedKey, string(ocrypto.Base64Encode([]byte(`{"uuid":"test"}`)))) + binding := createPolicyBinding(fixedKey, string(ocrypto.Base64Encode([]byte(`{"uuid":"test"}`))), false) assert.Equal(t, "YzFjZTM3OWQ0Y2FiMTZkNmRhNzJkYjllYWQ2NGQ3Y2I0Y2E5YmRhY2FiOGMwNjg1ZmY5MmUzZjc0YWEyYzEyZA==", binding.Hash) }) + // The spec's form is Base64(HMAC), without the hex layer. Same key and + // policy as the vector above, so both decode to the same 32-byte HMAC. + t.Run("known answer, spec-compliant", func(t *testing.T) { + fixedKey := make([]byte, kKeySize) + for i := range fixedKey { + fixedKey[i] = byte(i) + } + + binding := createPolicyBinding(fixedKey, string(ocrypto.Base64Encode([]byte(`{"uuid":"test"}`))), true) + + assert.Equal(t, hmacIntegrityAlgorithm, binding.Alg) + assert.Equal(t, "wc43nUyrFtbactuerWTXy0ypvayrjAaF/5Lj90qiwS0=", binding.Hash) + }) + t.Run("binds with HS256 over base64 policy", func(t *testing.T) { - binding := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(policyJSON)))) + binding := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(policyJSON))), false) assert.Equal(t, hmacIntegrityAlgorithm, binding.Alg) require.NotEmpty(t, binding.Hash) @@ -149,8 +164,8 @@ func TestCreatePolicyBinding(t *testing.T) { }) t.Run("different policies bind differently", func(t *testing.T) { - b1 := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(`{"policy":"test1"}`)))) - b2 := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(`{"policy":"test2"}`)))) + b1 := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(`{"policy":"test1"}`))), false) + b2 := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(`{"policy":"test2"}`))), false) assert.NotEqual(t, b1.Hash, b2.Hash) }) @@ -161,8 +176,33 @@ func TestCreatePolicyBinding(t *testing.T) { policy := string(ocrypto.Base64Encode([]byte(policyJSON))) assert.NotEqual(t, - createPolicyBinding(symKey, policy).Hash, - createPolicyBinding(otherKey, policy).Hash, + createPolicyBinding(symKey, policy, false).Hash, + createPolicyBinding(otherKey, policy, false).Hash, ) }) } + +func TestLegacyPolicyBindingHash(t *testing.T) { + const ( + // The two encodings of one HMAC; see TestCreatePolicyBinding. + raw = "wc43nUyrFtbactuerWTXy0ypvayrjAaF/5Lj90qiwS0=" + legacy = "YzFjZTM3OWQ0Y2FiMTZkNmRhNzJkYjllYWQ2NGQ3Y2I0Y2E5YmRhY2FiOGMwNjg1ZmY5MmUzZjc0YWEyYzEyZA==" + ) + + tests := []struct { + name string + in string + want string + }{ + {name: "spec-compliant binding is re-encoded", in: raw, want: legacy}, + {name: "legacy binding passes through", in: legacy, want: legacy}, + {name: "invalid base64 passes through", in: "not!base64", want: "not!base64"}, + {name: "other lengths pass through", in: "AAAAAAAAAAAAAAAAAAAAAA==", want: "AAAAAAAAAAAAAAAAAAAAAA=="}, + {name: "empty passes through", in: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, legacyPolicyBindingHash(tt.in)) + }) + } +} diff --git a/sdk/tdf_test.go b/sdk/tdf_test.go index 49ae7ad383..953efe05cd 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -704,6 +704,48 @@ func (s *TDFSuite) Test_SimpleTDF() { } } +// Test_PolicyBindingEncoding checks the policy binding CreateTDF writes is +// HMAC(payload key, base64 policy) in the expected encoding: legacy +// Base64(hex(HMAC)) unless the writer opts in to the spec's Base64(HMAC). +func (s *TDFSuite) Test_PolicyBindingEncoding() { + for _, tc := range []struct { + name string + opts []TDFOption + wantHex bool + }{ + {name: "default stays legacy", wantHex: true}, + {name: "spec-compliant opt-in", opts: []TDFOption{WithSpecCompliantPolicyBinding()}}, + { + name: "legacy target mode overrides opt-in", + opts: []TDFOption{WithSpecCompliantPolicyBinding(), WithTargetMode("4.2.2")}, + wantHex: true, + }, + } { + s.Run(tc.name, func() { + kasURL := s.kasTestURLLookup["https://a.kas/"] + opts := append([]TDFOption{WithKasInformation(KASInfo{URL: kasURL})}, tc.opts...) + + var buf bytes.Buffer + tdfObj, err := s.sdk.CreateTDF(&buf, bytes.NewReader([]byte("bound")), opts...) + s.Require().NoError(err) + + r, err := s.sdk.LoadTDF(bytes.NewReader(buf.Bytes()), WithKasAllowlist([]string{kasURL})) + s.Require().NoError(err) + payloadKey, err := r.UnsafePayloadKeyRetrieval() + s.Require().NoError(err) + + want := ocrypto.CalculateSHA256Hmac(payloadKey, []byte(tdfObj.Manifest().Policy)) + if tc.wantHex { + want = []byte(hex.EncodeToString(want)) + } + s.Require().Len(tdfObj.Manifest().KeyAccessObjs, 1) + binding, ok := tdfObj.Manifest().KeyAccessObjs[0].PolicyBinding.(PolicyBinding) + s.Require().True(ok, "policy binding should be a PolicyBinding, got %T", tdfObj.Manifest().KeyAccessObjs[0].PolicyBinding) + s.Equal(string(ocrypto.Base64Encode(want)), binding.Hash) + }) + } +} + func (s *TDFSuite) Test_SystemMetadataAssertions() { attributes := []string{ "https://example.com/attr/Classification/value/S", From e796eec23847717ef16de1ab5bb5f34fd09b0463 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 15 Oct 2026 21:03:22 -0400 Subject: [PATCH 2/3] refactor(sdk): rename policy binding opt-in to WithHexlessPolicyBinding "Spec compliant" depends on which spec version the reader has in mind. Name the option for what it changes instead: it drops the hex layer. WithSpecCompliantPolicyBinding -> WithHexlessPolicyBinding WithChunkedSpecCompliantPolicyBinding -> WithChunkedHexlessPolicyBinding Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Paul Flynn --- sdk/chunked_options.go | 14 +++++++------- sdk/chunked_test.go | 12 ++++++------ sdk/chunked_writer.go | 11 +++++------ sdk/experimental/tdf/options.go | 19 ++++++++++--------- sdk/experimental/tdf/writer.go | 4 ++-- sdk/experimental/tdf/writer_test.go | 4 ++-- sdk/key_splitter.go | 18 +++++++++--------- sdk/tdf.go | 10 +++++----- sdk/tdf_config.go | 14 +++++++------- sdk/tdf_helpers_test.go | 4 ++-- sdk/tdf_test.go | 4 ++-- 11 files changed, 57 insertions(+), 57 deletions(-) diff --git a/sdk/chunked_options.go b/sdk/chunked_options.go index a9dcd37cbb..317be3150e 100644 --- a/sdk/chunked_options.go +++ b/sdk/chunked_options.go @@ -213,16 +213,16 @@ func WithChunkedTargetMode(mode string) ChunkedWriterOption { } } -// WithChunkedSpecCompliantPolicyBinding writes each key access object's -// policy binding hash as Base64(HMAC), the form the TDF spec defines, -// instead of the legacy Base64(hex(HMAC)). See -// WithSpecCompliantPolicyBinding for the compatibility caveat. A -// pre-4.3.0 WithChunkedTargetMode overrides this option. +// WithChunkedHexlessPolicyBinding writes each key access object's +// policy binding hash as Base64(HMAC), dropping the hex layer of the +// legacy Base64(hex(HMAC)). See WithHexlessPolicyBinding for the +// compatibility caveat. A pre-4.3.0 WithChunkedTargetMode overrides +// this option. // // Experimental: not part of the stable SDK API; may change or be removed. -func WithChunkedSpecCompliantPolicyBinding() ChunkedWriterOption { +func WithChunkedHexlessPolicyBinding() ChunkedWriterOption { return func(c *chunkedWriterConfig) error { - c.specCompliantPolicyBinding = true + c.hexlessPolicyBinding = true return nil } } diff --git a/sdk/chunked_test.go b/sdk/chunked_test.go index 3cf3b60f43..909e22434b 100644 --- a/sdk/chunked_test.go +++ b/sdk/chunked_test.go @@ -642,7 +642,7 @@ func (k *chunkedFakeKAS) Rewrap(_ context.Context, in *connect.Request[kaspb.Rew // verifyChunkedPolicyBinding recomputes the HMAC a KAS checks before it will // release a key share, and compares it constant-time to the one the KAO // carries. It accepts only the legacy Base64(hex(HMAC)) form, like every -// released KAS, so a reader that forwards a spec-compliant binding unchanged +// released KAS, so a reader that forwards a hexless binding unchanged // fails here as it would against a deployed KAS. func verifyChunkedPolicyBinding(binding *kaspb.PolicyBinding, share []byte, base64Policy string) error { if binding == nil { @@ -917,7 +917,7 @@ func TestChunkedCurrentTargetMode(t *testing.T) { // TestChunkedPolicyBindingEncoding pins which policy binding form each // configuration writes, and that every form decrypts through a KAS that only -// verifies the legacy one -- as every released KAS does. The spec-compliant +// verifies the legacy one -- as every released KAS does. The hexless // case passes only because the reader re-encodes the binding in its rewrap // request. func TestChunkedPolicyBindingEncoding(t *testing.T) { @@ -933,18 +933,18 @@ func TestChunkedPolicyBindingEncoding(t *testing.T) { wantLen: 2 * sha256.Size, }, { - name: "spec-compliant opt-in", - opts: []ChunkedWriterOption{WithChunkedSpecCompliantPolicyBinding()}, + name: "hexless opt-in", + opts: []ChunkedWriterOption{WithChunkedHexlessPolicyBinding()}, wantLen: sha256.Size, }, { name: "legacy target mode overrides opt-in", - opts: []ChunkedWriterOption{WithChunkedSpecCompliantPolicyBinding(), WithChunkedTargetMode("4.2.2")}, + opts: []ChunkedWriterOption{WithChunkedHexlessPolicyBinding(), WithChunkedTargetMode("4.2.2")}, wantLen: 2 * sha256.Size, }, { name: "legacy target mode overrides opt-in in either order", - opts: []ChunkedWriterOption{WithChunkedTargetMode("4.2.2"), WithChunkedSpecCompliantPolicyBinding()}, + opts: []ChunkedWriterOption{WithChunkedTargetMode("4.2.2"), WithChunkedHexlessPolicyBinding()}, wantLen: 2 * sha256.Size, }, } diff --git a/sdk/chunked_writer.go b/sdk/chunked_writer.go index c5868abe57..ea744b4ea4 100644 --- a/sdk/chunked_writer.go +++ b/sdk/chunked_writer.go @@ -398,11 +398,10 @@ type chunkedWriterConfig struct { // which is right when every segment is the same length. segmentSize int64 - // specCompliantPolicyBinding writes policy bindings in the spec's - // Base64(HMAC) form rather than the legacy Base64(hex(HMAC)). Ignored - // when keyAccess is set or useHex is. Set by - // WithChunkedSpecCompliantPolicyBinding. - specCompliantPolicyBinding bool + // hexlessPolicyBinding writes policy bindings as Base64(HMAC) rather + // than the legacy Base64(hex(HMAC)). Ignored when keyAccess is set or + // useHex is. Set by WithChunkedHexlessPolicyBinding. + hexlessPolicyBinding bool // splitter maps attribute values to DEK splits at Finalize time. // Defaults to DefaultKeySplitter (single-KAS only). Ignored when @@ -614,7 +613,7 @@ func newChunkedWriter(cfg chunkedWriterConfig) (*chunkedWriter, error) { keyAccess = splitterKeyAccess{ splitter: cfg.splitter, // A pre-4.3.0 target keeps the legacy binding, as on the CreateTDF path. - specCompliantPolicyBinding: cfg.specCompliantPolicyBinding && !cfg.useHex, + hexlessPolicyBinding: cfg.hexlessPolicyBinding && !cfg.useHex, } } maxEncryptions := cfg.maxEncryptions diff --git a/sdk/experimental/tdf/options.go b/sdk/experimental/tdf/options.go index 3daf3ed253..12a35c36c2 100644 --- a/sdk/experimental/tdf/options.go +++ b/sdk/experimental/tdf/options.go @@ -140,9 +140,9 @@ type WriterConfig struct { // Empty selects the current format. See WithTargetMode. targetMode string - // specCompliantPolicyBinding writes policy bindings in the spec's - // Base64(HMAC) form. See WithSpecCompliantPolicyBinding. - specCompliantPolicyBinding bool + // hexlessPolicyBinding writes policy bindings as Base64(HMAC), + // without the legacy hex layer. See WithHexlessPolicyBinding. + hexlessPolicyBinding bool } // ReaderConfig contains configuration options for TDF Reader creation. @@ -417,18 +417,19 @@ func WithTargetMode(mode string) Option[*WriterConfig] { } } -// WithSpecCompliantPolicyBinding writes each key access object's policy -// binding hash as Base64(HMAC), the form the TDF spec defines, instead of -// the legacy Base64(hex(HMAC)) written by default. +// WithHexlessPolicyBinding writes each key access object's policy +// binding hash as Base64(HMAC), dropping the hex layer of the legacy +// Base64(hex(HMAC)) written by default. Base64(HMAC) is the form the TDF +// spec defines. // // Only a KAS carrying the policy binding dual-accept fix can verify the -// spec's form directly. The SDK's reader re-encodes it for older KAS, but +// hexless form directly. The SDK's reader re-encodes it for older KAS, but // other and older readers forward it unchanged, so a TDF written with this // option may not decrypt through an older reader against an older KAS. A // pre-4.3.0 [WithTargetMode] overrides this option. -func WithSpecCompliantPolicyBinding() Option[*WriterConfig] { +func WithHexlessPolicyBinding() Option[*WriterConfig] { return func(c *WriterConfig) { - c.specCompliantPolicyBinding = true + c.hexlessPolicyBinding = true } } diff --git a/sdk/experimental/tdf/writer.go b/sdk/experimental/tdf/writer.go index b46750c47b..4fd699a79a 100644 --- a/sdk/experimental/tdf/writer.go +++ b/sdk/experimental/tdf/writer.go @@ -143,8 +143,8 @@ func NewWriter(ctx context.Context, opts ...Option[*WriterConfig]) (*Writer, err sdk.WithChunkedKeySplitter(xorSplitter{}), sdk.WithChunkedTargetMode(config.targetMode), } - if config.specCompliantPolicyBinding { - chunkedOpts = append(chunkedOpts, sdk.WithChunkedSpecCompliantPolicyBinding()) + if config.hexlessPolicyBinding { + chunkedOpts = append(chunkedOpts, sdk.WithChunkedHexlessPolicyBinding()) } inner, err := sdk.NewChunkedWriter(ctx, chunkedOpts...) if err != nil { diff --git a/sdk/experimental/tdf/writer_test.go b/sdk/experimental/tdf/writer_test.go index dc4e7ed745..c945665245 100644 --- a/sdk/experimental/tdf/writer_test.go +++ b/sdk/experimental/tdf/writer_test.go @@ -337,10 +337,10 @@ func testPolicyBindingEncoding(t *testing.T) { wantLen int }{ {name: "default stays legacy", wantLen: 2 * sha256.Size}, - {name: "spec-compliant opt-in", opts: []Option[*WriterConfig]{WithSpecCompliantPolicyBinding()}, wantLen: sha256.Size}, + {name: "hexless opt-in", opts: []Option[*WriterConfig]{WithHexlessPolicyBinding()}, wantLen: sha256.Size}, { name: "legacy target mode overrides opt-in", - opts: []Option[*WriterConfig]{WithSpecCompliantPolicyBinding(), WithTargetMode("4.2.2")}, + opts: []Option[*WriterConfig]{WithHexlessPolicyBinding(), WithTargetMode("4.2.2")}, wantLen: 2 * sha256.Size, }, } { diff --git a/sdk/key_splitter.go b/sdk/key_splitter.go index e104dd8890..071846ce58 100644 --- a/sdk/key_splitter.go +++ b/sdk/key_splitter.go @@ -355,9 +355,9 @@ type splitterKeyAccess struct { // splitter maps attributes plus the DEK onto KAS-addressed shares. splitter KeySplitter - // specCompliantPolicyBinding writes policy bindings in the spec's - // Base64(HMAC) form; see createPolicyBinding. - specCompliantPolicyBinding bool + // hexlessPolicyBinding writes policy bindings as Base64(HMAC), + // without the legacy hex layer; see createPolicyBinding. + hexlessPolicyBinding bool } func (r splitterKeyAccess) resolve(ctx context.Context, dek []byte, cfg *chunkedFinalizeConfig) (string, []KeyAccess, error) { @@ -414,14 +414,14 @@ func (r splitterKeyAccess) resolve(ctx context.Context, dek []byte, cfg *chunked for _, v := range cfg.attributes { fqns = append(fqns, v.GetFqn()) } - return resolvePolicyAndKeyAccess(fqns, shares, cfg.encryptedMetadata, r.specCompliantPolicyBinding) + return resolvePolicyAndKeyAccess(fqns, shares, cfg.encryptedMetadata, r.hexlessPolicyBinding) } // resolvePolicyAndKeyAccess builds the policy document the DEK is bound to and wraps // every share to its KAS targets, returning the two manifest fields that bind a DEK to // policy. Shared by SDK.CreateTDF's KAO template path and the chunked writer's // KeySplitter path, so both emit byte-identical policy for the same attributes. -func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata string, specCompliantPolicyBinding bool) (string, []KeyAccess, error) { +func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata string, hexlessPolicyBinding bool) (string, []KeyAccess, error) { policyObj, err := createPolicyObjectFromFQNs(fqns) if err != nil { return "", nil, fmt.Errorf("fail to create policy object:%w", err) @@ -432,7 +432,7 @@ func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata stri } base64Policy := string(ocrypto.Base64Encode(policyObjectAsStr)) - kaos, err := buildKeyAccessObjects(shares, base64Policy, metadata, specCompliantPolicyBinding) + kaos, err := buildKeyAccessObjects(shares, base64Policy, metadata, hexlessPolicyBinding) if err != nil { return "", nil, err } @@ -441,8 +441,8 @@ func resolvePolicyAndKeyAccess(fqns []string, shares []splitShare, metadata stri // buildKeyAccessObjects wraps every share to each of its KAS targets, // emitting the manifest's keyAccess array in share order. See -// createPolicyBinding for specCompliantPolicyBinding. -func buildKeyAccessObjects(shares []splitShare, base64Policy, metadata string, specCompliantPolicyBinding bool) ([]KeyAccess, error) { +// createPolicyBinding for hexlessPolicyBinding. +func buildKeyAccessObjects(shares []splitShare, base64Policy, metadata string, hexlessPolicyBinding bool) ([]KeyAccess, error) { var kaos []KeyAccess for _, share := range shares { // A share with no KAS target contributes no key access object, @@ -460,7 +460,7 @@ func buildKeyAccessObjects(shares []splitShare, base64Policy, metadata string, s // Policy binding and metadata are keyed on the split share, not // on the KAS, so compute them once per share rather than once // per KAS URL in an OR-group. - policyBinding := createPolicyBinding(share.data, base64Policy, specCompliantPolicyBinding) + policyBinding := createPolicyBinding(share.data, base64Policy, hexlessPolicyBinding) var encryptedMetadata string if metadata != "" { diff --git a/sdk/tdf.go b/sdk/tdf.go index 833a554e5d..df8feafe52 100644 --- a/sdk/tdf.go +++ b/sdk/tdf.go @@ -585,8 +585,8 @@ func (s SDK) resolveKeyAccess(ctx context.Context, tdfConfig *TDFConfig, dek []b } // A pre-4.3.0 target keeps the legacy binding: it asks for output that legacy // readers and KAS accept, whatever else was requested. - specCompliantPolicyBinding := tdfConfig.specCompliantPolicyBinding && !tdfConfig.useHex - return resolvePolicyAndKeyAccess(fqns, shares, tdfConfig.metaData, specCompliantPolicyBinding) + hexlessPolicyBinding := tdfConfig.hexlessPolicyBinding && !tdfConfig.useHex + return resolvePolicyAndKeyAccess(fqns, shares, tdfConfig.metaData, hexlessPolicyBinding) } // templateSplitShares groups the KAO template by split ID and divides the DEK across @@ -658,13 +658,13 @@ func manifestSegmentIntegrityAlg(alg string) SegmentIntegrityAlg { // itself so that a KAS can verify the policy it is asked to enforce is the one the // creator bound. // -// The spec defines the hash as Base64(HMAC), which specCompliant selects. Writers have +// The spec defines the hash as Base64(HMAC), which hexless selects. Writers have // always emitted Base64(hex(HMAC)) instead, and no KAS released before the dual-accept // fix can verify the spec's form, so the legacy form stays the default until fixed KAS // are broadly deployed. -func createPolicyBinding(symKey []byte, base64Policy string, specCompliant bool) PolicyBinding { +func createPolicyBinding(symKey []byte, base64Policy string, hexless bool) PolicyBinding { mac := ocrypto.CalculateSHA256Hmac(symKey, []byte(base64Policy)) - if !specCompliant { + if !hexless { mac = []byte(hex.EncodeToString(mac)) } return PolicyBinding{ diff --git a/sdk/tdf_config.go b/sdk/tdf_config.go index 019ab7abaa..4656b7a20b 100644 --- a/sdk/tdf_config.go +++ b/sdk/tdf_config.go @@ -154,7 +154,7 @@ type TDFConfig struct { preferredKeyWrapAlg ocrypto.KeyType useHex bool excludeVersionFromManifest bool - specCompliantPolicyBinding bool + hexlessPolicyBinding bool addDefaultAssertion bool // maxSegmentEncryptions overrides maxAESGCMSegmentsPerDEK when non-zero. // Only tests set it, through withMaxSegmentEncryptions. @@ -356,18 +356,18 @@ func WithTargetMode(mode string) TDFOption { } } -// WithSpecCompliantPolicyBinding writes each key access object's policy binding -// hash as Base64(HMAC), the form the TDF spec defines, instead of the legacy -// Base64(hex(HMAC)) written by default. +// WithHexlessPolicyBinding writes each key access object's policy binding +// hash as Base64(HMAC), dropping the hex layer of the legacy Base64(hex(HMAC)) +// written by default. Base64(HMAC) is the form the TDF spec defines. // -// Only a KAS carrying the policy binding dual-accept fix can verify the spec's +// Only a KAS carrying the policy binding dual-accept fix can verify the hexless // form directly. This SDK's reader re-encodes it for older KAS, but other and // older readers forward it unchanged, so a TDF written with this option may not // decrypt through an older reader against an older KAS. A pre-4.3.0 // WithTargetMode overrides this option and keeps the legacy form. -func WithSpecCompliantPolicyBinding() TDFOption { +func WithHexlessPolicyBinding() TDFOption { return func(c *TDFConfig) error { - c.specCompliantPolicyBinding = true + c.hexlessPolicyBinding = true return nil } } diff --git a/sdk/tdf_helpers_test.go b/sdk/tdf_helpers_test.go index c93963bc20..75f17b86bd 100644 --- a/sdk/tdf_helpers_test.go +++ b/sdk/tdf_helpers_test.go @@ -142,7 +142,7 @@ func TestCreatePolicyBinding(t *testing.T) { // The spec's form is Base64(HMAC), without the hex layer. Same key and // policy as the vector above, so both decode to the same 32-byte HMAC. - t.Run("known answer, spec-compliant", func(t *testing.T) { + t.Run("known answer, hexless", func(t *testing.T) { fixedKey := make([]byte, kKeySize) for i := range fixedKey { fixedKey[i] = byte(i) @@ -194,7 +194,7 @@ func TestLegacyPolicyBindingHash(t *testing.T) { in string want string }{ - {name: "spec-compliant binding is re-encoded", in: raw, want: legacy}, + {name: "hexless binding is re-encoded", in: raw, want: legacy}, {name: "legacy binding passes through", in: legacy, want: legacy}, {name: "invalid base64 passes through", in: "not!base64", want: "not!base64"}, {name: "other lengths pass through", in: "AAAAAAAAAAAAAAAAAAAAAA==", want: "AAAAAAAAAAAAAAAAAAAAAA=="}, diff --git a/sdk/tdf_test.go b/sdk/tdf_test.go index 953efe05cd..05059f1efb 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -714,10 +714,10 @@ func (s *TDFSuite) Test_PolicyBindingEncoding() { wantHex bool }{ {name: "default stays legacy", wantHex: true}, - {name: "spec-compliant opt-in", opts: []TDFOption{WithSpecCompliantPolicyBinding()}}, + {name: "hexless opt-in", opts: []TDFOption{WithHexlessPolicyBinding()}}, { name: "legacy target mode overrides opt-in", - opts: []TDFOption{WithSpecCompliantPolicyBinding(), WithTargetMode("4.2.2")}, + opts: []TDFOption{WithHexlessPolicyBinding(), WithTargetMode("4.2.2")}, wantHex: true, }, } { From b7dfbb1118d33cbc624a40d9c0aac68a461b0f48 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 19 Oct 2026 13:04:49 -0400 Subject: [PATCH 3/3] test(sdk): pin hexless binding under option order and multi-KAS splits Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Paul Flynn --- sdk/chunked_writer.go | 10 +++++----- sdk/tdf_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/sdk/chunked_writer.go b/sdk/chunked_writer.go index ea744b4ea4..8fe72f0628 100644 --- a/sdk/chunked_writer.go +++ b/sdk/chunked_writer.go @@ -372,6 +372,11 @@ type chunkedWriterConfig struct { // agree. excludeVersion bool + // hexlessPolicyBinding writes policy bindings as Base64(HMAC) rather + // than the legacy Base64(hex(HMAC)). Ignored when keyAccess is set or + // useHex is. Set by WithChunkedHexlessPolicyBinding. + hexlessPolicyBinding bool + // initialAttributes are the attribute values used at Finalize // when the Finalize call does not supply its own. initialAttributes []*policy.Value @@ -398,11 +403,6 @@ type chunkedWriterConfig struct { // which is right when every segment is the same length. segmentSize int64 - // hexlessPolicyBinding writes policy bindings as Base64(HMAC) rather - // than the legacy Base64(hex(HMAC)). Ignored when keyAccess is set or - // useHex is. Set by WithChunkedHexlessPolicyBinding. - hexlessPolicyBinding bool - // splitter maps attribute values to DEK splits at Finalize time. // Defaults to DefaultKeySplitter (single-KAS only). Ignored when // keyAccess is set. diff --git a/sdk/tdf_test.go b/sdk/tdf_test.go index 05059f1efb..6890c0c21f 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -720,6 +720,11 @@ func (s *TDFSuite) Test_PolicyBindingEncoding() { opts: []TDFOption{WithHexlessPolicyBinding(), WithTargetMode("4.2.2")}, wantHex: true, }, + { + name: "legacy target mode before opt-in", + opts: []TDFOption{WithTargetMode("4.2.2"), WithHexlessPolicyBinding()}, + wantHex: true, + }, } { s.Run(tc.name, func() { kasURL := s.kasTestURLLookup["https://a.kas/"] @@ -742,8 +747,45 @@ func (s *TDFSuite) Test_PolicyBindingEncoding() { binding, ok := tdfObj.Manifest().KeyAccessObjs[0].PolicyBinding.(PolicyBinding) s.Require().True(ok, "policy binding should be a PolicyBinding, got %T", tdfObj.Manifest().KeyAccessObjs[0].PolicyBinding) s.Equal(string(ocrypto.Base64Encode(want)), binding.Hash) + + decoded, err := ocrypto.Base64Decode([]byte(binding.Hash)) + s.Require().NoError(err) + if tc.wantHex { + // Legacy encoding: Base64(hex(HMAC)) decodes to 64 hex chars + s.Require().Len(decoded, 2*sha256.Size, "legacy binding should decode to 64 hex chars") + } else { + // Hexless encoding: Base64(HMAC) decodes to 32 bytes + s.Require().Len(decoded, sha256.Size, "hexless binding should decode to 32 bytes") + } }) } + + s.Run("two key access objects", func() { + kasAURL := s.kasTestURLLookup["https://a.kas/"] + kasBURL := s.kasTestURLLookup["https://b.kas/"] + opts := []TDFOption{ + withSplitPlan( + keySplitStep{KAS: kasAURL, SplitID: "a"}, + keySplitStep{KAS: kasBURL, SplitID: "b"}, + ), + WithHexlessPolicyBinding(), + } + + var buf bytes.Buffer + tdfObj, err := s.sdk.CreateTDF(&buf, bytes.NewReader([]byte("bound")), opts...) + s.Require().NoError(err) + + s.Require().Len(tdfObj.Manifest().KeyAccessObjs, 2, "should have two key access objects") + + for i, kao := range tdfObj.Manifest().KeyAccessObjs { + binding, ok := kao.PolicyBinding.(PolicyBinding) + s.Require().True(ok, "KAO %d policy binding should be a PolicyBinding, got %T", i, kao.PolicyBinding) + + decoded, err := ocrypto.Base64Decode([]byte(binding.Hash)) + s.Require().NoError(err) + s.Require().Len(decoded, 32, "KAO %d hexless binding should decode to 32 bytes", i) + } + }) } func (s *TDFSuite) Test_SystemMetadataAssertions() {