diff --git a/go.mod b/go.mod index 3b4bf9da7..6b19b7fbc 100644 --- a/go.mod +++ b/go.mod @@ -346,7 +346,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect - github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/docker/cli v29.2.0+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.4 // indirect diff --git a/pkg/attestation/verifier/testdata/bundle_with_bad_timestamp.json b/pkg/attestation/verifier/testdata/bundle_with_bad_timestamp.json new file mode 100644 index 000000000..01ac342c1 --- /dev/null +++ b/pkg/attestation/verifier/testdata/bundle_with_bad_timestamp.json @@ -0,0 +1,21 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.3", + "verificationMaterial": { + "timestampVerificationData": { + "rfc3161Timestamps": [ + { + "signedTimestamp": "bm90LWEtdmFsaWQtdHNy" + } + ] + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEifQ==", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "TUVVQ0lRQ1JjOFVmeFVSMnBkL2UxVW1pVmhkRE5BZUxVRi83ODZ2WGxCT1VWM0dJcUFJZ0NqOTROczZwbzFQSzJjTW95MzBpOVB3Smx5c1E0R1RmTGI4TnZ5WlB1ckk9" + } + ] + } +} diff --git a/pkg/attestation/verifier/testdata/dsse_envelope.json b/pkg/attestation/verifier/testdata/dsse_envelope.json new file mode 100644 index 000000000..5be18f270 --- /dev/null +++ b/pkg/attestation/verifier/testdata/dsse_envelope.json @@ -0,0 +1,10 @@ +{ + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEifQ==", + "signatures": [ + { + "keyid": "", + "sig": "MEUCIQDtest" + } + ] +} diff --git a/pkg/attestation/verifier/timestamp.go b/pkg/attestation/verifier/timestamp.go index bb3f30050..a3b4ff327 100644 --- a/pkg/attestation/verifier/timestamp.go +++ b/pkg/attestation/verifier/timestamp.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -22,24 +22,45 @@ import ( "errors" "fmt" - "github.com/digitorus/timestamp" "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/verify" "github.com/sigstore/timestamp-authority/v2/pkg/verification" ) +var ( + // ErrTSAResponseInvalid indicates the RFC3161 timestamp response could not + // be verified against the TSA certificate chain. + ErrTSAResponseInvalid = errors.New("TSA response verification failed") + + // ErrTimestampOutsideTSAValidity indicates the timestamp's time falls + // outside the TSA certificate's NotBefore/NotAfter window. + ErrTimestampOutsideTSAValidity = errors.New("timestamp outside TSA certificate validity window") + + // ErrSigningCertNotValidAtTimestamp indicates the signing certificate + // was not valid at the timestamp's time. + ErrSigningCertNotValidAtTimestamp = errors.New("signing certificate not valid at timestamp time") + + // ErrNoTSARootsConfigured indicates the bundle contains signed timestamps + // but no TSA trust roots are configured on the server. + ErrNoTSARootsConfigured = errors.New("no TSA trust roots configured") +) + func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error { signedTimestamps, err := sb.Timestamps() if err != nil { if errors.Is(err, bundle.ErrMissingVerificationMaterial) { - // translate error return ErrMissingVerificationMaterial } return fmt.Errorf("could not get timestamps: %w", err) } if len(signedTimestamps) == 0 { - // nothing to do return ErrMissingVerificationMaterial } + + if len(tr.TimestampAuthorities) == 0 { + return ErrNoTSARootsConfigured + } + sc, err := sb.SignatureContent() if err != nil { return fmt.Errorf("could not get signature material: %w", err) @@ -53,48 +74,58 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error { dst := make([]byte, base64.RawURLEncoding.DecodedLen(len(signature))) i, err := base64.StdEncoding.Decode(dst, signature) if err == nil { - // get the decoded one sigBytes = dst[:i] } - var verifiedTimestamps []*timestamp.Timestamp + vc, vcErr := sb.VerificationContent() + if vcErr != nil && !errors.Is(vcErr, bundle.ErrMissingVerificationMaterial) { + return fmt.Errorf("could not get verification material: %w", vcErr) + } + for _, st := range signedTimestamps { - // let's try with all TSAs - for _, tsa := range tr.TimestampAuthorities { - tsaCert := tsa[0] - var roots []*x509.Certificate - var intermediates []*x509.Certificate - if len(tsa) > 1 { - roots = tsa[len(tsa)-1:] - intermediates = tsa[1 : len(tsa)-1] - } - ts, err := verification.VerifyTimestampResponse(st, bytes.NewReader(sigBytes), - verification.VerifyOpts{ - TSACertificate: tsaCert, - Intermediates: intermediates, - Roots: roots, - }) - if err != nil { - continue - } - // verify timestamp time - if ts.Time.After(tsaCert.NotAfter) || ts.Time.Before(tsaCert.NotBefore) { - continue - } - - vc, err := sb.VerificationContent() - if err != nil && !errors.Is(err, bundle.ErrMissingVerificationMaterial) { - return fmt.Errorf("could not get verification material: %w", err) - } - // verify signing certificate issuing time - if vc != nil && vc.Certificate() != nil && !vc.ValidAtTime(ts.Time, nil) { - continue - } - verifiedTimestamps = append(verifiedTimestamps, ts) + if err := verifyTimestamp(st, sigBytes, vc, tr); err != nil { + return err } } - if len(verifiedTimestamps) < len(signedTimestamps) { - return fmt.Errorf("some timestamps verification failed") - } return nil } + +// verifyTimestamp tries to verify a single signed timestamp against every +// configured TSA. Returns the error from the last attempted TSA on failure. +func verifyTimestamp(st []byte, sigBytes []byte, vc verify.VerificationContent, tr *TrustedRoot) error { + var lastErr error + for _, tsa := range tr.TimestampAuthorities { + tsaCert := tsa[0] + var roots []*x509.Certificate + var intermediates []*x509.Certificate + if len(tsa) > 1 { + roots = tsa[len(tsa)-1:] + intermediates = tsa[1 : len(tsa)-1] + } + + ts, err := verification.VerifyTimestampResponse(st, bytes.NewReader(sigBytes), + verification.VerifyOpts{ + TSACertificate: tsaCert, + Intermediates: intermediates, + Roots: roots, + }) + if err != nil { + lastErr = fmt.Errorf("%w: %w", ErrTSAResponseInvalid, err) + continue + } + + if ts.Time.After(tsaCert.NotAfter) || ts.Time.Before(tsaCert.NotBefore) { + lastErr = fmt.Errorf("%w: timestamp=%s, cert validity=[%s, %s]", + ErrTimestampOutsideTSAValidity, ts.Time, tsaCert.NotBefore, tsaCert.NotAfter) + continue + } + + if vc != nil && vc.Certificate() != nil && !vc.ValidAtTime(ts.Time, nil) { + lastErr = fmt.Errorf("%w: timestamp=%s", ErrSigningCertNotValidAtTimestamp, ts.Time) + continue + } + + return nil + } + return lastErr +} diff --git a/pkg/attestation/verifier/verifier_test.go b/pkg/attestation/verifier/verifier_test.go index b9eb647e7..a2e795bef 100644 --- a/pkg/attestation/verifier/verifier_test.go +++ b/pkg/attestation/verifier/verifier_test.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import ( "bytes" "context" "crypto/x509" + "errors" "os" "testing" @@ -64,6 +65,12 @@ func TestVerifyBundle(t *testing.T) { bundle: "testdata/bundle_invalid.json", expectErr: "validating the DSSE envelope", }, + { + name: "legacy DSSE envelope (not a bundle)", + roots: roots, + bundle: "testdata/dsse_envelope.json", + expectErr: "invalid bundle", + }, } for _, tc := range cases { @@ -80,3 +87,43 @@ func TestVerifyBundle(t *testing.T) { }) } } + +func TestVerifyTimestamps_TypedErrors(t *testing.T) { + ca, err := os.ReadFile("testdata/ca.pub") + require.NoError(t, err) + certs, err := cryptoutils.LoadCertificatesFromPEM(bytes.NewReader(ca)) + require.NoError(t, err) + + cases := []struct { + name string + roots *TrustedRoot + expectSentinel error + }{ + { + name: "bad timestamp with TSA configured", + roots: &TrustedRoot{ + TimestampAuthorities: map[string][]*x509.Certificate{ + "fake-tsa": certs, + }, + }, + expectSentinel: ErrTSAResponseInvalid, + }, + { + name: "timestamp with no TSA roots configured", + roots: &TrustedRoot{}, + expectSentinel: ErrNoTSARootsConfigured, + }, + } + + bundleBytes, err := os.ReadFile("testdata/bundle_with_bad_timestamp.json") + require.NoError(t, err) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := VerifyBundle(context.TODO(), bundleBytes, tc.roots) + require.Error(t, err) + assert.True(t, errors.Is(err, tc.expectSentinel), + "expected %v, got: %v", tc.expectSentinel, err) + }) + } +}