diff --git a/sdk/chunked_options.go b/sdk/chunked_options.go index 9eee293a0c..317be3150e 100644 --- a/sdk/chunked_options.go +++ b/sdk/chunked_options.go @@ -213,6 +213,20 @@ func WithChunkedTargetMode(mode string) ChunkedWriterOption { } } +// 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 WithChunkedHexlessPolicyBinding() ChunkedWriterOption { + return func(c *chunkedWriterConfig) error { + c.hexlessPolicyBinding = 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..909e22434b 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 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 { 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 hexless +// 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: "hexless opt-in", + opts: []ChunkedWriterOption{WithChunkedHexlessPolicyBinding()}, + wantLen: sha256.Size, + }, + { + name: "legacy target mode overrides opt-in", + 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"), WithChunkedHexlessPolicyBinding()}, + 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..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 @@ -605,7 +610,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. + hexlessPolicyBinding: cfg.hexlessPolicyBinding && !cfg.useHex, + } } maxEncryptions := cfg.maxEncryptions if maxEncryptions == 0 { diff --git a/sdk/experimental/tdf/options.go b/sdk/experimental/tdf/options.go index d674e43536..12a35c36c2 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 + + // hexlessPolicyBinding writes policy bindings as Base64(HMAC), + // without the legacy hex layer. See WithHexlessPolicyBinding. + hexlessPolicyBinding bool } // ReaderConfig contains configuration options for TDF Reader creation. @@ -413,6 +417,22 @@ func WithTargetMode(mode string) Option[*WriterConfig] { } } +// 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 +// 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 WithHexlessPolicyBinding() Option[*WriterConfig] { + return func(c *WriterConfig) { + c.hexlessPolicyBinding = 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..4fd699a79a 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.hexlessPolicyBinding { + chunkedOpts = append(chunkedOpts, sdk.WithChunkedHexlessPolicyBinding()) + } + 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..c945665245 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: "hexless opt-in", opts: []Option[*WriterConfig]{WithHexlessPolicyBinding()}, wantLen: sha256.Size}, + { + name: "legacy target mode overrides opt-in", + opts: []Option[*WriterConfig]{WithHexlessPolicyBinding(), 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..071846ce58 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 + + // 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) { @@ -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.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) (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) @@ -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, hexlessPolicyBinding) 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 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, @@ -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, hexlessPolicyBinding) var encryptedMetadata string if metadata != "" { diff --git a/sdk/tdf.go b/sdk/tdf.go index 5d2003f585..df8feafe52 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. + 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 @@ -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 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, hexless bool) PolicyBinding { + mac := ocrypto.CalculateSHA256Hmac(symKey, []byte(base64Policy)) + if !hexless { + 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..4656b7a20b 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 + hexlessPolicyBinding bool addDefaultAssertion bool // maxSegmentEncryptions overrides maxAESGCMSegmentsPerDEK when non-zero. // Only tests set it, through withMaxSegmentEncryptions. @@ -355,6 +356,22 @@ func WithTargetMode(mode string) TDFOption { } } +// 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 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 WithHexlessPolicyBinding() TDFOption { + return func(c *TDFConfig) error { + c.hexlessPolicyBinding = 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..75f17b86bd 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, hexless", 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: "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=="}, + {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..6890c0c21f 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -704,6 +704,90 @@ 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: "hexless opt-in", opts: []TDFOption{WithHexlessPolicyBinding()}}, + { + name: "legacy target mode overrides opt-in", + 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/"] + 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) + + 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() { attributes := []string{ "https://example.com/attr/Classification/value/S",