Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion internal/adapter/publish/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"fmt"
"net/http"
"os"
"strings"

"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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-<algorithm>-<hex>.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)
Expand Down Expand Up @@ -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-<algorithm>-<hex>).
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-<algorithm>-<hex>.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"
}
77 changes: 77 additions & 0 deletions internal/adapter/publish/sign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"crypto/x509"
"encoding/hex"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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-<algorithm>-<hex>.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)
}
}
Loading