From 6a757b58c4a9960f7eba3a69f1e7df42c5696bd7 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 31 Aug 2026 22:05:42 -0400 Subject: [PATCH] fix(sdk): reject a chunked split naming a KAS with no resolved public key `buildChunkedKeyAccessObjects` skipped any KAS URL that a split named but `SplitResult.KASPublicKeys` had no entry for. That silently drops the only key access object that would have let that KAS unwrap the share. In an OR-group the TDF still decrypts, just against a smaller set of KAS servers than the policy asked for -- and nothing in the output says so. If every URL on a split is unresolved the share is unrecoverable outright, and the failure surfaces much later as a generic "no valid key access objects generated", or not at all if some other split still produced KAOs. Treats a missing entry the same way an entry with an empty PEM was already treated: `errKasPubKeyMissing`, naming the split and the URL. A splitter that cannot resolve a key it referenced has a bug, and creation time is where it should be reported. Signed-off-by: Dave Mihalcik --- sdk/chunked_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++ sdk/chunked_writer.go | 11 +++++---- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/sdk/chunked_test.go b/sdk/chunked_test.go index bbecdf0eb1..4079337a8d 100644 --- a/sdk/chunked_test.go +++ b/sdk/chunked_test.go @@ -1405,3 +1405,59 @@ func TestChunkedGetManifestExcludesInFlightReservation(t *testing.T) { require.NoError(t, err) assert.Len(t, later.Segments, 2) } + +// partialSplitter names two KAS URLs on one split but resolves a public +// key for only the first, the shape a splitter produces when a KAS +// lookup fails and the failure is swallowed upstream. +type partialSplitter struct { + known *policy.SimpleKasKey + // missingURL is listed on the split but absent from KASPublicKeys. + missingURL string +} + +func (s partialSplitter) Split(_ context.Context, _ []*policy.Value, dek []byte, _ *policy.SimpleKasKey) (*SplitResult, error) { + url := s.known.GetKasUri() + share := make([]byte, len(dek)) + copy(share, dek) + return &SplitResult{ + KASPublicKeys: map[string]KASPublicKey{ + url: { + Algorithm: "rsa:2048", + KID: s.known.GetPublicKey().GetKid(), + PEM: s.known.GetPublicKey().GetPem(), + URL: url, + }, + }, + Splits: []Split{{ + Data: share, + KASURLs: []string{url, s.missingURL}, + }}, + }, nil +} + +// TestChunkedFinalizeRejectsUnresolvedKAS checks that a split naming a +// KAS with no resolved public key fails Finalize. Skipping it would +// emit a TDF whose KAO set silently omits that KAS -- and if every URL +// on a split were missing, the share would be unrecoverable. +func TestChunkedFinalizeRejectsUnresolvedKAS(t *testing.T) { + ctx := context.Background() + kasBundle := newChunkedFakeKAS(t) + defer kasBundle.server.Close() + + w, err := NewChunkedWriter(ctx, + WithChunkedDefaultKAS(kasBundle.simpleKey()), + WithChunkedKeySplitter(partialSplitter{ + known: kasBundle.simpleKey(), + missingURL: "https://unresolved.example.com", + }), + ) + require.NoError(t, err) + + _, err = w.WriteSegment(ctx, 0, []byte("payload")) + require.NoError(t, err) + + _, err = w.Finalize(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "https://unresolved.example.com") + assert.Contains(t, err.Error(), "kas public key is missing") +} diff --git a/sdk/chunked_writer.go b/sdk/chunked_writer.go index 008268ca90..79678a790d 100644 --- a/sdk/chunked_writer.go +++ b/sdk/chunked_writer.go @@ -875,11 +875,14 @@ func buildChunkedKeyAccessObjects(splits *SplitResult, policyBytes []byte, metad encMeta = m } for _, url := range split.KASURLs { + // A KAS named by a split but absent from KASPublicKeys is an + // error, not something to skip. Dropping it silently removes + // the only KAO that would have let that KAS unwrap this + // share; if every URL on the split is missing, the share + // becomes unrecoverable and the TDF undecryptable, with + // nothing in the output to say why. pk, ok := splits.KASPublicKeys[url] - if !ok { - continue - } - if pk.PEM == "" { + if !ok || pk.PEM == "" { return nil, fmt.Errorf("splitID:[%s], kas:[%s]: %w", split.ID, url, errKasPubKeyMissing) } kao, err := createKeyAccess(pk.toKASInfo(), split.Data, policyBinding, encMeta, split.ID)