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)