From 7eb2343c45c9530b140b8348b1e6a7296c5b203c Mon Sep 17 00:00:00 2001 From: Enrico Kaack Date: Mon, 28 Feb 2022 09:52:55 +0100 Subject: [PATCH] merge Normalisation and Signing of Component Descriptor (#47) extend cd with digests and signatures field, normalisation, signer, verifier, rsa signer --- bindings-go/apis/v2/accesstypes.go | 28 +++ bindings-go/apis/v2/componentdescriptor.go | 45 ++++ bindings-go/apis/v2/signatures/normalize.go | 233 ++++++++++++++++++ .../apis/v2/signatures/normalize_test.go | 125 ++++++++++ bindings-go/apis/v2/signatures/rsa.go | 120 +++++++++ bindings-go/apis/v2/signatures/rsa_test.go | 132 ++++++++++ bindings-go/apis/v2/signatures/sign.go | 72 ++++++ bindings-go/apis/v2/signatures/sign_test.go | 163 ++++++++++++ .../v2/signatures/signatures_suite_test.go | 13 + bindings-go/apis/v2/signatures/types.go | 49 ++++ bindings-go/examples/signatures/main.go | 108 ++++++++ bindings-python/gci/componentmodel.py | 20 +- .../component-descriptor-v2-schema.yaml | 60 +++++ 13 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 bindings-go/apis/v2/signatures/normalize.go create mode 100644 bindings-go/apis/v2/signatures/normalize_test.go create mode 100644 bindings-go/apis/v2/signatures/rsa.go create mode 100644 bindings-go/apis/v2/signatures/rsa_test.go create mode 100644 bindings-go/apis/v2/signatures/sign.go create mode 100644 bindings-go/apis/v2/signatures/sign_test.go create mode 100644 bindings-go/apis/v2/signatures/signatures_suite_test.go create mode 100644 bindings-go/apis/v2/signatures/types.go create mode 100644 bindings-go/examples/signatures/main.go diff --git a/bindings-go/apis/v2/accesstypes.go b/bindings-go/apis/v2/accesstypes.go index cd34d0be..fa27dbb5 100644 --- a/bindings-go/apis/v2/accesstypes.go +++ b/bindings-go/apis/v2/accesstypes.go @@ -219,3 +219,31 @@ func NewGitHubAccess(url, ref, commit string) *GitHubAccess { func (a GitHubAccess) GetType() string { return GitHubAccessType } + +// S3AccessType is the type of a s3 access. +const S3AccessType = "s3" + +// S3AccessType describes a s3 resource access. +type S3Access struct { + ObjectType `json:",inline"` + + // BucketName is the name of the s3 bucket. + BucketName string `json:"bucketName"` + // ObjectKey describes the referenced object. + ObjectKey string `json:"objectKey"` +} + +// NewS3Access creates a new s3 accessor +func NewS3Access(bucketName, objectKey string) *S3Access { + return &S3Access{ + ObjectType: ObjectType{ + Type: S3AccessType, + }, + BucketName: bucketName, + ObjectKey: objectKey, + } +} + +func (a S3Access) GetType() string { + return S3AccessType +} diff --git a/bindings-go/apis/v2/componentdescriptor.go b/bindings-go/apis/v2/componentdescriptor.go index c388a7c2..42b40cac 100644 --- a/bindings-go/apis/v2/componentdescriptor.go +++ b/bindings-go/apis/v2/componentdescriptor.go @@ -71,6 +71,9 @@ type ComponentDescriptor struct { Metadata Metadata `json:"meta"` // Spec contains the specification of the component. ComponentSpec `json:"component"` + + // Signatures contains a list of signatures for the ComponentDescriptor + Signatures []Signature `json:"signatures,omitempty"` } // ComponentSpec defines a virtual component with @@ -351,6 +354,10 @@ type SourceRef struct { type Resource struct { IdentityObjectMeta `json:",inline"` + // Digest is the optional digest of the referenced resource. + // +optional + Digest *DigestSpec `json:"digest,omitempty"` + // Relation describes the relation of the resource to the component. // Can be a local or external resource Relation ResourceRelation `json:"relation,omitempty"` @@ -377,6 +384,9 @@ type ComponentReference struct { // ExtraIdentity is the identity of an object. // An additional label with key "name" ist not allowed ExtraIdentity Identity `json:"extraIdentity,omitempty"` + // Digest is the optional digest of the referenced component. + // +optional + Digest *DigestSpec `json:"digest,omitempty"` // Labels defines an optional set of additional labels // describing the object. // +optional @@ -427,3 +437,38 @@ func (o *ComponentReference) GetIdentity() Identity { func (o *ComponentReference) GetIdentityDigest() []byte { return o.GetIdentity().Digest() } + +// DigestSpec defines the digest and algorithm. +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +type DigestSpec struct { + HashAlgorithm string `json:"hashAlgorithm"` + NormalisationAlgorithm string `json:"normalisationAlgorithm"` + Value string `json:"value"` +} + +// SignatureSpec defines the signature and algorithm. +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +type SignatureSpec struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` +} + +// NormalisationAlgorithm types and versions the algorithm used for digest generation. +type NormalisationAlgorithm string + +const ( + JsonNormalisationV1 NormalisationAlgorithm = "jsonNormalisation/V1" + ManifestDigestV1 NormalisationAlgorithm = "manifestDigest/V1" + GenericBlobDigestV1 NormalisationAlgorithm = "genericBlobDigest/V1" +) + +// Signature defines a digest and corresponding signature, identifyable by name. +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +type Signature struct { + Name string `json:"name"` + Digest DigestSpec `json:"digest"` + Signature SignatureSpec `json:"signature"` +} diff --git a/bindings-go/apis/v2/signatures/normalize.go b/bindings-go/apis/v2/signatures/normalize.go new file mode 100644 index 00000000..aa10eaae --- /dev/null +++ b/bindings-go/apis/v2/signatures/normalize.go @@ -0,0 +1,233 @@ +package signatures + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" +) + +// Entry is used for normalisation and has to contain one key +type Entry map[string]interface{} + +// AddDigestsToComponentDescriptor adds digest to componentReferences and resources as returned in the resolver functions +func AddDigestsToComponentDescriptor(ctx context.Context, cd *v2.ComponentDescriptor, + compRefResolver func(context.Context, v2.ComponentDescriptor, v2.ComponentReference) (*v2.DigestSpec, error), + resResolver func(context.Context, v2.ComponentDescriptor, v2.Resource) (*v2.DigestSpec, error)) error { + + for i, reference := range cd.ComponentReferences { + if reference.Digest == nil || reference.Digest.HashAlgorithm == "" || reference.Digest.NormalisationAlgorithm == "" || reference.Digest.Value == "" { + digest, err := compRefResolver(ctx, *cd, reference) + if err != nil { + return fmt.Errorf("failed resolving componentReference for %s:%s: %w", reference.Name, reference.Version, err) + } + cd.ComponentReferences[i].Digest = digest + } + } + + for i, res := range cd.Resources { + if res.Digest == nil || res.Digest.HashAlgorithm == "" || res.Digest.NormalisationAlgorithm == "" || res.Digest.Value == "" { + digest, err := resResolver(ctx, *cd, res) + if err != nil { + return fmt.Errorf("failed resolving resource for %s:%s: %w", res.Name, res.Version, err) + } + cd.Resources[i].Digest = digest + } + } + return nil +} + +// HashForComponentDescriptor return the hash for the component-descriptor, if it is normaliseable +// (= componentReferences and resources contain digest field) +func HashForComponentDescriptor(cd v2.ComponentDescriptor, hash Hasher) (*v2.DigestSpec, error) { + normalisedComponentDescriptor, err := normalizeComponentDescriptor(cd) + if err != nil { + return nil, fmt.Errorf("failed normalising component descriptor %w", err) + } + hash.HashFunction.Reset() + if _, err = hash.HashFunction.Write(normalisedComponentDescriptor); err != nil { + return nil, fmt.Errorf("failed hashing the normalisedComponentDescriptorJson: %w", err) + } + return &v2.DigestSpec{ + HashAlgorithm: hash.AlgorithmName, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: hex.EncodeToString(hash.HashFunction.Sum(nil)), + }, nil +} + +func normalizeComponentDescriptor(cd v2.ComponentDescriptor) ([]byte, error) { + if err := isNormaliseableUnsafe(cd); err != nil { + return nil, fmt.Errorf("can not normalise component-descriptor %s:%s: %w", cd.Name, cd.Version, err) + } + + meta := []Entry{ + {"schemaVersion": cd.Metadata.Version}, + } + + componentReferences := []interface{}{} + for _, ref := range cd.ComponentSpec.ComponentReferences { + extraIdentity := buildExtraIdentity(ref.ExtraIdentity) + + digest := []Entry{ + {"hashAlgorithm": ref.Digest.HashAlgorithm}, + {"normalisationAlgorithm": ref.Digest.NormalisationAlgorithm}, + {"value": ref.Digest.Value}, + } + + componentReference := []Entry{ + {"name": ref.Name}, + {"version": ref.Version}, + {"extraIdentity": extraIdentity}, + {"digest": digest}, + } + componentReferences = append(componentReferences, componentReference) + } + + resources := []interface{}{} + for _, res := range cd.ComponentSpec.Resources { + extraIdentity := buildExtraIdentity(res.ExtraIdentity) + + //ignore access.type=None for normalisation and hash calculation + if res.Access == nil || res.Access.Type == "None" { + resource := []Entry{ + {"name": res.Name}, + {"version": res.Version}, + {"extraIdentity": extraIdentity}, + } + resources = append(resources, resource) + continue + } + + //ignore a resource without digests + if res.Digest == nil { + resource := []Entry{ + {"name": res.Name}, + {"version": res.Version}, + {"extraIdentity": extraIdentity}, + } + resources = append(resources, resource) + continue + } + + digest := []Entry{ + {"hashAlgorithm": res.Digest.HashAlgorithm}, + {"normalisationAlgorithm": res.Digest.NormalisationAlgorithm}, + {"value": res.Digest.Value}, + } + + resource := []Entry{ + {"name": res.Name}, + {"version": res.Version}, + {"extraIdentity": extraIdentity}, + {"digest": digest}, + } + resources = append(resources, resource) + } + + componentSpec := []Entry{ + {"name": cd.ComponentSpec.Name}, + {"version": cd.ComponentSpec.Version}, + {"componentReferences": componentReferences}, + {"resources": resources}, + } + + normalizedComponentDescriptor := []Entry{ + {"meta": meta}, + {"component": componentSpec}, + } + + if err := deepSort(normalizedComponentDescriptor); err != nil { + return nil, fmt.Errorf("failed sorting during normalisation: %w", err) + } + + byteBuffer := bytes.NewBuffer([]byte{}) + encoder := json.NewEncoder(byteBuffer) + encoder.SetEscapeHTML(false) + + if err := encoder.Encode(normalizedComponentDescriptor); err != nil { + return nil, err + } + + normalizedJson := byteBuffer.Bytes() + + // encoder.Encode appends a newline that we do not want + if normalizedJson[len(normalizedJson)-1] == 10 { + normalizedJson = normalizedJson[:len(normalizedJson)-1] + } + + return normalizedJson, nil +} + +func buildExtraIdentity(identity v2.Identity) []Entry { + var extraIdentities []Entry + for k, v := range identity { + extraIdentities = append(extraIdentities, Entry{k: v}) + } + return extraIdentities +} + +// deepSort sorts Entry, []Enry and [][]Entry interfaces recursively, lexicographicly by key(Entry). +func deepSort(in interface{}) error { + switch castIn := in.(type) { + case []Entry: + // sort the values recursively for every entry + for _, entry := range castIn { + val := getOnlyValueInEntry(entry) + if err := deepSort(val); err != nil { + return err + } + } + // sort the entries based on the key + sort.SliceStable(castIn, func(i, j int) bool { + return getOnlyKeyInEntry(castIn[i]) < getOnlyKeyInEntry(castIn[j]) + }) + case Entry: + val := getOnlyValueInEntry(castIn) + if err := deepSort(val); err != nil { + return err + } + case []interface{}: + for _, v := range castIn { + if err := deepSort(v); err != nil { + return err + } + } + case string: + break + default: + return fmt.Errorf("unknown type in sorting. This should not happen") + } + return nil +} + +func getOnlyKeyInEntry(entry Entry) string { + var key string + for k := range entry { + key = k + } + return key +} + +func getOnlyValueInEntry(entry Entry) interface{} { + var value interface{} + for _, v := range entry { + value = v + } + return value +} + +// isNormaliseableUnsafe checks if componentReferences contain digest. It does not check resources for containing digests. +// Does NOT verify if the digests are correct +func isNormaliseableUnsafe(cd v2.ComponentDescriptor) error { + // check for digests on component references + for _, reference := range cd.ComponentReferences { + if reference.Digest == nil || reference.Digest.HashAlgorithm == "" || reference.Digest.NormalisationAlgorithm == "" || reference.Digest.Value == "" { + return fmt.Errorf("missing digest in componentReference for %s:%s", reference.Name, reference.Version) + } + } + return nil +} diff --git a/bindings-go/apis/v2/signatures/normalize_test.go b/bindings-go/apis/v2/signatures/normalize_test.go new file mode 100644 index 00000000..053230b7 --- /dev/null +++ b/bindings-go/apis/v2/signatures/normalize_test.go @@ -0,0 +1,125 @@ +package signatures_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" + "github.com/gardener/component-spec/bindings-go/apis/v2/signatures" +) + +var _ = Describe("Normalise/Hash component-descriptor", func() { + var baseCd v2.ComponentDescriptor + correctBaseCdHash := "5995a530e81af5e974fe377f1079991c4e6a762bcff2cb92334f80d3a1da5a8a" + //corresponding normalised CD: + //[{"component":[{"componentReferences":[[{"digest":[{"hashAlgorithm":"sha256"},{"normalisationAlgorithm":"jsonNormalisation/V1"},{"value":"00000000000000"}]},{"extraIdentity":[{"refKey":"refName"}]},{"name":"compRefName"},{"version":"v0.0.2compRef"}]]},{"name":"CD-Name"},{"resources":[[{"digest":[{"hashAlgorithm":"sha256"},{"normalisationAlgorithm":"manifestDigest/V1"},{"value":"00000000000000"}]},{"extraIdentity":[{"key":"value"}]},{"name":"Resource1"},{"version":"v0.0.3resource"}]]},{"version":"v0.0.1"}]},{"meta":[{"schemaVersion":"v2"}]}] + BeforeEach(func() { + baseCd = v2.ComponentDescriptor{ + Metadata: v2.Metadata{ + Version: "v2", + }, + ComponentSpec: v2.ComponentSpec{ + ObjectMeta: v2.ObjectMeta{ + Name: "CD-Name", + Version: "v0.0.1", + }, + ComponentReferences: []v2.ComponentReference{ + { + Name: "compRefName", + ComponentName: "compRefNameComponentName", + Version: "v0.0.2compRef", + ExtraIdentity: v2.Identity{ + "refKey": "refName", + }, + Digest: &v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: "00000000000000", + }, + }, + }, + Resources: []v2.Resource{ + { + IdentityObjectMeta: v2.IdentityObjectMeta{ + Name: "Resource1", + Version: "v0.0.3resource", + ExtraIdentity: v2.Identity{ + "key": "value", + }, + }, + Digest: &v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.ManifestDigestV1), + Value: "00000000000000", + }, + Access: v2.NewUnstructuredType(v2.OCIRegistryType, map[string]interface{}{"imageRef": "ref"}), + }, + }, + }, + } + }) + + Describe("missing componentReference Digest", func() { + It("should fail to hash", func() { + baseCd.ComponentSpec.ComponentReferences[0].Digest = nil + hasher, err := signatures.HasherForName(signatures.SHA256) + Expect(err).To(BeNil()) + hash, err := signatures.HashForComponentDescriptor(baseCd, *hasher) + Expect(hash).To(BeNil()) + Expect(err).ToNot(BeNil()) + }) + }) + Describe("should give the correct hash", func() { + It("with sha256", func() { + hasher, err := signatures.HasherForName(signatures.SHA256) + Expect(err).To(BeNil()) + hash, err := signatures.HashForComponentDescriptor(baseCd, *hasher) + Expect(err).To(BeNil()) + Expect(hash.Value).To(Equal(correctBaseCdHash)) + }) + }) + Describe("should ignore modifications in unhashed fields", func() { + It("should succeed with signature changes", func() { + baseCd.Signatures = append(baseCd.Signatures, v2.Signature{ + Name: "TestSig", + Digest: v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: "00000", + }, + Signature: v2.SignatureSpec{ + Algorithm: "test", + Value: "0000", + }, + }) + hasher, err := signatures.HasherForName(signatures.SHA256) + Expect(err).To(BeNil()) + hash, err := signatures.HashForComponentDescriptor(baseCd, *hasher) + Expect(err).To(BeNil()) + Expect(hash.Value).To(Equal(correctBaseCdHash)) + }) + It("should succeed with source changes", func() { + baseCd.Sources = append(baseCd.Sources, v2.Source{ + IdentityObjectMeta: v2.IdentityObjectMeta{ + Name: "source1", + Version: "v0.0.0", + }, + }) + hasher, err := signatures.HasherForName(signatures.SHA256) + Expect(err).To(BeNil()) + hash, err := signatures.HashForComponentDescriptor(baseCd, *hasher) + Expect(err).To(BeNil()) + Expect(hash.Value).To(Equal(correctBaseCdHash)) + }) + It("should succeed with resource access reference changes", func() { + access, err := v2.NewUnstructured(v2.NewOCIRegistryAccess("ociRef/path/to/image")) + Expect(err).To(BeNil()) + baseCd.Resources[0].Access = &access + hasher, err := signatures.HasherForName(signatures.SHA256) + Expect(err).To(BeNil()) + hash, err := signatures.HashForComponentDescriptor(baseCd, *hasher) + Expect(err).To(BeNil()) + Expect(hash.Value).To(Equal(correctBaseCdHash)) + }) + }) +}) diff --git a/bindings-go/apis/v2/signatures/rsa.go b/bindings-go/apis/v2/signatures/rsa.go new file mode 100644 index 00000000..4315ef94 --- /dev/null +++ b/bindings-go/apis/v2/signatures/rsa.go @@ -0,0 +1,120 @@ +package signatures + +import ( + "crypto" + "crypto/rsa" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "io/ioutil" + "strings" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" +) + +// RsaSigner is a signatures.Signer compatible struct to sign with RSASSA-PKCS1-V1_5-SIGN. +type RsaSigner struct { + privateKey rsa.PrivateKey +} + +// CreateRsaSignerFromKeyFile creates an Instance of RsaSigner with the given private key. +// The private key has to be in the PKCS #1, ASN.1 DER form, see x509.ParsePKCS1PrivateKey. +func CreateRsaSignerFromKeyFile(pathToPrivateKey string) (*RsaSigner, error) { + privKeyFile, err := ioutil.ReadFile(pathToPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed opening private key file %w", err) + } + + block, _ := pem.Decode([]byte(privKeyFile)) + if block == nil { + return nil, fmt.Errorf("failed decoding PEM formatted block in key %w", err) + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed parsing key %w", err) + } + return &RsaSigner{ + privateKey: *key, + }, nil +} + +// Sign returns the signature for the data for the component-descriptor. +func (s RsaSigner) Sign(componentDescriptor v2.ComponentDescriptor, digest v2.DigestSpec) (*v2.SignatureSpec, error) { + decodedHash, err := hex.DecodeString(digest.Value) + if err != nil { + return nil, fmt.Errorf("failed decoding hash to bytes") + } + hashType, err := hashAlgorithmLookup(digest.HashAlgorithm) + if err != nil { + return nil, fmt.Errorf("failed looking up hash algorithm") + } + signature, err := rsa.SignPKCS1v15(nil, &s.privateKey, hashType, decodedHash) + if err != nil { + return nil, fmt.Errorf("failed signing hash, %w", err) + } + return &v2.SignatureSpec{ + Algorithm: "RSASSA-PKCS1-V1_5-SIGN", + Value: hex.EncodeToString(signature), + }, nil +} + +// maps a hashing algorithm string to crypto.Hash +func hashAlgorithmLookup(algorithm string) (crypto.Hash, error) { + switch strings.ToLower(algorithm) { + case SHA256: + return crypto.SHA256, nil + } + return 0, fmt.Errorf("hash Algorithm %s not found", algorithm) +} + +// RsaVerifier is a signatures.Verifier compatible struct to verify RSASSA-PKCS1-V1_5-SIGN signatures. +type RsaVerifier struct { + publicKey rsa.PublicKey +} + +// CreateRsaVerifierFromKeyFile creates an Instance of RsaVerifier with the given rsa public key. +// The private key has to be in the PKIX, ASN.1 DER form, see x509.ParsePKIXPublicKey. +func CreateRsaVerifierFromKeyFile(pathToPublicKey string) (*RsaVerifier, error) { + publicKey, err := ioutil.ReadFile(pathToPublicKey) + if err != nil { + return nil, fmt.Errorf("failed opening public key file %w", err) + } + block, _ := pem.Decode([]byte(publicKey)) + if block == nil { + return nil, fmt.Errorf("failed decoding PEM formatted block in key %w", err) + } + untypedKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed parsing key %w", err) + } + switch key := untypedKey.(type) { + case *rsa.PublicKey: + return &RsaVerifier{ + publicKey: *key, + }, nil + default: + return nil, fmt.Errorf("public key format is not supported. Only rsa.PublicKey is supported") + } +} + +// Verify checks the signature, returns an error on verification failure +func (v RsaVerifier) Verify(componentDescriptor v2.ComponentDescriptor, signature v2.Signature) error { + decodedHash, err := hex.DecodeString(signature.Digest.Value) + if err != nil { + return fmt.Errorf("failed decoding hash %s: %w", signature.Digest.Value, err) + } + decodedSignature, err := hex.DecodeString(signature.Signature.Value) + if err != nil { + return fmt.Errorf("failed decoding hash %s: %w", signature.Digest.Value, err) + } + algorithm, err := hashAlgorithmLookup(signature.Digest.HashAlgorithm) + if err != nil { + return fmt.Errorf("failed looking up hash algorithm for %s: %w", signature.Digest.HashAlgorithm, err) + } + err = rsa.VerifyPKCS1v15(&v.publicKey, algorithm, decodedHash, decodedSignature) + if err != nil { + return fmt.Errorf("signature verification failed, %w", err) + } + return nil +} diff --git a/bindings-go/apis/v2/signatures/rsa_test.go b/bindings-go/apis/v2/signatures/rsa_test.go new file mode 100644 index 00000000..53617555 --- /dev/null +++ b/bindings-go/apis/v2/signatures/rsa_test.go @@ -0,0 +1,132 @@ +package signatures_test + +import ( + "crypto/sha256" + "encoding/hex" + "io/ioutil" + "os" + "os/exec" + "path" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" + "github.com/gardener/component-spec/bindings-go/apis/v2/signatures" +) + +var _ = Describe("RSA Sign/Verify", func() { + var pathPrivateKey string + var pathPublicKey string + var stringToHashAndSign string + var dir string + + BeforeEach(func() { + var err error + dir, err = ioutil.TempDir("", "component-spec-test") + Expect(err).To(BeNil()) + + // openssl genrsa -out private.key 4096 + pathPrivateKey = path.Join(dir, "private.key") + createPrivateKeyCommand := exec.Command("openssl", "genrsa", "-out", pathPrivateKey, "4096") + err = createPrivateKeyCommand.Run() + Expect(err).To(BeNil()) + + // openssl rsa -in private.key -outform PEM -pubout -out public.key + pathPublicKey = path.Join(dir, "public.key") + createPublicKeyCommand := exec.Command("openssl", "rsa", "-in", pathPrivateKey, "-outform", "PEM", "-pubout", "-out", pathPublicKey) + err = createPublicKeyCommand.Run() + Expect(err).To(BeNil()) + + stringToHashAndSign = "TestStringToSign" + }) + + AfterEach(func() { + os.RemoveAll(dir) + }) + + Describe("RSA Sign with private key", func() { + It("should create a signature", func() { + hashOfString := sha256.Sum256([]byte(stringToHashAndSign)) + + signer, err := signatures.CreateRsaSignerFromKeyFile(pathPrivateKey) + Expect(err).To(BeNil()) + signature, err := signer.Sign(v2.ComponentDescriptor{}, v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: hex.EncodeToString(hashOfString[:]), + }) + Expect(err).To(BeNil()) + Expect(signature.Algorithm).To(BeIdenticalTo("RSASSA-PKCS1-V1_5-SIGN")) + Expect(signature.Value).NotTo(BeNil()) + }) + It("should should fail on unknown Digest algorithm", func() { + hashOfString := sha256.Sum256([]byte(stringToHashAndSign)) + + signer, err := signatures.CreateRsaSignerFromKeyFile(pathPrivateKey) + Expect(err).To(BeNil()) + signature, err := signer.Sign(v2.ComponentDescriptor{}, v2.DigestSpec{ + HashAlgorithm: "unknown", + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: hex.EncodeToString(hashOfString[:]), + }) + Expect(err).ToNot(BeNil()) + Expect(signature).To(BeNil()) + }) + + }) + Describe("RSA Sign verify public key", func() { + It("should verify a signature", func() { + hashOfString := sha256.Sum256([]byte(stringToHashAndSign)) + + signer, err := signatures.CreateRsaSignerFromKeyFile(pathPrivateKey) + Expect(err).To(BeNil()) + digest := v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: hex.EncodeToString(hashOfString[:]), + } + signature, err := signer.Sign(v2.ComponentDescriptor{}, digest) + Expect(err).To(BeNil()) + Expect(signature.Algorithm).To(BeIdenticalTo("RSASSA-PKCS1-V1_5-SIGN")) + Expect(signature.Value).NotTo(BeNil()) + + verifier, err := signatures.CreateRsaVerifierFromKeyFile(pathPublicKey) + Expect(err).To(BeNil()) + err = verifier.Verify(v2.ComponentDescriptor{}, v2.Signature{ + Digest: digest, + Signature: *signature, + }) + Expect(err).To(BeNil()) + }) + It("should deny a signature from a wrong actor", func() { + hashOfString := sha256.Sum256([]byte(stringToHashAndSign)) + + //generate a wrong key (e.g. from a malicious actor) + pathWrongPrivateKey := path.Join(dir, "privateWrong.key") + createWrongPrivateKeyCommand := exec.Command("openssl", "genrsa", "-out", pathWrongPrivateKey, "4096") + err := createWrongPrivateKeyCommand.Run() + Expect(err).To(BeNil()) + + signer, err := signatures.CreateRsaSignerFromKeyFile(pathWrongPrivateKey) + Expect(err).To(BeNil()) + digest := v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: hex.EncodeToString(hashOfString[:]), + } + signature, err := signer.Sign(v2.ComponentDescriptor{}, digest) + Expect(err).To(BeNil()) + Expect(signature.Algorithm).To(BeIdenticalTo("RSASSA-PKCS1-V1_5-SIGN")) + Expect(signature.Value).NotTo(BeNil()) + + verifier, err := signatures.CreateRsaVerifierFromKeyFile(pathPublicKey) + Expect(err).To(BeNil()) + err = verifier.Verify(v2.ComponentDescriptor{}, v2.Signature{ + Digest: digest, + Signature: *signature, + }) + Expect(err.Error()).To(BeIdenticalTo("signature verification failed, crypto/rsa: verification error")) + }) + }) +}) diff --git a/bindings-go/apis/v2/signatures/sign.go b/bindings-go/apis/v2/signatures/sign.go new file mode 100644 index 00000000..6e161f04 --- /dev/null +++ b/bindings-go/apis/v2/signatures/sign.go @@ -0,0 +1,72 @@ +package signatures + +import ( + "fmt" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" +) + +// SignComponentDescriptor signs the given component-descriptor with the signer. +// The component-descriptor has to contain digests for componentReferences and resources. +func SignComponentDescriptor(cd *v2.ComponentDescriptor, signer Signer, hasher Hasher, signatureName string) error { + hashedDigest, err := HashForComponentDescriptor(*cd, hasher) + if err != nil { + return fmt.Errorf("failed getting hash for cd: %w", err) + } + + signature, err := signer.Sign(*cd, *hashedDigest) + if err != nil { + return fmt.Errorf("failed signing hash of normalised component descriptor, %w", err) + } + cd.Signatures = append(cd.Signatures, v2.Signature{ + Name: signatureName, + Digest: *hashedDigest, + Signature: *signature, + }) + return nil +} + +// VerifySignedComponentDescriptor verifies the signature (selected by signatureName) and hash of the component-descriptor (as specified in the signature). +// Returns error if verification fails. +func VerifySignedComponentDescriptor(cd *v2.ComponentDescriptor, verifier Verifier, signatureName string) error { + //find matching signature + + matchingSignature, err := SelectSignatureByName(cd, signatureName) + if err != nil { + return fmt.Errorf("failed checking signature: %w", err) + } + + //Verify hash with signature + err = verifier.Verify(*cd, *matchingSignature) + if err != nil { + return fmt.Errorf("failed verifying: %w", err) + } + + //get hasher by algorithm name + hasher, err := HasherForName(matchingSignature.Digest.HashAlgorithm) + if err != nil { + return fmt.Errorf("failed creating hasher for %s: %w", matchingSignature.Digest.HashAlgorithm, err) + } + + //Verify normalised cd to given (and verified) hash + hashCd, err := HashForComponentDescriptor(*cd, *hasher) + if err != nil { + return fmt.Errorf("failed getting hash for cd: %w", err) + } + if hashCd.Value != matchingSignature.Digest.Value { + return fmt.Errorf("normalised component-descriptor does not match signed hash") + } + + return nil +} + +// SelectSignatureByName returns the Signature (Digest and SigantureSpec) matching the given name +func SelectSignatureByName(cd *v2.ComponentDescriptor, signatureName string) (*v2.Signature, error) { + for _, signature := range cd.Signatures { + if signature.Name == signatureName { + return &signature, nil + } + } + return nil, fmt.Errorf("signature with name %s not found in component-descriptor", signatureName) + +} diff --git a/bindings-go/apis/v2/signatures/sign_test.go b/bindings-go/apis/v2/signatures/sign_test.go new file mode 100644 index 00000000..6e3e475e --- /dev/null +++ b/bindings-go/apis/v2/signatures/sign_test.go @@ -0,0 +1,163 @@ +package signatures_test + +import ( + "crypto/sha256" + "fmt" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" + "github.com/gardener/component-spec/bindings-go/apis/v2/signatures" +) + +type TestSigner struct{} + +func (s TestSigner) Sign(componentDescriptor v2.ComponentDescriptor, digest v2.DigestSpec) (*v2.SignatureSpec, error) { + return &v2.SignatureSpec{ + Algorithm: "testSignAlgorithm", + Value: fmt.Sprintf("%s:%s-signed", digest.HashAlgorithm, digest.Value), + }, nil +} + +type TestVerifier struct{} + +func (v TestVerifier) Verify(componentDescriptor v2.ComponentDescriptor, signature v2.Signature) error { + if signature.Signature.Value != fmt.Sprintf("%s:%s-signed", signature.Digest.HashAlgorithm, signature.Digest.Value) { + return fmt.Errorf("signature verification failed: Invalid signature") + } + return nil +} + +type TestSHA256Hasher signatures.Hasher + +var _ = Describe("Sign/Verify component-descriptor", func() { + var baseCd v2.ComponentDescriptor + testSHA256Hasher := signatures.Hasher{ + HashFunction: sha256.New(), + AlgorithmName: signatures.SHA256, + } + signatureName := "testSignatureName" + correctBaseCdHash := "5995a530e81af5e974fe377f1079991c4e6a762bcff2cb92334f80d3a1da5a8a" + + BeforeEach(func() { + baseCd = v2.ComponentDescriptor{ + Metadata: v2.Metadata{ + Version: "v2", + }, + ComponentSpec: v2.ComponentSpec{ + ObjectMeta: v2.ObjectMeta{ + Name: "CD-Name", + Version: "v0.0.1", + }, + ComponentReferences: []v2.ComponentReference{ + { + Name: "compRefName", + ComponentName: "compRefNameComponentName", + Version: "v0.0.2compRef", + ExtraIdentity: v2.Identity{ + "refKey": "refName", + }, + Digest: &v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: "00000000000000", + }, + }, + }, + Resources: []v2.Resource{ + { + IdentityObjectMeta: v2.IdentityObjectMeta{ + Name: "Resource1", + Version: "v0.0.3resource", + ExtraIdentity: v2.Identity{ + "key": "value", + }, + }, + Digest: &v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.ManifestDigestV1), + Value: "00000000000000", + }, + Access: v2.NewUnstructuredType(v2.OCIRegistryType, map[string]interface{}{"imageRef": "ref"}), + }, + }, + }, + } + }) + + Describe("sign component-descriptor", func() { + It("should add one signature", func() { + err := signatures.SignComponentDescriptor(&baseCd, TestSigner{}, testSHA256Hasher, signatureName) + Expect(err).To(BeNil()) + Expect(len(baseCd.Signatures)).To(BeIdenticalTo(1)) + Expect(baseCd.Signatures[0].Name).To(BeIdenticalTo(signatureName)) + Expect(baseCd.Signatures[0].Digest.NormalisationAlgorithm).To(BeIdenticalTo(string(v2.JsonNormalisationV1))) + Expect(baseCd.Signatures[0].Digest.HashAlgorithm).To(BeIdenticalTo(signatures.SHA256)) + Expect(baseCd.Signatures[0].Digest.Value).To(BeIdenticalTo(correctBaseCdHash)) + Expect(baseCd.Signatures[0].Signature.Algorithm).To(BeIdenticalTo("testSignAlgorithm")) + Expect(baseCd.Signatures[0].Signature.Value).To(BeIdenticalTo(fmt.Sprintf("%s:%s-signed", signatures.SHA256, correctBaseCdHash))) + }) + }) + Describe("verify component-descriptor signature", func() { + It("should verify one signature", func() { + err := signatures.SignComponentDescriptor(&baseCd, TestSigner{}, testSHA256Hasher, signatureName) + Expect(err).To(BeNil()) + Expect(len(baseCd.Signatures)).To(BeIdenticalTo(1)) + err = signatures.VerifySignedComponentDescriptor(&baseCd, TestVerifier{}, signatureName) + Expect(err).To(BeNil()) + }) + It("should reject an invalid signature", func() { + err := signatures.SignComponentDescriptor(&baseCd, TestSigner{}, testSHA256Hasher, signatureName) + Expect(err).To(BeNil()) + Expect(len(baseCd.Signatures)).To(BeIdenticalTo(1)) + baseCd.Signatures[0].Signature.Value = "invalidSignature" + err = signatures.VerifySignedComponentDescriptor(&baseCd, TestVerifier{}, signatureName) + Expect(err).ToNot(BeNil()) + }) + It("should reject a missing signature", func() { + err := signatures.VerifySignedComponentDescriptor(&baseCd, TestVerifier{}, signatureName) + Expect(err).ToNot(BeNil()) + }) + + It("should validate the correct signature if multiple are present", func() { + err := signatures.SignComponentDescriptor(&baseCd, TestSigner{}, testSHA256Hasher, signatureName) + Expect(err).To(BeNil()) + Expect(len(baseCd.Signatures)).To(BeIdenticalTo(1)) + + baseCd.Signatures = append(baseCd.Signatures, v2.Signature{ + Name: "testSignAlgorithmNOTRight", + Digest: v2.DigestSpec{ + HashAlgorithm: "testAlgorithm", + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: "testValue", + }, + Signature: v2.SignatureSpec{ + Algorithm: "testSigning", + Value: "AdditionalSignature", + }, + }) + err = signatures.VerifySignedComponentDescriptor(&baseCd, TestVerifier{}, signatureName) + Expect(err).To(BeNil()) + }) + }) + + Describe("verify normalised component-descriptor digest with signed digest ", func() { + It("should reject an invalid hash", func() { + err := signatures.SignComponentDescriptor(&baseCd, TestSigner{}, testSHA256Hasher, signatureName) + Expect(err).To(BeNil()) + Expect(len(baseCd.Signatures)).To(BeIdenticalTo(1)) + baseCd.Signatures[0].Digest.Value = "invalidHash" + err = signatures.VerifySignedComponentDescriptor(&baseCd, TestVerifier{}, signatureName) + Expect(err).ToNot(BeNil()) + }) + It("should reject a missing hash", func() { + err := signatures.SignComponentDescriptor(&baseCd, TestSigner{}, testSHA256Hasher, signatureName) + Expect(err).To(BeNil()) + Expect(len(baseCd.Signatures)).To(BeIdenticalTo(1)) + baseCd.Signatures[0].Digest = v2.DigestSpec{} + err = signatures.VerifySignedComponentDescriptor(&baseCd, TestVerifier{}, signatureName) + Expect(err).ToNot(BeNil()) + }) + }) +}) diff --git a/bindings-go/apis/v2/signatures/signatures_suite_test.go b/bindings-go/apis/v2/signatures/signatures_suite_test.go new file mode 100644 index 00000000..5da0e00d --- /dev/null +++ b/bindings-go/apis/v2/signatures/signatures_suite_test.go @@ -0,0 +1,13 @@ +package signatures_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestSignatures(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Signatures Test Suite") +} diff --git a/bindings-go/apis/v2/signatures/types.go b/bindings-go/apis/v2/signatures/types.go new file mode 100644 index 00000000..e1ffdd3a --- /dev/null +++ b/bindings-go/apis/v2/signatures/types.go @@ -0,0 +1,49 @@ +package signatures + +import ( + "context" + "crypto/sha256" + "fmt" + "hash" + "strings" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" +) + +// Signer interface is used to implement different signing algorithms. +// Each Signer should have a matching Verifier. +type Signer interface { + // Sign returns the signature for the data for the component-descriptor + Sign(componentDescriptor v2.ComponentDescriptor, digest v2.DigestSpec) (*v2.SignatureSpec, error) +} + +// Verifier interface is used to implement different verification algorithms. +// Each Verifier should have a matching Signer. +type Verifier interface { + // Verify checks the signature, returns an error on verification failure + Verify(componentDescriptor v2.ComponentDescriptor, signature v2.Signature) error +} + +// Hasher encapsulates a hash.Hash interface with an algorithm name. +type Hasher struct { + HashFunction hash.Hash + AlgorithmName string +} + +const SHA256 = "sha256" + +// HasherForName creates a Hasher instance for the algorithmName. +func HasherForName(algorithmName string) (*Hasher, error) { + switch strings.ToLower(algorithmName) { + case SHA256: + return &Hasher{ + HashFunction: sha256.New(), + AlgorithmName: SHA256, + }, nil + } + return nil, fmt.Errorf("hash algorithm %s not found/implemented", algorithmName) +} + +type ResourceDigester interface { + DigestForResource(ctx context.Context, componentDescriptor v2.ComponentDescriptor, resource v2.Resource, hasher Hasher) (*v2.DigestSpec, error) +} diff --git a/bindings-go/examples/signatures/main.go b/bindings-go/examples/signatures/main.go new file mode 100644 index 00000000..0468e3ba --- /dev/null +++ b/bindings-go/examples/signatures/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "fmt" + + v2 "github.com/gardener/component-spec/bindings-go/apis/v2" + "github.com/gardener/component-spec/bindings-go/apis/v2/signatures" +) + +func main() { + cd := v2.ComponentDescriptor{ + Metadata: v2.Metadata{ + Version: "v2", + }, + ComponentSpec: v2.ComponentSpec{ + ObjectMeta: v2.ObjectMeta{ + Name: "CD-Namecool Unicode ♥ unprintable characters \u0007 \u0031", + Version: "v0.0.1", + }, + ComponentReferences: []v2.ComponentReference{ + { + Name: "compRefName", + ComponentName: "compRefNameComponentName", + Version: "v0.0.2compRef", + ExtraIdentity: v2.Identity{ + "refKey": "refName", + }, + Digest: &v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: "00000000000000", + }, + }, + }, + Resources: []v2.Resource{ + { + IdentityObjectMeta: v2.IdentityObjectMeta{ + Name: "Resource1", + Version: "v0.0.3resource", + ExtraIdentity: v2.Identity{ + "key": "value", + }, + }, + Digest: &v2.DigestSpec{ + HashAlgorithm: signatures.SHA256, + NormalisationAlgorithm: string(v2.ManifestDigestV1), + Value: "00000000000000", + }, + }, + }, + }, + } + ctx := context.TODO() + err := signatures.AddDigestsToComponentDescriptor(ctx, &cd, func(ctx context.Context, cd v2.ComponentDescriptor, cr v2.ComponentReference) (*v2.DigestSpec, error) { + return &v2.DigestSpec{ + HashAlgorithm: "testing", + NormalisationAlgorithm: string(v2.JsonNormalisationV1), + Value: string(cr.GetIdentityDigest()), + }, nil + }, func(ctx context.Context, cd v2.ComponentDescriptor, r v2.Resource) (*v2.DigestSpec, error) { + return &v2.DigestSpec{ + HashAlgorithm: "testing", + NormalisationAlgorithm: string(v2.ManifestDigestV1), + Value: string(r.GetIdentityDigest()), + }, nil + }) + if err != nil { + fmt.Printf("ERROR addingDigestsToComponentDescriptor %s", err) + } + + hasher, err := signatures.HasherForName(signatures.SHA256) + if err != nil { + fmt.Printf("ERROR: %s", err) + } + + norm, err := signatures.HashForComponentDescriptor(cd, *hasher) + if err != nil { + fmt.Printf("ERROR: %s", err) + return + } + fmt.Println(norm.Value) + + signer, err := signatures.CreateRsaSignerFromKeyFile("private") + if err != nil { + fmt.Printf("ERROR create signer: %s", err) + return + } + + err = signatures.SignComponentDescriptor(&cd, signer, *hasher, "mySignatureName") + if err != nil { + fmt.Printf("ERROR sign: %s", err) + return + } + fmt.Println(cd) + + verifier, err := signatures.CreateRsaVerifierFromKeyFile("public") + if err != nil { + fmt.Printf("ERROR create verifier: %s", err) + return + } + err = signatures.VerifySignedComponentDescriptor(&cd, verifier, "mySignatureName") + if err != nil { + fmt.Printf("ERROR verify signature: %s", err) + return + } + fmt.Println("If not error is printed, successful") +} diff --git a/bindings-python/gci/componentmodel.py b/bindings-python/gci/componentmodel.py index d789b8e8..21e11f6e 100644 --- a/bindings-python/gci/componentmodel.py +++ b/bindings-python/gci/componentmodel.py @@ -171,6 +171,22 @@ def set_label( labels=patched_labels, ) +@dc +class DigestSpec: + hashAlgorithm: str + normalisationAlgorithm: str + value: str + +@dc +class SignatureSpec: + algorithm: str + value: str + +@dc +class Signature: + name: str + digest: DigestSpec + signature: SignatureSpec class Provider(enum.Enum): ''' @@ -311,6 +327,7 @@ class ComponentReference(Artifact, LabelMethodsMixin): name: str componentName: str version: str + digest: typing.Optional[DigestSpec] = None extraIdentity: typing.Dict[str, str] = dataclasses.field(default_factory=dict) labels: typing.List[Label] = dataclasses.field(default_factory=tuple) @@ -339,12 +356,12 @@ class Resource(Artifact, LabelMethodsMixin): ResourceAccess, None, ] + digest: typing.Optional[DigestSpec] = None extraIdentity: typing.Dict[str, str] = dataclasses.field(default_factory=dict) relation: ResourceRelation = ResourceRelation.LOCAL labels: typing.List[Label] = dataclasses.field(default_factory=tuple) srcRefs: typing.List[SourceReference] = dataclasses.field(default_factory=tuple) - @dc(frozen=True) class RepositoryContext: pass # actually, must have attr `type` @@ -412,6 +429,7 @@ def enum_or_string(v, enum_type: enum.Enum): class ComponentDescriptor: meta: Metadata component: Component + signatures: typing.List[Signature] = dataclasses.field(default_factory=list) @staticmethod def validate( diff --git a/language-independent/component-descriptor-v2-schema.yaml b/language-independent/component-descriptor-v2-schema.yaml index af5482e1..d4333b8a 100644 --- a/language-independent/component-descriptor-v2-schema.yaml +++ b/language-independent/component-descriptor-v2-schema.yaml @@ -128,6 +128,45 @@ definitions: - $ref: '#/definitions/githubAccess' - $ref: '#/definitions/httpAccess' + digestSpec: + type: 'object' + required: + - hashAlgorithm + - normalisationAlgorithm + - value + properties: + hashAlgorithm: + type: string + normalisationAlgorithm: + type: string + value: + type: string + + signatureSpec: + type: 'object' + required: + - algorithm + - value + properties: + algorithm: + type: string + value: + type: string + + signature: + type: 'object' + required: + - name + - digest + - signature + properties: + name: + type: string + digest: + $ref: '#/definitions/digestSpec' + signature: + $ref: '#/definitions/signatureSpec' + srcRef: type: 'object' description: 'a reference to a (component-local) source' @@ -160,6 +199,10 @@ definitions: type: 'array' items: $ref: '#/definitions/label' + digest: + oneOf: + - type: 'null' + - $ref: '#/definitions/digestSpec' resourceType: type: 'object' @@ -197,6 +240,10 @@ definitions: - $ref: '#/definitions/ociBlobAccess' - $ref: '#/definitions/localFilesystemBlobAccess' - $ref: '#/definitions/localOciBlobAccess' + digest: + oneOf: + - type: 'null' + - $ref: '#/definitions/digestSpec' ociImageAccess: type: 'object' @@ -282,6 +329,11 @@ definitions: $ref: '#/definitions/label' access: $ref: '#/definitions/ociImageAccess' + digest: + oneOf: + - type: 'null' + - $ref: '#/definitions/digestSpec' + httpAccess: type: 'object' @@ -328,6 +380,10 @@ definitions: $ref: '#/definitions/label' access: $ref: '#/definitions/genericAccess' + digest: + oneOf: + - type: 'null' + - $ref: '#/definitions/digestSpec' component: type: 'object' @@ -385,3 +441,7 @@ properties: $ref: '#/definitions/meta' component: $ref: '#/definitions/component' + signatures: + type: 'array' + items: + $ref: '#/definitions/signature'