Skip to content
Draft
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
48 changes: 42 additions & 6 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 @@ -650,10 +651,23 @@ func verifyChunkedPolicyBinding(binding *kaspb.PolicyBinding, share []byte, base
if binding.GetAlgorithm() != hmacIntegrityAlgorithm {
return fmt.Errorf("unsupported policy binding algorithm %q", binding.GetAlgorithm())
}
want := string(ocrypto.Base64Encode([]byte(
hex.EncodeToString(ocrypto.CalculateSHA256Hmac(share, []byte(base64Policy))),
)))
if subtle.ConstantTimeCompare([]byte(want), []byte(binding.GetHash())) != 1 {
// Mirror the real KAS (service/kas/access.decodePolicyBinding): decode to raw
// HMAC bytes, accepting both the spec >= 4.3.0 base64(hmac) form and the
// pre-4.3.0 base64(hex(hmac)) form. The two are unambiguous by length, so a
// KAS dual-accepts without needing a version signal. A fake that only took
// hex would pass while every real KAS rejected the output, and vice versa.
got, err := ocrypto.Base64Decode([]byte(binding.GetHash()))
if err != nil {
return fmt.Errorf("policy binding is not base64: %w", err)
}
if len(got) == hex.EncodedLen(sha256.Size) {
dehexed := make([]byte, hex.DecodedLen(len(got)))
if _, err := hex.Decode(dehexed, got); err == nil {
got = dehexed
}
}
want := ocrypto.CalculateSHA256Hmac(share, []byte(base64Policy))
if subtle.ConstantTimeCompare(want, got) != 1 {
return errors.New("policy binding does not match the policy")
}
return nil
Expand Down Expand Up @@ -746,7 +760,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 +826,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 @@ -843,6 +857,20 @@ func TestChunkedFinalizeManifestIsIndependent(t *testing.T) {
assert.Equal(t, wantHash, got.Segments[0].Hash)
}

// chunkedPolicyBindingBytes base64-decodes the first KAO's policy binding
// hash. The length of the result is what distinguishes the two wire forms:
// sha256.Size for the 4.3.0 raw HMAC, twice that for the pre-4.3.0 hex.
func chunkedPolicyBindingBytes(t *testing.T, m *Manifest) []byte {
t.Helper()
require.NotNil(t, m)
require.NotEmpty(t, m.KeyAccessObjs)
binding, ok := m.KeyAccessObjs[0].PolicyBinding.(PolicyBinding)
require.True(t, ok, "policy binding should be a PolicyBinding, got %T", m.KeyAccessObjs[0].PolicyBinding)
decoded, err := ocrypto.Base64Decode([]byte(binding.Hash))
require.NoError(t, err, "policy binding hash should be base64")
return decoded
}

// TestChunkedLegacyTargetMode verifies that a pre-4.3.0 target mode
// produces the doubly-encoded (hex-then-base64) signatures that legacy
// readers require, and that the mainline reader -- which infers legacy
Expand Down Expand Up @@ -874,6 +902,10 @@ func TestChunkedLegacyTargetMode(t *testing.T) {
assert.Lenf(t, segSig, 2*kGMACPayloadLength, "segment %d hash must be hex-encoded before base64", i)
}

// The policy binding follows the same threshold as the signatures above.
assert.Len(t, chunkedPolicyBindingBytes(t, fin.Manifest), hex.EncodedLen(sha256.Size),
"legacy policy binding must be hex-encoded before base64")

tdfBytes := bytes.Join([][]byte{body, fin.Data}, nil)
reader, err := s.LoadTDF(bytes.NewReader(tdfBytes),
WithKasAllowlist([]string{kasBundle.url}),
Expand Down Expand Up @@ -902,6 +934,10 @@ func TestChunkedCurrentTargetMode(t *testing.T) {
require.NoError(t, err)
assert.Len(t, rootSig, 32, "root signature must be the raw HMAC, not hex")

// The policy binding follows the same threshold as the signature above.
assert.Len(t, chunkedPolicyBindingBytes(t, fin.Manifest), sha256.Size,
"policy binding must be the raw HMAC, not hex")

tdfBytes := bytes.Join([][]byte{body, fin.Data}, nil)
reader, err := s.LoadTDF(bytes.NewReader(tdfBytes),
WithKasAllowlist([]string{kasBundle.url}),
Expand Down
9 changes: 5 additions & 4 deletions sdk/chunked_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,10 @@ type chunkedWriterConfig struct {
// keyAccess is set.
splitter KeySplitter

// useHex hex-encodes segment, root, and assertion signatures
// before base64, producing the doubly-encoded form that readers
// older than 4.3.0 require. Set by WithChunkedTargetMode.
// useHex hex-encodes segment, root, and assertion signatures and
// the policy binding before base64, producing the doubly-encoded
// form that readers older than 4.3.0 require. Set by
// WithChunkedTargetMode.
useHex bool
}

Expand Down Expand Up @@ -605,7 +606,7 @@ func newChunkedWriter(cfg chunkedWriterConfig) (*chunkedWriter, error) {
}
keyAccess := cfg.keyAccess
if keyAccess == nil {
keyAccess = splitterKeyAccess{splitter: cfg.splitter}
keyAccess = splitterKeyAccess{splitter: cfg.splitter, useHex: cfg.useHex}
}
maxEncryptions := cfg.maxEncryptions
if maxEncryptions == 0 {
Expand Down
4 changes: 1 addition & 3 deletions sdk/experimental/tdf/keysplit_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
package tdf

import (
"encoding/hex"
"fmt"
"testing"

Expand Down Expand Up @@ -89,8 +88,7 @@ func TestXORSplitterAdapterRoundTrip(t *testing.T) {

binding, ok := kao.PolicyBinding.(PolicyBinding)
require.True(t, ok, "policy binding should be a PolicyBinding, got %T", kao.PolicyBinding)
expectedHash := ocrypto.CalculateSHA256Hmac(share, policyBytes)
expected := string(ocrypto.Base64Encode([]byte(hex.EncodeToString(expectedHash))))
expected := string(ocrypto.Base64Encode(ocrypto.CalculateSHA256Hmac(share, policyBytes)))
require.Equal(t, expected, binding.Hash, "recovered share for KAS %s must reproduce its own policy binding", kao.KasURL)

if dek == nil {
Expand Down
4 changes: 1 addition & 3 deletions sdk/experimental/tdf/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package tdf
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"os"
Expand Down Expand Up @@ -942,8 +941,7 @@ func hybridUnwrapForTest(t *testing.T, ktype ocrypto.KeyType, privatePEM string,
require.NoError(t, err, "hybrid Decrypt")
require.Len(t, dek, 32, "%s recovered DEK should be 32 bytes", ktype)

hash := hex.EncodeToString(ocrypto.CalculateSHA256Hmac(dek, []byte(manifest.Policy)))
expected := string(ocrypto.Base64Encode([]byte(hash)))
expected := string(ocrypto.Base64Encode(ocrypto.CalculateSHA256Hmac(dek, []byte(manifest.Policy))))

binding, ok := keyAccess.PolicyBinding.(PolicyBinding)
require.True(t, ok, "policy binding should be a PolicyBinding, got %T", keyAccess.PolicyBinding)
Expand Down
13 changes: 8 additions & 5 deletions sdk/key_splitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ func (r staticKeyAccess) resolve(_ context.Context, _ []byte, _ *chunkedFinalize
type splitterKeyAccess struct {
// splitter maps attributes plus the DEK onto KAS-addressed shares.
splitter KeySplitter

// useHex selects the pre-4.3.0 Base64(hex(HMAC)) policy binding.
useHex bool
}

func (r splitterKeyAccess) resolve(ctx context.Context, dek []byte, cfg *chunkedFinalizeConfig) (string, []KeyAccess, error) {
Expand Down Expand Up @@ -410,14 +413,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.useHex)
}

// 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, useHex 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,7 +431,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, useHex)
if err != nil {
return "", nil, err
}
Expand All @@ -437,7 +440,7 @@ 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) {
func buildKeyAccessObjects(shares []splitShare, base64Policy, metadata string, useHex 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 +458,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, useHex)

var encryptedMetadata string
if metadata != "" {
Expand Down
15 changes: 11 additions & 4 deletions sdk/tdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ 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)
return resolvePolicyAndKeyAccess(fqns, shares, tdfConfig.metaData, tdfConfig.useHex)
}

// templateSplitShares groups the KAO template by split ID and divides the DEK across
Expand Down Expand Up @@ -654,11 +654,18 @@ 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)))
//
// Spec (>= 4.3.0) requires Base64(HMAC); pre-4.3.0 TDFs used Base64(hex(HMAC)).
// useHex tracks the same threshold as segment/root signatures via WithTargetMode,
// so the binding stays consistent with the rest of the manifest's integrity values.
func createPolicyBinding(symKey []byte, base64Policy string, useHex bool) PolicyBinding {
hash := ocrypto.CalculateSHA256Hmac(symKey, []byte(base64Policy))
if useHex {
hash = []byte(hex.EncodeToString(hash))
}
return PolicyBinding{
Alg: hmacIntegrityAlgorithm,
Hash: string(ocrypto.Base64Encode([]byte(policyBindingHash))),
Hash: string(ocrypto.Base64Encode(hash)),
}
}

Expand Down
48 changes: 32 additions & 16 deletions sdk/tdf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sdk

import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"testing"

Expand Down Expand Up @@ -123,34 +124,49 @@ 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
// 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.
// Two wire formats, selected by useHex (see WithTargetMode). Spec >= 4.3.0 is
// base64(hmac); pre-4.3.0 is base64(hex(hmac)). Either layer is easy to drop or
// re-add in a rewrite -- every property below still holds, but the result would
// no longer match what KAS decodes for that target mode. Pin both to vectors.
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"}`))))

policy := string(ocrypto.Base64Encode([]byte(`{"uuid":"test"}`)))
assert.Equal(t,
"wc43nUyrFtbactuerWTXy0ypvayrjAaF/5Lj90qiwS0=",
createPolicyBinding(fixedKey, policy, false).Hash,
"spec >= 4.3.0 binding should be base64(hmac)")
assert.Equal(t,
"YzFjZTM3OWQ0Y2FiMTZkNmRhNzJkYjllYWQ2NGQ3Y2I0Y2E5YmRhY2FiOGMwNjg1ZmY5MmUzZjc0YWEyYzEyZA==",
binding.Hash)
createPolicyBinding(fixedKey, policy, true).Hash,
"pre-4.3.0 binding should be base64(hex(hmac))")
})

t.Run("binds with HS256 over base64 policy", func(t *testing.T) {
binding := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(policyJSON))))

assert.Equal(t, hmacIntegrityAlgorithm, binding.Alg)
require.NotEmpty(t, binding.Hash)
_, err := ocrypto.Base64Decode([]byte(binding.Hash))
require.NoError(t, err, "hash should be base64")
for _, useHex := range []bool{false, true} {
binding := createPolicyBinding(symKey, string(ocrypto.Base64Encode([]byte(policyJSON))), useHex)

assert.Equal(t, hmacIntegrityAlgorithm, binding.Alg)
require.NotEmpty(t, binding.Hash)
decoded, err := ocrypto.Base64Decode([]byte(binding.Hash))
require.NoError(t, err, "hash should be base64")

// The two encodings are distinguishable by length alone, which is how
// KAS dual-accepts them without a version signal.
if useHex {
assert.Len(t, decoded, hex.EncodedLen(sha256.Size))
} else {
assert.Len(t, decoded, sha256.Size)
}
}
})

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)
})

Expand All @@ -161,8 +177,8 @@ 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,
)
})
}
Loading
Loading