Repository: sigstore/cosign
Pull request #2454 (2022-11-14 12:27:14)

Add TSA bundle annotation
Signed-off-by: Hector Fernandez <hector@chainguard.dev>

<!--
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. Pleases 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 change is related to https://github.com/sigstore/cosign/issues/2331. This PR adds a new annotation for the TSA Bundle.

#### 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.

-->

#### 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

-->

codecov-commenter: # [Codecov](https://codecov.io/gh/sigstore/cosign/pull/2454?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) Report
> Merging [#2454](https://codecov.io/gh/sigstore/cosign/pull/2454?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) (6cd3f95) into [main](https://codecov.io/gh/sigstore/cosign/commit/da32b916370329a19505346f121bb956486c6fe8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) (da32b91) will **increase** coverage by `0.32%`.
> The diff coverage is `87.75%`.

```diff
@@            Coverage Diff             @@
##             main    #2454      +/-   ##
==========================================
+ Coverage   30.65%   30.97%   +0.32%     
==========================================
  Files         138      138              
  Lines        8404     8452      +48     
==========================================
+ Hits         2576     2618      +42     
- Misses       5494     5498       +4     
- Partials      334      336       +2     
```


| [Impacted Files](https://codecov.io/gh/sigstore/cosign/pull/2454?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) | Coverage Δ | |
|---|---|---|
| [pkg/cosign/keys.go](https://codecov.io/gh/sigstore/cosign/pull/2454/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL2Nvc2lnbi9rZXlzLmdv) | `61.36% <ø> (ø)` | |
| [pkg/oci/static/options.go](https://codecov.io/gh/sigstore/cosign/pull/2454/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL29jaS9zdGF0aWMvb3B0aW9ucy5nbw==) | `88.46% <70.00%> (-4.40%)` | :arrow_down: |
| [pkg/oci/mutate/signature.go](https://codecov.io/gh/sigstore/cosign/pull/2454/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL29jaS9tdXRhdGUvc2lnbmF0dXJlLmdv) | `81.08% <76.92%> (-0.74%)` | :arrow_down: |
| [pkg/oci/internal/signature/layer.go](https://codecov.io/gh/sigstore/cosign/pull/2454/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL29jaS9pbnRlcm5hbC9zaWduYXR1cmUvbGF5ZXIuZ28=) | `79.68% <100.00%> (+3.76%)` | :arrow_up: |
| [pkg/oci/mutate/options.go](https://codecov.io/gh/sigstore/cosign/pull/2454/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL29jaS9tdXRhdGUvb3B0aW9ucy5nbw==) | `90.24% <100.00%> (+1.05%)` | :arrow_up: |
| [pkg/oci/signature/layer.go](https://codecov.io/gh/sigstore/cosign/pull/2454/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL29jaS9zaWduYXR1cmUvbGF5ZXIuZ28=) | `79.68% <100.00%> (+3.76%)` | :arrow_up: |
| [pkg/oci/static/signature.go](https://codecov.io/gh/sigstore/cosign/pull/2454/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL29jaS9zdGF0aWMvc2lnbmF0dXJlLmdv) | `50.96% <100.00%> (+0.96%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore)


Patch:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 7702a075..1dbed8b6 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -67,7 +67,7 @@ jobs:
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # v2.1.31
+      uses: github/codeql-action/init@4238421316c33d73aeea2801274dd286f157c2bb # v2.1.32
       with:
         languages: ${{ matrix.language }}
 
@@ -75,4 +75,4 @@ jobs:
       run: make cosign
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # v2.1.31
+      uses: github/codeql-action/analyze@4238421316c33d73aeea2801274dd286f157c2bb # v2.1.32
diff --git a/.github/workflows/scorecard_action.yml b/.github/workflows/scorecard_action.yml
index c1e6ccd4..3faab1a5 100644
--- a/.github/workflows/scorecard_action.yml
+++ b/.github/workflows/scorecard_action.yml
@@ -52,6 +52,6 @@ jobs:
 
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # v2.1.31
+        uses: github/codeql-action/upload-sarif@4238421316c33d73aeea2801274dd286f157c2bb # v2.1.32
         with:
           sarif_file: results.sarif
diff --git a/cmd/cosign/cli/options/verify.go b/cmd/cosign/cli/options/verify.go
index 72baa8b1..be09ad06 100644
--- a/cmd/cosign/cli/options/verify.go
+++ b/cmd/cosign/cli/options/verify.go
@@ -19,6 +19,15 @@ import (
 	"github.com/spf13/cobra"
 )
 
+type CommonVerifyOptions struct {
+	Offline bool // Force offline verification
+}
+
+func (o *CommonVerifyOptions) AddFlags(cmd *cobra.Command) {
+	cmd.Flags().BoolVar(&o.Offline, "offline", false,
+		"only allow offline verification")
+}
+
 // VerifyOptions is the top level wrapper for the `verify` command.
 type VerifyOptions struct {
 	Key          string
@@ -28,11 +37,12 @@ type VerifyOptions struct {
 	SignatureRef string
 	LocalImage   bool
 
-	SecurityKey     SecurityKeyOptions
-	CertVerify      CertVerifyOptions
-	Rekor           RekorOptions
-	Registry        RegistryOptions
-	SignatureDigest SignatureDigestOptions
+	CommonVerifyOptions CommonVerifyOptions
+	SecurityKey         SecurityKeyOptions
+	CertVerify          CertVerifyOptions
+	Rekor               RekorOptions
+	Registry            RegistryOptions
+	SignatureDigest     SignatureDigestOptions
 	AnnotationOptions
 }
 
@@ -46,6 +56,7 @@ func (o *VerifyOptions) AddFlags(cmd *cobra.Command) {
 	o.Registry.AddFlags(cmd)
 	o.SignatureDigest.AddFlags(cmd)
 	o.AnnotationOptions.AddFlags(cmd)
+	o.CommonVerifyOptions.AddFlags(cmd)
 
 	cmd.Flags().StringVar(&o.Key, "key", "",
 		"path to the public key file, KMS URI or Kubernetes Secret")
@@ -73,13 +84,14 @@ type VerifyAttestationOptions struct {
 	CheckClaims bool
 	Output      string
 
-	SecurityKey SecurityKeyOptions
-	Rekor       RekorOptions
-	CertVerify  CertVerifyOptions
-	Registry    RegistryOptions
-	Predicate   PredicateRemoteOptions
-	Policies    []string
-	LocalImage  bool
+	CommonVerifyOptions CommonVerifyOptions
+	SecurityKey         SecurityKeyOptions
+	Rekor               RekorOptions
+	CertVerify          CertVerifyOptions
+	Registry            RegistryOptions
+	Predicate           PredicateRemoteOptions
+	Policies            []string
+	LocalImage          bool
 }
 
 var _ Interface = (*VerifyAttestationOptions)(nil)
@@ -91,6 +103,7 @@ func (o *VerifyAttestationOptions) AddFlags(cmd *cobra.Command) {
 	o.CertVerify.AddFlags(cmd)
 	o.Registry.AddFlags(cmd)
 	o.Predicate.AddFlags(cmd)
+	o.CommonVerifyOptions.AddFlags(cmd)
 
 	cmd.Flags().StringVar(&o.Key, "key", "",
 		"path to the public key file, KMS URI or Kubernetes Secret")
@@ -114,10 +127,11 @@ type VerifyBlobOptions struct {
 	Signature  string
 	BundlePath string
 
-	SecurityKey SecurityKeyOptions
-	CertVerify  CertVerifyOptions
-	Rekor       RekorOptions
-	Registry    RegistryOptions
+	SecurityKey         SecurityKeyOptions
+	CertVerify          CertVerifyOptions
+	Rekor               RekorOptions
+	Registry            RegistryOptions
+	CommonVerifyOptions CommonVerifyOptions
 }
 
 var _ Interface = (*VerifyBlobOptions)(nil)
@@ -128,6 +142,7 @@ func (o *VerifyBlobOptions) AddFlags(cmd *cobra.Command) {
 	o.Rekor.AddFlags(cmd)
 	o.CertVerify.AddFlags(cmd)
 	o.Registry.AddFlags(cmd)
+	o.CommonVerifyOptions.AddFlags(cmd)
 
 	cmd.Flags().StringVar(&o.Key, "key", "",
 		"path to the public key file, KMS URI or Kubernetes Secret")
diff --git a/cmd/cosign/cli/verify.go b/cmd/cosign/cli/verify.go
index d016fefa..4999d7fb 100644
--- a/cmd/cosign/cli/verify.go
+++ b/cmd/cosign/cli/verify.go
@@ -92,7 +92,7 @@ against the transparency log.`,
 				return err
 			}
 
-			v := verify.VerifyCommand{
+			v := &verify.VerifyCommand{
 				RegistryOptions:              o.Registry,
 				CheckClaims:                  o.CheckClaims,
 				KeyRef:                       o.Key,
@@ -117,6 +117,7 @@ against the transparency log.`,
 				HashAlgorithm:                hashAlgorithm,
 				SignatureRef:                 o.SignatureRef,
 				LocalImage:                   o.LocalImage,
+				Offline:                      o.CommonVerifyOptions.Offline,
 			}
 
 			if o.Registry.AllowInsecure {
@@ -183,7 +184,7 @@ against the transparency log.`,
 		Args:             cobra.MinimumNArgs(1),
 		PersistentPreRun: options.BindViper,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			v := verify.VerifyAttestationCommand{
+			v := &verify.VerifyAttestationCommand{
 				RegistryOptions:              o.Registry,
 				CheckClaims:                  o.CheckClaims,
 				CertRef:                      o.CertVerify.Cert,
@@ -207,6 +208,7 @@ against the transparency log.`,
 				Policies:                     o.Policies,
 				LocalImage:                   o.LocalImage,
 				NameOptions:                  o.Registry.NameOptions(),
+				Offline:                      o.CommonVerifyOptions.Offline,
 			}
 
 			return v.Exec(cmd.Context(), args)
@@ -275,7 +277,7 @@ The blob may be specified as a path to a file or - for stdin.`,
 				RekorURL:   o.Rekor.URL,
 				BundlePath: o.BundlePath,
 			}
-			verifyBlobCmd := verify.VerifyBlobCmd{
+			verifyBlobCmd := &verify.VerifyBlobCmd{
 				KeyOpts:                      ko,
 				CertRef:                      o.CertVerify.Cert,
 				CertEmail:                    o.CertVerify.CertEmail,
@@ -290,6 +292,7 @@ The blob may be specified as a path to a file or - for stdin.`,
 				CertGithubWorkflowRef:        o.CertVerify.CertGithubWorkflowRef,
 				IgnoreSCT:                    o.CertVerify.IgnoreSCT,
 				SCTRef:                       o.CertVerify.SCT,
+				Offline:                      o.CommonVerifyOptions.Offline,
 			}
 			if err := verifyBlobCmd.Exec(cmd.Context(), args[0]); err != nil {
 				return fmt.Errorf("verifying blob %s: %w", args, err)
diff --git a/cmd/cosign/cli/verify/verify.go b/cmd/cosign/cli/verify/verify.go
index c0ebb1ce..5135edfb 100644
--- a/cmd/cosign/cli/verify/verify.go
+++ b/cmd/cosign/cli/verify/verify.go
@@ -74,6 +74,7 @@ type VerifyCommand struct {
 	HashAlgorithm                crypto.Hash
 	LocalImage                   bool
 	NameOptions                  []name.Option
+	Offline                      bool
 }
 
 // Exec runs the verification command
@@ -111,6 +112,7 @@ func (c *VerifyCommand) Exec(ctx context.Context, images []string) (err error) {
 		CertGithubWorkflowRef:        c.CertGithubWorkflowRef,
 		IgnoreSCT:                    c.IgnoreSCT,
 		SignatureRef:                 c.SignatureRef,
+		Offline:                      c.Offline,
 	}
 	if c.CheckClaims {
 		co.ClaimVerifier = cosign.SimpleClaimVerifier
diff --git a/cmd/cosign/cli/verify/verify_blob.go b/cmd/cosign/cli/verify/verify_blob.go
index a99ae22b..e5745c5f 100644
--- a/cmd/cosign/cli/verify/verify_blob.go
+++ b/cmd/cosign/cli/verify/verify_blob.go
@@ -82,6 +82,7 @@ type VerifyBlobCmd struct {
 	CertGithubWorkflowRef        string
 	IgnoreSCT                    bool
 	SCTRef                       string
+	Offline                      bool
 }
 
 // nolint
@@ -114,6 +115,7 @@ func (c *VerifyBlobCmd) Exec(ctx context.Context, blobRef string) error {
 		CertGithubWorkflowRepository: c.CertGithubWorkflowRepository,
 		CertGithubWorkflowRef:        c.CertGithubWorkflowRef,
 		IgnoreSCT:                    c.IgnoreSCT,
+		Offline:                      c.Offline,
 	}
 	if options.EnableExperimental() {
 		if c.RekorURL != "" {
diff --git a/doc/cosign_dockerfile_verify.md b/doc/cosign_dockerfile_verify.md
index 9774518f..9e421659 100644
--- a/doc/cosign_dockerfile_verify.md
+++ b/doc/cosign_dockerfile_verify.md
@@ -74,6 +74,7 @@ cosign dockerfile verify [flags]
       --k8s-keychain                                                                             whether to use the kubernetes keychain instead of the default keychain (supports workload identity).
       --key string                                                                               path to the public key file, KMS URI or Kubernetes Secret
       --local-image                                                                              whether the specified image is a path to an image saved locally via 'cosign save'
+      --offline                                                                                  only allow offline verification
   -o, --output string                                                                            output format for the signing image information (json|text) (default "json")
       --rekor-url string                                                                         [EXPERIMENTAL] address of rekor STL server (default "https://rekor.sigstore.dev")
       --sct string                                                                               path to a detached Signed Certificate Timestamp, formatted as a RFC6962 AddChainResponse struct. If a certificate contains an SCT, verification will check both the detached and embedded SCTs.
diff --git a/doc/cosign_manifest_verify.md b/doc/cosign_manifest_verify.md
index f7299fe5..2a586275 100644
--- a/doc/cosign_manifest_verify.md
+++ b/doc/cosign_manifest_verify.md
@@ -68,6 +68,7 @@ cosign manifest verify [flags]
       --k8s-keychain                                                                             whether to use the kubernetes keychain instead of the default keychain (supports workload identity).
       --key string                                                                               path to the public key file, KMS URI or Kubernetes Secret
       --local-image                                                                              whether the specified image is a path to an image saved locally via 'cosign save'
+      --offline                                                                                  only allow offline verification
   -o, --output string                                                                            output format for the signing image information (json|text) (default "json")
       --rekor-url string                                                                         [EXPERIMENTAL] address of rekor STL server (default "https://rekor.sigstore.dev")
       --sct string                                                                               path to a detached Signed Certificate Timestamp, formatted as a RFC6962 AddChainResponse struct. If a certificate contains an SCT, verification will check both the detached and embedded SCTs.
diff --git a/doc/cosign_verify-blob.md b/doc/cosign_verify-blob.md
index 620d1b82..a96feb58 100644
--- a/doc/cosign_verify-blob.md
+++ b/doc/cosign_verify-blob.md
@@ -76,6 +76,7 @@ cosign verify-blob [flags]
       --insecure-ignore-sct                                                                      when set, verification will not check that a certificate contains an embedded SCT, a proof of inclusion in a certificate transparency log
       --k8s-keychain                                                                             whether to use the kubernetes keychain instead of the default keychain (supports workload identity).
       --key string                                                                               path to the public key file, KMS URI or Kubernetes Secret
+      --offline                                                                                  only allow offline verification
       --rekor-url string                                                                         [EXPERIMENTAL] address of rekor STL server (default "https://rekor.sigstore.dev")
       --sct string                                                                               path to a detached Signed Certificate Timestamp, formatted as a RFC6962 AddChainResponse struct. If a certificate contains an SCT, verification will check both the detached and embedded SCTs.
       --signature string                                                                         signature content or path or remote URL
diff --git a/doc/cosign_verify.md b/doc/cosign_verify.md
index bb59b56e..0157f7a3 100644
--- a/doc/cosign_verify.md
+++ b/doc/cosign_verify.md
@@ -84,6 +84,7 @@ cosign verify [flags]
       --k8s-keychain                                                                             whether to use the kubernetes keychain instead of the default keychain (supports workload identity).
       --key string                                                                               path to the public key file, KMS URI or Kubernetes Secret
       --local-image                                                                              whether the specified image is a path to an image saved locally via 'cosign save'
+      --offline                                                                                  only allow offline verification
   -o, --output string                                                                            output format for the signing image information (json|text) (default "json")
       --rekor-url string                                                                         [EXPERIMENTAL] address of rekor STL server (default "https://rekor.sigstore.dev")
       --sct string                                                                               path to a detached Signed Certificate Timestamp, formatted as a RFC6962 AddChainResponse struct. If a certificate contains an SCT, verification will check both the detached and embedded SCTs.
diff --git a/pkg/cosign/keys.go b/pkg/cosign/keys.go
index 0a4e9398..f224ab92 100644
--- a/pkg/cosign/keys.go
+++ b/pkg/cosign/keys.go
@@ -46,6 +46,7 @@ const (
 	// PEM-encoded PKCS #8 RSA, ECDSA or ED25519 private key
 	PrivateKeyPemType = "PRIVATE KEY"
 	BundleKey         = static.BundleAnnotationKey
+	TSABundleKey      = static.TSABundleAnnotationKey
 )
 
 // PassFunc is the function to be called to retrieve the signer password. If
diff --git a/pkg/cosign/verify.go b/pkg/cosign/verify.go
index 97540d84..5b98fa34 100644
--- a/pkg/cosign/verify.go
+++ b/pkg/cosign/verify.go
@@ -121,6 +121,9 @@ type CheckOpts struct {
 	// to be met for the signature to ve valid.
 	// Supercedes CertEmail / CertOidcIssuer
 	Identities []Identity
+
+	// Force offline verification of the signature
+	Offline bool
 }
 
 // This is a substitutable signature verification function that can be used for verifying
@@ -673,8 +676,13 @@ func verifyInternal(ctx context.Context, sig oci.Signature, h v1.Hash,
 	}
 
 	bundleVerified, err = VerifyBundle(ctx, sig, co.RekorClient)
-	if err != nil && co.RekorClient == nil {
-		return false, fmt.Errorf("unable to verify bundle: %w", err)
+	if err != nil {
+		return false, fmt.Errorf("error verifying bundle: %w", err)
+	}
+
+	// If the --offline flag was specified, fail here
+	if !bundleVerified && co.Offline {
+		return false, fmt.Errorf("offline verification failed")
 	}
 
 	if !bundleVerified && co.RekorClient != nil {
diff --git a/pkg/oci/internal/signature/layer.go b/pkg/oci/internal/signature/layer.go
index 3a815d3c..35b1c29e 100644
--- a/pkg/oci/internal/signature/layer.go
+++ b/pkg/oci/internal/signature/layer.go
@@ -29,10 +29,11 @@ import (
 )
 
 const (
-	sigkey    = "dev.cosignproject.cosign/signature"
-	certkey   = "dev.sigstore.cosign/certificate"
-	chainkey  = "dev.sigstore.cosign/chain"
-	BundleKey = "dev.sigstore.cosign/bundle"
+	sigkey       = "dev.cosignproject.cosign/signature"
+	certkey      = "dev.sigstore.cosign/certificate"
+	chainkey     = "dev.sigstore.cosign/chain"
+	BundleKey    = "dev.sigstore.cosign/bundle"
+	TSABundleKey = "dev.sigstore.cosign/3161timestamp"
 )
 
 type sigLayer struct {
@@ -115,3 +116,16 @@ func (s *sigLayer) Bundle() (*bundle.RekorBundle, error) {
 	}
 	return &b, nil
 }
+
+// TSABundle implements oci.Signature
+func (s *sigLayer) TSABundle() (*bundle.TSABundle, error) {
+	val := s.desc.Annotations[TSABundleKey]
+	if val == "" {
+		return nil, nil
+	}
+	var b bundle.TSABundle
+	if err := json.Unmarshal([]byte(val), &b); err != nil {
+		return nil, fmt.Errorf("unmarshaling tsa bundle: %w", err)
+	}
+	return &b, nil
+}
diff --git a/pkg/oci/mutate/options.go b/pkg/oci/mutate/options.go
index 0c19f3da..35e9282f 100644
--- a/pkg/oci/mutate/options.go
+++ b/pkg/oci/mutate/options.go
@@ -62,6 +62,7 @@ func WithReplaceOp(ro ReplaceOp) SignOption {
 type signatureOpts struct {
 	annotations map[string]string
 	bundle      *bundle.RekorBundle
+	tsaBundle   *bundle.TSABundle
 	cert        []byte
 	chain       []byte
 	mediaType   types.MediaType
@@ -83,6 +84,13 @@ func WithBundle(b *bundle.RekorBundle) SignatureOption {
 	}
 }
 
+// WithTSABundle specifies the new TSABundle the Signature should have.
+func WithTSABundle(b *bundle.TSABundle) SignatureOption {
+	return func(so *signatureOpts) {
+		so.tsaBundle = b
+	}
+}
+
 // WithCertChain specifies the new cert and chain the Signature should have.
 func WithCertChain(cert, chain []byte) SignatureOption {
 	return func(so *signatureOpts) {
diff --git a/pkg/oci/mutate/signature.go b/pkg/oci/mutate/signature.go
index ed07f454..0955a4af 100644
--- a/pkg/oci/mutate/signature.go
+++ b/pkg/oci/mutate/signature.go
@@ -34,6 +34,7 @@ type sigWrapper struct {
 
 	annotations map[string]string
 	bundle      *bundle.RekorBundle
+	tsaBundle   *bundle.TSABundle
 	cert        *x509.Certificate
 	chain       []*x509.Certificate
 	mediaType   types.MediaType
@@ -92,6 +93,14 @@ func (sw *sigWrapper) Bundle() (*bundle.RekorBundle, error) {
 	return sw.wrapped.Bundle()
 }
 
+// TSABundle implements oci.Signature.
+func (sw *sigWrapper) TSABundle() (*bundle.TSABundle, error) {
+	if sw.tsaBundle != nil {
+		return sw.tsaBundle, nil
+	}
+	return sw.wrapped.TSABundle()
+}
+
 // MediaType implements v1.Layer
 func (sw *sigWrapper) MediaType() (types.MediaType, error) {
 	if sw.mediaType != "" {
@@ -139,7 +148,7 @@ func Signature(original oci.Signature, opts ...SignatureOption) (oci.Signature,
 	if so.annotations != nil {
 		newAnn = copyAnnotations(so.annotations)
 		newAnn[static.SignatureAnnotationKey] = oldAnn[static.SignatureAnnotationKey]
-		for _, key := range []string{static.BundleAnnotationKey, static.CertificateAnnotationKey, static.ChainAnnotationKey} {
+		for _, key := range []string{static.BundleAnnotationKey, static.CertificateAnnotationKey, static.ChainAnnotationKey, static.TSABundleAnnotationKey} {
 			if val, isSet := oldAnn[key]; isSet {
 				newAnn[key] = val
 			} else {
@@ -159,6 +168,15 @@ func Signature(original oci.Signature, opts ...SignatureOption) (oci.Signature,
 		newAnn[static.BundleAnnotationKey] = string(b)
 	}
 
+	if so.tsaBundle != nil {
+		newSig.tsaBundle = so.tsaBundle
+		b, err := json.Marshal(so.tsaBundle)
+		if err != nil {
+			return nil, err
+		}
+		newAnn[static.TSABundleAnnotationKey] = string(b)
+	}
+
 	if so.cert != nil {
 		var cert *x509.Certificate
 		var chain []*x509.Certificate
diff --git a/pkg/oci/signature/layer.go b/pkg/oci/signature/layer.go
index 3a815d3c..35b1c29e 100644
--- a/pkg/oci/signature/layer.go
+++ b/pkg/oci/signature/layer.go
@@ -29,10 +29,11 @@ import (
 )
 
 const (
-	sigkey    = "dev.cosignproject.cosign/signature"
-	certkey   = "dev.sigstore.cosign/certificate"
-	chainkey  = "dev.sigstore.cosign/chain"
-	BundleKey = "dev.sigstore.cosign/bundle"
+	sigkey       = "dev.cosignproject.cosign/signature"
+	certkey      = "dev.sigstore.cosign/certificate"
+	chainkey     = "dev.sigstore.cosign/chain"
+	BundleKey    = "dev.sigstore.cosign/bundle"
+	TSABundleKey = "dev.sigstore.cosign/3161timestamp"
 )
 
 type sigLayer struct {
@@ -115,3 +116,16 @@ func (s *sigLayer) Bundle() (*bundle.RekorBundle, error) {
 	}
 	return &b, nil
 }
+
+// TSABundle implements oci.Signature
+func (s *sigLayer) TSABundle() (*bundle.TSABundle, error) {
+	val := s.desc.Annotations[TSABundleKey]
+	if val == "" {
+		return nil, nil
+	}
+	var b bundle.TSABundle
+	if err := json.Unmarshal([]byte(val), &b); err != nil {
+		return nil, fmt.Errorf("unmarshaling tsa bundle: %w", err)
+	}
+	return &b, nil
+}
diff --git a/pkg/oci/signatures.go b/pkg/oci/signatures.go
index e66b7c6c..cec3d5bb 100644
--- a/pkg/oci/signatures.go
+++ b/pkg/oci/signatures.go
@@ -59,4 +59,8 @@ type Signature interface {
 	// Bundle fetches the optional metadata that records the ephemeral
 	// Fulcio key in the transparency log.
 	Bundle() (*bundle.RekorBundle, error)
+
+	// TSABundle fetches the optional metadata that records a
+	// RFC3161 signed timestamp.
+	TSABundle() (*bundle.TSABundle, error)
 }
diff --git a/pkg/oci/static/options.go b/pkg/oci/static/options.go
index e00b6cdb..62f7960e 100644
--- a/pkg/oci/static/options.go
+++ b/pkg/oci/static/options.go
@@ -30,6 +30,7 @@ type options struct {
 	LayerMediaType  types.MediaType
 	ConfigMediaType types.MediaType
 	Bundle          *bundle.RekorBundle
+	TSABundle       *bundle.TSABundle
 	Cert            []byte
 	Chain           []byte
 	Annotations     map[string]string
@@ -59,6 +60,13 @@ func makeOptions(opts ...Option) (*options, error) {
 		o.Annotations[BundleAnnotationKey] = string(b)
 	}
 
+	if o.TSABundle != nil {
+		b, err := json.Marshal(o.TSABundle)
+		if err != nil {
+			return nil, err
+		}
+		o.Annotations[TSABundleAnnotationKey] = string(b)
+	}
 	return o, nil
 }
 
@@ -90,6 +98,13 @@ func WithBundle(b *bundle.RekorBundle) Option {
 	}
 }
 
+// WithTSABundle sets the time-stamping bundle to attach to the signature
+func WithTSABundle(b *bundle.TSABundle) Option {
+	return func(o *options) {
+		o.TSABundle = b
+	}
+}
+
 // WithCertChain sets the certificate chain for this signature.
 func WithCertChain(cert, chain []byte) Option {
 	return func(o *options) {
diff --git a/pkg/oci/static/signature.go b/pkg/oci/static/signature.go
index 1766102c..19051c5a 100644
--- a/pkg/oci/static/signature.go
+++ b/pkg/oci/static/signature.go
@@ -32,6 +32,7 @@ const (
 	CertificateAnnotationKey = "dev.sigstore.cosign/certificate"
 	ChainAnnotationKey       = "dev.sigstore.cosign/chain"
 	BundleAnnotationKey      = "dev.sigstore.cosign/bundle"
+	TSABundleAnnotationKey   = "dev.sigstore.cosign/tsabundle"
 )
 
 // NewSignature constructs a new oci.Signature from the provided options.
@@ -162,6 +163,11 @@ func (l *staticLayer) Bundle() (*bundle.RekorBundle, error) {
 	return l.opts.Bundle, nil
 }
 
+// TSABundle implements oci.Signature
+func (l *staticLayer) TSABundle() (*bundle.TSABundle, error) {
+	return l.opts.TSABundle, nil
+}
+
 // Digest implements v1.Layer
 func (l *staticLayer) Digest() (v1.Hash, error) {
 	h, _, err := v1.SHA256(bytes.NewReader(l.b))


Test patch:
diff --git a/.github/workflows/kind-verify-attestation.yaml b/.github/workflows/kind-verify-attestation.yaml
index d37fd7ba..98022e4e 100644
--- a/.github/workflows/kind-verify-attestation.yaml
+++ b/.github/workflows/kind-verify-attestation.yaml
@@ -57,7 +57,7 @@ jobs:
     - uses: imjasonh/setup-ko@ace48d793556083a76f1e3e6068850c1f4a369aa # v0.6
 
     - name: Install yq
-      uses: mikefarah/yq@83ef7ee5cfd9e39d967130871def934042d94b21 # v4.30.1
+      uses: mikefarah/yq@ce9701ca3a1ac2779ff5f3857d04ee003acda066 # v4.30.2
 
     - name: build cosign
       run: |
diff --git a/cmd/cosign/cli/verify/verify_attestation.go b/cmd/cosign/cli/verify/verify_attestation.go
index a7f7d406..2d2cd8d6 100644
--- a/cmd/cosign/cli/verify/verify_attestation.go
+++ b/cmd/cosign/cli/verify/verify_attestation.go
@@ -64,6 +64,7 @@ type VerifyAttestationCommand struct {
 	Policies                     []string
 	LocalImage                   bool
 	NameOptions                  []name.Option
+	Offline                      bool
 }
 
 // Exec runs the verification command
@@ -91,6 +92,7 @@ func (c *VerifyAttestationCommand) Exec(ctx context.Context, images []string) (e
 		CertGithubWorkflowRepository: c.CertGithubWorkflowRepository,
 		CertGithubWorkflowRef:        c.CertGithubWorkflowRef,
 		IgnoreSCT:                    c.IgnoreSCT,
+		Offline:                      c.Offline,
 	}
 	if c.CheckClaims {
 		co.ClaimVerifier = cosign.IntotoSubjectClaimVerifier
diff --git a/doc/cosign_verify-attestation.md b/doc/cosign_verify-attestation.md
index 12be0e2a..f79d218d 100644
--- a/doc/cosign_verify-attestation.md
+++ b/doc/cosign_verify-attestation.md
@@ -78,6 +78,7 @@ cosign verify-attestation [flags]
       --k8s-keychain                                                                             whether to use the kubernetes keychain instead of the default keychain (supports workload identity).
       --key string                                                                               path to the public key file, KMS URI or Kubernetes Secret
       --local-image                                                                              whether the specified image is a path to an image saved locally via 'cosign save'
+      --offline                                                                                  only allow offline verification
   -o, --output string                                                                            output format for the signing image information (json|text) (default "json")
       --policy strings                                                                           specify CUE or Rego files will be using for validation
       --rekor-url string                                                                         [EXPERIMENTAL] address of rekor STL server (default "https://rekor.sigstore.dev")
diff --git a/pkg/oci/internal/signature/layer_test.go b/pkg/oci/internal/signature/layer_test.go
index 929dd975..8721b5c7 100644
--- a/pkg/oci/internal/signature/layer_test.go
+++ b/pkg/oci/internal/signature/layer_test.go
@@ -278,3 +278,178 @@ Hr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==
 		})
 	}
 }
+
+func TestSignatureWithTSAAnnotation(t *testing.T) {
+	layer, err := random.Layer(300 /* byteSize */, types.DockerLayer)
+	if err != nil {
+		t.Fatalf("random.Layer() = %v", err)
+	}
+	digest, err := layer.Digest()
+	if err != nil {
+		t.Fatalf("Digest() = %v", err)
+	}
+
+	tests := []struct {
+		name           string
+		l              *sigLayer
+		wantPayloadErr error
+		wantSig        string
+		wantSigErr     error
+		wantCert       bool
+		wantCertErr    error
+		wantChain      int
+		wantChainErr   error
+		wantBundle     *bundle.TSABundle
+		wantBundleErr  error
+	}{{
+		name: "just payload and signature",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey: "blah",
+				},
+			},
+		},
+		wantSig: "blah",
+	}, {
+		name: "with empty other keys",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:       "blah",
+					certkey:      "",
+					chainkey:     "",
+					TSABundleKey: "",
+				},
+			},
+		},
+		wantSig: "blah",
+	}, {
+		name: "missing signature",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+			},
+		},
+		wantSigErr: fmt.Errorf("signature layer %s is missing %q annotation", digest, sigkey),
+	}, {
+		name: "min plus bad bundle",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:       "blah",
+					TSABundleKey: `}`,
+				},
+			},
+		},
+		wantSig:       "blah",
+		wantBundleErr: errors.New(`unmarshaling tsa bundle: invalid character '}' looking for beginning of value`),
+	}, {
+		name: "min plus bad cert",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:  "blah",
+					certkey: `GARBAGE`,
+				},
+			},
+		},
+		wantSig:     "blah",
+		wantCertErr: errors.New(`error during PEM decoding`),
+	}, {
+		name: "min plus bad chain",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:   "blah",
+					chainkey: `GARBAGE`,
+				},
+			},
+		},
+		wantSig:      "blah",
+		wantChainErr: errors.New(`error during PEM decoding`),
+	}, {
+		name: "min plus TSA bundle",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey: "TSA blah",
+					// This was extracted from gcr.io/distroless/static:nonroot on 2021/09/16.
+					// The Body has been removed for brevity
+					TSABundleKey: `{"SignedRFC3161Timestamp":"MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="}`,
+				},
+			},
+		},
+		wantSig: "TSA blah",
+		wantBundle: &bundle.TSABundle{
+			SignedRFC3161Timestamp: mustDecode("MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="),
+		},
+	}}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			b, err := test.l.Payload()
+			switch {
+			case (err != nil) != (test.wantPayloadErr != nil):
+				t.Errorf("Payload() = %v, wanted %v", err, test.wantPayloadErr)
+			case (err != nil) && (test.wantPayloadErr != nil) && err.Error() != test.wantPayloadErr.Error():
+				t.Errorf("Payload() = %v, wanted %v", err, test.wantPayloadErr)
+			case err == nil:
+				if got, _, err := v1.SHA256(bytes.NewBuffer(b)); err != nil {
+					t.Errorf("v1.SHA256() = %v", err)
+				} else if want := digest; want != got {
+					t.Errorf("v1.SHA256() = %v, wanted %v", got, want)
+				}
+			}
+
+			switch got, err := test.l.Base64Signature(); {
+			case (err != nil) != (test.wantSigErr != nil):
+				t.Errorf("Base64Signature() = %v, wanted %v", err, test.wantSigErr)
+			case (err != nil) && (test.wantSigErr != nil) && err.Error() != test.wantSigErr.Error():
+				t.Errorf("Base64Signature() = %v, wanted %v", err, test.wantSigErr)
+			case got != test.wantSig:
+				t.Errorf("Base64Signature() = %v, wanted %v", got, test.wantSig)
+			}
+
+			switch got, err := test.l.Cert(); {
+			case (err != nil) != (test.wantCertErr != nil):
+				t.Errorf("Cert() = %v, wanted %v", err, test.wantCertErr)
+			case (err != nil) && (test.wantCertErr != nil) && err.Error() != test.wantCertErr.Error():
+				t.Errorf("Cert() = %v, wanted %v", err, test.wantCertErr)
+			case (got != nil) != test.wantCert:
+				t.Errorf("Cert() = %v, wanted cert? %v", got, test.wantCert)
+			}
+
+			switch got, err := test.l.Chain(); {
+			case (err != nil) != (test.wantChainErr != nil):
+				t.Errorf("Chain() = %v, wanted %v", err, test.wantChainErr)
+			case (err != nil) && (test.wantChainErr != nil) && err.Error() != test.wantChainErr.Error():
+				t.Errorf("Chain() = %v, wanted %v", err, test.wantChainErr)
+			case len(got) != test.wantChain:
+				t.Errorf("Chain() = %v, wanted chain of length %d", got, test.wantChain)
+			}
+
+			switch got, err := test.l.TSABundle(); {
+			case (err != nil) != (test.wantBundleErr != nil):
+				t.Errorf("TSABundle() = %v, wanted %v", err, test.wantBundleErr)
+			case (err != nil) && (test.wantBundleErr != nil) && err.Error() != test.wantBundleErr.Error():
+				t.Errorf("TSABundle() = %v, wanted %v", err, test.wantBundleErr)
+			case !cmp.Equal(got, test.wantBundle):
+				t.Errorf("TSABundle() %s", cmp.Diff(got, test.wantBundle))
+			}
+		})
+	}
+}
diff --git a/pkg/oci/mutate/signature_test.go b/pkg/oci/mutate/signature_test.go
index 1e17e9bc..6d7ab5f8 100644
--- a/pkg/oci/mutate/signature_test.go
+++ b/pkg/oci/mutate/signature_test.go
@@ -128,6 +128,21 @@ func assertSignaturesEqual(t *testing.T, wanted, got oci.Signature) {
 		}
 	})
 
+	t.Run("TSA Bundles match", func(t *testing.T) {
+		t.Helper()
+		wantedBundle, err := wanted.TSABundle()
+		if err != nil {
+			t.Fatalf("wanted.Bundle() returned error: %v", err)
+		}
+		gotBundle, err := got.TSABundle()
+		if err != nil {
+			t.Fatalf("got.TSABundle() returned error: %v", err)
+		}
+		if diff := cmp.Diff(wantedBundle, gotBundle); diff != "" {
+			t.Errorf("TSABundle() mismatch (-want +got):\n%s", diff)
+		}
+	})
+
 	t.Run("Certs match", func(t *testing.T) {
 		t.Helper()
 		wantedCert, err := wanted.Cert()
@@ -292,7 +307,7 @@ func TestSignatureWithBundle(t *testing.T) {
 	payload := "this is the TestSignatureWithBundle content!"
 	b64sig := "b64 content2="
 	b := &bundle.RekorBundle{
-		SignedEntryTimestamp: mustBase64Decode(t, "MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="),
+		SignedEntryTimestamp: mustBase64Decode(t, "MEUCIEDcarEwRYkrxE9ne+kzEVvUhnWaauYzxhUyXOLy1hwAAiEA4VdVCvNRs+D/5o33C2KBy+q2YX3lP4Y7nqRFU+K3hi0="),
 		Payload: bundle.RekorPayload{
 			Body:           "REMOVED",
 			IntegratedTime: 1631646761,
@@ -311,6 +326,23 @@ func TestSignatureWithBundle(t *testing.T) {
 	assertSignaturesEqual(t, expectedSig, newSig)
 }
 
+func TestSignatureWithTSABundle(t *testing.T) {
+	payload := "this is the TestSignatureWithBundle content!"
+	b64sig := "b64 content2="
+	b := &bundle.TSABundle{
+		SignedRFC3161Timestamp: mustBase64Decode(t, "MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="),
+	}
+	originalSig := mustCreateSignature(t, []byte(payload), b64sig)
+	expectedSig := mustCreateSignature(t, []byte(payload), b64sig, static.WithTSABundle(b))
+
+	newSig, err := Signature(originalSig, WithTSABundle(b))
+	if err != nil {
+		t.Fatalf("Signature(WithTSABundle()) returned error: %v", err)
+	}
+
+	assertSignaturesEqual(t, expectedSig, newSig)
+}
+
 func TestSignatureWithCertChain(t *testing.T) {
 	payload := "this is the TestSignatureWithCertChain content!"
 	b64sig := "b64 content3="
@@ -380,3 +412,36 @@ func TestSignatureWithEverything(t *testing.T) {
 
 	assertSignaturesEqual(t, expectedSig, newSig)
 }
+
+func TestSignatureWithEverythingTSA(t *testing.T) {
+	payload := "this is the TestSignatureWithEverything content!"
+	b64sig := "b64 content5="
+	annotations := map[string]string{
+		"foo":  "bar",
+		"test": "yes",
+	}
+	b := &bundle.TSABundle{
+		SignedRFC3161Timestamp: mustBase64Decode(t, "MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="),
+	}
+	mediaType := types.MediaType("test/media.type")
+
+	originalSig := mustCreateSignature(t, []byte(payload), b64sig)
+
+	expectedSig := mustCreateSignature(t, []byte(payload), b64sig,
+		static.WithAnnotations(annotations),
+		static.WithTSABundle(b),
+		static.WithCertChain(testCertBytes, testChainBytes),
+		static.WithLayerMediaType(mediaType))
+
+	newSig, err := Signature(originalSig,
+		WithAnnotations(annotations),
+		WithTSABundle(b),
+		WithCertChain(testCertBytes, testChainBytes),
+		WithMediaType(mediaType))
+
+	if err != nil {
+		t.Fatalf("Signature(With...) returned error: %v", err)
+	}
+
+	assertSignaturesEqual(t, expectedSig, newSig)
+}
diff --git a/pkg/oci/signature/layer_test.go b/pkg/oci/signature/layer_test.go
index 929dd975..1801b363 100644
--- a/pkg/oci/signature/layer_test.go
+++ b/pkg/oci/signature/layer_test.go
@@ -278,3 +278,178 @@ Hr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==
 		})
 	}
 }
+
+func TestSignatureWithTSAAnnotation(t *testing.T) {
+	layer, err := random.Layer(300 /* byteSize */, types.DockerLayer)
+	if err != nil {
+		t.Fatalf("random.Layer() = %v", err)
+	}
+	digest, err := layer.Digest()
+	if err != nil {
+		t.Fatalf("Digest() = %v", err)
+	}
+
+	tests := []struct {
+		name           string
+		l              *sigLayer
+		wantPayloadErr error
+		wantSig        string
+		wantSigErr     error
+		wantCert       bool
+		wantCertErr    error
+		wantChain      int
+		wantChainErr   error
+		wantBundle     *bundle.TSABundle
+		wantBundleErr  error
+	}{{
+		name: "just payload and signature",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey: "blah",
+				},
+			},
+		},
+		wantSig: "blah",
+	}, {
+		name: "with empty other keys",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:       "blah",
+					certkey:      "",
+					chainkey:     "",
+					TSABundleKey: "",
+				},
+			},
+		},
+		wantSig: "blah",
+	}, {
+		name: "missing signature",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+			},
+		},
+		wantSigErr: fmt.Errorf("signature layer %s is missing %q annotation", digest, sigkey),
+	}, {
+		name: "min plus bad TSA bundle",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:       "blah",
+					TSABundleKey: `}`,
+				},
+			},
+		},
+		wantSig:       "blah",
+		wantBundleErr: errors.New(`unmarshaling tsa bundle: invalid character '}' looking for beginning of value`),
+	}, {
+		name: "min plus bad cert",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:  "blah",
+					certkey: `GARBAGE`,
+				},
+			},
+		},
+		wantSig:     "blah",
+		wantCertErr: errors.New(`error during PEM decoding`),
+	}, {
+		name: "min plus bad chain",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey:   "blah",
+					chainkey: `GARBAGE`,
+				},
+			},
+		},
+		wantSig:      "blah",
+		wantChainErr: errors.New(`error during PEM decoding`),
+	}, {
+		name: "min plus TSA bundle",
+		l: &sigLayer{
+			Layer: layer,
+			desc: v1.Descriptor{
+				Digest: digest,
+				Annotations: map[string]string{
+					sigkey: "tsa blah",
+					// This was extracted from gcr.io/distroless/static:nonroot on 2021/09/16.
+					// The Body has been removed for brevity
+					TSABundleKey: `{"SignedRFC3161Timestamp":"MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE=","Payload":{"body":"REMOVED","integratedTime":1631646761,"logIndex":693591,"logID":"c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"}}`,
+				},
+			},
+		},
+		wantSig: "tsa blah",
+		wantBundle: &bundle.TSABundle{
+			SignedRFC3161Timestamp: mustDecode("MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="),
+		},
+	}}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			b, err := test.l.Payload()
+			switch {
+			case (err != nil) != (test.wantPayloadErr != nil):
+				t.Errorf("Payload() = %v, wanted %v", err, test.wantPayloadErr)
+			case (err != nil) && (test.wantPayloadErr != nil) && err.Error() != test.wantPayloadErr.Error():
+				t.Errorf("Payload() = %v, wanted %v", err, test.wantPayloadErr)
+			case err == nil:
+				if got, _, err := v1.SHA256(bytes.NewBuffer(b)); err != nil {
+					t.Errorf("v1.SHA256() = %v", err)
+				} else if want := digest; want != got {
+					t.Errorf("v1.SHA256() = %v, wanted %v", got, want)
+				}
+			}
+
+			switch got, err := test.l.Base64Signature(); {
+			case (err != nil) != (test.wantSigErr != nil):
+				t.Errorf("Base64Signature() = %v, wanted %v", err, test.wantSigErr)
+			case (err != nil) && (test.wantSigErr != nil) && err.Error() != test.wantSigErr.Error():
+				t.Errorf("Base64Signature() = %v, wanted %v", err, test.wantSigErr)
+			case got != test.wantSig:
+				t.Errorf("Base64Signature() = %v, wanted %v", got, test.wantSig)
+			}
+
+			switch got, err := test.l.Cert(); {
+			case (err != nil) != (test.wantCertErr != nil):
+				t.Errorf("Cert() = %v, wanted %v", err, test.wantCertErr)
+			case (err != nil) && (test.wantCertErr != nil) && err.Error() != test.wantCertErr.Error():
+				t.Errorf("Cert() = %v, wanted %v", err, test.wantCertErr)
+			case (got != nil) != test.wantCert:
+				t.Errorf("Cert() = %v, wanted cert? %v", got, test.wantCert)
+			}
+
+			switch got, err := test.l.Chain(); {
+			case (err != nil) != (test.wantChainErr != nil):
+				t.Errorf("Chain() = %v, wanted %v", err, test.wantChainErr)
+			case (err != nil) && (test.wantChainErr != nil) && err.Error() != test.wantChainErr.Error():
+				t.Errorf("Chain() = %v, wanted %v", err, test.wantChainErr)
+			case len(got) != test.wantChain:
+				t.Errorf("Chain() = %v, wanted chain of length %d", got, test.wantChain)
+			}
+
+			switch got, err := test.l.TSABundle(); {
+			case (err != nil) != (test.wantBundleErr != nil):
+				t.Errorf("TSABundle() = %v, wanted %v", err, test.wantBundleErr)
+			case (err != nil) && (test.wantBundleErr != nil) && err.Error() != test.wantBundleErr.Error():
+				t.Errorf("TSABundle() = %v, wanted %v", err, test.wantBundleErr)
+			case !cmp.Equal(got, test.wantBundle):
+				t.Errorf("TSABundle() %s", cmp.Diff(got, test.wantBundle))
+			}
+		})
+	}
+}
diff --git a/pkg/oci/static/signature_test.go b/pkg/oci/static/signature_test.go
index 1ef5becc..85d76cfc 100644
--- a/pkg/oci/static/signature_test.go
+++ b/pkg/oci/static/signature_test.go
@@ -447,6 +447,110 @@ Hr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==
 	})
 }
 
