Skip to content

Commit 81e188c

Browse files
authored
fix(attestation): decode DSSE signature at bundle source (#3060)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 8290290 commit 81e188c

5 files changed

Lines changed: 141 additions & 22 deletions

File tree

app/cli/pkg/action/attestation_push.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import (
2525
"time"
2626

2727
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
28-
"github.com/chainloop-dev/chainloop/pkg/attestation"
2928
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
3029
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
3130
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer"
@@ -304,8 +303,6 @@ func (action *AttestationPush) saveBundle(bundle *protobundle.Bundle) error {
304303
}
305304

306305
func pushToControlPlane(ctx context.Context, conn *grpc.ClientConn, bundle *protobundle.Bundle, workflowRunID string, markVersionAsReleased bool) (string, error) {
307-
// remove additional base64 encoding in signature. See https://github.com/chainloop-dev/chainloop/issues/1832
308-
attestation.FixSignatureInBundle(bundle)
309306
encodedBundle, err := encodeBundle(bundle)
310307
if err != nil {
311308
return "", fmt.Errorf("encoding attestation: %w", err)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package testhelpers
17+
18+
import (
19+
"encoding/json"
20+
"os"
21+
"testing"
22+
23+
"github.com/chainloop-dev/chainloop/pkg/attestation"
24+
"github.com/secure-systems-lab/go-securesystemslib/dsse"
25+
"github.com/stretchr/testify/require"
26+
"google.golang.org/protobuf/encoding/protojson"
27+
)
28+
29+
// BundleBytesFromEnvelope reads a DSSE envelope fixture from disk and returns the
30+
// protojson-encoded bytes of an equivalent Sigstore bundle.
31+
func BundleBytesFromEnvelope(t *testing.T, path string) []byte {
32+
t.Helper()
33+
raw, err := os.ReadFile(path)
34+
require.NoError(t, err)
35+
var env dsse.Envelope
36+
require.NoError(t, json.Unmarshal(raw, &env))
37+
b, err := attestation.BundleFromDSSEEnvelope(&env)
38+
require.NoError(t, err)
39+
out, err := protojson.Marshal(b)
40+
require.NoError(t, err)
41+
return out
42+
}

app/controlplane/pkg/biz/workflowrun_integration_test.go

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -468,18 +468,6 @@ func testBundle(t *testing.T, path string) (*v1.Bundle, []byte) {
468468
return &bundle, bundleJSON
469469
}
470470

471-
// testBundleBytesFromEnvelope wraps a DSSE envelope file into a Sigstore bundle and returns its protojson bytes.
472-
func testBundleBytesFromEnvelope(t *testing.T, path string) []byte {
473-
_, envBytes := testEnvelope(t, path)
474-
var env dsse.Envelope
475-
require.NoError(t, json.Unmarshal(envBytes, &env))
476-
b, err := attestation.BundleFromDSSEEnvelope(&env)
477-
require.NoError(t, err)
478-
out, err := protojson.Marshal(b)
479-
require.NoError(t, err)
480-
return out
481-
}
482-
483471
const (
484472
version1 = "v1"
485473
version2 = "v2"
@@ -519,7 +507,7 @@ func setupWorkflowRunTestData(t *testing.T, suite *testhelpers.TestingUseCases,
519507
ProjectVersion: version1,
520508
})
521509
assert.NoError(err)
522-
bundleBytes := testBundleBytesFromEnvelope(t, "testdata/attestations/full.json")
510+
bundleBytes := testhelpers.BundleBytesFromEnvelope(t, "testdata/attestations/full.json")
523511
d, err := suite.WorkflowRun.SaveAttestation(ctx, s.runOrg1.ID.String(), bundleBytes)
524512
assert.NoError(err)
525513
s.digestAtt1 = d.String()
@@ -530,7 +518,7 @@ func setupWorkflowRunTestData(t *testing.T, suite *testhelpers.TestingUseCases,
530518
ProjectVersion: version1,
531519
})
532520
assert.NoError(err)
533-
bundleBytes = testBundleBytesFromEnvelope(t, "testdata/attestations/empty.json")
521+
bundleBytes = testhelpers.BundleBytesFromEnvelope(t, "testdata/attestations/empty.json")
534522
d, err = suite.WorkflowRun.SaveAttestation(ctx, s.runOrg2.ID.String(), bundleBytes)
535523
assert.NoError(err)
536524
s.digestAttOrg2 = d.String()
@@ -541,7 +529,7 @@ func setupWorkflowRunTestData(t *testing.T, suite *testhelpers.TestingUseCases,
541529
ProjectVersion: version2,
542530
})
543531
assert.NoError(err)
544-
bundleBytes = testBundleBytesFromEnvelope(t, "testdata/attestations/with-string.json")
532+
bundleBytes = testhelpers.BundleBytesFromEnvelope(t, "testdata/attestations/with-string.json")
545533
d, err = suite.WorkflowRun.SaveAttestation(ctx, s.runOrg2Public.ID.String(), bundleBytes)
546534
assert.NoError(err)
547535
s.digestAttPublic = d.String()

pkg/attestation/attestations.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,18 @@ func DSSEEnvelopeFromBundle(bundle *protobundle.Bundle) *dsse.Envelope {
7070
}
7171

7272
func BundleFromDSSEEnvelope(dsseEnvelope *dsse.Envelope) (*protobundle.Bundle, error) {
73-
// DSSE Envelope is already base64 encoded, we need to decode to prevent it from being encoded twice
73+
if len(dsseEnvelope.Signatures) == 0 {
74+
return nil, fmt.Errorf("DSSE envelope has no signatures")
75+
}
76+
// DSSE Envelope payload and signature are base64 encoded, we need to decode them so they
77+
// are not encoded twice when stored as raw bytes in the Sigstore bundle. See #1832.
7478
payload, err := base64.StdEncoding.DecodeString(dsseEnvelope.Payload)
7579
if err != nil {
76-
return nil, fmt.Errorf("decoding: %w", err)
80+
return nil, fmt.Errorf("decoding payload: %w", err)
81+
}
82+
sig, err := base64.StdEncoding.DecodeString(dsseEnvelope.Signatures[0].Sig)
83+
if err != nil {
84+
return nil, fmt.Errorf("decoding signature: %w", err)
7785
}
7886
return &protobundle.Bundle{
7987
MediaType: "application/vnd.dev.sigstore.bundle+json;version=0.3",
@@ -82,7 +90,7 @@ func BundleFromDSSEEnvelope(dsseEnvelope *dsse.Envelope) (*protobundle.Bundle, e
8290
PayloadType: dsseEnvelope.PayloadType,
8391
Signatures: []*sigstoredsse.Signature{
8492
{
85-
Sig: []byte(dsseEnvelope.Signatures[0].Sig),
93+
Sig: sig,
8694
Keyid: dsseEnvelope.Signatures[0].KeyID,
8795
},
8896
},
@@ -106,7 +114,9 @@ func DSSEEnvelopeFromBundleBytes(bundle []byte) (*dsse.Envelope, error) {
106114
}
107115

108116
// FixSignatureInBundle removes any additional base64 encoding from the signature in the bundle.
109-
// Old attestations have signatures base64 encoded twice, see https://github.com/chainloop-dev/chainloop/issues/1832
117+
// Kept for backward compatibility with attestations stored before the fix for
118+
// https://github.com/chainloop-dev/chainloop/issues/1832, whose signatures are base64-encoded twice.
119+
// New bundles produced by BundleFromDSSEEnvelope already carry a properly decoded signature.
110120
func FixSignatureInBundle(bundle *protobundle.Bundle) {
111121
sig := bundle.GetDsseEnvelope().GetSignatures()[0].GetSig()
112122
dst := make([]byte, base64.StdEncoding.EncodedLen(len(sig)))
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package attestation_test
17+
18+
import (
19+
"encoding/base64"
20+
"testing"
21+
22+
"github.com/chainloop-dev/chainloop/pkg/attestation"
23+
"github.com/secure-systems-lab/go-securesystemslib/dsse"
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
var (
29+
testRawSig = []byte{0x30, 0x44, 0x02, 0x20, 0xAA, 0xBB, 0xCC, 0xDD}
30+
testRawPayload = []byte(`{"_type":"statement"}`)
31+
)
32+
33+
func newTestEnvelope(t *testing.T) *dsse.Envelope {
34+
t.Helper()
35+
return &dsse.Envelope{
36+
PayloadType: "application/vnd.in-toto+json",
37+
Payload: base64.StdEncoding.EncodeToString(testRawPayload),
38+
Signatures: []dsse.Signature{
39+
{KeyID: "key-1", Sig: base64.StdEncoding.EncodeToString(testRawSig)},
40+
},
41+
}
42+
}
43+
44+
// Guards against the double-base64 bug in https://github.com/chainloop-dev/chainloop/issues/1832.
45+
func TestBundleFromDSSEEnvelopeDecodesSignature(t *testing.T) {
46+
bundle, err := attestation.BundleFromDSSEEnvelope(newTestEnvelope(t))
47+
require.NoError(t, err)
48+
49+
gotEnv := bundle.GetDsseEnvelope()
50+
assert.Equal(t, testRawPayload, gotEnv.GetPayload())
51+
require.Len(t, gotEnv.GetSignatures(), 1)
52+
assert.Equal(t, testRawSig, gotEnv.GetSignatures()[0].GetSig())
53+
assert.Equal(t, "key-1", gotEnv.GetSignatures()[0].GetKeyid())
54+
}
55+
56+
func TestBundleFromDSSEEnvelopeNoSignatures(t *testing.T) {
57+
env := newTestEnvelope(t)
58+
env.Signatures = nil
59+
_, err := attestation.BundleFromDSSEEnvelope(env)
60+
require.Error(t, err)
61+
}
62+
63+
func TestFixSignatureInBundleIsNoOpOnFixedBundles(t *testing.T) {
64+
bundle, err := attestation.BundleFromDSSEEnvelope(newTestEnvelope(t))
65+
require.NoError(t, err)
66+
67+
before := bundle.GetDsseEnvelope().GetSignatures()[0].GetSig()
68+
attestation.FixSignatureInBundle(bundle)
69+
assert.Equal(t, before, bundle.GetDsseEnvelope().GetSignatures()[0].GetSig())
70+
}
71+
72+
func TestFixSignatureInBundleRepairsLegacyBundles(t *testing.T) {
73+
bundle, err := attestation.BundleFromDSSEEnvelope(newTestEnvelope(t))
74+
require.NoError(t, err)
75+
76+
// Simulate the legacy bug: signature is stored as the ASCII bytes of the base64 string.
77+
encodedSig := base64.StdEncoding.EncodeToString(testRawSig)
78+
bundle.GetDsseEnvelope().GetSignatures()[0].Sig = []byte(encodedSig)
79+
80+
attestation.FixSignatureInBundle(bundle)
81+
assert.Equal(t, testRawSig, bundle.GetDsseEnvelope().GetSignatures()[0].GetSig())
82+
}

0 commit comments

Comments
 (0)