Repository: sigstore/cosign
Pull request #3752 (2024-06-28 18:57:14)

Adding protobuf bundle support to sign-blob and attest-blob
<!--
Thanks for opening a pull request! Please do not just delete this text.  The three fields below are mandatory.

Please remember to:
- This PR requires an issue. If it is a new feature, the issue should proceed the PR and will have allowed sufficent time for discussions to take place. Please use
issue tags such as "Closes #XYZ" or "Resolves sigstore/repo-name#XYZ".
  [documentation](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)
- ensure your commits are signed-off, as sigstore uses the [DCO](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin) using `git commit -s`, or `git commit -s --amend` if you want to amend already existing commits
- lastly, ensure there are no merge commits!

Thank you :)
-->

#### Summary
<!--
 Explain the **motivation** for making this change. What existing problem does the pull request solve? How can reviewers test this PR?
-->

This pull requests addresses the first part of #3139: adding protobuf bundle support for `cosign sign-blob` and `cosign attest-blob`.

You can test this by generating the new bundles, for example signing a local file with a `cosign` provisioned key (requesting a signed timestamp to corroborate):

```bash
go run cmd/cosign/main.go sign-blob --bundle="test-bundle.sigstore.json" --new-bundle-format=true --key="cosign.key" --tlog-upload=false --timestamp-server-url="https://timestamp.githubapp.com/api/v1/timestamp" ../sigstore-go/examples/sigstore-go-signing/hello_world.txt
```

Or using Fulcio to get a signing certificate for an attestation:

```bash
go run cmd/cosign/main.go attest-blob --bundle="pgi-bundle.sigstore.json" --new-bundle-format=true --fulcio-url='https://fulcio.sigstore.dev' --rekor-url='https://rekor.sigstore.dev' --identity-token='...' --type=something --predicate="../sigstore-go/examples/sigstore-go-signing/intoto.txt" ../sigstore-go/examples/sigstore-go-signing/hello_world.txt
```

You can then verify the public good instance bundle using sigstore-go doing something like:
```bash
go run cmd/sigstore-go/main.go -artifact examples/sigstore-go-signing/hello_world.txt -trustedrootJSONpath public-good-trusted-root.json -expectedIssuer "https://token.actions.githubusercontent.com" -expectedSANRegex ".+" ../cosign/pgi-bundle.sigstore.json
```

#### Release Note
<!--
Add a release note for each of the following conditions:

* Config changes (additions, deletions, updates)
* API additions—new endpoint, new response fields, or newly accepted request parameters
* Database changes (any)
* Websocket additions or changes
* Anything noteworthy to an administrator running private sigstore instances (err on the side of over-communicating)
* New features and improvements, including behavioural changes, UI changes and CLI changes
* Bug fixes and fixes of previous known issues
* Deprecation warnings, breaking changes, or compatibility notes

If no release notes are required write NONE. Use past-tense.

-->

NONE - we probably want to finish #3139 (especially the more comprehensive conformance testing!) before we announce this as released.

#### Documentation
<!--

Does this change require an update to documentation? How will users implement your new feature?

Please reference a PR within https://docs.sigstore.dev

-->