+func TestNewSignatureCertChainAndTSABundle(t *testing.T) {
+	payload := "this is the other content!"
+	b64sig := "b64 content="
+
+	// This was extracted from gcr.io/distroless/static:nonroot on 2021/09/16
+	var (
+		cert = []byte(`
+-----BEGIN CERTIFICATE-----
+MIICjzCCAhSgAwIBAgITV2heiswW9YldtVEAu98QxDO8TTAKBggqhkjOPQQDAzAq
+MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIx
+MDkxNDE5MTI0MFoXDTIxMDkxNDE5MzIzOVowADBZMBMGByqGSM49AgEGCCqGSM49
+AwEHA0IABMF1AWZcfvubslc4ABNnvGbRjm6GWVHxrJ1RRthTHMCE4FpFmiHQBfGt
+6n80DqszGj77Whb35O33+Dal4Y2po+CjggFBMIIBPTAOBgNVHQ8BAf8EBAMCB4Aw
+EwYDVR0lBAwwCgYIKwYBBQUHAwMwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU340G
+3G1ozVNmFC5TBFV0yNuouvowHwYDVR0jBBgwFoAUyMUdAEGaJCkyUSTrDa5K7UoG
+0+wwgY0GCCsGAQUFBwEBBIGAMH4wfAYIKwYBBQUHMAKGcGh0dHA6Ly9wcml2YXRl
+Y2EtY29udGVudC02MDNmZTdlNy0wMDAwLTIyMjctYmY3NS1mNGY1ZTgwZDI5NTQu
+c3RvcmFnZS5nb29nbGVhcGlzLmNvbS9jYTM2YTFlOTYyNDJiOWZjYjE0Ni9jYS5j
+cnQwOAYDVR0RAQH/BC4wLIEqa2V5bGVzc0BkaXN0cm9sZXNzLmlhbS5nc2Vydmlj
+ZWFjY291bnQuY29tMAoGCCqGSM49BAMDA2kAMGYCMQDcH9cdkxW6ugsbPHqX9qrM
+wlMaprcwnlktS3+5xuABr5icuqwrB/Fj5doFtS7AnM0CMQD9MjSaUmHFFF7zoLMx
+uThR1Z6JuA21HwxtL3GyJ8UQZcEPOlTBV593HrSAwBhiCoY=
+-----END CERTIFICATE-----
+`)
+		chain = []byte(`
+-----BEGIN CERTIFICATE-----
+MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAq
+MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIx
+MDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUu
+ZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSy
+A7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0Jcas
+taRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6Nm
+MGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYE
+FMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2u
+Su1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJx
+Ve/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uup
+Hr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==
+-----END CERTIFICATE-----
+`)
+		b = &bundle.TSABundle{
+			SignedRFC3161Timestamp: mustDecode("MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="),
+		}
+	)
+
+	l, err := NewSignature([]byte(payload), b64sig,
+		WithCertChain(cert, chain), WithTSABundle(b))
+	if err != nil {
+		t.Fatalf("NewSignature() = %v", err)
+	}
+
+	t.Run("check signature accessors", func(t *testing.T) {
+		gotPayload, err := l.Payload()
+		if err != nil {
+			t.Fatalf("Payload() = %v", err)
+		}
+		if got, want := string(gotPayload), payload; got != want {
+			t.Errorf("Payload() = %s, wanted %s", got, want)
+		}
+
+		gotSig, err := l.Base64Signature()
+		if err != nil {
+			t.Fatalf("Base64Signature() = %v", err)
+		}
+		if got, want := gotSig, b64sig; got != want {
+			t.Errorf("Base64Signature() = %s, wanted %s", got, want)
+		}
+
+		if got, err := l.TSABundle(); err != nil {
+			t.Fatalf("TSABundle() = %v", err)
+		} else if got != b {
+			t.Errorf("TSABundle() = %#v, wanted %#v", got, b)
+		}
+
+		if got, err := l.Cert(); err != nil {
+			t.Fatalf("Cert() = %v", err)
+		} else if got == nil {
+			t.Error("Cert() = nil, wanted non-nil")
+		}
+
+		if got, err := l.Chain(); err != nil {
+			t.Fatalf("Chain() = %v", err)
+		} else if len(got) != 1 {
+			t.Errorf("len(Chain()) = %d, wanted 1", len(got))
+		}
+	})
+
+	t.Run("check annotations", func(t *testing.T) {
+		want := map[string]string{
+			SignatureAnnotationKey:   b64sig,
+			CertificateAnnotationKey: string(cert),
+			ChainAnnotationKey:       string(chain),
+
+			TSABundleAnnotationKey: `{"SignedRFC3161Timestamp":"MEUCIQClUkUqZNf+6dxBc/pxq22JIluTB7Kmip1G0FIF5E0C1wIgLqXm+IM3JYW/P/qjMZSXW+J8bt5EOqNfe3R+0A9ooFE="}`,
+		}
+		got, err := l.Annotations()
+		if err != nil {
+			t.Fatalf("Annotations() = %v", err)
+		}
+		if !cmp.Equal(got, want) {
+			t.Errorf("Annotations = %s", cmp.Diff(got, want))
+		}
+	})
+}
+
 func TestNewSignatureBadCertChain(t *testing.T) {
 	payload := "this is the other content!"
 	b64sig := "b64 content="
diff --git a/pkg/policy/attestation_test.go b/pkg/policy/attestation_test.go
index bd2139c4..d7c69b94 100644
--- a/pkg/policy/attestation_test.go
+++ b/pkg/policy/attestation_test.go
@@ -55,6 +55,9 @@ func (fa *failingAttestation) Chain() ([]*x509.Certificate, error) {
 func (fa *failingAttestation) Bundle() (*bundle.RekorBundle, error) {
 	return nil, fmt.Errorf("unimplemented")
 }
+func (fa *failingAttestation) TSABundle() (*bundle.TSABundle, error) {
+	return nil, fmt.Errorf("unimplemented")
+}
 func (fa *failingAttestation) Digest() (v1.Hash, error) {
 	return v1.Hash{}, fmt.Errorf("unimplemented")
 }
diff --git a/test/e2e_test.go b/test/e2e_test.go
index 2b67b037..24d5c886 100644
--- a/test/e2e_test.go
+++ b/test/e2e_test.go
@@ -58,6 +58,7 @@ import (
 	"github.com/sigstore/cosign/cmd/cosign/cli/upload"
 	cliverify "github.com/sigstore/cosign/cmd/cosign/cli/verify"
 	"github.com/sigstore/cosign/pkg/cosign"
+	"github.com/sigstore/cosign/pkg/cosign/bundle"
 	"github.com/sigstore/cosign/pkg/cosign/env"
 	"github.com/sigstore/cosign/pkg/cosign/kubernetes"
 	cremote "github.com/sigstore/cosign/pkg/cosign/remote"
@@ -1566,3 +1567,70 @@ func TestAttestBlobSignVerify(t *testing.T) {
 	blobVerifyAttestationCmd.PredicateType = predicateType
 	mustErr(blobVerifyAttestationCmd.Exec(ctx, anotherBlob), t)
 }
+
+func TestOffline(t *testing.T) {
+	regName, stop := reg(t)
+	defer stop()
+	td := t.TempDir()
+
+	img1 := path.Join(regName, "cosign-e2e")
+
+	imgRef, _, cleanup := mkimage(t, img1)
+	defer cleanup()
+
+	_, privKeyPath, pubKeyPath := keypair(t, td)
+
+	ctx := context.Background()
+
+	// Sign image1 and store the entry in rekor
+	ko := options.KeyOpts{KeyRef: privKeyPath, PassFunc: passFunc, RekorURL: rekorURL}
+	so := options.SignOptions{
+		Upload:           true,
+		TlogUpload:       true,
+		SkipConfirmation: true,
+	}
+	must(sign.SignCmd(ro, ko, so, []string{img1}), t)
+	// verify image1 online and offline
+	must(verify(pubKeyPath, img1, true, nil, ""), t)
+	verifyCmd := &cliverify.VerifyCommand{
+		KeyRef:      pubKeyPath,
+		Offline:     true,
+		CheckClaims: true,
+	}
+	must(verifyCmd.Exec(ctx, []string{img1}), t)
+
+	// Get signatures
+	si, err := ociremote.SignedEntity(imgRef)
+	must(err, t)
+	sigs, err := si.Signatures()
+	must(err, t)
+	gottenSigs, err := sigs.Get()
+	must(err, t)
+
+	fakeBundle := &bundle.RekorBundle{
+		SignedEntryTimestamp: []byte(""),
+		Payload: bundle.RekorPayload{
+			Body: "",
+		},
+	}
+	newSig, err := mutate.Signature(gottenSigs[0], mutate.WithBundle(fakeBundle))
+	must(err, t)
+
+	sigsTag, err := ociremote.SignatureTag(imgRef)
+	if err := remote.Delete(sigsTag); err != nil {
+		t.Fatal(err)
+	}
+
+	si, err = ociremote.SignedEntity(imgRef)
+	must(err, t)
+	newImage, err := mutate.AttachSignatureToEntity(si, newSig)
+	must(err, t)
+
+	mustErr(verify(pubKeyPath, img1, true, nil, ""), t)
+	if err := ociremote.WriteSignatures(sigsTag.Repository, newImage); err != nil {
+		t.Fatal(err)
+	}
+
+	// Confirm offline verification fails
+	mustErr(verifyCmd.Exec(ctx, []string{img1}), t)
+}
diff --git a/test/sign_blob_test.sh b/test/sign_blob_test.sh
index 621ad72d..f7389488 100755
--- a/test/sign_blob_test.sh
+++ b/test/sign_blob_test.sh
@@ -81,7 +81,7 @@ curl -X POST https://rekor.sigstore.dev/api/v1/log/entries -H 'Content-Type: app
 
 # Verifying should still work
 echo "Verifying ..."
-$COSIGN_CLI verify-blob --signature "$SIG_FILE" --cert "$CERT_FILE" --certificate-chain "$CERT_FILE" "$BLOB"
+$COSIGN_CLI verify-blob --signature "$SIG_FILE" --cert "$CERT_FILE" --certificate-chain "$CERT_FILE" --insecure-ignore-sct "$BLOB"
 
 echo "Verifying using cosign ENV variables ..."
-COSIGN_SIGNATURE="$SIG_FILE" COSIGN_CERTIFICATE_CHAIN="$CERT_FILE" COSIGN_CERTIFICATE="$CERT_FILE" $COSIGN_CLI verify-blob "$BLOB"
+COSIGN_SIGNATURE="$SIG_FILE" COSIGN_CERTIFICATE_CHAIN="$CERT_FILE" COSIGN_CERTIFICATE="$CERT_FILE" $COSIGN_CLI verify-blob --insecure-ignore-sct "$BLOB"