Skip to content

Commit

Permalink
feat: cosign-compatible signatures for OCI-SIF
Browse files Browse the repository at this point in the history
Add a new `--cosign` mode to `singularity sign`, which will apply a
cosign-compatible signature to a container image in an OCI-SIF, and
store the signature image in the OCI-SIF, using the name.ref
association defined by sylabs/oci-tools.

Unlike the upstream sylabs/oci-tools code, Singularity currently only
creates / considers OCI-SIF images that contain a single OCI image.
Consequently there is no signature handling for image indices in
Singularity at this point.

From this commit onwards, Singularity ignores cosign images in the
OCI-SIF when looking for an OCI image to execute, push etc. Older
versions of Singularity will error when attempting to execute a signed
image, as they expect only one image in an OCI-SIF, with no filtering
of non-executable cosign related images.

Fixes sylabs#3492
  • Loading branch information
dtrudg committed Feb 4, 2025
1 parent 5d15224 commit 1252eea
Show file tree
Hide file tree
Showing 18 changed files with 10,317 additions and 3,337 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from `/etc/subid` and `/etc/subgid` regardless of system configuration. Note
that `singularity config fakeroot` always modifies `/etc/subid` and
`/etc/subgid`files.
- `singularity sign` now supports signing an image in an OCI-SIF with a
cosign-compatible sigstore signature. Use the `--cosign` flag, and provide
a private key with the `--key` flag.

## Requirements / Packaging

Expand Down
12,605 changes: 9,317 additions & 3,288 deletions LICENSE_DEPENDENCIES.md

Large diffs are not rendered by default.

68 changes: 65 additions & 3 deletions cmd/internal/cli/sign.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
// Copyright (c) 2017-2023, Sylabs Inc. All rights reserved.
// Copyright (c) 2017-2025, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.

package cli