N/A - same as above

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sigstore/cosign/pull/3752?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) Report
Attention: Patch coverage is `17.14286%` with `116 lines` in your changes missing coverage. Please review.
> Project coverage is 37.95%. Comparing base [(`2ef6022`)](https://app.codecov.io/gh/sigstore/cosign/commit/2ef6022f3f52299a56e0faacaed9eb2203b6b4a6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) to head [(`095856f`)](https://app.codecov.io/gh/sigstore/cosign/commit/095856f384c174ec4981f46741a5efd3479dca31?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore).
> Report is 139 commits behind head on main.

| [Files](https://app.codecov.io/gh/sigstore/cosign/pull/3752?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) | Patch % | Lines |
|---|---|---|
| [cmd/cosign/cli/sign/sign\_blob.go](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&filepath=cmd%2Fcosign%2Fcli%2Fsign%2Fsign_blob.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-Y21kL2Nvc2lnbi9jbGkvc2lnbi9zaWduX2Jsb2IuZ28=) | 0.00% | [55 Missing :warning: ](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |
| [cmd/cosign/cli/attest/attest\_blob.go](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&filepath=cmd%2Fcosign%2Fcli%2Fattest%2Fattest_blob.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-Y21kL2Nvc2lnbi9jbGkvYXR0ZXN0L2F0dGVzdF9ibG9iLmdv) | 3.84% | [49 Missing and 1 partial :warning: ](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |
| [pkg/cosign/bundle/protobundle.go](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&filepath=pkg%2Fcosign%2Fbundle%2Fprotobundle.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL2Nvc2lnbi9idW5kbGUvcHJvdG9idW5kbGUuZ28=) | 81.48% | [4 Missing and 1 partial :warning: ](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |
| [cmd/cosign/cli/options/attest\_blob.go](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&filepath=cmd%2Fcosign%2Fcli%2Foptions%2Fattest_blob.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-Y21kL2Nvc2lnbi9jbGkvb3B0aW9ucy9hdHRlc3RfYmxvYi5nbw==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |
| [cmd/cosign/cli/options/signblob.go](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&filepath=cmd%2Fcosign%2Fcli%2Foptions%2Fsignblob.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-Y21kL2Nvc2lnbi9jbGkvb3B0aW9ucy9zaWduYmxvYi5nbw==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |
| [cmd/cosign/cli/attest\_blob.go](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&filepath=cmd%2Fcosign%2Fcli%2Fattest_blob.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-Y21kL2Nvc2lnbi9jbGkvYXR0ZXN0X2Jsb2IuZ28=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |
| [cmd/cosign/cli/signblob.go](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&filepath=cmd%2Fcosign%2Fcli%2Fsignblob.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-Y21kL2Nvc2lnbi9jbGkvc2lnbmJsb2IuZ28=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/sigstore/cosign/pull/3752?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3752      +/-   ##
==========================================
- Coverage   40.10%   37.95%   -2.15%     
==========================================
  Files         155      201      +46     
  Lines       10044     9871     -173     
==========================================
- Hits         4028     3747     -281     
- Misses       5530     5577      +47     
- Partials      486      547      +61     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/sigstore/cosign/pull/3752?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore).

haydentherapper: @steiza can you rebase? Just to check, anything else here or any other testing, or is this good to merge?
steiza: > Just to check, anything else here or any other testing, or is this good to merge?

I think this is good to merge!

Patch:
diff --git a/cmd/cosign/cli/options/key.go b/cmd/cosign/cli/options/key.go
index 6af69afd..634911fd 100644
--- a/cmd/cosign/cli/options/key.go
+++ b/cmd/cosign/cli/options/key.go
@@ -32,6 +32,7 @@ type KeyOpts struct {
 	OIDCDisableProviders bool   // Disable OIDC credential providers in keyless signer
 	OIDCProvider         string // Specify which OIDC credential provider to use for keyless signer
 	BundlePath           string
+	NewBundleFormat      bool
 	SkipConfirmation     bool
 	TSAClientCACert      string
 	TSAClientCert        string
diff --git a/cmd/cosign/cli/options/signblob.go b/cmd/cosign/cli/options/signblob.go
index 7cddde63..d6326690 100644
--- a/cmd/cosign/cli/options/signblob.go
+++ b/cmd/cosign/cli/options/signblob.go
@@ -33,6 +33,7 @@ type SignBlobOptions struct {
 	OIDC                 OIDCOptions
 	Registry             RegistryOptions
 	BundlePath           string
+	NewBundleFormat      bool
 	SkipConfirmation     bool
 	TlogUpload           bool
 	TSAClientCACert      string
@@ -75,6 +76,10 @@ func (o *SignBlobOptions) AddFlags(cmd *cobra.Command) {
 		"write everything required to verify the blob to a FILE")
 	_ = cmd.Flags().SetAnnotation("bundle", cobra.BashCompFilenameExt, []string{})
 
+	// TODO: have this default to true as a breaking change
+	cmd.Flags().BoolVar(&o.NewBundleFormat, "new-bundle-format", false,
+		"output bundle in new format that contains all verification material")
+
 	cmd.Flags().BoolVarP(&o.SkipConfirmation, "yes", "y", false,
 		"skip confirmation prompts for non-destructive operations")
 
diff --git a/cmd/cosign/cli/sign/sign_blob.go b/cmd/cosign/cli/sign/sign_blob.go
index b60db5b7..5a7b960b 100644
--- a/cmd/cosign/cli/sign/sign_blob.go
+++ b/cmd/cosign/cli/sign/sign_blob.go
@@ -18,12 +18,15 @@ package sign
 import (
 	"context"
 	"crypto/sha256"
+	"crypto/x509"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
 
+	"google.golang.org/protobuf/encoding/protojson"
+
 	"github.com/sigstore/cosign/v2/cmd/cosign/cli/options"
 	"github.com/sigstore/cosign/v2/cmd/cosign/cli/rekor"
 	internal "github.com/sigstore/cosign/v2/internal/pkg/cosign"
@@ -32,6 +35,9 @@ import (
 	"github.com/sigstore/cosign/v2/internal/ui"
 	"github.com/sigstore/cosign/v2/pkg/cosign"
 	cbundle "github.com/sigstore/cosign/v2/pkg/cosign/bundle"
+	protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
+	protocommon "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1"
+	"github.com/sigstore/rekor/pkg/generated/models"
 	"github.com/sigstore/sigstore/pkg/cryptoutils"
 	signatureoptions "github.com/sigstore/sigstore/pkg/signature/options"
 )
@@ -49,6 +55,7 @@ func SignBlobCmd(ro *options.RootOptions, ko options.KeyOpts, payloadPath string
 	} else {
 		ui.Infof(ctx, "Using payload from: %s", payloadPath)
 		f, err := os.Open(filepath.Clean(payloadPath))
+		defer f.Close()
 		if err != nil {
 			return nil, err
 		}
@@ -68,23 +75,25 @@ func SignBlobCmd(ro *options.RootOptions, ko options.KeyOpts, payloadPath string
 	if err != nil {
 		return nil, fmt.Errorf("signing blob: %w", err)
 	}
+	digest := payload.Sum(nil)
 
 	signedPayload := cosign.LocalSignedPayload{}
-
+	var rekorEntry *models.LogEntryAnon
 	var rfc3161Timestamp *cbundle.RFC3161Timestamp
+	var timestampBytes []byte
+
 	if ko.TSAServerURL != "" {
-		if ko.RFC3161TimestampPath == "" {
-			return nil, fmt.Errorf("timestamp output path must be set")
+		if ko.RFC3161TimestampPath == "" && !ko.NewBundleFormat {
+			return nil, fmt.Errorf("must use protobuf bundle or set timestamp output path")
 		}
-		var respBytes []byte
 		var err error
 		if ko.TSAClientCACert == "" && ko.TSAClientCert == "" { // no mTLS params or custom CA
-			respBytes, err = tsa.GetTimestampedSignature(sig, client.NewTSAClient(ko.TSAServerURL))
+			timestampBytes, err = tsa.GetTimestampedSignature(sig, client.NewTSAClient(ko.TSAServerURL))
 			if err != nil {
 				return nil, err
 			}
 		} else {
-			respBytes, err = tsa.GetTimestampedSignature(sig, client.NewTSAClientMTLS(ko.TSAServerURL,
+			timestampBytes, err = tsa.GetTimestampedSignature(sig, client.NewTSAClientMTLS(ko.TSAServerURL,
 				ko.TSAClientCACert,
 				ko.TSAClientCert,
 				ko.TSAClientKey,
@@ -95,20 +104,23 @@ func SignBlobCmd(ro *options.RootOptions, ko options.KeyOpts, payloadPath string
 			}
 		}
 
-		rfc3161Timestamp = cbundle.TimestampToRFC3161Timestamp(respBytes)
+		rfc3161Timestamp = cbundle.TimestampToRFC3161Timestamp(timestampBytes)
 		// TODO: Consider uploading RFC3161 TS to Rekor
 
 		if rfc3161Timestamp == nil {
 			return nil, fmt.Errorf("rfc3161 timestamp is nil")
 		}
-		ts, err := json.Marshal(rfc3161Timestamp)
-		if err != nil {
-			return nil, err
-		}
-		if err := os.WriteFile(ko.RFC3161TimestampPath, ts, 0600); err != nil {
-			return nil, fmt.Errorf("create RFC3161 timestamp file: %w", err)
+
+		if ko.RFC3161TimestampPath != "" {
+			ts, err := json.Marshal(rfc3161Timestamp)
+			if err != nil {
+				return nil, err
+			}
+			if err := os.WriteFile(ko.RFC3161TimestampPath, ts, 0600); err != nil {
+				return nil, fmt.Errorf("create RFC3161 timestamp file: %w", err)
+			}
+			ui.Infof(ctx, "RFC3161 timestamp written to file %s\n", ko.RFC3161TimestampPath)
 		}
-		ui.Infof(ctx, "RFC3161 timestamp written to file %s\n", ko.RFC3161TimestampPath)
 	}
 	shouldUpload, err := ShouldUploadToTlog(ctx, ko, nil, tlogUpload)
 	if err != nil {
@@ -123,28 +135,76 @@ func SignBlobCmd(ro *options.RootOptions, ko options.KeyOpts, payloadPath string
 		if err != nil {
 			return nil, err
 		}
-		entry, err := cosign.TLogUpload(ctx, rekorClient, sig, &payload, rekorBytes)
+		rekorEntry, err = cosign.TLogUpload(ctx, rekorClient, sig, &payload, rekorBytes)
 		if err != nil {
 			return nil, err
 		}
-		ui.Infof(ctx, "tlog entry created with index: %d", *entry.LogIndex)
-		signedPayload.Bundle = cbundle.EntryToBundle(entry)
+		ui.Infof(ctx, "tlog entry created with index: %d", *rekorEntry.LogIndex)
+		signedPayload.Bundle = cbundle.EntryToBundle(rekorEntry)
 	}
 
 	// if bundle is specified, just do that and ignore the rest
 	if ko.BundlePath != "" {
-		signedPayload.Base64Signature = base64.StdEncoding.EncodeToString(sig)
+		var contents []byte
+		if ko.NewBundleFormat {
+			// Determine if signature is certificate or not
+			var hint string
+			var rawCert []byte
 
-		certBytes, err := extractCertificate(ctx, sv)
-		if err != nil {
-			return nil, err
-		}
-		signedPayload.Cert = base64.StdEncoding.EncodeToString(certBytes)
+			signer, err := sv.Bytes(ctx)
+			if err != nil {
+				return nil, fmt.Errorf("error getting signer: %w", err)
+			}
+			cert, err := cryptoutils.UnmarshalCertificatesFromPEM(signer)
+			if err != nil || len(cert) == 0 {
+				pubKey, err := sv.PublicKey()
+				if err != nil {
+					return nil, err
+				}
+				pkixPubKey, err := x509.MarshalPKIXPublicKey(pubKey)
+				if err != nil {
+					return nil, err
+				}
+				hashedBytes := sha256.Sum256(pkixPubKey)
+				hint = base64.StdEncoding.EncodeToString(hashedBytes[:])
+			} else {
+				rawCert = cert[0].Raw
+			}
 
-		contents, err := json.Marshal(signedPayload)
-		if err != nil {
-			return nil, err
+			bundle, err := cbundle.MakeProtobufBundle(hint, rawCert, rekorEntry, timestampBytes)
+			if err != nil {
+				return nil, err
+			}
+
+			bundle.Content = &protobundle.Bundle_MessageSignature{
+				MessageSignature: &protocommon.MessageSignature{
+					MessageDigest: &protocommon.HashOutput{
+						Algorithm: protocommon.HashAlgorithm_SHA2_256,
+						Digest:    digest,
+					},
+					Signature: sig,
+				},
+			}
+
+			contents, err = protojson.Marshal(bundle)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			signedPayload.Base64Signature = base64.StdEncoding.EncodeToString(sig)
+
+			certBytes, err := extractCertificate(ctx, sv)
+			if err != nil {
+				return nil, err
+			}
+			signedPayload.Cert = base64.StdEncoding.EncodeToString(certBytes)
+
+			contents, err = json.Marshal(signedPayload)
+			if err != nil {
+				return nil, err
+			}
 		}
+
 		if err := os.WriteFile(ko.BundlePath, contents, 0600); err != nil {
 			return nil, fmt.Errorf("create bundle file: %w", err)
 		}
diff --git a/cmd/cosign/cli/signblob.go b/cmd/cosign/cli/signblob.go
index 956edc6e..e1a2cbf8 100644
--- a/cmd/cosign/cli/signblob.go
+++ b/cmd/cosign/cli/signblob.go
@@ -84,6 +84,7 @@ func SignBlob() *cobra.Command {
 				OIDCRedirectURL:                o.OIDC.RedirectURL,
 				OIDCDisableProviders:           o.OIDC.DisableAmbientProviders,
 				BundlePath:                     o.BundlePath,
+				NewBundleFormat:                o.NewBundleFormat,
 				SkipConfirmation:               o.SkipConfirmation,
 				TSAClientCACert:                o.TSAClientCACert,
 				TSAClientCert:                  o.TSAClientCert,
diff --git a/doc/cosign_sign-blob.md b/doc/cosign_sign-blob.md
index a53f34e4..d78f9fb2 100644
--- a/doc/cosign_sign-blob.md
+++ b/doc/cosign_sign-blob.md
@@ -45,6 +45,7 @@ cosign sign-blob [flags]
       --insecure-skip-verify             skip verifying fulcio published to the SCT (this should only be used for testing).
       --issue-certificate                issue a code signing certificate from Fulcio, even if a key is provided
       --key string                       path to the private key file, KMS URI or Kubernetes Secret
+      --new-bundle-format                output bundle in new format that contains all verification material
       --oidc-client-id string            OIDC client ID for application (default "sigstore")
       --oidc-client-secret-file string   Path to file containing OIDC client secret for application
       --oidc-disable-ambient-providers   Disable ambient OIDC providers. When true, ambient credentials will not be read
diff --git a/go.mod b/go.mod
index 4a10202a..243677bc 100644
--- a/go.mod
+++ b/go.mod
@@ -32,6 +32,7 @@ require (
 	github.com/pkg/errors v0.9.1
 	github.com/secure-systems-lab/go-securesystemslib v0.8.0
 	github.com/sigstore/fulcio v1.5.1
+	github.com/sigstore/protobuf-specs v0.3.0
 	github.com/sigstore/rekor v1.3.6
 	github.com/sigstore/sigstore v1.8.7
 	github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.7
@@ -53,6 +54,7 @@ require (
 	golang.org/x/sync v0.7.0
 	golang.org/x/term v0.22.0
 	google.golang.org/api v0.189.0
+	google.golang.org/protobuf v1.34.2
 	k8s.io/api v0.28.3
 	k8s.io/apimachinery v0.28.3
 	k8s.io/client-go v0.28.3
@@ -257,17 +259,16 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.27.0 // indirect
 	golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
-	golang.org/x/mod v0.17.0 // indirect
+	golang.org/x/mod v0.19.0 // indirect
 	golang.org/x/net v0.27.0 // indirect
 	golang.org/x/sys v0.22.0 // indirect
 	golang.org/x/text v0.16.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
-	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
+	golang.org/x/tools v0.23.0 // indirect
 	google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
 	google.golang.org/grpc v1.65.0 // indirect
-	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
diff --git a/go.sum b/go.sum
index 89b5dca9..1962c60c 100644
--- a/go.sum
+++ b/go.sum
@@ -603,6 +603,8 @@ github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh
 github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
 github.com/sigstore/fulcio v1.5.1 h1:Iasy1zfNjaq8BV4S8o6pXspLDU28PQC2z07GmOu9zpM=
 github.com/sigstore/fulcio v1.5.1/go.mod h1:W1A/UHrTopy1IBZPMtHmxg7GPYAu+vt5dRXM3W6yjPo=
+github.com/sigstore/protobuf-specs v0.3.0 h1:E49qS++llp4psM+3NNVEb+C4AD422bT9VkOQIPrNLpA=
+github.com/sigstore/protobuf-specs v0.3.0/go.mod h1:ynKzXpqr3dUj2Xk9O/5ZUhjnpi0F53DNi5AdH6pS3jc=
 github.com/sigstore/rekor v1.3.6 h1:QvpMMJVWAp69a3CHzdrLelqEqpTM3ByQRt5B5Kspbi8=
 github.com/sigstore/rekor v1.3.6/go.mod h1:JDTSNNMdQ/PxdsS49DJkJ+pRJCO/83nbR5p3aZQteXc=
 github.com/sigstore/sigstore v1.8.7 h1:L7/zKauHTg0d0Hukx7qlR4nifh6T6O6UIt9JBwAmTIg=
@@ -762,8 +764,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
+golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -876,8 +878,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
+golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/pkg/cosign/bundle/protobundle.go b/pkg/cosign/bundle/protobundle.go
new file mode 100644
index 00000000..a26e2fb2
--- /dev/null
+++ b/pkg/cosign/bundle/protobundle.go
@@ -0,0 +1,65 @@
+// Copyright 2024 The Sigstore Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bundle
+
+import (
+	protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
+	protocommon "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1"
+	protorekor "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1"
+	"github.com/sigstore/rekor/pkg/generated/models"
+	"github.com/sigstore/rekor/pkg/tle"
+)
+
+const bundleV03MediaType = "application/vnd.dev.sigstore.bundle.v0.3+json"
+
+func MakeProtobufBundle(hint string, rawCert []byte, rekorEntry *models.LogEntryAnon, timestampBytes []byte) (*protobundle.Bundle, error) {
+	bundle := &protobundle.Bundle{MediaType: bundleV03MediaType}
+
+	if hint != "" {
+		bundle.VerificationMaterial = &protobundle.VerificationMaterial{
+			Content: &protobundle.VerificationMaterial_PublicKey{
+				PublicKey: &protocommon.PublicKeyIdentifier{
+					Hint: hint,
+				},
+			},
+		}
+	} else if len(rawCert) > 0 {
+		bundle.VerificationMaterial = &protobundle.VerificationMaterial{
+			Content: &protobundle.VerificationMaterial_Certificate{
+				Certificate: &protocommon.X509Certificate{
+					RawBytes: rawCert,
+				},
+			},
+		}
+	}
+
+	if len(timestampBytes) > 0 {
+		bundle.VerificationMaterial.TimestampVerificationData = &protobundle.TimestampVerificationData{
+			Rfc3161Timestamps: []*protocommon.RFC3161SignedTimestamp{
+				{SignedTimestamp: timestampBytes},
+			},
+		}
+	}
+
+	if rekorEntry != nil {
+		tlogEntry, err := tle.GenerateTransparencyLogEntry(*rekorEntry)
+		if err != nil {
+			return nil, err
+		}
+		bundle.VerificationMaterial.TlogEntries = []*protorekor.TransparencyLogEntry{tlogEntry}
+	}
+
+	return bundle, nil
+}


Test patch:
diff --git a/cmd/cosign/cli/attest/attest_blob.go b/cmd/cosign/cli/attest/attest_blob.go
index a8a69362..50795215 100644
--- a/cmd/cosign/cli/attest/attest_blob.go
+++ b/cmd/cosign/cli/attest/attest_blob.go
@@ -18,6 +18,8 @@ import (
 	"bytes"
 	"context"
 	"crypto"
+	"crypto/sha256"
+	"crypto/x509"
 	"encoding/base64"
 	"encoding/hex"
 	"encoding/json"
@@ -30,6 +32,9 @@ import (
 	"time"
 
 	"github.com/pkg/errors"
+	"github.com/secure-systems-lab/go-securesystemslib/dsse"
+	"google.golang.org/protobuf/encoding/protojson"
+
 	"github.com/sigstore/cosign/v2/cmd/cosign/cli/options"
 	"github.com/sigstore/cosign/v2/cmd/cosign/cli/rekor"
 	"github.com/sigstore/cosign/v2/cmd/cosign/cli/sign"
@@ -39,10 +44,12 @@ import (
 	"github.com/sigstore/cosign/v2/pkg/cosign/attestation"
 	cbundle "github.com/sigstore/cosign/v2/pkg/cosign/bundle"
 	"github.com/sigstore/cosign/v2/pkg/types"
+	protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
+	protodsse "github.com/sigstore/protobuf-specs/gen/pb-go/dsse"
 	"github.com/sigstore/rekor/pkg/generated/models"
 	"github.com/sigstore/sigstore/pkg/cryptoutils"
 	"github.com/sigstore/sigstore/pkg/signature"
-	"github.com/sigstore/sigstore/pkg/signature/dsse"
+	sigstoredsse "github.com/sigstore/sigstore/pkg/signature/dsse"
 	signatureoptions "github.com/sigstore/sigstore/pkg/signature/options"
 )
 
@@ -88,8 +95,8 @@ func (c *AttestBlobCommand) Exec(ctx context.Context, artifactPath string) error
 		defer cancelFn()
 	}
 
-	if c.TSAServerURL != "" && c.RFC3161TimestampPath == "" {
-		return errors.New("expected an rfc3161-timestamp path when using a TSA server")
+	if c.TSAServerURL != "" && c.RFC3161TimestampPath == "" && !c.NewBundleFormat {
+		return errors.New("expected either new bundle or an rfc3161-timestamp path when using a TSA server")
 	}
 
 	var artifact []byte
@@ -129,7 +136,7 @@ func (c *AttestBlobCommand) Exec(ctx context.Context, artifactPath string) error
 		return fmt.Errorf("getting signer: %w", err)
 	}
 	defer sv.Close()
-	wrapped := dsse.WrapSigner(sv, types.IntotoPayloadType)
+	wrapped := sigstoredsse.WrapSigner(sv, types.IntotoPayloadType)
 
 	base := path.Base(artifactPath)
 
@@ -154,28 +161,34 @@ func (c *AttestBlobCommand) Exec(ctx context.Context, artifactPath string) error
 	}
 
 	var rfc3161Timestamp *cbundle.RFC3161Timestamp
+	var timestampBytes []byte
+	var rekorEntry *models.LogEntryAnon
+
 	if c.TSAServerURL != "" {
-		respBytes, err := tsa.GetTimestampedSignature(sig, client.NewTSAClient(c.TSAServerURL))
+		timestampBytes, err = tsa.GetTimestampedSignature(sig, client.NewTSAClient(c.TSAServerURL))
 		if err != nil {
 			return err
 		}
-		rfc3161Timestamp = cbundle.TimestampToRFC3161Timestamp(respBytes)
+		rfc3161Timestamp = cbundle.TimestampToRFC3161Timestamp(timestampBytes)
 		// TODO: Consider uploading RFC3161 TS to Rekor
 
 		if rfc3161Timestamp == nil {
 			return fmt.Errorf("rfc3161 timestamp is nil")
 		}
-		ts, err := json.Marshal(rfc3161Timestamp)
-		if err != nil {
-			return err
-		}
-		if err := os.WriteFile(c.RFC3161TimestampPath, ts, 0600); err != nil {
-			return fmt.Errorf("create RFC3161 timestamp file: %w", err)
+
+		if c.RFC3161TimestampPath != "" {
+			ts, err := json.Marshal(rfc3161Timestamp)
+			if err != nil {
+				return err
+			}
+			if err := os.WriteFile(c.RFC3161TimestampPath, ts, 0600); err != nil {
+				return fmt.Errorf("create RFC3161 timestamp file: %w", err)
+			}
+			fmt.Fprintln(os.Stderr, "RFC3161 timestamp bundle written to file ", c.RFC3161TimestampPath)
 		}
-		fmt.Fprintln(os.Stderr, "RFC3161 timestamp bundle written to file ", c.RFC3161TimestampPath)
 	}
 
-	rekorBytes, err := sv.Bytes(ctx)
+	signer, err := sv.Bytes(ctx)
 	if err != nil {
 		return err
 	}
@@ -189,28 +202,36 @@ func (c *AttestBlobCommand) Exec(ctx context.Context, artifactPath string) error
 		if err != nil {
 			return err
 		}
-		var entry *models.LogEntryAnon
 		if c.RekorEntryType == "intoto" {
-			entry, err = cosign.TLogUploadInTotoAttestation(ctx, rekorClient, sig, rekorBytes)
+			rekorEntry, err = cosign.TLogUploadInTotoAttestation(ctx, rekorClient, sig, signer)
 		} else {
-			entry, err = cosign.TLogUploadDSSEEnvelope(ctx, rekorClient, sig, rekorBytes)
+			rekorEntry, err = cosign.TLogUploadDSSEEnvelope(ctx, rekorClient, sig, signer)
 		}
 
 		if err != nil {
 			return err
 		}
-		fmt.Fprintln(os.Stderr, "tlog entry created with index:", *entry.LogIndex)
-		signedPayload.Bundle = cbundle.EntryToBundle(entry)
+		fmt.Fprintln(os.Stderr, "tlog entry created with index:", *rekorEntry.LogIndex)
+		signedPayload.Bundle = cbundle.EntryToBundle(rekorEntry)
 	}
 
 	if c.BundlePath != "" {
-		signedPayload.Base64Signature = base64.StdEncoding.EncodeToString(sig)
-		signedPayload.Cert = base64.StdEncoding.EncodeToString(rekorBytes)
+		var contents []byte
+		if c.NewBundleFormat {
+			contents, err = makeNewBundle(sv, rekorEntry, payload, sig, signer, timestampBytes)
+			if err != nil {
+				return err
+			}
+		} else {
+			signedPayload.Base64Signature = base64.StdEncoding.EncodeToString(sig)
+			signedPayload.Cert = base64.StdEncoding.EncodeToString(signer)
 
-		contents, err := json.Marshal(signedPayload)
-		if err != nil {
-			return err
+			contents, err = json.Marshal(signedPayload)
+			if err != nil {
+				return err
+			}
 		}
+
 		if err := os.WriteFile(c.BundlePath, contents, 0600); err != nil {
 			return fmt.Errorf("create bundle file: %w", err)
 		}
@@ -258,3 +279,64 @@ func (c *AttestBlobCommand) Exec(ctx context.Context, artifactPath string) error
 
 	return nil
 }
+
+func makeNewBundle(sv *sign.SignerVerifier, rekorEntry *models.LogEntryAnon, payload, sig, signer, timestampBytes []byte) ([]byte, error) {
+	// Determine if signature is certificate or not
+	var hint string
+	var rawCert []byte
+
+	cert, err := cryptoutils.UnmarshalCertificatesFromPEM(signer)
+	if err != nil || len(cert) == 0 {
+		pubKey, err := sv.PublicKey()
+		if err != nil {
+			return nil, err
+		}
+		pkixPubKey, err := x509.MarshalPKIXPublicKey(pubKey)
+		if err != nil {
+			return nil, err
+		}
+		hashedBytes := sha256.Sum256(pkixPubKey)
+		hint = base64.StdEncoding.EncodeToString(hashedBytes[:])
+	} else {
+		rawCert = cert[0].Raw
+	}
+
+	bundle, err := cbundle.MakeProtobufBundle(hint, rawCert, rekorEntry, timestampBytes)
+	if err != nil {
+		return nil, err
+	}
+
+	var envelope dsse.Envelope
+	err = json.Unmarshal(sig, &envelope)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(envelope.Signatures) == 0 {
+		return nil, fmt.Errorf("no signature in DSSE envelope")
+	}
+
+	sigBytes, err := base64.StdEncoding.DecodeString(envelope.Signatures[0].Sig)
+	if err != nil {
+		return nil, err
+	}
+
+	bundle.Content = &protobundle.Bundle_DsseEnvelope{
+		DsseEnvelope: &protodsse.Envelope{
+			Payload:     payload,
+			PayloadType: envelope.PayloadType,
+			Signatures: []*protodsse.Signature{
+				{
+					Sig: sigBytes,
+				},
+			},
+		},
+	}
+
+	contents, err := protojson.Marshal(bundle)
+	if err != nil {
+		return nil, err
+	}
+
+	return contents, nil
+}
diff --git a/cmd/cosign/cli/attest/attest_blob_test.go b/cmd/cosign/cli/attest/attest_blob_test.go
index dd695d0e..f563c7e6 100644
--- a/cmd/cosign/cli/attest/attest_blob_test.go
+++ b/cmd/cosign/cli/attest/attest_blob_test.go
@@ -122,6 +122,7 @@ func TestAttestBlobCmdLocalKeyAndCert(t *testing.T) {
 				keyref       string
 				certref      string
 				certchainref string
+				newBundle    bool
 				errString    string
 			}{
 				{
@@ -133,6 +134,12 @@ func TestAttestBlobCmdLocalKeyAndCert(t *testing.T) {
 					keyref:  keyRef,
 					certref: certRef,
 				},
+				{
+					name:      "new bundle generation",
+					keyref:    keyRef,
+					certref:   certRef,
+					newBundle: true,
+				},
 				{
 					name:      "fail: cert no match key",
 					keyref:    keyRef,
@@ -160,8 +167,12 @@ func TestAttestBlobCmdLocalKeyAndCert(t *testing.T) {
 				},
 			} {
 				t.Run(tc.name, func(t *testing.T) {
+					keyOpts := options.KeyOpts{KeyRef: tc.keyref}
+					if tc.newBundle {
+						keyOpts.NewBundleFormat = true
+					}
 					at := AttestBlobCommand{
-						KeyOpts:        options.KeyOpts{KeyRef: tc.keyref},
+						KeyOpts:        keyOpts,
 						CertPath:       tc.certref,
 						CertChainPath:  tc.certchainref,
 						PredicatePath:  predicatePath,
diff --git a/cmd/cosign/cli/attest_blob.go b/cmd/cosign/cli/attest_blob.go
index 3cefa61b..d9a7108b 100644
--- a/cmd/cosign/cli/attest_blob.go
+++ b/cmd/cosign/cli/attest_blob.go
@@ -73,6 +73,7 @@ func AttestBlob() *cobra.Command {
 				TSAServerURL:             o.TSAServerURL,
 				RFC3161TimestampPath:     o.RFC3161TimestampPath,
 				BundlePath:               o.BundlePath,
+				NewBundleFormat:          o.NewBundleFormat,
 			}
 			v := attest.AttestBlobCommand{
 				KeyOpts:           ko,
diff --git a/cmd/cosign/cli/options/attest_blob.go b/cmd/cosign/cli/options/attest_blob.go
index 593c787e..b6f48b49 100644
--- a/cmd/cosign/cli/options/attest_blob.go
+++ b/cmd/cosign/cli/options/attest_blob.go
@@ -36,6 +36,7 @@ type AttestBlobOptions struct {
 	OutputAttestation string
 	OutputCertificate string
 	BundlePath        string
+	NewBundleFormat   bool
 
 	RekorEntryType string
 
@@ -85,6 +86,10 @@ func (o *AttestBlobOptions) AddFlags(cmd *cobra.Command) {
 		"write everything required to verify the blob to a FILE")
 	_ = cmd.Flags().SetAnnotation("bundle", cobra.BashCompFilenameExt, []string{})
 
+	// TODO: have this default to true as a breaking change
+	cmd.Flags().BoolVar(&o.NewBundleFormat, "new-bundle-format", false,
+		"output bundle in new format that contains all verification material")
+
 	cmd.Flags().StringVar(&o.Hash, "hash", "",
 		"hash of blob in hexadecimal (base16). Used if you want to sign an artifact stored elsewhere and have the hash")
 
diff --git a/cmd/cosign/cli/sign/sign_blob_test.go b/cmd/cosign/cli/sign/sign_blob_test.go
new file mode 100644
index 00000000..2f085358
--- /dev/null
+++ b/cmd/cosign/cli/sign/sign_blob_test.go
@@ -0,0 +1,62 @@
+// Copyright 2024 The Sigstore Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sign
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/sigstore/cosign/v2/cmd/cosign/cli/options"
+	"github.com/sigstore/cosign/v2/pkg/cosign"
+)
+
+func TestSignBlobCmd(t *testing.T) {
+	td := t.TempDir()
+	bundlePath := filepath.Join(td, "bundle.sigstore.json")
+
+	keys, _ := cosign.GenerateKeyPair(nil)
+	keyRef := writeFile(t, td, string(keys.PrivateBytes), "key.pem")
+
+	blob := []byte("foo")
+	blobPath := writeFile(t, td, string(blob), "foo.txt")
+
+	rootOpts := &options.RootOptions{}
+	keyOpts := options.KeyOpts{KeyRef: keyRef, BundlePath: bundlePath}
+
+	// Test happy path
+	_, err := SignBlobCmd(rootOpts, keyOpts, blobPath, true, "", "", false)
+	if err != nil {
+		t.Fatalf("unexpected error %v", err)
+	}
+
+	// Test file outputs
+	keyOpts.NewBundleFormat = true
+	sigPath := filepath.Join(td, "output.sig")
+	certPath := filepath.Join(td, "output.pem")
+	_, err = SignBlobCmd(rootOpts, keyOpts, blobPath, false, sigPath, certPath, false)
+	if err != nil {
+		t.Fatalf("unexpected error %v", err)
+	}
+}
+
+func writeFile(t *testing.T, td string, blob string, name string) string {
+	// Write blob to disk
+	blobPath := filepath.Join(td, name)
+	if err := os.WriteFile(blobPath, []byte(blob), 0644); err != nil {
+		t.Fatal(err)
+	}
+	return blobPath
+}
diff --git a/doc/cosign_attest-blob.md b/doc/cosign_attest-blob.md
index f9c50468..4849bb93 100644
--- a/doc/cosign_attest-blob.md
+++ b/doc/cosign_attest-blob.md
@@ -43,6 +43,7 @@ cosign attest-blob [flags]
       --identity-token string             identity token to use for certificate from fulcio. the token or a path to a file containing the token is accepted.
       --insecure-skip-verify              skip verifying fulcio published to the SCT (this should only be used for testing).
       --key string                        path to the private key file, KMS URI or Kubernetes Secret
+      --new-bundle-format                 output bundle in new format that contains all verification material
       --oidc-client-id string             OIDC client ID for application (default "sigstore")
       --oidc-client-secret-file string    Path to file containing OIDC client secret for application
       --oidc-disable-ambient-providers    Disable ambient OIDC providers. When true, ambient credentials will not be read
diff --git a/pkg/cosign/bundle/protobundle_test.go b/pkg/cosign/bundle/protobundle_test.go
new file mode 100644
index 00000000..dea1bce5
--- /dev/null
+++ b/pkg/cosign/bundle/protobundle_test.go
@@ -0,0 +1,92 @@
+// Copyright 2024 The Sigstore Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bundle
+
+import (
+	"testing"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+	"github.com/sigstore/rekor/pkg/generated/models"
+	_ "github.com/sigstore/rekor/pkg/types/hashedrekord"
+	_ "github.com/sigstore/rekor/pkg/types/hashedrekord/v0.0.1"
+)
+
+func TestMakeProtobufBundle(t *testing.T) {
+	testCases := []struct {
+		name           string
+		hint           string
+		rawCert        []byte
+		rekorEntry     *models.LogEntryAnon
+		timestampBytes []byte
+	}{
+		{
+			name:           "hint with timestamp",
+			hint:           "asdf",
+			rawCert:        []byte{},
+			rekorEntry:     nil,
+			timestampBytes: []byte("timestamp"),
+		},
+		{
+			name:           "only cert",
+			hint:           "",
+			rawCert:        []byte("cert stuff"),
+			rekorEntry:     nil,
+			timestampBytes: []byte{},
+		},
+		{
+			name:    "cert with rekor entry",
+			hint:    "",
+			rawCert: []byte("cert stuff"),
+			rekorEntry: &models.LogEntryAnon{
+				Body:           "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI2MmQwOGYyOGM2OWNhZGE3YjQyYTQ1Nzk0YjQ3ZWU2YzgxYTdkZmE3MTY4NDZiMzljODhmMGFkMTljMjA2OTk3In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQm14U0N1TW1HSzhNQWRMd1FWZ21TZjVXKzlkdU5iQXN1cUNQNlNucUxCUkFpRUFvNGtGRVdDTG9HcTVUaysrUEhtTEgrb3N1emVTRjN4OTdBbmVicTRlbVRvPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVUk1ha05EUVhKVFowRjNTVUpCWjBsVVRWQkRlVXdyYmxOb2MycHdaa2hZYUZkYVRVWkNUVUZIUlVSQlMwSm5aM0ZvYTJwUFVGRlJSRUY2UVhFS1RWSlZkMFYzV1VSV1VWRkxSWGQ0ZW1GWFpIcGtSemw1V2xNMWExcFlXWGhGVkVGUVFtZE9Wa0pCVFZSRFNFNXdXak5PTUdJelNteE5RalJZUkZSSmVRcE5SRWwzVFdwRmQwNUVXWGhOVm05WVJGUkplVTFFU1hkTmFrVjNUbFJaZUUxR2IzZEZla1ZTVFVFNFIwRXhWVVZEYUUxSll6SnNibU16VW5aamJWVjNDbGRVUVZSQ1oyTnhhR3RxVDFCUlNVSkNaMmR4YUd0cVQxQlJUVUpDZDA1RFFVRlVVMVJ2VEhWS2N5OTFSV05IU2tRME5VWmFiVE5wWmxKTU4yOXVRVWNLWlZaNWJuWkhVbmN6WnpKMU0wbFhTREZuU2tSamNERjRSWFI2UVZCUWJYQmhlVGRtTmxCNE1XeFpNa0ZyWnpsMGEyb3dRa1J2UTNkdk5FbENlbXBEUXdwQlkyOTNSR2RaUkZaU01GQkJVVWd2UWtGUlJFRm5aVUZOUWsxSFFURlZaRXBSVVUxTlFXOUhRME56UjBGUlZVWkNkMDFFVFVGM1IwRXhWV1JGZDBWQ0NpOTNVVU5OUVVGM1NGRlpSRlpTTUU5Q1FsbEZSazlSYTNZNVoyMVpXVFpWU0doQ1pWSnJMMWx4VlVsaU1WRldiMDFDT0VkQk1WVmtTWGRSV1UxQ1lVRUtSa1pxUVVoc0sxSlNZVlp0Y1ZoeVRXdExSMVJKZEVGeGVHTllOazFIVFVkQk1WVmtSVkZTWTAxR2NVZFhSMmd3WkVoQ2VrOXBPSFphTW13d1lVaFdhUXBNYlU1MllsTTVjbGx1VGpCTU0xSnNZMjVLYUZwdE9YbGlVekZ5WkZkS2JHTXpVbWhaTW5OMlRHMWtjR1JIYURGWmFUa3pZak5LY2xwdGVIWmtNMDEyQ21KWFJuQmlhVFUxWWxkNFFXTnRWbTFqZVRsdldsZEdhMk41T1hSWldFNHdXbGhKZDBWbldVdExkMWxDUWtGSFJIWjZRVUpCWjFGRlkwaFdlbUZFUVcwS1FtZHZja0puUlVWQldVOHZUVUZGUmtKQ2FISlpiazR3VEROU2JHTnVTbWhhYlRsNVlsTXhjbVJYU214ak0xSm9XVEp6ZDA1bldVdExkMWxDUWtGSFJBcDJla0ZDUVhkUmIxcHFSVEZPVjFGNVdXMUplRTU2V210TlZFVTFXV3BHYlU5VVNUSk9WRlV6V1hwTk5WbDZTbWxQVkdzMVdtcFNhRnBxWjNkWmVrRTFDa0puYjNKQ1owVkZRVmxQTDAxQlJVSkNRM1J2WkVoU2QyTjZiM1pNTTFKMllUSldkVXh0Um1wa1IyeDJZbTVOZFZveWJEQmhTRlpwWkZoT2JHTnRUbllLWW01U2JHSnVVWFZaTWpsMFRVSTRSME5wYzBkQlVWRkNaemM0ZDBGUldVVkZXRXBzV201TmRtRkhWbWhhU0UxMllsZEdlbVJIVm5sTlEwRkhRMmx6UndwQlVWRkNaemM0ZDBGUlVVVkZhMG94WVZkNGEwbEdVbXhqTTFGblZVaFdhV0pIYkhwaFJFRkxRbWRuY1docmFrOVFVVkZFUVhkT2IwRkVRbXhCYWtWQkNtdDJORTFLYUdGRGFFMUJaMHBWVTNWWll6bFBWRWt3WTB0bU9XTnlObU14Y1RreVYyOXFMM1ZsV0RKRFR6Z3JMMDQyU25SM1FVNTRVSElyTjNWNlpGQUtRV3BDYVhwR2NHZEVMelJzWW5aa1NuRnplWE5HYlVSeU1TdFNNSGhKWjI1S1N5c3JaWGROYmtKaVMxQkVMemd3VTNJelFYTTVMMWxxV1U5M05EVjRkUXA2ZVdzOUNpMHRMUzB0UlU1RUlFTkZVbFJKUmtsRFFWUkZMUzB0TFMwSyJ9fX19",
+				IntegratedTime: swag.Int64(123),
+				LogID:          swag.String("deadbeef"),
+				LogIndex:       swag.Int64(2),
+				Verification: &models.LogEntryAnonVerification{
+					InclusionProof: &models.InclusionProof{
+						Checkpoint: swag.String("checkpoint"),
+						Hashes:     []string{"deadbeef", "abcdefaa"},
+						LogIndex:   swag.Int64(1),
+						RootHash:   swag.String("abcdefaa"),
+						TreeSize:   swag.Int64(2),
+					},
+					SignedEntryTimestamp: strfmt.Base64("set"),
+				},
+			},
+			timestampBytes: []byte{},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			bundle, err := MakeProtobufBundle(tc.hint, tc.rawCert, tc.rekorEntry, tc.timestampBytes)
+			if err != nil {
+				t.Errorf("unexpected err %s", err)
+			}
+			if tc.hint != "" && bundle.VerificationMaterial.GetPublicKey() == nil {
+				t.Errorf("Verification material should be public key")
+			}
+			if len(tc.rawCert) > 0 && bundle.VerificationMaterial.GetCertificate() == nil {
+				t.Errorf("Verification material should be certificate")
+			}
+			if tc.rekorEntry != nil && len(bundle.VerificationMaterial.GetTlogEntries()) == 0 {
+				t.Errorf("Verification material should contain Tlog Entries")
+			}
+			if len(tc.timestampBytes) > 0 && bundle.VerificationMaterial.GetTimestampVerificationData() == nil {
+				t.Errorf("Verification material should have timestamp")
+			}
+		})
+	}
+}