diff --git a/README.md b/README.md index f1665da..e18f737 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,14 @@ configuration burden, validator.DefaultSet returns a reasonably configured validator.Set instance that's ready to be used. See the documentation of the validator subpackage for details. +## Signing events and verifying signatures + +The SDK supports cryptographic signing of (typically) outbound events as +well as verification of the signature of inbound events. The signing is +done according to the standard method, with the signature and metadata under +the `meta.security` field. See the documentation of the signature subpackage +for details and code examples. + ## Code of Conduct and Contributing To get involved, please see [Code of Conduct](https://github.com/eiffel-community/.github/blob/master/CODE_OF_CONDUCT.md) and [contribution guidelines](https://github.com/eiffel-community/.github/blob/master/CONTRIBUTING.md). diff --git a/go.mod b/go.mod index 9a07d95..b88d295 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,15 @@ require ( github.com/Showmax/go-fqdn v1.0.0 github.com/clarketm/json v1.17.1 github.com/gertd/go-pluralize v0.2.1 + github.com/go-ldap/ldap v3.0.3+incompatible github.com/google/renameio v1.0.1 github.com/google/uuid v1.6.0 + github.com/gowebpki/jcs v1.0.1 github.com/lestrrat-go/jsschema v0.0.0-20181205002244-5c81c58ffcc3 github.com/package-url/packageurl-go v0.1.0 github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.17.1 + github.com/tidwall/sjson v1.2.5 github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/text v0.15.0 gopkg.in/yaml.v3 v3.0.1 @@ -32,5 +35,6 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 7fa7839..6b1434f 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= +github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk= +github.com/go-ldap/ldap v3.0.3+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU= github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -50,12 +54,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= @@ -94,6 +101,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/signature/errors.go b/signature/errors.go new file mode 100644 index 0000000..42f5115 --- /dev/null +++ b/signature/errors.go @@ -0,0 +1,31 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import "errors" + +var ( + ErrMarshaling = errors.New("the marshaling of the event was unsuccessful") + ErrPublicKeyInvalid = errors.New("public key had the wrong type") + ErrPublicKeyLookup = errors.New("an error occurred looking up the public key for this identity") + ErrPublicKeyNotFound = errors.New("no public key for verifying events signed by this identify was found") + ErrSignatureMismatch = errors.New("the signature couldn't be verified") + ErrSigningFailed = errors.New("signing of the event failed") + ErrUnsupportedAlgorithm = errors.New("unsupported algorithm") + ErrUnverifiableEvent = errors.New("event cannot be verified because an essential field is unset or empty") + ErrVerificationFailed = errors.New("the signature couldn't be verified by any of the available public keys") +) diff --git a/signature/signer.go b/signature/signer.go new file mode 100644 index 0000000..29e4d08 --- /dev/null +++ b/signature/signer.go @@ -0,0 +1,182 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/gowebpki/jcs" + "github.com/tidwall/sjson" + + "github.com/eiffel-community/eiffelevents-sdk-go" +) + +// Algorithm describes the set of algorithms used when signing or verifying a payload. +// It includes both the signing algorithm and the hash algorithm. The latter is +// important since it's the hash that's signed rather than the payload itself. +type Algorithm string + +const ( + RS256 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_RS256) // RSASSA-PKCS1-v1_5 using SHA-256 + RS384 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_RS384) // RSASSA-PKCS1-v1_5 using SHA-384 + RS512 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_RS512) // RSASSA-PKCS1-v1_5 using SHA-512 + ES256 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_ES256) // ECDSA using P-256 and SHA-256 + ES384 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_ES384) // ECDSA using P-384 and SHA-384 + ES512 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_ES512) // ECDSA using P-521 and SHA-512 + PS256 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_PS256) // RSASSA-PSS using SHA-256 and MGF1 with SHA-256 + PS384 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_PS384) // RSASSA-PSS using SHA-384 and MGF1 with SHA-384 + PS512 = Algorithm(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_PS512) // RSASSA-PSS using SHA-512 and MGF1 with SHA-512 +) + +const ( + authorIdentityField = "meta.security.authorIdentity" + algorithmField = "meta.security.integrityProtection.alg" + signatureField = "meta.security.integrityProtection.signature" +) + +// Signer signs Eiffel event payloads in the standard way. +type Signer struct { + identity string + alg Algorithm + pk crypto.PrivateKey + hashFunc func([]byte) []byte + signFunc func(crypto.PrivateKey, crypto.Hash, []byte) ([]byte, error) + signerOpts crypto.SignerOpts +} + +// NewKeySigner initializes a Signer with a private key and an identity. +func NewKeySigner(identity string, alg Algorithm, pk crypto.PrivateKey) (*Signer, error) { + s := &Signer{ + identity: identity, + alg: alg, + pk: pk, + } + switch s.alg { + case RS256: + s.hashFunc = hashSHA256 + s.signerOpts = crypto.SHA256 + s.signFunc = signPKCS1v15 + case ES256: + s.hashFunc = hashSHA256 + s.signerOpts = crypto.SHA256 + s.signFunc = signECDSA + case PS256: + s.hashFunc = hashSHA256 + s.signerOpts = crypto.SHA256 + s.signFunc = signPSS + case RS384: + s.hashFunc = hashSHA384 + s.signerOpts = crypto.SHA384 + s.signFunc = signPKCS1v15 + case ES384: + s.hashFunc = hashSHA384 + s.signerOpts = crypto.SHA384 + s.signFunc = signECDSA + case PS384: + s.hashFunc = hashSHA384 + s.signerOpts = crypto.SHA384 + s.signFunc = signPSS + case RS512: + s.hashFunc = hashSHA512 + s.signerOpts = crypto.SHA512 + s.signFunc = signPKCS1v15 + case ES512: + s.hashFunc = hashSHA512 + s.signerOpts = crypto.SHA512 + s.signFunc = signECDSA + case PS512: + s.hashFunc = hashSHA512 + s.signerOpts = crypto.SHA512 + s.signFunc = signPSS + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedAlgorithm, s.alg) + } + return s, nil +} + +// Sign signs the provided event and returns it as a byte slice that includes +// the signature itself and the details needed to verify the signature. +// +// - If something goes wrong while modifying the event in preparation of +// the signing, ErrMarshaling is returned. +// - If the signing itself fails, ErrSigningFailed is returned. +// +// Errors will be returned in wrapped form so make sure you use errors.Is +// rather than direct comparisons. +func (s *Signer) Sign(event json.Marshaler) ([]byte, error) { + // TODO: Check if meta.security is supported in this event version. + + eventBytes, err := event.MarshalJSON() + if err != nil { + return nil, errors.Join(ErrMarshaling, err) + } + + // Set the signing-related fields, make sure there's an empty signature field, + // and transform the event to canonical JSON. + if eventBytes, err = sjson.SetBytes(eventBytes, authorIdentityField, s.identity); err != nil { + return nil, errors.Join(ErrMarshaling, err) + } + if eventBytes, err = sjson.SetBytes(eventBytes, algorithmField, s.alg); err != nil { + return nil, errors.Join(ErrMarshaling, err) + } + if eventBytes, err = sjson.SetBytes(eventBytes, signatureField, ""); err != nil { + return nil, errors.Join(ErrMarshaling, err) + } + if eventBytes, err = jcs.Transform(eventBytes); err != nil { + return nil, errors.Join(ErrMarshaling, err) + } + + sig, err := s.signFunc(s.pk, s.signerOpts.HashFunc(), s.hashFunc(eventBytes)) + if err != nil { + return nil, errors.Join(ErrSigningFailed, err) + } + if eventBytes, err = sjson.SetBytes(eventBytes, signatureField, base64.StdEncoding.EncodeToString(sig)); err != nil { + return nil, errors.Join(ErrMarshaling, err) + } + return eventBytes, nil +} + +func signECDSA(priv crypto.PrivateKey, hash crypto.Hash, digest []byte) ([]byte, error) { + privECDSA, ok := priv.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key had the wrong type; expected *ecdsa.PrivateKey, got %T", priv) + } + return ecdsa.SignASN1(rand.Reader, privECDSA, digest) +} + +func signPKCS1v15(priv crypto.PrivateKey, hash crypto.Hash, digest []byte) ([]byte, error) { + privRSA, ok := priv.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key had the wrong type; expected *rsa.PrivateKey, got %T", priv) + } + return rsa.SignPKCS1v15(rand.Reader, privRSA, hash, digest) +} + +func signPSS(priv crypto.PrivateKey, hash crypto.Hash, digest []byte) ([]byte, error) { + privRSA, ok := priv.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key had the wrong type; expected *rsa.PrivateKey, got %T", priv) + } + return rsa.SignPSS(rand.Reader, privRSA, hash, digest, nil) +} diff --git a/signature/signer_example_test.go b/signature/signer_example_test.go new file mode 100644 index 0000000..6737952 --- /dev/null +++ b/signature/signer_example_test.go @@ -0,0 +1,116 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "bytes" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "os" + + eiffelevents "github.com/eiffel-community/eiffelevents-sdk-go" +) + +// nolint:gosec +const privKeyPEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEA4SWVb369VxfJXrkASESVh3RFy+ArrKk8cAc1AEaRks/RhIhY +QQzZDxYtJVYa/K4JmEyxk2jZl16qc9weaSadVL1ZlnukVW5fPejgbgF7+1xyLJ9g +RYTq/hL5vIhpDCR9lxMHZwtZE1cI197n2YFCiYfijBdEzWmh2bdwJzXiZSHC8yry +8+ekBx17Q5c/PhDym8gGIQB/3F1tI8JYQBWfWHDyNPYkvY2fNg+vDObBD3AC56yf +YOsGiIhpdh5z92mDcXo/e5fVsJLL3kJQHGp4a+6/azKPpQRPVYxzzvRox26GGuvY +r2Xj/fDlBpD7E25sVQuxgemzGD/M+7biBeLgEwIDAQABAoIBAQCHQlkAXpfJVtT3 +PxVYVTuv4L59uPMEC7fvZaUFwV97X7ZzdKXwjpNoaN4+a/hSjQven1SfRoJSWeD1 +MexjJ3uliQvlR+p2GJTHULxj2iht3iAJhsYDfdLfSO8XwKu7S8DXner4kOy2nbcG +WTfYh7s9fJExsFj5PtipP3b1V33nWrwqs0n2+ICejTd0mFRSbQKZL1U513rV+cuu +wT+kofAAqxSfHouWmDQR2PtAxhVk7X4T4dB3JNT5IHqjD5ie9FcVvNGKEy3VfyjI +vVLyL2EymAj1Sx4sJFOOz+ylEHRRV6HKAARgCi0UuNlrUWR4MEZBNbQIih+ikXxq +q6wJtlQhAoGBAONKKifkCgGBQm3bxP7I7w/KV/m+sbJBNVB28lGAyzL32+jqej0Z +MKLZwikA7JSbYDUErw9FAItbLZTEA3kfJGMs63i0+ovY9t8MTrIxI8Fz+qVLgj2Z +Y5sc65E1z1wviz9Ak0YxSoJhLgjzpcYaip6FCAigXetzE6T9rcrPBXM3AoGBAP2W +H+UU5TS4y0c8fikwLPV6Ur9QTjNXWU8aA/m0b1neT/nNO7mFmML5DFbvLWVds6nH +NttlnXhAYiquqGOkTJPB3dILbYMBD1XRAGo+YjxM6it2c7l8QOrRe5e9ZKgV31rr +tTLOvgEM8tTDtINF5Uvq1EOxrTm2lz02z6WMhWAFAoGBALZm5GHS/byrcRYc0oDt +2/w+FFAWmyBEeHa0nk6OH4QtqUvIMIUr2/405z5kwXeZIaIquhp087TiXTgP/gGL +3nXArM/X3WGxopzpkZYrHVi4rKNOb5zjpi3rDZkhJ+IBPaxrNEWWdQcg2gLRFW5g +CnKgrAvQNs8nMNKtynUBoowNAoGABnoxMl7IRAJ8XsNyzYaHf3Wya2SXusP+agDW +HSi4t2jwTgcqAWEiN8i4wfe2ByLPlgSaqBv+W7X5S/HOJ01pD1UiX10fXPtH8v81 +rYEObU/ho16RMim0VssnBwc1bP2yCNaAeF3DiK9V/I1LLRc59ih3Z4tAS3sYfd3K +jAX82ikCgYEAp8eeBj2pbtcbWRIlquj68xycx2C06bjR1TeNfbRvnHIBnj7rSe0P +6BleLBsxFxlhnFH9TF84IsfT0+vof/VRTleNU5+em9eN8FUC2xcEfFAMZizXTswY +gESLXERbVkkFCtc1KyZAC6K8/5YRrTcvzzuN2RhRx8prYi8yviqpU8o= +-----END RSA PRIVATE KEY----- +` + +func ExampleSigner_Sign() { + privKeyBlock, _ := pem.Decode([]byte(privKeyPEM)) + privKey, err := x509.ParsePKCS1PrivateKey(privKeyBlock.Bytes) + if err != nil { + panic(err.Error()) + } + + // Create an event with some fields set to fixed values so the signed event is kept stable. + event, err := eiffelevents.NewCompositionDefinedV3() + if err != nil { + panic(err.Error()) + } + event.Meta.ID = "4cd302e1-a636-4c2c-9142-8ec82e39a5f8" + event.Meta.Time = 1718376599257 + event.Meta.Version = "3.2.0" + event.Data.Name = "random composition name" + + // Set up the signer and sign the event to a byte slice. + signer, err := NewKeySigner("CN=test", RS256, privKey) + if err != nil { + panic(err.Error()) + } + eventBytes, err := signer.Sign(event) + if err != nil { + panic(err.Error()) + } + + // Pretty-print the event to stdout. + var prettyEvent bytes.Buffer + if err := json.Indent(&prettyEvent, eventBytes, "", " "); err != nil { + panic(err.Error()) + } + if _, err := io.Copy(os.Stdout, &prettyEvent); err != nil { + panic(err.Error()) + } + + // Output: { + // "data": { + // "name": "random composition name" + // }, + // "links": [], + // "meta": { + // "id": "4cd302e1-a636-4c2c-9142-8ec82e39a5f8", + // "security": { + // "authorIdentity": "CN=test", + // "integrityProtection": { + // "alg": "RS256", + // "signature": "LZ0dqqlutep7flccDJuo5I7xxzrKqAeLcZ7aMBHhUGYBi5J+7Rd2dcOEHzN8p+hr6F3dudAb34WYmTgc3/C9dAL0G2RpItnBlPasJkecTJen/AhWfIzjzo8Oji6b4RrSV6LeDC/p7sKr2ocdAEEVV4opHGhavTke9PilPpDndNQCLCssSqRu0ikWkKXwj18lNsCWDcu4phiHkb/BckXJ9ntniDT9evQoBSliOduOa8B8rL0LyRYFC2L++fqJULssCLZ9VoHJZ/FwC1RhFxOofth0kpyhhA+0qGcBMZBX+YqjUmi8PbcuFke4Xlowy+KjQoVWPt2N5Rcg6eSg/716Tw==" + // } + // }, + // "time": 1718376599257, + // "type": "EiffelCompositionDefinedEvent", + // "version": "3.2.0" + // } + // } +} diff --git a/signature/signer_test.go b/signature/signer_test.go new file mode 100644 index 0000000..7d7f481 --- /dev/null +++ b/signature/signer_test.go @@ -0,0 +1,142 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "testing" + + rooteiffelevents "github.com/eiffel-community/eiffelevents-sdk-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func generateRSAKey(t *testing.T) crypto.Signer { + s, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return s +} + +func generateECDSAKey(t *testing.T, c elliptic.Curve) crypto.Signer { + s, err := ecdsa.GenerateKey(c, rand.Reader) + require.NoError(t, err) + return s +} + +func TestSigner(t *testing.T) { + const identity = "CN=test" + rsaKey := generateRSAKey(t) + ecdsa256Key := generateECDSAKey(t, elliptic.P256()) + ecdsa384Key := generateECDSAKey(t, elliptic.P384()) + ecdsa521Key := generateECDSAKey(t, elliptic.P521()) + + testcases := []struct { + name string + alg Algorithm + key crypto.Signer + eventFactory func() (json.Marshaler, error) + expectedError error + }{ + { + name: "Happy path with RS256", + alg: RS256, + key: rsaKey, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with RS384", + alg: RS384, + key: rsaKey, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with RS512", + alg: RS512, + key: rsaKey, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with ES256", + alg: ES256, + key: ecdsa256Key, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with ES384", + alg: ES384, + key: ecdsa384Key, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with ES512", + alg: ES512, + key: ecdsa521Key, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with PS256", + alg: PS256, + key: rsaKey, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with PS384", + alg: PS384, + key: rsaKey, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Happy path with PS512", + alg: PS512, + key: rsaKey, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + }, + { + name: "Algorithm and key mismatch", + alg: RS256, + key: ecdsa256Key, + eventFactory: func() (json.Marshaler, error) { return rooteiffelevents.NewCompositionDefinedV3() }, + expectedError: ErrSigningFailed, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + event, err := tc.eventFactory() + require.NoError(t, err) + + signer, err := NewKeySigner(identity, tc.alg, tc.key) + require.NoError(t, err) + b, err := signer.Sign(event) + if tc.expectedError == nil { + require.NoError(t, err) + assert.Equal(t, identity, gjson.GetBytes(b, authorIdentityField).String()) + assert.Equal(t, string(tc.alg), gjson.GetBytes(b, algorithmField).String()) + // We have other tests that actually try to do something with the signature. + // We'll just check that it's a non-empty string. + assert.NotEmpty(t, gjson.GetBytes(b, signatureField).String()) + } else { + require.ErrorIs(t, err, tc.expectedError) + } + }) + } +} diff --git a/signature/verifier.go b/signature/verifier.go new file mode 100644 index 0000000..4aaf3cd --- /dev/null +++ b/signature/verifier.go @@ -0,0 +1,269 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "errors" + "fmt" + + "github.com/go-ldap/ldap" + "github.com/gowebpki/jcs" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/eiffel-community/eiffelevents-sdk-go" +) + +// PublicKeyLocator locates one or more public keys that can be used +// to verify the signature of a given identity. +type PublicKeyLocator interface { + // Locate returns one or more public keys corresponding to the provided identity, + // or an empty or nil slice if no public keys were found. An error return indicates + // that the lookup itself failed. + Locate(ctx context.Context, identity *AuthorIdentity) ([]crypto.PublicKey, error) +} + +// AuthorIdentity is a representation of the distinguished name +// in the meta.security.authorIdentity field of an Eiffel event. +// It can be compared to other values of the same type. +type AuthorIdentity struct { + dn *ldap.DN + original string +} + +func NewAuthorIdentity(s string) (*AuthorIdentity, error) { + dn, err := ldap.ParseDN(s) + if err != nil { + return nil, fmt.Errorf("error parsing author identity %q: %w", s, err) + } + return &AuthorIdentity{ + dn: dn, + original: s, + }, nil +} + +// Equal returns true if the provided *AuthorIdentity is equal to this one, +// igoring differences in whitespace etc. +func (ai *AuthorIdentity) Equal(other *AuthorIdentity) bool { + return ai.dn.Equal(other.dn) +} + +func (ai *AuthorIdentity) String() string { + return ai.original +} + +// Verifier can verify whether the signature of a given Eiffel event matches +// any of the keys known by the associated PublicKeyLocator. +type Verifier struct { + keyLocator PublicKeyLocator +} + +func NewVerifier(keyLocator PublicKeyLocator) *Verifier { + return &Verifier{ + keyLocator: keyLocator, + } +} + +// Verify attempts to verify the signature of the provided event payload, +// using the Verifier's PublicKeyLocator to obtain a public key suitable +// for verifying the event. +// +// - If the event can't be verified because of its contents, e.g. because +// it doesn't include a signature, ErrUnverifiableEvent is returned. +// - If the event is signed with an unsupported algorithm, +// ErrUnsupportedAlgorithm is returned. +// - If no public key that matches the event sender's identity was found, +// ErrPublicKeyNotFound is returned. +// - If the public key that was found doesn't match the algorithm in +// the event payload, ErrPublicKeyInvalid is returned. +// - If something goes wrong while modifying the event in preparation of +// the verification, ErrMarshaling is returned. +// - If the verification itself fails for all of the public keys, +// ErrVerificationFailed is returned. This error will wrap the individual +// errors returned for each public key, including ErrSignatureMismatch. +// +// Errors will be returned in wrapped form so make sure you use errors.Is +// rather than direct comparisons. +func (v *Verifier) Verify(ctx context.Context, event []byte) error { + // Extract the signature itself and the other fields we need for + // the verification and return an error if either of them are missing. + values := gjson.GetManyBytes(event, algorithmField, authorIdentityField, signatureField) + alg := values[0].String() + identity := values[1].String() + sig := values[2].String() + + if alg == "" { + return fmt.Errorf("%w: %s", ErrUnverifiableEvent, algorithmField) + } + if identity == "" { + return fmt.Errorf("%w: %s", ErrUnverifiableEvent, authorIdentityField) + } + if sig == "" { + return fmt.Errorf("%w: %s", ErrUnverifiableEvent, signatureField) + } + + var ( + hash crypto.Hash + hashFunc func([]byte) []byte + verifyFunc func(pk crypto.PublicKey, hash crypto.Hash, hashed []byte, sig []byte) error + ) + + switch alg { + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_RS256): + hash = crypto.SHA256 + hashFunc = hashSHA256 + verifyFunc = verifyPKCS1v15 + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_RS384): + hash = crypto.SHA384 + hashFunc = hashSHA384 + verifyFunc = verifyPKCS1v15 + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_RS512): + hash = crypto.SHA512 + hashFunc = hashSHA512 + verifyFunc = verifyPKCS1v15 + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_ES256): + hash = crypto.SHA256 + hashFunc = hashSHA256 + verifyFunc = verifyECDSA + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_ES384): + hash = crypto.SHA384 + hashFunc = hashSHA384 + verifyFunc = verifyECDSA + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_ES512): + hash = crypto.SHA512 + hashFunc = hashSHA512 + verifyFunc = verifyECDSA + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_PS256): + hash = crypto.SHA256 + hashFunc = hashSHA256 + verifyFunc = verifyPSS + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_PS384): + hash = crypto.SHA384 + hashFunc = hashSHA384 + verifyFunc = verifyPSS + case string(eiffelevents.MetaV3SecurityIntegrityProtectionAlg_PS512): + hash = crypto.SHA512 + hashFunc = hashSHA512 + verifyFunc = verifyPSS + default: + return fmt.Errorf("%w: %s", ErrUnsupportedAlgorithm, alg) + } + + // Clear the signature field and transform the event to canonical JSON. + var err error + if event, err = sjson.SetBytes(event, signatureField, ""); err != nil { + return errors.Join(ErrMarshaling, err) + } + if event, err = jcs.Transform(event); err != nil { + return errors.Join(ErrMarshaling, err) + } + + // DecodedLen returns the worst case decoded length (when no padding was + // required), thus the amount we need to allocate. Using the actual number + // of decoded bytes to truncate sigBytes afterwards is crucial to avoid + // trailing null bytes. + sigBytes := make([]byte, base64.RawStdEncoding.DecodedLen(len(sig))) + n, err := base64.StdEncoding.Decode(sigBytes, []byte(sig)) + if err != nil { + return errors.Join(ErrMarshaling, err) + } + sigBytes = sigBytes[:n] + + // TODO: Implement a cache that maps the identity strings to their + // *AuthorIdentity equivalents. + dn, err := NewAuthorIdentity(identity) + if err != nil { + return errors.Join(ErrMarshaling, err) + } + + keys, err := v.keyLocator.Locate(ctx, dn) + if err != nil { + return fmt.Errorf("%w: %s", ErrPublicKeyLookup, identity) + } + if len(keys) == 0 { + return fmt.Errorf("%w: %s", ErrPublicKeyNotFound, identity) + } + + // Collect the error for each public key we try, and start with + // ErrVerificationFailed to represent the failure of the whole operation. + errs := []error{ErrVerificationFailed} + hashBytes := hashFunc(event) + for _, key := range keys { + err = verifyFunc(key, hash, hashBytes, sigBytes) + if err == nil { + return nil + } + errs = append(errs, err) + } + return errors.Join(errs...) +} + +func hashSHA256(data []byte) []byte { + h := sha256.Sum256(data) + return h[:] +} + +func hashSHA384(data []byte) []byte { + h := sha512.Sum384(data) + return h[:] +} + +func hashSHA512(data []byte) []byte { + h := sha512.Sum512(data) + return h[:] +} + +func verifyECDSA(pub crypto.PublicKey, hash crypto.Hash, digest []byte, sig []byte) error { + pubECDSA, ok := pub.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("%w; expected *ecdsa.PublicKey, got %T", ErrPublicKeyInvalid, pub) + } + if ecdsa.VerifyASN1(pubECDSA, digest, sig) { + return nil + } else { + return ErrSignatureMismatch + } +} + +func verifyPKCS1v15(pub crypto.PublicKey, hash crypto.Hash, digest []byte, sig []byte) error { + pubRSA, ok := pub.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("%w; expected *rsa.PublicKey, got %T", ErrPublicKeyInvalid, pub) + } + if err := rsa.VerifyPKCS1v15(pubRSA, hash, digest, sig); err != nil { + return errors.Join(ErrSignatureMismatch, err) + } + return nil +} + +func verifyPSS(pub crypto.PublicKey, hash crypto.Hash, digest []byte, sig []byte) error { + pubRSA, ok := pub.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("%w; expected *rsa.PublicKey, got %T", ErrPublicKeyInvalid, pub) + } + if err := rsa.VerifyPSS(pubRSA, hash, digest, sig, nil); err != nil { + return errors.Join(ErrSignatureMismatch, err) + } + return nil +} diff --git a/signature/verifier_example_test.go b/signature/verifier_example_test.go new file mode 100644 index 0000000..451fbfb --- /dev/null +++ b/signature/verifier_example_test.go @@ -0,0 +1,129 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "context" + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" +) + +const ( + pubKeyPEM = ` +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEA4SWVb369VxfJXrkASESVh3RFy+ArrKk8cAc1AEaRks/RhIhYQQzZ +DxYtJVYa/K4JmEyxk2jZl16qc9weaSadVL1ZlnukVW5fPejgbgF7+1xyLJ9gRYTq +/hL5vIhpDCR9lxMHZwtZE1cI197n2YFCiYfijBdEzWmh2bdwJzXiZSHC8yry8+ek +Bx17Q5c/PhDym8gGIQB/3F1tI8JYQBWfWHDyNPYkvY2fNg+vDObBD3AC56yfYOsG +iIhpdh5z92mDcXo/e5fVsJLL3kJQHGp4a+6/azKPpQRPVYxzzvRox26GGuvYr2Xj +/fDlBpD7E25sVQuxgemzGD/M+7biBeLgEwIDAQAB +-----END RSA PUBLIC KEY-----` + signedEvent = ` +{ + "data": { + "name": "random composition name" + }, + "links": [], + "meta": { + "id": "4cd302e1-a636-4c2c-9142-8ec82e39a5f8", + "security": { + "authorIdentity": "CN=test", + "integrityProtection": { + "alg": "RS256", + "signature": "LZ0dqqlutep7flccDJuo5I7xxzrKqAeLcZ7aMBHhUGYBi5J+7Rd2dcOEHzN8p+hr6F3dudAb34WYmTgc3/C9dAL0G2RpItnBlPasJkecTJen/AhWfIzjzo8Oji6b4RrSV6LeDC/p7sKr2ocdAEEVV4opHGhavTke9PilPpDndNQCLCssSqRu0ikWkKXwj18lNsCWDcu4phiHkb/BckXJ9ntniDT9evQoBSliOduOa8B8rL0LyRYFC2L++fqJULssCLZ9VoHJZ/FwC1RhFxOofth0kpyhhA+0qGcBMZBX+YqjUmi8PbcuFke4Xlowy+KjQoVWPt2N5Rcg6eSg/716Tw==" + } + }, + "time": 1718376599257, + "type": "EiffelCompositionDefinedEvent", + "version": "3.2.0" + } +}` +) + +// keyLocator holds sets of public keys belonging to one or more identities. +// It implements the Locator interface and can therefore be used together +// with the Verifier type. +type keyLocator struct { + keySets []keySet +} + +// keySet contains an identity and one or more public keys. +type keySet struct { + subject *AuthorIdentity + keys []crypto.PublicKey +} + +// AddKey adds a public key to the set of keys owned by the identified entity. +func (kl *keyLocator) AddKey(identity string, key crypto.PublicKey) error { + ai, err := NewAuthorIdentity(identity) + if err != nil { + return err + } + + // Is there an existing keySet we can append to? + for i, k := range kl.keySets { + if k.subject.Equal(ai) { + k.keys = append(k.keys, key) + kl.keySets[i] = k + return nil + } + } + + // No, create a new keySet. + kl.keySets = []keySet{ + { + subject: ai, + keys: []crypto.PublicKey{key}, + }, + } + return nil +} + +// Locate returns the public keys owned by an entity, or a nil slice +// if no keys are known for that identity. +func (kl *keyLocator) Locate(ctx context.Context, identity *AuthorIdentity) ([]crypto.PublicKey, error) { + for _, k := range kl.keySets { + if k.subject.Equal(identity) { + return k.keys, nil + } + } + return nil, nil +} + +func ExampleVerifier_Verify() { + pubKeyBlock, _ := pem.Decode([]byte(pubKeyPEM)) + pubKey, err := x509.ParsePKCS1PublicKey(pubKeyBlock.Bytes) + if err != nil { + panic(err.Error()) + } + + locator := &keyLocator{} + if err := locator.AddKey("CN=test", pubKey); err != nil { + panic(err.Error()) + } + verifier := NewVerifier(locator) + + err = verifier.Verify(context.Background(), []byte(signedEvent)) + if err != nil { + panic(err.Error()) + } + fmt.Println("Signature checked out") + + // Output: Signature checked out +} diff --git a/signature/verifier_test.go b/signature/verifier_test.go new file mode 100644 index 0000000..808338d --- /dev/null +++ b/signature/verifier_test.go @@ -0,0 +1,149 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "context" + "crypto" + "crypto/elliptic" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + eiffelevents "github.com/eiffel-community/eiffelevents-sdk-go/editions/lyon" +) + +func TestSignAndVerify(t *testing.T) { + rsaKey := generateRSAKey(t) + ecdsa256Key := generateECDSAKey(t, elliptic.P256()) + ecdsa384Key := generateECDSAKey(t, elliptic.P384()) + ecdsa521Key := generateECDSAKey(t, elliptic.P521()) + + testcases := []struct { + name string + alg Algorithm + key crypto.Signer + lookupPublicKeys []crypto.PublicKey + lookupError error + expectedError error + }{ + { + name: "Happy path", + alg: RS256, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{rsaKey.Public()}, + }, + { + name: "Happy path", + alg: RS384, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{rsaKey.Public()}, + }, + { + name: "Happy path", + alg: RS512, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{rsaKey.Public()}, + }, + { + name: "Happy path with ES256", + alg: ES256, + key: ecdsa256Key, + lookupPublicKeys: []crypto.PublicKey{ecdsa256Key.Public()}, + }, + { + name: "Happy path with ES384", + alg: ES384, + key: ecdsa384Key, + lookupPublicKeys: []crypto.PublicKey{ecdsa384Key.Public()}, + }, + { + name: "Happy path with ES512", + alg: ES512, + key: ecdsa521Key, + lookupPublicKeys: []crypto.PublicKey{ecdsa521Key.Public()}, + }, + { + name: "Happy path with PS256", + alg: PS256, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{rsaKey.Public()}, + }, + { + name: "Happy path with PS384", + alg: PS384, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{rsaKey.Public()}, + }, + { + name: "Happy path with PS384", + alg: PS512, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{rsaKey.Public()}, + }, + { + name: "No matching public keys", + alg: PS512, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{}, + expectedError: ErrPublicKeyNotFound, + }, + { + name: "Public key lookup error", + alg: PS512, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{}, + lookupError: errors.New("random error"), + expectedError: ErrPublicKeyLookup, + }, + { + name: "Multiple matching public keys", + alg: PS512, + key: rsaKey, + lookupPublicKeys: []crypto.PublicKey{ecdsa521Key.Public(), rsaKey.Public()}, + }, + } + for _, tc := range testcases { + t.Run(fmt.Sprintf("%s: %s", tc.alg, tc.name), func(t *testing.T) { + event, err := eiffelevents.NewCompositionDefined() + require.NoError(t, err) + + signer, err := NewKeySigner("CN=test", tc.alg, tc.key) + require.NoError(t, err) + b, err := signer.Sign(event) + require.NoError(t, err) + + err = NewVerifier(&constantPublicKeyLocator{tc.lookupPublicKeys, tc.lookupError}).Verify(context.Background(), b) + if tc.expectedError == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.expectedError) + } + }) + } +} + +type constantPublicKeyLocator struct { + keys []crypto.PublicKey + err error +} + +func (cpkl *constantPublicKeyLocator) Locate(ctx context.Context, identity *AuthorIdentity) ([]crypto.PublicKey, error) { + return cpkl.keys, cpkl.err +}