Repository: sigstore/cosign (go)

Issue:
OCI manifest created timestamp wrong with v2.2.0
**Description**

We are using a cosign container inside our pipeline to sign newly build images. 
Up until v2.1.1 (public.ecr.aws/bitnami/cosign:2.1.1) this works without problems:
`cosign sign --yes --key cosign.key {{image}}@sha256:{{digest}}`
That creates a new OCI entry inside our IBM Container Registry:
```json
{
  "Id": "sha256:[...]",
  "Parent": "",
  "Comment": "",
  "Created": "2023-10-12T14:52:17.989015938Z",
[...]
  "Size": 251,
  "VirtualSize": 251,
  "RootFS": {
    "Type": "layers",
    "Layers": [
      "sha256:[...]"
    ]
  },
  "ManifestType": "application/vnd.oci.image.manifest.v1+json"
}
```
After updating to v2.2.0 (public.ecr.aws/bitnami/cosign:2.2.0) the **Created** timestamp is totally wrong and leads to this:
```json
{
  "Id": "sha256:[...]",
  "Parent": "",
  "Comment": "",
  "Created": "0001-01-01T00:00:00Z",
 [...]
  "Size": 251,
  "VirtualSize": 251,
  "RootFS": {
    "Type": "layers",
    "Layers": [
      "sha256:[...]"
    ]
  },
  "ManifestType": "application/vnd.oci.image.manifest.v1+json"
}
```

As we have some rules to delete older entries inside our registry these signatures are deleted soon after creation.
Image can be verified with `cosign verify` in both cases. The output does not differ.
This can be reproduced with different images.

**Version**

`public.ecr.aws/bitnami/cosign:2.2.0`

Pull request description:
Honor creation timestamp for signatures again
closes #3298

#### Summary

As the timestamp is part of the OCI spec it makes no sense to omit it. signatures that are pushed to an OCI registry are not immutable by default and honoring the creation timestamp will enable people to use time based cleanup policies in their registries again (as in GCP, GitLab etc.).

#### Release Note

Fixes null timestamp in signatures and honor creation timestamp again


