diff --git a/internal/adapter/publish/sign.go b/internal/adapter/publish/sign.go index 6d399043..33a2f4f9 100644 --- a/internal/adapter/publish/sign.go +++ b/internal/adapter/publish/sign.go @@ -12,6 +12,7 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -33,6 +34,13 @@ const DefaultFulcioURL = "https://fulcio.sigstore.dev" // (see internal/adapter/signing.recordFromManifest). const cosignSignatureAnnotation = "dev.cosignproject.cosign/signature" +// cosignSignatureArtifactType is the OCI manifest ArtifactType cosign filters on +// when discovering signatures via OCI 1.1 referrers. It matches the value +// github.com/sigstore/cosign/v3/pkg/oci/remote tests expect for a signature +// referrer (wantArtifactType in write_test.go). Cosign does not export the +// constant, so we keep a local mirror with its source noted. +const cosignSignatureArtifactType = "application/vnd.dev.cosign.artifact.sig.v1+json" + // SignResult is the output of a Signer: the raw signature over the // simple-signing payload plus, for keyless mode, the Fulcio leaf certificate and // a Sigstore bundle (cert + transparency-log inclusion proof + signed entry @@ -254,7 +262,8 @@ func buildSignatureManifest(artifactDesc *ocispec.Descriptor, payload []byte, re } sigManifest := ocispec.Manifest{ - MediaType: ocispec.MediaTypeImageManifest, + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: cosignSignatureArtifactType, // An OCI image manifest requires a valid config descriptor. Without one // the zero value marshals as {"mediaType":"","digest":"","size":0}, // which strict registries (notably GHCR) reject with a 500 on push. Use @@ -276,11 +285,22 @@ func buildSignatureManifest(artifactDesc *ocispec.Descriptor, payload []byte, re return manifestJSON, payload } +// tagPusher is a store that can both push manifests and tag them by reference. +// remote.Repository and oras memory/oci stores implement this. +type tagPusher interface { + content.Pusher + Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error +} + // signArtifact signs artifactDesc with signer and pushes the cosign signature // manifest (a referrer) plus its payload blob into pusher. For a remote // repository the registry records the referrer against the artifact; for an // in-memory/on-disk store the manifest is staged for later copy. Returns the // signature manifest descriptor. +// +// To stay discoverable by default (non-experimental) cosign, the signature +// manifest is also tagged as sha256--.sig, the legacy tag +// cosign's non-OCI-1.1 path resolves. func signArtifact(ctx context.Context, pusher content.Pusher, ref oci.Reference, artifactDesc *ocispec.Descriptor, signer Signer) (ocispec.Descriptor, error) { payload := simpleSigningPayload(ref, artifactDesc.Digest) res, err := signer.Sign(payload) @@ -315,5 +335,24 @@ func signArtifact(ctx context.Context, pusher content.Pusher, ref oci.Reference, if err := pusher.Push(ctx, sigDesc, bytes.NewReader(manifestJSON)); err != nil { return ocispec.Descriptor{}, fmt.Errorf("publish: push signature manifest: %w", err) } + + // Also publish the legacy cosign signature tag so default (non-OCI-1.1) + // cosign verification can find the signature. The tag is content-addressed + // from the artifact digest and does not conflict with the OCI 1.1 referrers + // fallback index tag (sha256--). + if tp, ok := pusher.(tagPusher); ok { + sigTag := legacyCosignSignatureTag(artifactDesc.Digest) + if err := tp.Tag(ctx, sigDesc, sigTag); err != nil { + return ocispec.Descriptor{}, fmt.Errorf("publish: tag signature manifest %q: %w", sigTag, err) + } + } + return sigDesc, nil } + +// legacyCosignSignatureTag returns the cosign legacy signature tag for a digest: +// "sha256--.sig". This matches cosign's SignatureTag naming +// (github.com/sigstore/cosign/v3/pkg/oci/remote.normalize). +func legacyCosignSignatureTag(d digest.Digest) string { + return strings.Replace(d.String(), ":", "-", 1) + ".sig" +} diff --git a/internal/adapter/publish/sign_test.go b/internal/adapter/publish/sign_test.go index d31fe4c1..d62de508 100644 --- a/internal/adapter/publish/sign_test.go +++ b/internal/adapter/publish/sign_test.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "encoding/hex" "encoding/json" + "io" "os" "path/filepath" "strings" @@ -170,4 +171,80 @@ func TestBuildSignatureManifest_HasEmptyConfigAndSchemaVersion(t *testing.T) { if m.Subject == nil || m.Subject.Digest != artifactDesc.Digest { t.Errorf("subject must reference the artifact digest") } + if m.ArtifactType != cosignSignatureArtifactType { + t.Errorf("artifactType = %q, want %q", m.ArtifactType, cosignSignatureArtifactType) + } +} + +// TestLegacyCosignSignatureTag matches the cosign "sha256--.sig" +// convention. This is the tag default cosign verification resolves when the +// registry does not support the OCI 1.1 referrers API. +func TestLegacyCosignSignatureTag(t *testing.T) { + dg := digest.FromString("some-artifact") + got := legacyCosignSignatureTag(dg) + want := strings.Replace(dg.String(), ":", "-", 1) + ".sig" + if got != want { + t.Errorf("legacyCosignSignatureTag = %q, want %q", got, want) + } + if !strings.HasPrefix(got, "sha256-") || !strings.HasSuffix(got, ".sig") { + t.Errorf("legacy tag %q does not look like a cosign signature tag", got) + } +} + +// recordingTagPusher is a content.Pusher that also records tags pushed to it. +type recordingTagPusher struct { + pushed map[digest.Digest][]byte + tagged map[string]digest.Digest +} + +func newRecordingTagPusher() *recordingTagPusher { + return &recordingTagPusher{pushed: map[digest.Digest][]byte{}, tagged: map[string]digest.Digest{}} +} + +//nolint:gocritic // desc is passed by value to satisfy the content.Pusher interface signature. +func (p *recordingTagPusher) Push(ctx context.Context, desc ocispec.Descriptor, r io.Reader) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + p.pushed[desc.Digest] = data + return nil +} + +//nolint:gocritic // desc is passed by value to satisfy the tagPusher interface signature. +func (p *recordingTagPusher) Tag(_ context.Context, desc ocispec.Descriptor, reference string) error { + p.tagged[reference] = desc.Digest + return nil +} + +// TestSignArtifact_PublishesLegacySigTag verifies signArtifact tags the +// signature manifest with the cosign legacy .sig tag when the store supports +// tagging. +func TestSignArtifact_PublishesLegacySigTag(t *testing.T) { + artifact := ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.Descriptor{MediaType: mediaTypeAdapterConfig, Digest: digest.FromString("{}"), Size: 2}, + } + artifactData, _ := json.Marshal(artifact) + artifactDesc := ocispec.Descriptor{MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(artifactData), Size: int64(len(artifactData))} + + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + ref := oci.Reference{Registry: "localhost:5001", Repo: "test/artifact", Tag: "v1"} + p := newRecordingTagPusher() + + sigDesc, err := signArtifact(context.Background(), p, ref, &artifactDesc, KeySigner{Priv: priv}) + if err != nil { + t.Fatalf("signArtifact: %v", err) + } + + wantTag := legacyCosignSignatureTag(artifactDesc.Digest) + if taggedDigest, ok := p.tagged[wantTag]; !ok { + t.Errorf("signature manifest was not tagged with %q", wantTag) + } else if taggedDigest != sigDesc.Digest { + t.Errorf("tagged digest for %q = %q, want %q", wantTag, taggedDigest, sigDesc.Digest) + } }