import (
"context"
"crypto"
"fmt"
"os"

"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/sigstore/sigstore/pkg/signature"
"github.com/spf13/cobra"
"github.com/sylabs/singularity/v4/docs"
cosignsignature "github.com/sylabs/singularity/v4/internal/pkg/cosign"
sifsignature "github.com/sylabs/singularity/v4/internal/pkg/signature"
"github.com/sylabs/singularity/v4/internal/pkg/sypgp"
"github.com/sylabs/singularity/v4/pkg/cmdline"
Expand All @@ -22,6 +27,7 @@ var (
priKeyPath string
priKeyIdx int
signAll bool
useCosign bool
)

// -g|--group-id
Expand Down Expand Up @@ -95,6 +101,16 @@ var signAllFlag = cmdline.Flag{
Deprecated: "now the default behavior",
}

// -c|--cosign
var cosignFlag = cmdline.Flag{
ID: "cosignFlag",
Value: &useCosign,
DefaultValue: false,
Name: "cosign",
ShortHand: "c",
Usage: "sign an OCI-SIF with a cosign-compatible sigstore signature",
}

func init() {
addCmdInit(func(cmdManager *cmdline.CommandManager) {
cmdManager.RegisterCmd(SignCmd)
Expand All @@ -106,6 +122,7 @@ func init() {
cmdManager.RegisterFlagForCmd(&signPrivateKeyFlag, SignCmd)
cmdManager.RegisterFlagForCmd(&signKeyIdxFlag, SignCmd)
cmdManager.RegisterFlagForCmd(&signAllFlag, SignCmd)
cmdManager.RegisterFlagForCmd(&cosignFlag, SignCmd)
})
}

Expand All @@ -126,6 +143,30 @@ var SignCmd = &cobra.Command{
}

func doSignCmd(cmd *cobra.Command, cpath string) {
if useCosign {
if priKeyPath == "" {
sylog.Fatalf("--cosign signatures require a private --key to be specified")
}
if priKeyIdx != 0 {
sylog.Fatalf("--keyidx not supported: --cosign signatures use a private --key, not the PGP keyring")
}
if signAll || sifGroupID != 0 || sifDescID != 0 {
sylog.Fatalf("--cosign signatures sign an OCI image, specifying SIF descriptors / groups is not supported")
}
err := signCosign(cmd.Context(), cpath, priKeyPath)
if err != nil {
sylog.Fatalf("%v", err)
}
return
}

err := signSIF(cmd, cpath)
if err != nil {
sylog.Fatalf("%v", err)
}
}

func signSIF(cmd *cobra.Command, cpath string) error {
var opts []sifsignature.SignOpt

// Set key material.
Expand All @@ -135,7 +176,7 @@ func doSignCmd(cmd *cobra.Command, cpath string) {

s, err := signature.LoadSignerFromPEMFile(priKeyPath, crypto.SHA256, cryptoutils.GetPasswordFromStdIn)
if err != nil {
sylog.Fatalf("Failed to load key material: %v", err)
return fmt.Errorf("Failed to load key material: %v", err)
}
opts = append(opts, sifsignature.OptSignWithSigner(s))

Expand Down Expand Up @@ -165,7 +206,28 @@ func doSignCmd(cmd *cobra.Command, cpath string) {

// Sign the image.
if err := sifsignature.Sign(cmd.Context(), cpath, opts...); err != nil {
sylog.Fatalf("Failed to sign container: %v", err)
return fmt.Errorf("Failed to sign container: %w", err)
}
sylog.Infof("Signature created and applied to image '%v'", cpath)
return nil
}

func signCosign(ctx context.Context, sifPath, keyPath string) error {
sylog.Infof("Sigstore/cosign compatible signature, using key material from '%v'", priKeyPath)
kb, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("failed to load key material: %w", err)
}

pass, err := cryptoutils.GetPasswordFromStdIn(false)
if err != nil {
return fmt.Errorf("couldn't read key password: %w", err)
}

sv, err := cosign.LoadPrivateKey(kb, pass)
if err != nil {
return fmt.Errorf("failed to open OCI-SIF: %w", err)
}

return cosignsignature.SignOCISIF(ctx, sifPath, sv)
}
16 changes: 3 additions & 13 deletions e2e/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import (
dockerclient "github.com/docker/docker/client"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/pkg/errors"
ocisif "github.com/sylabs/oci-tools/pkg/sif"
"github.com/sylabs/sif/v2/pkg/sif"
"github.com/sylabs/singularity/v4/e2e/internal/e2e"
"github.com/sylabs/singularity/v4/e2e/internal/testhelper"
"github.com/sylabs/singularity/v4/internal/pkg/ocisif"
"github.com/sylabs/singularity/v4/internal/pkg/test/tool/require"
"github.com/sylabs/singularity/v4/internal/pkg/test/tool/tmpl"
"github.com/sylabs/singularity/v4/internal/pkg/util/fs"
Expand Down Expand Up @@ -1478,12 +1478,7 @@ func checkOCISIFPlatform(t *testing.T, imgPath, platform string) {
t.Errorf("while loading SIF: %v", err)
}

ofi, err := ocisif.FromFileImage(fi)
if err != nil {
t.Fatal(err)
}

img, err := ofi.Image(nil)
img, err := ocisif.GetSingleImage(fi)
if err != nil {
t.Errorf("while initializing image: %v", err)
}
Expand Down Expand Up @@ -1569,12 +1564,7 @@ func verifyImgArch(t *testing.T, imgPath, arch string) {
}
defer fi.UnloadContainer()

ofi, err := ocisif.FromFileImage(fi)
if err != nil {
t.Fatal(err)
}

img, err := ofi.Image(nil)
img, err := ocisif.GetSingleImage(fi)
if err != nil {
t.Fatalf("while initializing image from %s: %v", imgPath, err)
}
Expand Down
8 changes: 1 addition & 7 deletions e2e/pull/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/types"
ocitsif "github.com/sylabs/oci-tools/pkg/sif"
"github.com/sylabs/sif/v2/pkg/sif"
"github.com/sylabs/singularity/v4/e2e/internal/e2e"
"github.com/sylabs/singularity/v4/e2e/internal/testhelper"
Expand Down Expand Up @@ -612,12 +611,7 @@ func checkOCISIF(t *testing.T, imgFile string, expectLayers int) {
}
defer fi.UnloadContainer()

ofi, err := ocitsif.FromFileImage(fi)
if err != nil {
t.Fatalf("while loading OCI-SIF: %v", err)
}

img, err := ofi.Image(nil)
img, err := ocisif.GetSingleImage(fi)
if err != nil {
t.Fatalf("while initializing image: %v", err)
}
Expand Down
144 changes: 144 additions & 0 deletions e2e/sign/oci.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright (c) 2025, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.

package sign

import (
"context"
"path/filepath"
"testing"

"github.com/sylabs/oci-tools/pkg/sourcesink"
"github.com/sylabs/singularity/v4/e2e/internal/e2e"
"github.com/sylabs/singularity/v4/internal/pkg/util/fs"
)

func (c *ctx) signOCICosign(t *testing.T) {
e2e.EnsureOCISIF(t, c.TestEnv)
testSif := filepath.Join(t.TempDir(), "test.sif")
if err := fs.CopyFile(c.TestEnv.OCISIFPath, testSif, 0o755); err != nil {
t.Fatal(err)
}
keyPath := filepath.Join("..", "test", "keys", "cosign.key")

tests := []struct {
name string
flags []string
expectCode int
expectOps []e2e.SingularityCmdResultOp
expectSignatures int
}{
{
flags: []string{"--cosign"},
name: "NoKey",
expectCode: 255,
expectOps: []e2e.SingularityCmdResultOp{
e2e.ExpectError(e2e.ContainMatch, "require a private --key"),
},
},
{
name: "UnsupportedObjectID",
expectCode: 255,
flags: []string{"--cosign", "--key=" + keyPath, "--sif-id", "1"},
expectOps: []e2e.SingularityCmdResultOp{
e2e.ExpectError(e2e.ContainMatch, "not supported"),
},
},
{
name: "UnsupportedGroupIDFlag",
expectCode: 255,
flags: []string{"--cosign", "--key=" + keyPath, "--group-id", "1"},
expectOps: []e2e.SingularityCmdResultOp{
e2e.ExpectError(e2e.ContainMatch, "not supported"),
},
},
{
name: "UnsupportedAllFlag",
expectCode: 255,
flags: []string{"--cosign", "--key=" + keyPath, "--all"},
expectOps: []e2e.SingularityCmdResultOp{
e2e.ExpectError(e2e.ContainMatch, "not supported"),
},
},
{
flags: []string{"--cosign", "--key=" + keyPath},
name: "SignOnce",
expectCode: 0,
expectSignatures: 1,
},
{
flags: []string{"--cosign", "--key=" + keyPath},
name: "SignTwice",
expectCode: 0,
expectSignatures: 2,
},
}

for _, tt := range tests {
c.RunSingularity(t,
e2e.AsSubtest(tt.name),
e2e.WithProfile(e2e.UserProfile),
e2e.WithCommand("sign"),
e2e.WithArgs(append(tt.flags, testSif)...),
e2e.ExpectExit(tt.expectCode, tt.expectOps...),
e2e.PostRun(func(t *testing.T) {
// Expected number of signatures can be retrieced from image.
checkSignatures(t, testSif, tt.expectSignatures)
// Signed image is still usable.
if tt.expectSignatures > 0 {
c.checkExec(t, testSif)
}
}),
)
}
}

func checkSignatures(t *testing.T, sifPath string, expectSignatures int) {
t.Helper()

s, err := sourcesink.SIFFromPath(sifPath)
if err != nil {
t.Fatal(err)
}
d, err := s.Get(context.Background())
if err != nil {
t.Fatal(err)
}

sd, ok := d.(sourcesink.SignedDescriptor)
if !ok {
t.Fatal("could not upgrade Descriptor to SignedDescriptor")
}

si, err := sd.SignedImage(context.Background())
if err != nil {
t.Fatal(err)
}

sigsImage, err := si.Signatures()
if err != nil {
t.Fatal(err)
}

sigs, err := sigsImage.Get()
if err != nil {
t.Fatal(err)
}

if len(sigs) != expectSignatures {
t.Fatalf("expected %d signatures, found %d", expectSignatures, len(sigs))
}
}

func (c *ctx) checkExec(t *testing.T, sifPath string) {
t.Helper()
c.RunSingularity(t,
e2e.AsSubtest("exec"),
e2e.WithProfile(e2e.OCIUserProfile),
e2e.WithCommand("exec"),
e2e.WithArgs(sifPath, "/bin/true"),
e2e.ExpectExit(0),
)
}
1 change: 1 addition & 0 deletions e2e/sign/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ func E2ETests(env e2e.TestEnv) testhelper.Tests {
c.importPGPKeypairs(t)

t.Run("Sign", c.sign)
t.Run("signOCICosign", c.signOCICosign)
},
}
}
Loading

0 comments on commit 1252eea

Please sign in to comment.