Patch:
diff --git a/cmd/cosign/cli/options/sign.go b/cmd/cosign/cli/options/sign.go
index c7cef860..70ec9aca 100644
--- a/cmd/cosign/cli/options/sign.go
+++ b/cmd/cosign/cli/options/sign.go
@@ -21,26 +21,27 @@ import (
 
 // SignOptions is the top level wrapper for the sign command.
 type SignOptions struct {
-	Key                   string
-	Cert                  string
-	CertChain             string
-	Upload                bool
-	Output                string // deprecated: TODO remove when the output flag is fully deprecated
-	OutputSignature       string // TODO: this should be the root output file arg.
-	OutputPayload         string
-	OutputCertificate     string
-	PayloadPath           string
-	Recursive             bool
-	Attachment            string
-	SkipConfirmation      bool
-	TlogUpload            bool
-	TSAClientCACert       string
-	TSAClientCert         string
-	TSAClientKey          string
-	TSAServerName         string
-	TSAServerURL          string
-	IssueCertificate      bool
-	SignContainerIdentity string
+	Key                     string
+	Cert                    string
+	CertChain               string
+	Upload                  bool
+	Output                  string // deprecated: TODO remove when the output flag is fully deprecated
+	OutputSignature         string // TODO: this should be the root output file arg.
+	OutputPayload           string
+	OutputCertificate       string
+	PayloadPath             string
+	Recursive               bool
+	Attachment              string
+	SkipConfirmation        bool
+	TlogUpload              bool
+	TSAClientCACert         string
+	TSAClientCert           string
+	TSAClientKey            string
+	TSAServerName           string
+	TSAServerURL            string
+	IssueCertificate        bool
+	SignContainerIdentity   string
+	RecordCreationTimestamp bool
 
 	Rekor       RekorOptions
 	Fulcio      FulcioOptions
@@ -130,4 +131,6 @@ func (o *SignOptions) AddFlags(cmd *cobra.Command) {
 
 	cmd.Flags().StringVar(&o.SignContainerIdentity, "sign-container-identity", "",
 		"manually set the .critical.docker-reference field for the signed identity, which is useful when image proxies are being used where the pull reference should match the signature")
+
+	cmd.Flags().BoolVar(&o.RecordCreationTimestamp, "record-creation-timestamp", false, "set the createdAt timestamp in the signature artifact to the time it was created; by default, cosign sets this to the zero value")
 }
diff --git a/cmd/cosign/cli/sign.go b/cmd/cosign/cli/sign.go
index 110a01a6..76f84a32 100644
--- a/cmd/cosign/cli/sign.go
+++ b/cmd/cosign/cli/sign.go
@@ -83,7 +83,10 @@ race conditions or (worse) malicious tampering.
   cosign sign --key cosign.key --tlog-upload=false <IMAGE DIGEST>
 
   # sign a container image by manually setting the container image identity
-  cosign sign --sign-container-identity <NEW IMAGE DIGEST> <IMAGE DIGEST>`,
+  cosign sign --sign-container-identity <NEW IMAGE DIGEST> <IMAGE DIGEST>
+
+  # sign a container image and honor the creation timestamp of the signature
+  cosign sign --key cosign.key --record-creation-timestamp <IMAGE DIGEST>`,
 
 		Args:             cobra.MinimumNArgs(1),
 		PersistentPreRun: options.BindViper,
diff --git a/cmd/cosign/cli/sign/sign.go b/cmd/cosign/cli/sign/sign.go
index 122aaa33..1289e7b1 100644
--- a/cmd/cosign/cli/sign/sign.go
+++ b/cmd/cosign/cli/sign/sign.go
@@ -329,7 +329,7 @@ func signDigest(ctx context.Context, digest name.Digest, payload []byte, ko opti
 	}
 
 	// Attach the signature to the entity.
-	newSE, err := mutate.AttachSignatureToEntity(se, ociSig, mutate.WithDupeDetector(dd))
+	newSE, err := mutate.AttachSignatureToEntity(se, ociSig, mutate.WithDupeDetector(dd), mutate.WithRecordCreationTimestamp(signOpts.RecordCreationTimestamp))
 	if err != nil {
 		return err
 	}
diff --git a/doc/cosign_sign.md b/doc/cosign_sign.md
index a6cf4885..de309400 100644
--- a/doc/cosign_sign.md
+++ b/doc/cosign_sign.md
@@ -64,6 +64,9 @@ cosign sign [flags]
 
   # sign a container image by manually setting the container image identity
   cosign sign --sign-container-identity <NEW IMAGE DIGEST> <IMAGE DIGEST>
+
+  # sign a container image and honor the creation timestamp of the signature
+  cosign sign --key cosign.key --record-creation-timestamp <IMAGE DIGEST>
 ```
 
 ### Options
@@ -93,6 +96,7 @@ cosign sign [flags]
       --output-payload string                                                                    write the signed payload to FILE
       --output-signature string                                                                  write the signature to FILE
       --payload string                                                                           path to a payload file to use rather than generating one
+      --record-creation-timestamp                                                                set the createdAt timestamp in the signature artifact to the time it was created; by default, cosign sets this to the zero value
   -r, --recursive                                                                                if a multi-arch image is specified, additionally sign each discrete image
       --registry-password string                                                                 registry basic auth password
       --registry-referrers-mode registryReferrersMode                                            mode for fetching references from the registry. allowed: legacy, oci-1-1
diff --git a/pkg/oci/mutate/mutate.go b/pkg/oci/mutate/mutate.go
index f934cb35..59ba2c0c 100644
--- a/pkg/oci/mutate/mutate.go
+++ b/pkg/oci/mutate/mutate.go
@@ -377,5 +377,5 @@ func (so *signOpts) dedupeAndReplace(sig oci.Signature, basefn func() (oci.Signa
 		}
 		return ReplaceSignatures(replace)
 	}
-	return AppendSignatures(base, sig)
+	return AppendSignatures(base, so.rct, sig)
 }
diff --git a/pkg/oci/mutate/options.go b/pkg/oci/mutate/options.go
index a4c41b6f..342eea4e 100644
--- a/pkg/oci/mutate/options.go
+++ b/pkg/oci/mutate/options.go
@@ -33,8 +33,9 @@ type ReplaceOp interface {
 type SignOption func(*signOpts)
 
 type signOpts struct {
-	dd DupeDetector
-	ro ReplaceOp
+	dd  DupeDetector
+	ro  ReplaceOp
+	rct bool
 }
 
 func makeSignOpts(opts ...SignOption) *signOpts {
@@ -59,6 +60,12 @@ func WithReplaceOp(ro ReplaceOp) SignOption {
 	}
 }
 
+func WithRecordCreationTimestamp(rct bool) SignOption {
+	return func(so *signOpts) {
+		so.rct = rct
+	}
+}
+
 type signatureOpts struct {
 	annotations      map[string]string
 	bundle           *bundle.RekorBundle
diff --git a/pkg/oci/mutate/signatures.go b/pkg/oci/mutate/signatures.go
index 648cab04..4f3bd98f 100644
--- a/pkg/oci/mutate/signatures.go
+++ b/pkg/oci/mutate/signatures.go
@@ -19,12 +19,13 @@ import (
 	v1 "github.com/google/go-containerregistry/pkg/v1"
 	"github.com/google/go-containerregistry/pkg/v1/empty"
 	"github.com/google/go-containerregistry/pkg/v1/mutate"
+	"github.com/sigstore/cosign/v2/internal/pkg/now"
 	"github.com/sigstore/cosign/v2/pkg/oci"
 )
 
 // AppendSignatures produces a new oci.Signatures with the provided signatures
 // appended to the provided base signatures.
-func AppendSignatures(base oci.Signatures, sigs ...oci.Signature) (oci.Signatures, error) {
+func AppendSignatures(base oci.Signatures, recordCreationTimestamp bool, sigs ...oci.Signature) (oci.Signatures, error) {
 	adds := make([]mutate.Addendum, 0, len(sigs))
 	for _, sig := range sigs {
 		ann, err := sig.Annotations()
@@ -42,6 +43,19 @@ func AppendSignatures(base oci.Signatures, sigs ...oci.Signature) (oci.Signature
 		return nil, err
 	}
 
+	if recordCreationTimestamp {
+		t, err := now.Now()
+		if err != nil {
+			return nil, err
+		}
+
+		// Set the Created date to time of execution
+		img, err = mutate.CreatedAt(img, v1.Time{Time: t})
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	return &sigAppender{
 		Image: img,
 		base:  base,
diff --git a/pkg/oci/static/file.go b/pkg/oci/static/file.go
index 6fc55d83..c3435be4 100644
--- a/pkg/oci/static/file.go
+++ b/pkg/oci/static/file.go
@@ -22,6 +22,7 @@ import (
 	"github.com/google/go-containerregistry/pkg/v1/empty"
 	"github.com/google/go-containerregistry/pkg/v1/mutate"
 	"github.com/google/go-containerregistry/pkg/v1/types"
+	"github.com/sigstore/cosign/v2/internal/pkg/now"
 	"github.com/sigstore/cosign/v2/pkg/oci"
 	"github.com/sigstore/cosign/v2/pkg/oci/signed"
 )
@@ -48,6 +49,19 @@ func NewFile(payload []byte, opts ...Option) (oci.File, error) {
 	// Add annotations from options
 	img = mutate.Annotations(img, o.Annotations).(v1.Image)
 
+	if o.RecordCreationTimestamp {
+		t, err := now.Now()
+		if err != nil {
+			return nil, err
+		}
+
+		// Set the Created date to time of execution
+		img, err = mutate.CreatedAt(img, v1.Time{Time: t})
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	return &file{
 		SignedImage: signed.Image(img),
 		layer:       layer,
diff --git a/pkg/oci/static/options.go b/pkg/oci/static/options.go
index 100c988e..b240fb22 100644
--- a/pkg/oci/static/options.go
+++ b/pkg/oci/static/options.go
@@ -27,13 +27,14 @@ import (
 type Option func(*options)
 
 type options struct {
-	LayerMediaType   types.MediaType
-	ConfigMediaType  types.MediaType
-	Bundle           *bundle.RekorBundle
-	RFC3161Timestamp *bundle.RFC3161Timestamp
-	Cert             []byte
-	Chain            []byte
-	Annotations      map[string]string
+	LayerMediaType          types.MediaType
+	ConfigMediaType         types.MediaType
+	Bundle                  *bundle.RekorBundle
+	RFC3161Timestamp        *bundle.RFC3161Timestamp
+	Cert                    []byte
+	Chain                   []byte
+	Annotations             map[string]string
+	RecordCreationTimestamp bool
 }
 
 func makeOptions(opts ...Option) (*options, error) {
@@ -112,3 +113,10 @@ func WithCertChain(cert, chain []byte) Option {
 		o.Chain = chain
 	}
 }
+
+// WithRecordCreationTimestamp sets the feature flag to honor the creation timestamp to time of running
+func WithRecordCreationTimestamp(rct bool) Option {
+	return func(o *options) {
+		o.RecordCreationTimestamp = rct
+	}
+}


Test patch:
diff --git a/pkg/oci/mutate/signatures_test.go b/pkg/oci/mutate/signatures_test.go
index 25384a60..b5f2b10c 100644
--- a/pkg/oci/mutate/signatures_test.go
+++ b/pkg/oci/mutate/signatures_test.go
@@ -38,17 +38,17 @@ func TestAppendSignatures(t *testing.T) {
 		t.Fatalf("NewSignature() = %v", err)
 	}
 
-	oneSig, err := AppendSignatures(base, s1)
+	oneSig, err := AppendSignatures(base, false, s1)
 	if err != nil {
 		t.Fatalf("AppendSignatures() = %v", err)
 	}
 
-	twoSig, err := AppendSignatures(oneSig, s2)
+	twoSig, err := AppendSignatures(oneSig, false, s2)
 	if err != nil {
 		t.Fatalf("AppendSignatures() = %v", err)
 	}
 
-	threeSig, err := AppendSignatures(oneSig, s2, s3)
+	threeSig, err := AppendSignatures(oneSig, true, s2, s3)
 	if err != nil {
 		t.Fatalf("AppendSignatures() = %v", err)
 	}
@@ -73,7 +73,13 @@ func TestAppendSignatures(t *testing.T) {
 
 	if testCfg, err := threeSig.ConfigFile(); err != nil {
 		t.Fatalf("ConfigFile() = %v", err)
-	} else if !testCfg.Created.Time.IsZero() {
-		t.Errorf("Date of Signature was not Zero")
+	} else if testCfg.Created.Time.IsZero() {
+		t.Errorf("Date of Signature was Zero")
+	}
+
+	if testDefaultCfg, err := twoSig.ConfigFile(); err != nil {
+		t.Fatalf("ConfigFile() = %v", err)
+	} else if !testDefaultCfg.Created.Time.IsZero() {
+		t.Errorf("Date of Signature was Zero")
 	}
 }
diff --git a/pkg/oci/static/file_test.go b/pkg/oci/static/file_test.go
index 3dc5f304..0866f25d 100644
--- a/pkg/oci/static/file_test.go
+++ b/pkg/oci/static/file_test.go
@@ -32,6 +32,12 @@ func TestNewFile(t *testing.T) {
 		t.Fatalf("NewFile() = %v", err)
 	}
 
+	timestampedFile, err := NewFile([]byte(payload), WithLayerMediaType("foo"), WithAnnotations(map[string]string{"foo": "bar"}), WithRecordCreationTimestamp(true))
+
+	if err != nil {
+		t.Fatalf("NewFile() = %v", err)
+	}
+
 	layers, err := file.Layers()
 	if err != nil {
 		t.Fatalf("Layers() = %v", err)
@@ -129,6 +135,13 @@ func TestNewFile(t *testing.T) {
 		if !fileCfg.Created.Time.IsZero() {
 			t.Errorf("Date of Signature was not Zero")
 		}
+		tsCfg, err := timestampedFile.ConfigFile()
+		if err != nil {
+			t.Fatalf("ConfigFile() = %v", err)
+		}
+		if tsCfg.Created.Time.IsZero() {
+			t.Errorf("Date of Signature was Zero")
+		}
 	})
 
 	t.Run("check annotations", func(t *testing.T) {