Skip to content

Commit

Permalink
feat: add support for signing blob (#379)
Browse files Browse the repository at this point in the history
This PR adds support for signing blobs.

Signed-off-by: Pritesh Bandi <priteshbandi@gmail.com>
  • Loading branch information
priteshbandi committed Mar 14, 2024
1 parent 7fa8404 commit ec42378
Show file tree
Hide file tree
Showing 8 changed files with 472 additions and 83 deletions.
4 changes: 2 additions & 2 deletions example_localSign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ func Example_localSign() {
// Users should replace `exampleCertTuple.PrivateKey` with their own private
// key and replace `exampleCerts` with the corresponding full certificate
// chain, following the Notary certificate requirements:
// https://github.com/notaryproject/notaryproject/blob/v1.0.0-rc.1/specs/signature-specification.md#certificate-requirements
exampleSigner, err := signer.New(exampleCertTuple.PrivateKey, exampleCerts)
// https://github.com/notaryproject/notaryproject/blob/v1.0.0/specs/signature-specification.md#certificate-requirements
exampleSigner, err := signer.NewGenericSigner(exampleCertTuple.PrivateKey, exampleCerts)
if err != nil {
panic(err) // Handle error
}
Expand Down
7 changes: 4 additions & 3 deletions example_remoteSign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ import (
"crypto/x509"
"fmt"

"oras.land/oras-go/v2/registry/remote"

"github.com/notaryproject/notation-core-go/signature/cose"
"github.com/notaryproject/notation-core-go/testhelper"
"github.com/notaryproject/notation-go"
"github.com/notaryproject/notation-go/registry"
"github.com/notaryproject/notation-go/signer"
"oras.land/oras-go/v2/registry/remote"
)

// Both COSE ("application/cose") and JWS ("application/jose+json")
Expand All @@ -45,8 +46,8 @@ func Example_remoteSign() {
// Users should replace `exampleCertTuple.PrivateKey` with their own private
// key and replace `exampleCerts` with the corresponding full certificate
// chain, following the Notary certificate requirements:
// https://github.com/notaryproject/notaryproject/blob/v1.0.0-rc.1/specs/signature-specification.md#certificate-requirements
exampleSigner, err := signer.New(exampleCertTuple.PrivateKey, exampleCerts)
// https://github.com/notaryproject/notaryproject/blob/v1.0.0/specs/signature-specification.md#certificate-requirements
exampleSigner, err := signer.NewGenericSigner(exampleCertTuple.PrivateKey, exampleCerts)
if err != nil {
panic(err) // Handle error
}
Expand Down
113 changes: 97 additions & 16 deletions notation.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,22 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"strings"
"time"

orasRegistry "oras.land/oras-go/v2/registry"

"github.com/notaryproject/notation-core-go/signature"
"github.com/notaryproject/notation-core-go/signature/cose"
"github.com/notaryproject/notation-core-go/signature/jws"
"github.com/notaryproject/notation-go/internal/envelope"
"github.com/notaryproject/notation-go/log"
"github.com/notaryproject/notation-go/registry"
"github.com/notaryproject/notation-go/verifier/trustpolicy"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
orasRegistry "oras.land/oras-go/v2/registry"
)

var errDoneVerification = errors.New("done verification")
Expand All @@ -41,7 +46,7 @@ var reservedAnnotationPrefixes = [...]string{"io.cncf.notary"}
// SignerSignOptions contains parameters for Signer.Sign.
type SignerSignOptions struct {
// SignatureMediaType is the envelope type of the signature.
// Currently both `application/jose+json` and `application/cose` are
// Currently, both `application/jose+json` and `application/cose` are
// supported.
SignatureMediaType string

Expand All @@ -56,15 +61,37 @@ type SignerSignOptions struct {
SigningAgent string
}

// Signer is a generic interface for signing an artifact.
// Signer is a generic interface for signing an OCI artifact.
// The interface allows signing with local or remote keys,
// and packing in various signature formats.
type Signer interface {
// Sign signs the artifact described by its descriptor,
// Sign signs the OCI artifact described by its descriptor,
// and returns the signature and SignerInfo.
Sign(ctx context.Context, desc ocispec.Descriptor, opts SignerSignOptions) ([]byte, *signature.SignerInfo, error)
}

// SignBlobOptions contains parameters for notation.SignBlob.
type SignBlobOptions struct {
SignerSignOptions
// ContentMediaType is the media-type of the blob being signed.
ContentMediaType string
// UserMetadata contains key-value pairs that are added to the signature
// payload
UserMetadata map[string]string
}

// BlobDescriptorGenerator creates descriptor using the digest Algorithm.
type BlobDescriptorGenerator func(digest.Algorithm) (ocispec.Descriptor, error)

// BlobSigner is a generic interface for signing arbitrary data.
// The interface allows signing with local or remote keys,
// and packing in various signature formats.
type BlobSigner interface {
// SignBlob signs the descriptor returned by genDesc ,
// and returns the signature and SignerInfo
SignBlob(ctx context.Context, genDesc BlobDescriptorGenerator, opts SignerSignOptions) ([]byte, *signature.SignerInfo, error)
}

// signerAnnotation facilitates return of manifest annotations by signers
type signerAnnotation interface {
// PluginAnnotations returns signature manifest annotations returned from
Expand All @@ -85,22 +112,16 @@ type SignOptions struct {
UserMetadata map[string]string
}

// Sign signs the artifact and push the signature to the Repository.
// The descriptor of the sign content is returned upon sucessful signing.
// Sign signs the OCI artifact and push the signature to the Repository.
// The descriptor of the sign content is returned upon successful signing.
func Sign(ctx context.Context, signer Signer, repo registry.Repository, signOpts SignOptions) (ocispec.Descriptor, error) {
// sanity check
if signer == nil {
return ocispec.Descriptor{}, errors.New("signer cannot be nil")
if err := validateSignArguments(signer, signOpts.SignerSignOptions); err != nil {
return ocispec.Descriptor{}, err
}
if repo == nil {
return ocispec.Descriptor{}, errors.New("repo cannot be nil")
}
if signOpts.ExpiryDuration < 0 {
return ocispec.Descriptor{}, fmt.Errorf("expiry duration cannot be a negative value")
}
if signOpts.ExpiryDuration%time.Second != 0 {
return ocispec.Descriptor{}, fmt.Errorf("expiry duration supports minimum granularity of seconds")
}

logger := log.GetLogger(ctx)
artifactRef := signOpts.ArtifactReference
Expand Down Expand Up @@ -152,6 +173,50 @@ func Sign(ctx context.Context, signer Signer, repo registry.Repository, signOpts
return targetDesc, nil
}

// SignBlob signs the arbitrary data and returns the signature
func SignBlob(ctx context.Context, signer BlobSigner, blobReader io.Reader, signBlobOpts SignBlobOptions) ([]byte, *signature.SignerInfo, error) {
// sanity checks
if err := validateSignArguments(signer, signBlobOpts.SignerSignOptions); err != nil {
return nil, nil, err
}

if blobReader == nil {
return nil, nil, errors.New("blobReader cannot be nil")
}

if signBlobOpts.ContentMediaType == "" {
return nil, nil, errors.New("content media-type cannot be empty")
}

if _, _, err := mime.ParseMediaType(signBlobOpts.ContentMediaType); err != nil {
return nil, nil, fmt.Errorf("invalid content media-type '%s': %v", signBlobOpts.ContentMediaType, err)
}

getDescFunc := getDescriptorFunc(ctx, blobReader, signBlobOpts.ContentMediaType, signBlobOpts.UserMetadata)
return signer.SignBlob(ctx, getDescFunc, signBlobOpts.SignerSignOptions)
}

func validateSignArguments(signer any, signOpts SignerSignOptions) error {
if signer == nil {
return errors.New("signer cannot be nil")
}
if signOpts.ExpiryDuration < 0 {
return errors.New("expiry duration cannot be a negative value")
}
if signOpts.ExpiryDuration%time.Second != 0 {
return errors.New("expiry duration supports minimum granularity of seconds")
}
if signOpts.SignatureMediaType == "" {
return errors.New("signature media-type cannot be empty")
}

if !(signOpts.SignatureMediaType == jws.MediaTypeEnvelope || signOpts.SignatureMediaType == cose.MediaTypeEnvelope) {
return fmt.Errorf("invalid signature media-type '%s'", signOpts.SignatureMediaType)
}

return nil
}

func addUserMetadataToDescriptor(ctx context.Context, desc ocispec.Descriptor, userMetadata map[string]string) (ocispec.Descriptor, error) {
logger := log.GetLogger(ctx)

Expand Down Expand Up @@ -236,7 +301,7 @@ func (outcome *VerificationOutcome) UserMetadata() (map[string]string, error) {

// VerifierVerifyOptions contains parameters for Verifier.Verify.
type VerifierVerifyOptions struct {
// ArtifactReference is the reference of the artifact that is been
// ArtifactReference is the reference of the artifact that is being
// verified against to. It must be a full reference.
ArtifactReference string

Expand Down Expand Up @@ -270,7 +335,7 @@ type verifySkipper interface {

// VerifyOptions contains parameters for notation.Verify.
type VerifyOptions struct {
// ArtifactReference is the reference of the artifact that is been
// ArtifactReference is the reference of the artifact that is being
// verified against to.
ArtifactReference string

Expand Down Expand Up @@ -449,3 +514,19 @@ func generateAnnotations(signerInfo *signature.SignerInfo, annotations map[strin
annotations[ocispec.AnnotationCreated] = signingTime.Format(time.RFC3339)
return annotations, nil
}

func getDescriptorFunc(ctx context.Context, reader io.Reader, contentMediaType string, userMetadata map[string]string) BlobDescriptorGenerator {
return func(hashAlgo digest.Algorithm) (ocispec.Descriptor, error) {
digester := hashAlgo.Digester()
bytes, err := io.Copy(digester.Hash(), reader)
if err != nil {
return ocispec.Descriptor{}, err
}
targetDesc := ocispec.Descriptor{
MediaType: contentMediaType,
Digest: digester.Digest(),
Size: bytes,
}
return addUserMetadataToDescriptor(ctx, targetDesc, userMetadata)
}
}
109 changes: 108 additions & 1 deletion notation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,22 @@ import (
"context"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/notaryproject/notation-core-go/signature"
"github.com/notaryproject/notation-core-go/signature/cose"
"github.com/notaryproject/notation-core-go/signature/jws"
"github.com/notaryproject/notation-go/internal/mock"
"github.com/notaryproject/notation-go/plugin"
"github.com/notaryproject/notation-go/registry"
"github.com/notaryproject/notation-go/verifier/trustpolicy"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

Expand All @@ -47,6 +51,7 @@ func TestSignSuccess(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(b *testing.T) {
opts := SignOptions{}
opts.SignatureMediaType = jws.MediaTypeEnvelope
opts.ExpiryDuration = tc.dur
opts.ArtifactReference = mock.SampleArtifactUri

Expand All @@ -58,11 +63,91 @@ func TestSignSuccess(t *testing.T) {
}
}

func TestSignBlobSuccess(t *testing.T) {
reader := strings.NewReader("some content")
testCases := []struct {
name string
dur time.Duration
mtype string
agent string
pConfig map[string]string
metadata map[string]string
}{
{"expiryInHours", 24 * time.Hour, "video/mp4", "", nil, nil},
{"oneSecondExpiry", 1 * time.Second, "video/mp4", "", nil, nil},
{"zeroExpiry", 0, "video/mp4", "", nil, nil},
{"validContentType", 1 * time.Second, "video/mp4", "", nil, nil},
{"emptyContentType", 1 * time.Second, "video/mp4", "someDummyAgent", map[string]string{"hi": "hello"}, map[string]string{"bye": "tata"}},
}
for _, tc := range testCases {
t.Run(tc.name, func(b *testing.T) {
opts := SignBlobOptions{
SignerSignOptions: SignerSignOptions{
SignatureMediaType: jws.MediaTypeEnvelope,
ExpiryDuration: tc.dur,
PluginConfig: tc.pConfig,
SigningAgent: tc.agent,
},
UserMetadata: expectedMetadata,
ContentMediaType: tc.mtype,
}

_, _, err := SignBlob(context.Background(), &dummySigner{}, reader, opts)
if err != nil {
b.Fatalf("Sign failed with error: %v", err)
}
})
}
}

func TestSignBlobError(t *testing.T) {
reader := strings.NewReader("some content")
testCases := []struct {
name string
signer BlobSigner
dur time.Duration
rdr io.Reader
sigMType string
ctMType string
errMsg string
}{
{"negativeExpiry", &dummySigner{}, -1 * time.Second, nil, "video/mp4", jws.MediaTypeEnvelope, "expiry duration cannot be a negative value"},
{"milliSecExpiry", &dummySigner{}, 1 * time.Millisecond, nil, "video/mp4", jws.MediaTypeEnvelope, "expiry duration supports minimum granularity of seconds"},
{"invalidContentMediaType", &dummySigner{}, 1 * time.Second, reader, "video/mp4/zoping", jws.MediaTypeEnvelope, "invalid content media-type 'video/mp4/zoping': mime: unexpected content after media subtype"},
{"emptyContentMediaType", &dummySigner{}, 1 * time.Second, reader, "", jws.MediaTypeEnvelope, "content media-type cannot be empty"},
{"invalidSignatureMediaType", &dummySigner{}, 1 * time.Second, reader, "", "", "content media-type cannot be empty"},
{"nilReader", &dummySigner{}, 1 * time.Second, nil, "video/mp4", jws.MediaTypeEnvelope, "blobReader cannot be nil"},
{"nilSigner", nil, 1 * time.Second, reader, "video/mp4", jws.MediaTypeEnvelope, "signer cannot be nil"},
{"signerError", &dummySigner{fail: true}, 1 * time.Second, reader, "video/mp4", jws.MediaTypeEnvelope, "expected SignBlob failure"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
opts := SignBlobOptions{
SignerSignOptions: SignerSignOptions{
SignatureMediaType: jws.MediaTypeEnvelope,
ExpiryDuration: tc.dur,
PluginConfig: nil,
},
ContentMediaType: tc.sigMType,
}

_, _, err := SignBlob(context.Background(), tc.signer, tc.rdr, opts)
if err == nil {
t.Fatalf("expected error but didnt found")
}
if err.Error() != tc.errMsg {
t.Fatalf("expected err message to be '%s' but found '%s'", tc.errMsg, err.Error())
}
})
}
}

func TestSignSuccessWithUserMetadata(t *testing.T) {
repo := mock.NewRepository()
opts := SignOptions{}
opts.ArtifactReference = mock.SampleArtifactUri
opts.UserMetadata = expectedMetadata
opts.SignatureMediaType = jws.MediaTypeEnvelope

_, err := Sign(context.Background(), &verifyMetadataSigner{}, repo, opts)
if err != nil {
Expand Down Expand Up @@ -182,6 +267,9 @@ func TestSignDigestNotMatchResolve(t *testing.T) {
repo := mock.NewRepository()
repo.MissMatchDigest = true
signOpts := SignOptions{
SignerSignOptions: SignerSignOptions{
SignatureMediaType: jws.MediaTypeEnvelope,
},
ArtifactReference: mock.SampleArtifactUri,
}

Expand Down Expand Up @@ -320,7 +408,9 @@ func dummyPolicyStatement() (policyStatement trustpolicy.TrustPolicy) {
return
}

type dummySigner struct{}
type dummySigner struct {
fail bool
}

func (s *dummySigner) Sign(ctx context.Context, desc ocispec.Descriptor, opts SignerSignOptions) ([]byte, *signature.SignerInfo, error) {
return []byte("ABC"), &signature.SignerInfo{
Expand All @@ -330,6 +420,23 @@ func (s *dummySigner) Sign(ctx context.Context, desc ocispec.Descriptor, opts Si
}, nil
}

func (s *dummySigner) SignBlob(_ context.Context, descGenFunc BlobDescriptorGenerator, _ SignerSignOptions) ([]byte, *signature.SignerInfo, error) {
if s.fail {
return nil, nil, errors.New("expected SignBlob failure")
}

_, err := descGenFunc(digest.SHA384)
if err != nil {
return nil, nil, err
}

return []byte("ABC"), &signature.SignerInfo{
SignedAttributes: signature.SignedAttributes{
SigningTime: time.Now(),
},
}, nil
}

type verifyMetadataSigner struct{}

func (s *verifyMetadataSigner) Sign(ctx context.Context, desc ocispec.Descriptor, opts SignerSignOptions) ([]byte, *signature.SignerInfo, error) {
Expand Down
Loading

0 comments on commit ec42378

Please sign in to comment.