diff --git a/README.md b/README.md index b963eb3f52284066eb98b5009ce6250ea86b7897..3efa276282dbe23c0029fc7271c8f3f00217958d 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ It is developed using the [Goa v3](https://goa.design/) framework and uses the [Go OPA framework](https://github.com/open-policy-agent/opa) as a library. -While the service is up and running, you can see a live Swagger API -description at `servicehost:serviceport/swagger-ui`. In the local docker-compose -environment, the Swagger URL is available at http://localhost:8081/swagger-ui/ +[Swagger OpenAPI documentation](https://gitlab.eclipse.org/eclipse/xfsc/tsa/policy/-/blob/main/gen/http/openapi3.json) + +In the local docker-compose environment, the Swagger URL is available at http://localhost:8081/swagger-ui/ ### High-level Overview diff --git a/cmd/policy/main.go b/cmd/policy/main.go index df96345889461bb0379e4c6653cc8cfac345eeaf..b1ac8800ac4af97777f1333cd226a397ae48e31a 100644 --- a/cmd/policy/main.go +++ b/cmd/policy/main.go @@ -166,7 +166,7 @@ func main() { healthSvc goahealth.Service ) { - policySvc = policy.New(storage, regocache, cache, signer, logger) + policySvc = policy.New(storage, regocache, cache, signer, cfg.ExternalAddr, httpClient, logger) healthSvc = health.New(Version) } diff --git a/design/design.go b/design/design.go index 3bbaa145230e0eecae68f2569e63b18cc5e2f818..e205c3467dab5cf6dd67892fd35db579c3794fd2 100644 --- a/design/design.go +++ b/design/design.go @@ -83,6 +83,34 @@ var _ = Service("policy", func() { }) }) + Method("ImportBundle", func() { + Description("Import a signed policy bundle.") + Payload(func() { + Attribute("length", Int) + }) + Result(Any) + HTTP(func() { + POST("/policy/import") + Header("length:Content-Length") + + SkipRequestBodyEncodeDecode() + + Response(StatusOK) + Response(StatusForbidden) + Response(StatusInternalServerError) + }) + }) + + Method("PolicyPublicKey", func() { + Description("PolicyPublicKey returns the public key in JWK format which must be used to verify a signed policy bundle.") + Payload(PolicyPublicKeyRequest) + Result(Any) + HTTP(func() { + GET("/policy/{repository}/{group}/{policyName}/{version}/key") + Response(StatusOK) + }) + }) + Method("ListPolicies", func() { Description("List policies from storage with optional filters.") Payload(PoliciesRequest) diff --git a/design/types.go b/design/types.go index aefd61be4fd9326fb437618cd2e9c9916d6bcad6..c7d384a5fec9304af095cbd19582727edcd0e5ee 100644 --- a/design/types.go +++ b/design/types.go @@ -69,6 +69,22 @@ var ExportBundleResult = Type("ExportBundleResult", func() { Required("content-type", "content-length", "content-disposition") }) +var PolicyPublicKeyRequest = Type("PolicyPublicKeyRequest", func() { + Field(1, "repository", String, "Policy repository.", func() { + Example("policies") + }) + Field(2, "group", String, "Policy group.", func() { + Example("example") + }) + Field(3, "policyName", String, "Policy name.", func() { + Example("returnDID") + }) + Field(4, "version", String, "Policy version.", func() { + Example("1.0") + }) + Required("repository", "group", "policyName", "version") +}) + var Policy = Type("Policy", func() { Field(1, "repository", String, "Policy repository.") Field(2, "policyName", String, "Policy name.") diff --git a/internal/clients/signer/client.go b/internal/clients/signer/client.go index b078b0be004bf73a5c247deaf1781ab2b8bdc607..d9509f4f5e04552afe9a2238438a5395d0efbe17 100644 --- a/internal/clients/signer/client.go +++ b/internal/clients/signer/client.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "io" "net/http" @@ -78,6 +79,35 @@ func (c *Client) Sign(ctx context.Context, namespace, key string, data []byte) ( return base64.StdEncoding.DecodeString(result.Signature) } +func (c *Client) Key(ctx context.Context, namespace string, key string) (any, error) { + if c.addr == "" { + return nil, errors.New(errors.ServiceUnavailable, "signer address is not set") + } + + keyPath := fmt.Sprintf("/v1/jwk/%s/%s", namespace, key) + req, err := http.NewRequestWithContext(ctx, "GET", c.addr+keyPath, nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() // nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return nil, errors.New(errors.GetKind(resp.StatusCode), getErrorBody(resp)) + } + + var pubkey map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&pubkey); err != nil { + return nil, err + } + + return pubkey, nil +} + func getErrorBody(resp *http.Response) string { body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index eaaaadb782e8a4cda17c4056dacbfadc9b6114bc..d3996c12cc2ec9675b5b70711d024945684da77a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,12 @@ type Config struct { Nats natsConfig Policy policyConfig + // ExternalAddr specifies the external address where + // the policy service could be reached, so that + // policy bundle verifiers can fetch public keys + // for verification. + ExternalAddr string `envconfig:"EXTERNAL_HTTP_ADDR" default:"http://localhost:8081"` + LogLevel string `envconfig:"LOG_LEVEL" default:"INFO"` } diff --git a/internal/service/policy/bundle.go b/internal/service/policy/bundle.go index f7d2458c59cade14ae46f0441718177a63e71fd0..875f4f77701a23cddc4d6f14106e515069aef949 100644 --- a/internal/service/policy/bundle.go +++ b/internal/service/policy/bundle.go @@ -4,9 +4,12 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" + "io" "strings" "time" + "gitlab.eclipse.org/eclipse/xfsc/tsa/golib/errors" "gitlab.eclipse.org/eclipse/xfsc/tsa/policy/internal/storage" ) @@ -24,6 +27,7 @@ type Metadata struct { Locked bool `json:"locked"` LastUpdate time.Time `json:"lastUpdate"` } `json:"policy"` + PublicKeyURL string `json:"publicKeyURL"` } func (s *Service) createPolicyBundle(policy *storage.Policy) ([]byte, error) { @@ -62,6 +66,14 @@ func (s *Service) createPolicyBundle(policy *storage.Policy) ([]byte, error) { }) } + // prepare json schema config file + if strings.TrimSpace(policy.OutputSchema) != "" { + files = append(files, ZipFile{ + Name: "output-schema.json", + Content: []byte(policy.OutputSchema), + }) + } + return s.createZipArchive(files) } @@ -73,6 +85,7 @@ func (s *Service) createMetadata(policy *storage.Policy) ([]byte, error) { meta.Policy.Repository = policy.Repository meta.Policy.Locked = policy.Locked meta.Policy.LastUpdate = policy.LastUpdate + meta.PublicKeyURL = s.policyPublicKeyURL(policy) return json.Marshal(meta) } @@ -97,3 +110,72 @@ func (s *Service) createZipArchive(files []ZipFile) ([]byte, error) { return buf.Bytes(), nil } + +func (s *Service) unzip(archive []byte) ([]ZipFile, error) { + r, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, err + } + + var files []ZipFile + for _, file := range r.File { + reader, err := file.Open() + if err != nil { + return nil, err + } + content, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + files = append(files, ZipFile{ + Name: file.Name, + Content: content, + }) + } + + return files, nil +} + +func (s *Service) policyPublicKeyURL(policy *storage.Policy) string { + return fmt.Sprintf("%s/policy/%s/%s/%s/%s/key", + s.externalHostname, + policy.Repository, + policy.Group, + policy.Name, + policy.Version, + ) +} + +func (s *Service) policyFromBundle(bundle []byte) (*storage.Policy, error) { + bundleFiles, err := s.unzip(bundle) + if err != nil { + return nil, errors.New("error unzipping bundle archive", err) + } + + var policy storage.Policy + for _, f := range bundleFiles { + switch f.Name { + case "metadata.json": + var metadata Metadata + if err := json.Unmarshal(f.Content, &metadata); err != nil { + return nil, err + } + policy.Repository = metadata.Policy.Repository + policy.Group = metadata.Policy.Group + policy.Name = metadata.Policy.Name + policy.Version = metadata.Policy.Version + policy.Locked = metadata.Policy.Locked + policy.LastUpdate = metadata.Policy.LastUpdate + case "policy.rego": + policy.Rego = string(f.Content) + case "data.json": + policy.Data = string(f.Content) + case "data-config.json": + policy.DataConfig = string(f.Content) + case "output-schema.json": + policy.OutputSchema = string(f.Content) + } + } + + return &policy, nil +} diff --git a/internal/service/policy/bundle_signer.go b/internal/service/policy/bundle_signer.go deleted file mode 100644 index 8e6b9bcb0e3aaad6041874ba4a204f83bc1e8eb8..0000000000000000000000000000000000000000 --- a/internal/service/policy/bundle_signer.go +++ /dev/null @@ -1,49 +0,0 @@ -package policy - -import ( - "context" - "fmt" - - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" -) - -const JwaVaultSignature jwa.SignatureAlgorithm = "VaultSignature" - -// signAdapter implements the jws.Signer interface so that it -// can be used with the lestrrat-go library. Under the hood it -// does the signing by calling an external signer service. -type signAdapter struct { - signer Signer -} - -type signAdapterKey struct { - Namespace string - Key string -} - -func (a *signAdapter) Sign(data []byte, key interface{}) ([]byte, error) { - signKey, ok := key.(*signAdapterKey) - if !ok { - return nil, fmt.Errorf("unexpected sign adapter key: %T", key) - } - - return a.signer.Sign(context.Background(), signKey.Namespace, signKey.Key, data) -} - -func (a *signAdapter) Algorithm() jwa.SignatureAlgorithm { - return JwaVaultSignature -} - -func (s *Service) sign(namespace, key string, data []byte) ([]byte, error) { - signature, err := jws.Sign( - nil, - jws.WithKey(JwaVaultSignature, &signAdapterKey{ - Namespace: namespace, - Key: key, - }), - jws.WithDetachedPayload(data), - ) - - return signature, err -} diff --git a/internal/service/policy/bundle_test.go b/internal/service/policy/bundle_test.go index b32e12c2b7bd6c02f2eab81507000c3dcbd99e70..b52ff73a9874e8c9fa24fddca704ff3b2f7f698b 100644 --- a/internal/service/policy/bundle_test.go +++ b/internal/service/policy/bundle_test.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "io" + "net/http" "testing" "time" @@ -44,10 +45,11 @@ var testMetadata = Metadata{ Locked: true, LastUpdate: time.Date(2023, 11, 7, 1, 0, 0, 0, time.UTC), }, + PublicKeyURL: "https://policyservice.com/policy/myrepo/example/mypolicy/1.0/key", } func TestPolicy_createPolicyBundle(t *testing.T) { - svc := New(nil, nil, nil, nil, zap.NewNop()) + svc := New(nil, nil, nil, nil, "https://policyservice.com", http.DefaultClient, zap.NewNop()) bundle, err := svc.createPolicyBundle(testPolicy) assert.NoError(t, err) assert.NotNil(t, bundle) @@ -94,3 +96,15 @@ func TestPolicy_createPolicyBundle(t *testing.T) { require.NoError(t, err) assert.Equal(t, `{"cfg":"static data config"}`, string(dataConfig)) } + +func TestPolicy_policyFromBundle(t *testing.T) { + svc := New(nil, nil, nil, nil, "https://policyservice.com", http.DefaultClient, zap.NewNop()) + bundle, err := svc.createPolicyBundle(testPolicy) + require.NoError(t, err) + require.NotNil(t, bundle) + + policy, err := svc.policyFromBundle(bundle) + require.NoError(t, err) + require.NotNil(t, policy) + assert.Equal(t, testPolicy, policy) +} diff --git a/internal/service/policy/bundle_verify.go b/internal/service/policy/bundle_verify.go new file mode 100644 index 0000000000000000000000000000000000000000..88876b487b6db1f95e28eddcf46df879bab24514 --- /dev/null +++ b/internal/service/policy/bundle_verify.go @@ -0,0 +1,136 @@ +package policy + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + "encoding/json" + "fmt" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +func (s *Service) verifyBundle(ctx context.Context, files []ZipFile) error { + policyBundleFile := files[0] + signatureFile := files[1] + + if policyBundleFile.Name != BundleFilename { + return fmt.Errorf("verify bundle: invalid bundle filename: %q", files[0].Name) + } + + if signatureFile.Name != BundleSignatureFilename { + return fmt.Errorf("verify bundle: invalid signature filename: %q", files[1].Name) + } + + bundleFiles, err := s.unzip(policyBundleFile.Content) + if err != nil { + return err + } + + if len(bundleFiles) == 0 || bundleFiles[0].Name != "metadata.json" { + return fmt.Errorf("invalid bundle") + } + + var metadata Metadata + if err := json.Unmarshal(bundleFiles[0].Content, &metadata); err != nil { + return fmt.Errorf("failed to unmarshal metadata: %v", err) + } + + // whitelist is insecure to allow fetching keys from arbitrary external locations + // TODO: this can be fine-tuned with configuration variable so that organizations + // can specify trusted import locations. + keyset, err := jwk.Fetch(ctx, + metadata.PublicKeyURL, + jwk.WithHTTPClient(s.httpClient), + jwk.WithFetchWhitelist(jwk.InsecureWhitelist{}), + ) + if err != nil { + return fmt.Errorf("verify bundle: %v", err) + } + + // we expect to receive a single verification key + verKey, ok := keyset.Key(0) + if !ok { + return fmt.Errorf("cannot get bundle verification key") + } + + // the payload that is signed on policy export is the sha256 hash of the + // policy bundle zip file itself, so this is the payload that should be verified + payload := sha256.Sum256(policyBundleFile.Content) + + switch kt := verKey.KeyType(); kt { + case jwa.EC: + err = s.verifyECDSA(payload[:], signatureFile.Content, verKey) + case jwa.OKP: + err = s.verifyED25519(payload[:], signatureFile.Content, verKey) + case jwa.RSA: + err = s.verifyRSA(payload[:], signatureFile.Content, verKey) + default: + return fmt.Errorf("unsupported public key type: %v", kt) + } + + return err +} + +func (s *Service) verifyECDSA(payload []byte, signature []byte, key jwk.Key) error { + // convert key from JWK to ecdsa.PublicKey + var ecdsaKey ecdsa.PublicKey + if err := key.Raw(&ecdsaKey); err != nil { + return err + } + + // hash function is always sha-256 by default when Hashicorp Vault signs + // data with ECDSA keys and no specific/different hash function is selected + // by the client. + hash := sha256.Sum256(payload) + + // ECDSA signatures returned by Hashicorp Vault are ASN encoded + valid := ecdsa.VerifyASN1(&ecdsaKey, hash[:], signature) + if !valid { + return fmt.Errorf("invalid signature") + } + + return nil +} + +func (s *Service) verifyED25519(payload []byte, signature []byte, key jwk.Key) error { + // convert key from JWK to ed25519.PublicKey + var ed25519Key ed25519.PublicKey + if err := key.Raw(&ed25519Key); err != nil { + return err + } + + // for ed25519 signatures we must not specifically hash the payload + // as this signature algorithm is using its own hash function internally + valid := ed25519.Verify(ed25519Key, payload, signature) + if !valid { + return fmt.Errorf("invalid signature") + } + + return nil +} + +func (s *Service) verifyRSA(payload []byte, signature []byte, key jwk.Key) error { + // convert key from JWK to rsa.PublicKey + var rsaKey rsa.PublicKey + if err := key.Raw(&rsaKey); err != nil { + return err + } + + // hash function is always sha-256 by default when Hashicorp Vault signs + // data with RSA keys and no specific/different hash function is selected + // by the client. + hash := sha256.Sum256(payload) + + err := rsa.VerifyPSS(&rsaKey, crypto.SHA256, hash[:], signature, nil) + if err != nil { + return fmt.Errorf("invalid signature: %v", err) + + } + + return nil +} diff --git a/internal/service/policy/policyfakes/fake_signer.go b/internal/service/policy/policyfakes/fake_signer.go index 3eac58f165dd71776cea3b11827fbb5743428348..73f80bc19fccb0d79dfb04002df63b0fa12cbcbb 100644 --- a/internal/service/policy/policyfakes/fake_signer.go +++ b/internal/service/policy/policyfakes/fake_signer.go @@ -9,6 +9,21 @@ import ( ) type FakeSigner struct { + KeyStub func(context.Context, string, string) (any, error) + keyMutex sync.RWMutex + keyArgsForCall []struct { + arg1 context.Context + arg2 string + arg3 string + } + keyReturns struct { + result1 any + result2 error + } + keyReturnsOnCall map[int]struct { + result1 any + result2 error + } SignStub func(context.Context, string, string, []byte) ([]byte, error) signMutex sync.RWMutex signArgsForCall []struct { @@ -29,6 +44,72 @@ type FakeSigner struct { invocationsMutex sync.RWMutex } +func (fake *FakeSigner) Key(arg1 context.Context, arg2 string, arg3 string) (any, error) { + fake.keyMutex.Lock() + ret, specificReturn := fake.keyReturnsOnCall[len(fake.keyArgsForCall)] + fake.keyArgsForCall = append(fake.keyArgsForCall, struct { + arg1 context.Context + arg2 string + arg3 string + }{arg1, arg2, arg3}) + stub := fake.KeyStub + fakeReturns := fake.keyReturns + fake.recordInvocation("Key", []interface{}{arg1, arg2, arg3}) + fake.keyMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeSigner) KeyCallCount() int { + fake.keyMutex.RLock() + defer fake.keyMutex.RUnlock() + return len(fake.keyArgsForCall) +} + +func (fake *FakeSigner) KeyCalls(stub func(context.Context, string, string) (any, error)) { + fake.keyMutex.Lock() + defer fake.keyMutex.Unlock() + fake.KeyStub = stub +} + +func (fake *FakeSigner) KeyArgsForCall(i int) (context.Context, string, string) { + fake.keyMutex.RLock() + defer fake.keyMutex.RUnlock() + argsForCall := fake.keyArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeSigner) KeyReturns(result1 any, result2 error) { + fake.keyMutex.Lock() + defer fake.keyMutex.Unlock() + fake.KeyStub = nil + fake.keyReturns = struct { + result1 any + result2 error + }{result1, result2} +} + +func (fake *FakeSigner) KeyReturnsOnCall(i int, result1 any, result2 error) { + fake.keyMutex.Lock() + defer fake.keyMutex.Unlock() + fake.KeyStub = nil + if fake.keyReturnsOnCall == nil { + fake.keyReturnsOnCall = make(map[int]struct { + result1 any + result2 error + }) + } + fake.keyReturnsOnCall[i] = struct { + result1 any + result2 error + }{result1, result2} +} + func (fake *FakeSigner) Sign(arg1 context.Context, arg2 string, arg3 string, arg4 []byte) ([]byte, error) { var arg4Copy []byte if arg4 != nil { @@ -104,6 +185,8 @@ func (fake *FakeSigner) SignReturnsOnCall(i int, result1 []byte, result2 error) func (fake *FakeSigner) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() + fake.keyMutex.RLock() + defer fake.keyMutex.RUnlock() fake.signMutex.RLock() defer fake.signMutex.RUnlock() copiedInvocations := map[string][][]interface{}{} diff --git a/internal/service/policy/policyfakes/fake_storage.go b/internal/service/policy/policyfakes/fake_storage.go index 1cbab1b9910c438a89835f64ef360762892a4af9..215cabbd5b784342053e28cd0c6147f47a2127b5 100644 --- a/internal/service/policy/policyfakes/fake_storage.go +++ b/internal/service/policy/policyfakes/fake_storage.go @@ -102,6 +102,18 @@ type FakeStorage struct { result1 *storage.Policy result2 error } + SavePolicyStub func(context.Context, *storage.Policy) error + savePolicyMutex sync.RWMutex + savePolicyArgsForCall []struct { + arg1 context.Context + arg2 *storage.Policy + } + savePolicyReturns struct { + result1 error + } + savePolicyReturnsOnCall map[int]struct { + result1 error + } SetDataStub func(context.Context, string, map[string]interface{}) error setDataMutex sync.RWMutex setDataArgsForCall []struct { @@ -585,6 +597,68 @@ func (fake *FakeStorage) PolicyReturnsOnCall(i int, result1 *storage.Policy, res }{result1, result2} } +func (fake *FakeStorage) SavePolicy(arg1 context.Context, arg2 *storage.Policy) error { + fake.savePolicyMutex.Lock() + ret, specificReturn := fake.savePolicyReturnsOnCall[len(fake.savePolicyArgsForCall)] + fake.savePolicyArgsForCall = append(fake.savePolicyArgsForCall, struct { + arg1 context.Context + arg2 *storage.Policy + }{arg1, arg2}) + stub := fake.SavePolicyStub + fakeReturns := fake.savePolicyReturns + fake.recordInvocation("SavePolicy", []interface{}{arg1, arg2}) + fake.savePolicyMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeStorage) SavePolicyCallCount() int { + fake.savePolicyMutex.RLock() + defer fake.savePolicyMutex.RUnlock() + return len(fake.savePolicyArgsForCall) +} + +func (fake *FakeStorage) SavePolicyCalls(stub func(context.Context, *storage.Policy) error) { + fake.savePolicyMutex.Lock() + defer fake.savePolicyMutex.Unlock() + fake.SavePolicyStub = stub +} + +func (fake *FakeStorage) SavePolicyArgsForCall(i int) (context.Context, *storage.Policy) { + fake.savePolicyMutex.RLock() + defer fake.savePolicyMutex.RUnlock() + argsForCall := fake.savePolicyArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeStorage) SavePolicyReturns(result1 error) { + fake.savePolicyMutex.Lock() + defer fake.savePolicyMutex.Unlock() + fake.SavePolicyStub = nil + fake.savePolicyReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeStorage) SavePolicyReturnsOnCall(i int, result1 error) { + fake.savePolicyMutex.Lock() + defer fake.savePolicyMutex.Unlock() + fake.SavePolicyStub = nil + if fake.savePolicyReturnsOnCall == nil { + fake.savePolicyReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.savePolicyReturnsOnCall[i] = struct { + result1 error + }{result1} +} + func (fake *FakeStorage) SetData(arg1 context.Context, arg2 string, arg3 map[string]interface{}) error { fake.setDataMutex.Lock() ret, specificReturn := fake.setDataReturnsOnCall[len(fake.setDataArgsForCall)] @@ -733,6 +807,8 @@ func (fake *FakeStorage) Invocations() map[string][][]interface{} { defer fake.listenPolicyDataChangesMutex.RUnlock() fake.policyMutex.RLock() defer fake.policyMutex.RUnlock() + fake.savePolicyMutex.RLock() + defer fake.savePolicyMutex.RUnlock() fake.setDataMutex.RLock() defer fake.setDataMutex.RUnlock() fake.setPolicyLockMutex.RLock() diff --git a/internal/service/policy/service.go b/internal/service/policy/service.go index 65b85ce5c9c41c02baeb546277a02a244e663055..083df02be50b8131f010675c490059c8b3dc0209 100644 --- a/internal/service/policy/service.go +++ b/internal/service/policy/service.go @@ -7,11 +7,10 @@ import ( "encoding/json" "fmt" "io" + "net/http" "strings" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" "github.com/open-policy-agent/opa/rego" "github.com/open-policy-agent/opa/storage/inmem" "go.uber.org/zap" @@ -29,6 +28,11 @@ import ( //go:generate counterfeiter . RegoCache //go:generate counterfeiter . Signer +const ( + BundleFilename = "policy_bundle.zip" + BundleSignatureFilename = "signature.raw" +) + type Cache interface { Set(ctx context.Context, key, namespace, scope string, value []byte, ttl int) error } @@ -39,6 +43,7 @@ type RegoCache interface { } type Signer interface { + Key(ctx context.Context, namespace string, key string) (any, error) Sign(ctx context.Context, namespace string, key string, data []byte) ([]byte, error) } @@ -47,33 +52,26 @@ type Service struct { policyCache RegoCache cache Cache signer Signer + httpClient *http.Client logger *zap.Logger -} - -func New(storage Storage, policyCache RegoCache, cache Cache, signer Signer, logger *zap.Logger) *Service { - signerFactory := func(signer Signer) func() (jws.Signer, error) { - return func() (jws.Signer, error) { - return &signAdapter{signer: signer}, nil - } - } - // This unregister/register sequence is done mainly for the unit tests - // because each tests creates a new service instance, but the jws.Signer - // implementations are kept in a global variable map, which is the same - // for all test cases. In order for tests to work and to be able to register - // different signer implementations, the previous signer must be explicitly - // unregistered. - jws.UnregisterSigner(JwaVaultSignature) - jwa.UnregisterSignatureAlgorithm(JwaVaultSignature) - jwa.RegisterSignatureAlgorithm(JwaVaultSignature) - jws.RegisterSigner(JwaVaultSignature, jws.SignerFactoryFn(signerFactory(signer))) + // externalHostname specifies the hostname where the policy service can be + // reached from the public internet. This setting is very important for + // export/import of policy bundles, as the policy service must include the + // full path to its verification public key in the bundle, so that verifiers + // can use it for signature verification. + externalHostname string +} +func New(storage Storage, policyCache RegoCache, cache Cache, signer Signer, hostname string, httpClient *http.Client, logger *zap.Logger) *Service { return &Service{ - storage: storage, - policyCache: policyCache, - cache: cache, - signer: signer, - logger: logger, + storage: storage, + policyCache: policyCache, + cache: cache, + signer: signer, + httpClient: httpClient, + logger: logger, + externalHostname: hostname, } } @@ -259,7 +257,7 @@ func (s *Service) ExportBundle(ctx context.Context, req *policy.ExportBundleRequ // TODO(penkovski): namespace and key must be taken from policy export configuration // This will be implemented with issue #41, for now some test values are hardcoded // https://gitlab.eclipse.org/eclipse/xfsc/tsa/policy/-/issues/41 - signature, err := s.sign("transit", "key1", bundleDigest[:]) + signature, err := s.signer.Sign(ctx, "transit", "key1", bundleDigest[:]) if err != nil { logger.Error("error signing policy bundle", zap.Error(err)) return nil, nil, err @@ -269,11 +267,11 @@ func (s *Service) ExportBundle(ctx context.Context, req *policy.ExportBundleRequ // zip file and the jws detached payload signature file var files = []ZipFile{ { - Name: "policy_bundle.zip", + Name: BundleFilename, Content: bundle, }, { - Name: "policy_bundle.jws", + Name: BundleSignatureFilename, Content: signature, }, } @@ -294,6 +292,76 @@ func (s *Service) ExportBundle(ctx context.Context, req *policy.ExportBundleRequ }, io.NopCloser(bytes.NewReader(signedBundle)), nil } +// PolicyPublicKey returns the public key in JWK format which must be used to +// verify a signed policy bundle. +func (s *Service) PolicyPublicKey(ctx context.Context, req *policy.PolicyPublicKeyRequest) (any, error) { + logger := s.logger.With( + zap.String("operation", "policyPublicKey"), + zap.String("repository", req.Repository), + zap.String("group", req.Group), + zap.String("name", req.PolicyName), + zap.String("version", req.Version), + ) + + // TODO: get key and namespace from policy export configuration + key, err := s.signer.Key(ctx, "transit", "key1") + if err != nil { + logger.Error("error getting policy public key", zap.Error(err)) + return nil, err + } + + return key, nil +} + +// ImportBundle imports a signed policy bundle. +func (s *Service) ImportBundle(ctx context.Context, _ *policy.ImportBundlePayload, payload io.ReadCloser) (any, error) { + logger := s.logger.With(zap.String("operation", "importBundle")) + + archive, err := io.ReadAll(payload) + if err != nil { + logger.Error("error reading bundle payload", zap.Error(err)) + return nil, errors.New(errors.BadRequest, fmt.Errorf("error reading bundle payload: %v", err)) + } + + files, err := s.unzip(archive) + if err != nil { + logger.Error("failed to unzip bundle", zap.Error(err)) + return nil, errors.New(errors.BadRequest, fmt.Errorf("failed to unzip bundle: %v", err)) + } + + if len(files) != 2 { + err := fmt.Errorf("expected to contain two files, but has: %d", len(files)) + logger.Error("invalid bundle", zap.Error(err)) + return nil, errors.New(errors.BadRequest, "invalid bundle", err) + } + + if err := s.verifyBundle(ctx, files); err != nil { + logger.Error("failed to verify bundle", zap.Error(err)) + return nil, errors.New(errors.Forbidden, "failed to verify bundle", err) + } + logger.Debug("signature is valid") + + policy, err := s.policyFromBundle(files[0].Content) + if err != nil { + logger.Error("cannot make policy from bundle", zap.Error(err)) + return nil, errors.New("cannot make policy from bundle", err) + } + + if err := s.storage.SavePolicy(ctx, policy); err != nil { + logger.Error("error saving imported policy bundle", zap.Error(err)) + return nil, errors.New("error saving imported policy bundle", err) + } + + return map[string]interface{}{ + "repository": policy.Repository, + "group": policy.Group, + "name": policy.Name, + "version": policy.Version, + "locked": policy.Locked, + "lastUpdate": policy.LastUpdate, + }, err +} + func (s *Service) ListPolicies(ctx context.Context, req *policy.PoliciesRequest) (*policy.PoliciesResult, error) { logger := s.logger.With(zap.String("operation", "listPolicies")) diff --git a/internal/service/policy/service_test.go b/internal/service/policy/service_test.go index 43c1ff736952b64dba389da54339ed068121367b..e853b80394b3d89a42ba104bfbe90c2c47e54cb2 100644 --- a/internal/service/policy/service_test.go +++ b/internal/service/policy/service_test.go @@ -4,7 +4,6 @@ import ( "archive/zip" "bytes" "context" - "encoding/base64" "fmt" "io" "net/http" @@ -26,7 +25,7 @@ import ( ) func TestNew(t *testing.T) { - svc := policy.New(nil, nil, nil, nil, zap.NewNop()) + svc := policy.New(nil, nil, nil, nil, "hostname.com", http.DefaultClient, zap.NewNop()) assert.Implements(t, (*goapolicy.Service)(nil), svc) } @@ -373,7 +372,7 @@ func TestService_Evaluate(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - svc := policy.New(test.storage, test.regocache, test.cache, nil, zap.NewNop()) + svc := policy.New(test.storage, test.regocache, test.cache, nil, "hostname.com", http.DefaultClient, zap.NewNop()) ctx := context.Background() if test.ctx != nil { ctx = test.ctx @@ -479,7 +478,7 @@ func TestService_Lock(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - svc := policy.New(test.storage, nil, nil, nil, zap.NewNop()) + svc := policy.New(test.storage, nil, nil, nil, "hostname.com", http.DefaultClient, zap.NewNop()) err := svc.Lock(context.Background(), test.req) if err == nil { assert.Empty(t, test.errtext) @@ -577,7 +576,7 @@ func TestService_Unlock(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - svc := policy.New(test.storage, nil, nil, nil, zap.NewNop()) + svc := policy.New(test.storage, nil, nil, nil, "hostname.com", http.DefaultClient, zap.NewNop()) err := svc.Unlock(context.Background(), test.req) if err == nil { assert.Empty(t, test.errtext) @@ -809,7 +808,7 @@ func TestService_ListPolicies(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - svc := policy.New(test.storage, nil, nil, nil, zap.NewNop()) + svc := policy.New(test.storage, nil, nil, nil, "hostname.com", http.DefaultClient, zap.NewNop()) result, err := svc.ListPolicies(context.Background(), test.request) if test.errText != "" { @@ -874,7 +873,7 @@ func TestService_SubscribeForPolicyChange(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - svc := policy.New(test.storage, nil, nil, nil, zap.NewNop()) + svc := policy.New(test.storage, nil, nil, nil, "hostname.com", http.DefaultClient, zap.NewNop()) res, err := svc.SubscribeForPolicyChange(context.Background(), test.request) if test.errText != "" { assert.ErrorContains(t, err, test.errText) @@ -894,7 +893,7 @@ func TestService_ExportBundleError(t *testing.T) { return nil, errors.New(errors.NotFound, "policy not found") }, } - svc := policy.New(storage, nil, nil, nil, zap.NewNop()) + svc := policy.New(storage, nil, nil, nil, "https://policyservice.com", http.DefaultClient, zap.NewNop()) res, reader, err := svc.ExportBundle(context.Background(), &goapolicy.ExportBundleRequest{}) assert.Nil(t, res) assert.Nil(t, reader) @@ -911,7 +910,7 @@ func TestService_ExportBundleError(t *testing.T) { return nil, errors.New("unexpected error") }, } - svc := policy.New(storage, nil, nil, nil, zap.NewNop()) + svc := policy.New(storage, nil, nil, nil, "https://policyservice.com", http.DefaultClient, zap.NewNop()) res, reader, err := svc.ExportBundle(context.Background(), &goapolicy.ExportBundleRequest{}) assert.Nil(t, res) assert.Nil(t, reader) @@ -945,7 +944,7 @@ func TestService_ExportBundleError(t *testing.T) { }, } - svc := policy.New(storage, nil, nil, signer, zap.NewNop()) + svc := policy.New(storage, nil, nil, signer, "https://policyservice.com", http.DefaultClient, zap.NewNop()) res, reader, err := svc.ExportBundle(context.Background(), &goapolicy.ExportBundleRequest{}) assert.Nil(t, res) assert.Nil(t, reader) @@ -977,7 +976,7 @@ func TestService_ExportBundleSuccess(t *testing.T) { }, } - svc := policy.New(storage, nil, nil, signer, zap.NewNop()) + svc := policy.New(storage, nil, nil, signer, "https://policyservice.com", http.DefaultClient, zap.NewNop()) res, reader, err := svc.ExportBundle(context.Background(), &goapolicy.ExportBundleRequest{}) require.NoError(t, err) require.NotNil(t, res) @@ -997,11 +996,11 @@ func TestService_ExportBundleSuccess(t *testing.T) { // check if policy_bundle.zip is present require.NotNil(t, r.File[0]) - require.Equal(t, "policy_bundle.zip", r.File[0].Name) + require.Equal(t, policy.BundleFilename, r.File[0].Name) // check if policy_bundle.jws is present require.NotNil(t, r.File[1]) - require.Equal(t, "policy_bundle.jws", r.File[1].Name) + require.Equal(t, policy.BundleSignatureFilename, r.File[1].Name) // check if signature matches the returned value from signer reader, err = r.File[1].Open() @@ -1011,11 +1010,5 @@ func TestService_ExportBundleSuccess(t *testing.T) { require.NoError(t, err) require.NotNil(t, sig) - jwsParts := bytes.Split(sig, []byte(".")) - assert.Len(t, jwsParts, 3) - assert.NotEmpty(t, jwsParts[2]) - - s, err := base64.StdEncoding.DecodeString(string(jwsParts[2])) - require.NoError(t, err) - assert.Equal(t, "signature", string(s)) + assert.Equal(t, []byte("signature"), sig) } diff --git a/internal/service/policy/storage.go b/internal/service/policy/storage.go index d22b45eab4fcedb2acc9ff9781d8a2a7e72c1fd8..2a8f0b0cdf5941947674e844916a001c0681b36e 100644 --- a/internal/service/policy/storage.go +++ b/internal/service/policy/storage.go @@ -8,6 +8,7 @@ import ( type Storage interface { Policy(ctx context.Context, repository, group, name, version string) (*storage.Policy, error) + SavePolicy(ctx context.Context, policy *storage.Policy) error SetPolicyLock(ctx context.Context, repository, group, name, version string, lock bool) error GetPolicies(ctx context.Context, locked *bool) ([]*storage.Policy, error) AddPolicyChangeSubscribers(subscribers ...storage.PolicyChangeSubscriber) diff --git a/internal/storage/memory/storage.go b/internal/storage/memory/storage.go index 06df4001cd1e2cdf06391dd127dee75494199e72..fe5c2281e9a46d1d08560731989775fde6cb5f42 100644 --- a/internal/storage/memory/storage.go +++ b/internal/storage/memory/storage.go @@ -56,7 +56,31 @@ func (s *Storage) Policy(_ context.Context, repository, group, name, version str return &res, nil } -func (s *Storage) SetPolicyLock(_ context.Context, repository, group, name, version string, lock bool) error { +func (s *Storage) SavePolicy(ctx context.Context, policy *storage.Policy) error { + key := s.keyConstructor.ConstructKey( + policy.Repository, + policy.Group, + policy.Name, + policy.Version, + ) + + s.mu.Lock() + s.policies[key] = policy + s.mu.Unlock() + + // send the changed policy to subscribers + go func(policy *storage.Policy) { + select { + case s.changes <- *policy: + case <-time.After(10 * time.Second): + case <-ctx.Done(): + } + }(policy) + + return nil +} + +func (s *Storage) SetPolicyLock(ctx context.Context, repository, group, name, version string, lock bool) error { key := s.keyConstructor.ConstructKey(repository, group, name, version) s.mu.Lock() @@ -70,7 +94,11 @@ func (s *Storage) SetPolicyLock(_ context.Context, repository, group, name, vers // send the changed policy to subscribers go func(policy *storage.Policy) { - s.changes <- *policy + select { + case s.changes <- *policy: + case <-time.After(10 * time.Second): + case <-ctx.Done(): + } }(p) return nil diff --git a/internal/storage/mongodb/storage.go b/internal/storage/mongodb/storage.go index 354a0a4c94afc36ee469824644a3aa412645e739..56ed1a10588b3752317a40d642b46acfdaa13d7d 100644 --- a/internal/storage/mongodb/storage.go +++ b/internal/storage/mongodb/storage.go @@ -80,6 +80,29 @@ func (s *Storage) Policy(ctx context.Context, repository, group, name, version s return &policy, nil } +func (s *Storage) SavePolicy(ctx context.Context, policy *storage.Policy) error { + opts := options.Update().SetUpsert(true) + filter := bson.M{ + "repository": policy.Repository, + "group": policy.Group, + "name": policy.Name, + "version": policy.Version, + } + update := bson.M{"$set": bson.M{ + "locked": policy.Locked, + "rego": policy.Rego, + "data": policy.Data, + "dataConfig": policy.DataConfig, + "outputSchema": policy.OutputSchema, + "lastUpdate": time.Now(), + "nextDataRefreshTime": time.Time{}, + }} + + _, err := s.policy.UpdateOne(ctx, filter, update, opts) + + return err +} + func (s *Storage) SetPolicyLock(ctx context.Context, repository, group, name, version string, lock bool) error { _, err := s.policy.UpdateOne( ctx,