Skip to content
Open
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
14 changes: 14 additions & 0 deletions sdk/chunked_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 67 additions & 3 deletions sdk/chunked_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"encoding/hex"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion sdk/chunked_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions sdk/experimental/tdf/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions sdk/experimental/tdf/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
42 changes: 42 additions & 0 deletions sdk/experimental/tdf/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package tdf
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
Expand Down Expand Up @@ -63,6 +65,7 @@ func TestWriterEndToEnd(t *testing.T) {
{"HybridXWingFlow", testHybridXWingFlow},
{"HybridP256MLKEM768Flow", testHybridP256MLKEM768Flow},
{"HybridP384MLKEM1024Flow", testHybridP384MLKEM1024Flow},
{"PolicyBindingEncoding", testPolicyBindingEncoding},
}

for _, tc := range testCases {
Expand Down Expand Up @@ -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()
Expand Down
17 changes: 11 additions & 6 deletions sdk/key_splitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -428,16 +432,17 @@ 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
}
return base64Policy, kaos, nil
}

// 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,
Expand All @@ -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 != "" {
Expand Down
35 changes: 30 additions & 5 deletions sdk/tdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)),
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading