diff --git a/.gitignore b/.gitignore index 8d917b9150..c5ba39c892 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ venv2/ python/dist/ neosync_ee_ca.key +neosync_cloud_ca.key diff --git a/backend/internal/cmds/mgmt/serve/connect/cmd.go b/backend/internal/cmds/mgmt/serve/connect/cmd.go index df8a7aaa2d..d770ea4758 100644 --- a/backend/internal/cmds/mgmt/serve/connect/cmd.go +++ b/backend/internal/cmds/mgmt/serve/connect/cmd.go @@ -62,6 +62,7 @@ import ( v1alpha1_useraccountservice "github.com/nucleuscloud/neosync/backend/services/mgmt/v1alpha1/user-account-service" awsmanager "github.com/nucleuscloud/neosync/internal/aws" "github.com/nucleuscloud/neosync/internal/billing" + cloudlicense "github.com/nucleuscloud/neosync/internal/ee/cloud-license" "github.com/nucleuscloud/neosync/internal/ee/license" presidioapi "github.com/nucleuscloud/neosync/internal/ee/presidio" neomigrate "github.com/nucleuscloud/neosync/internal/migrate" @@ -115,9 +116,11 @@ func serve(ctx context.Context) error { } slogger.Debug(fmt.Sprintf("ee license enabled: %t", eelicense.IsValid())) - if getIsNeosyncCloud() { - slogger.Debug("neosync cloud is enabled") + ncloudlicense, err := cloudlicense.NewFromEnv() + if err != nil { + return err } + slogger.Debug(fmt.Sprintf("neosync cloud enabled: %t", ncloudlicense.IsValid())) mux := http.NewServeMux() @@ -301,7 +304,7 @@ func serve(ctx context.Context) error { if err != nil { return err } - apikeyClient := auth_apikey.New(db.Q, db.Db, getAllowedWorkerApiKeys(getIsNeosyncCloud()), []string{ + apikeyClient := auth_apikey.New(db.Q, db.Db, getAllowedWorkerApiKeys(ncloudlicense.IsValid()), []string{ mgmtv1alpha1connect.JobServiceGetJobProcedure, mgmtv1alpha1connect.JobServiceGetRunContextProcedure, mgmtv1alpha1connect.JobServiceSetRunContextProcedure, @@ -431,7 +434,7 @@ func serve(ctx context.Context) error { useraccountService := v1alpha1_useraccountservice.New(&v1alpha1_useraccountservice.Config{ IsAuthEnabled: isAuthEnabled, - IsNeosyncCloud: getIsNeosyncCloud(), + IsNeosyncCloud: ncloudlicense.IsValid(), DefaultMaxAllowedRecords: getDefaultMaxAllowedRecords(), }, db, temporalConfigProvider, authclient, authadminclient, billingClient) api.Handle( @@ -480,7 +483,7 @@ func serve(ctx context.Context) error { ) jobhookOpts := []jobhooks.Option{} - if getIsNeosyncCloud() || eelicense.IsValid() { + if ncloudlicense.IsValid() || eelicense.IsValid() { jobhookOpts = append(jobhookOpts, jobhooks.WithEnabled()) } @@ -497,7 +500,7 @@ func serve(ctx context.Context) error { jobServiceConfig := &v1alpha1_jobservice.Config{ IsAuthEnabled: isAuthEnabled, - IsNeosyncCloud: getIsNeosyncCloud(), + IsNeosyncCloud: ncloudlicense.IsValid(), RunLogConfig: runLogConfig, } jobService := v1alpha1_jobservice.New( @@ -522,7 +525,7 @@ func serve(ctx context.Context) error { var presAnalyzeClient presidioapi.AnalyzeInterface var presAnonClient presidioapi.AnonymizeInterface var presEntityClient presidioapi.EntityInterface - if getIsNeosyncCloud() { + if ncloudlicense.IsValid() { analyzeClient, ok, err := getPresidioAnalyzeClient() if err != nil { return fmt.Errorf("unable to initialize presidio analyze client: %w", err) @@ -543,8 +546,8 @@ func serve(ctx context.Context) error { } transformerService := v1alpha1_transformerservice.New(&v1alpha1_transformerservice.Config{ - IsPresidioEnabled: getIsNeosyncCloud(), - IsNeosyncCloud: getIsNeosyncCloud(), + IsPresidioEnabled: ncloudlicense.IsValid(), + IsNeosyncCloud: ncloudlicense.IsValid(), }, db, useraccountService, presEntityClient) api.Handle( mgmtv1alpha1connect.NewTransformersServiceHandler( @@ -557,9 +560,9 @@ func serve(ctx context.Context) error { ) anonymizationService := v1alpha1_anonymizationservice.New(&v1alpha1_anonymizationservice.Config{ - IsPresidioEnabled: getIsNeosyncCloud(), + IsPresidioEnabled: ncloudlicense.IsValid(), IsAuthEnabled: isAuthEnabled, - IsNeosyncCloud: getIsNeosyncCloud(), + IsNeosyncCloud: ncloudlicense.IsValid(), }, anonymizerMeter, useraccountService, presAnalyzeClient, presAnonClient, db) api.Handle( mgmtv1alpha1connect.NewAnonymizationServiceHandler( @@ -891,10 +894,6 @@ func getAuthApiProvider() string { return viper.GetString("AUTH_API_PROVIDER") } -func getIsNeosyncCloud() bool { - return viper.GetBool("NEOSYNC_CLOUD") -} - func getAllowedWorkerApiKeys(isNeosyncCloud bool) []string { if isNeosyncCloud { return viper.GetStringSlice("NEOSYNC_CLOUD_ALLOWED_WORKER_API_KEYS") diff --git a/internal/ee/cloud-license/license.go b/internal/ee/cloud-license/license.go new file mode 100644 index 0000000000..405250de4d --- /dev/null +++ b/internal/ee/cloud-license/license.go @@ -0,0 +1,144 @@ +package cloudlicense + +import ( + "crypto/ed25519" + "crypto/x509" + _ "embed" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "time" + + "github.com/spf13/viper" +) + +//go:embed neosync_cloud_pub.pem +var publicKeyPEM string + +// The expected base64 decoded structure of the NEOSYNC_CLOUD_LICENSE file +type licenseFile struct { + License string `json:"license"` + Signature string `json:"signature"` +} + +type Interface interface { + IsValid() bool +} + +var _ Interface = (*CloudLicense)(nil) + +type CloudLicense struct { + contents *licenseContents +} + +// Determines if Neosync Cloud is enabled. +// If not enabled, returns a valid struct where IsValid returns false +// If enabled but no license if provided, returns an error +func NewFromEnv() (*CloudLicense, error) { + lc, isEnabled, err := getFromEnv() + if err != nil { + return nil, err + } + if !isEnabled { + return &CloudLicense{contents: nil}, nil + } + return &CloudLicense{contents: lc}, nil +} + +func (c *CloudLicense) IsValid() bool { + return c.contents != nil && c.contents.IsValid() +} + +type licenseContents struct { + Version string `json:"version"` + Id string `json:"id"` + IssuedTo string `json:"issued_to"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (l *licenseContents) IsValid() bool { + return time.Now().UTC().Before(l.ExpiresAt) +} + +const ( + cloudLicenseEvKey = "NEOSYNC_CLOUD_LICENSE" + cloudEnabledEvKey = "NEOSYNC_CLOUD" +) + +func getFromEnv() (*licenseContents, bool, error) { + isCloud := viper.GetBool(cloudEnabledEvKey) + if !isCloud { + return nil, false, nil + } + + input := viper.GetString(cloudLicenseEvKey) + if input == "" { + return nil, false, fmt.Errorf("%s was true but no license was found at %s", cloudEnabledEvKey, cloudLicenseEvKey) + } + pk, err := parsePublicKey(publicKeyPEM) + if err != nil { + return nil, false, fmt.Errorf("unable to parse neosync cloud public key: %w", err) + } + contents, err := getLicense(input, pk) + if err != nil { + return nil, false, fmt.Errorf("failed to parse provided license: %w", err) + } + return contents, true, nil +} + +// Expected the license data to be a base64 encoded json string that matches the licenseFile structure. +func getLicense(licenseData string, publicKey ed25519.PublicKey) (*licenseContents, error) { + licenseDataContents, err := base64.StdEncoding.DecodeString(licenseData) + if err != nil { + return nil, fmt.Errorf("unable to decode license data: %w", err) + } + + var license licenseFile + err = json.Unmarshal(licenseDataContents, &license) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal license data from input: %w", err) + } + contents, err := base64.StdEncoding.DecodeString(license.License) + if err != nil { + return nil, fmt.Errorf("unable to decode contents: %w", err) + } + signature, err := base64.StdEncoding.DecodeString(license.Signature) + if err != nil { + return nil, fmt.Errorf("unable to decode signature: %w", err) + } + + ok := ed25519.Verify(publicKey, contents, signature) + if !ok { + return nil, errors.New("unable to verify contents against public key") + } + + var lc licenseContents + err = json.Unmarshal(contents, &lc) + if err != nil { + return nil, fmt.Errorf("contents verified, but unable to unmarshal license contents from input: %w", err) + } + + return &lc, nil +} + +func parsePublicKey(data string) (ed25519.PublicKey, error) { + block, _ := pem.Decode([]byte(data)) + if block == nil { + return nil, errors.New("failed to parse PEM block containing the public key") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse DER encoded public key: %v", err) + } + + switch pub := pub.(type) { + case ed25519.PublicKey: + return pub, nil + default: + return nil, fmt.Errorf("unsupported public key: %T", pub) + } +} diff --git a/internal/ee/cloud-license/license_test.go b/internal/ee/cloud-license/license_test.go new file mode 100644 index 0000000000..c496534dc6 --- /dev/null +++ b/internal/ee/cloud-license/license_test.go @@ -0,0 +1,72 @@ +package cloudlicense + +import ( + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func Test_parsePublicKey(t *testing.T) { + t.Run("empty", func(t *testing.T) { + actual, err := parsePublicKey("") + require.Error(t, err) + require.Nil(t, actual) + }) + t.Run("invalid format", func(t *testing.T) { + actual, err := parsePublicKey("blah") + require.Error(t, err) + require.Nil(t, actual) + }) + t.Run("valid", func(t *testing.T) { + actual, err := parsePublicKey(publicKeyPEM) + require.NoError(t, err) + require.NotNil(t, actual) + }) +} + +const ( + // generated using the gen-cust-license shell script with the neosync cloud private key + // ./scripts/gen-cust-license.sh ./neosync_cloud_ca.key license.json | pbcopy + validExpiredTestLicense = "eyJsaWNlbnNlIjoiZXdvZ0lDQWdJblpsY25OcGIyNGlPaUFpZGpFaUxBb2dJQ0FnSW1sa0lqb2dJbVk0TW1aaVlXWmtMVFppTnpVdE5HSXpaUzFoWmpRekxUZGhaRFF3TldNNFpEUTRZaUlzQ2lBZ0lDQWlhWE56ZFdWa1gzUnZJam9nSWtGamJXVWdRMjh1SWl3S0lDQWdJQ0pwYzNOMVpXUmZZWFFpT2lBaU1qQXlNaTB4TWkwek1WUXhNam93TURvd01Gb2lMQW9nSUNBZ0ltVjRjR2x5WlhOZllYUWlPaUFpTWpBeU15MHhNaTB6TVZReE1qb3dNRG93TUZvaUNuMEsiLCJzaWduYXR1cmUiOiJMOWxTT3dkL2VjMmlpZVlYYUFSRENlUzhtaE5INS85c1M0VHQvNkJVMHJmQXMraTRLYVJRV1p5eG9Id203eC8vb2VReXd4cmN1VGpQUXFvemFHbHJEdz09In0K" +) + +func Test_getLicense(t *testing.T) { + t.Run("ok", func(t *testing.T) { + publicKey, err := parsePublicKey(publicKeyPEM) + require.NoError(t, err) + contents, err := getLicense(validExpiredTestLicense, publicKey) + require.NoError(t, err) + require.NotEmpty(t, contents) + + require.Equal(t, "f82fbafd-6b75-4b3e-af43-7ad405c8d48b", contents.Id) + require.Equal(t, "v1", contents.Version) + require.Equal(t, time.Date(2023, 12, 31, 12, 0, 0, 0, time.UTC), contents.ExpiresAt) + require.Equal(t, time.Date(2022, 12, 31, 12, 0, 0, 0, time.UTC), contents.IssuedAt) + require.Equal(t, "Acme Co.", contents.IssuedTo) + require.False(t, contents.IsValid()) + }) +} + +func Test_NewFromEnv(t *testing.T) { + t.Run("present", func(t *testing.T) { + viper.Set(cloudEnabledEvKey, true) + viper.Set(cloudLicenseEvKey, validExpiredTestLicense) + eelicense, err := NewFromEnv() + require.NoError(t, err) + require.NotNil(t, eelicense) + + require.False(t, eelicense.IsValid()) + }) + t.Run("empty", func(t *testing.T) { + viper.Set(cloudLicenseEvKey, "") + + viper.Set(cloudLicenseEvKey, validExpiredTestLicense) + eelicense, err := NewFromEnv() + require.NoError(t, err) + require.NotNil(t, eelicense) + + require.False(t, eelicense.IsValid()) + }) +} diff --git a/internal/ee/cloud-license/neosync_cloud_pub.pem b/internal/ee/cloud-license/neosync_cloud_pub.pem new file mode 100644 index 0000000000..36ca6be027 --- /dev/null +++ b/internal/ee/cloud-license/neosync_cloud_pub.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAovxmeEF1/FShZgPv7p3J1A1FAdmbhFvFp8P0MbrNIEc= +-----END PUBLIC KEY----- diff --git a/internal/ee/license/cloud.go b/internal/ee/license/cloud.go deleted file mode 100644 index 5242fd4908..0000000000 --- a/internal/ee/license/cloud.go +++ /dev/null @@ -1,22 +0,0 @@ -package license - -import "github.com/spf13/viper" - -// Conforms to the EE License for Neosync Cloud -type CloudLicense struct { - isCloud bool -} - -var _ EEInterface = (*CloudLicense)(nil) - -func NewCloudLicense(isCloud bool) *CloudLicense { - return &CloudLicense{isCloud: isCloud} -} - -func NewCloudLicenseFromEnv() *CloudLicense { - return &CloudLicense{isCloud: viper.GetBool("NEOSYNC_CLOUD")} -} - -func (c *CloudLicense) IsValid() bool { - return c.isCloud -} diff --git a/internal/ee/license/license.go b/internal/ee/license/license.go index 068645282e..61571c5d95 100644 --- a/internal/ee/license/license.go +++ b/internal/ee/license/license.go @@ -78,11 +78,11 @@ func getLicenseFromEnv() (*licenseContents, bool, error) { } pk, err := parsePublicKey(publicKeyPEM) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("unable to parse ee public key: %w", err) } contents, err := getLicense(input, pk) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("failed to parse provided ee license: %w", err) } return contents, true, nil } diff --git a/worker/internal/cmds/worker/serve/serve.go b/worker/internal/cmds/worker/serve/serve.go index 05005239b7..65e3120c06 100644 --- a/worker/internal/cmds/worker/serve/serve.go +++ b/worker/internal/cmds/worker/serve/serve.go @@ -24,6 +24,7 @@ import ( mssql_queries "github.com/nucleuscloud/neosync/backend/pkg/mssql-querier" "github.com/nucleuscloud/neosync/backend/pkg/sqlconnect" sql_manager "github.com/nucleuscloud/neosync/backend/pkg/sqlmanager" + cloudlicense "github.com/nucleuscloud/neosync/internal/ee/cloud-license" "github.com/nucleuscloud/neosync/internal/ee/license" neosyncotel "github.com/nucleuscloud/neosync/internal/otel" accountstatus_activity "github.com/nucleuscloud/neosync/worker/pkg/workflows/datasync/activities/account-status" @@ -76,8 +77,11 @@ func serve(ctx context.Context) error { } logger.Debug(fmt.Sprintf("ee license enabled: %t", eelicense.IsValid())) - isNeosyncCloud := getIsNeosyncCloud() - logger.Debug(fmt.Sprintf("neosync cloud enabled: %t", isNeosyncCloud)) + ncloudlicense, err := cloudlicense.NewFromEnv() + if err != nil { + return err + } + logger.Debug(fmt.Sprintf("neosync cloud enabled: %t", ncloudlicense.IsValid())) var syncActivityMeter metric.Meter temporalClientInterceptors := []interceptor.ClientInterceptor{} @@ -259,7 +263,7 @@ func serve(ctx context.Context) error { _ = w cascadelicense := license.NewCascadeLicense( - license.NewCloudLicenseFromEnv(), + ncloudlicense, eelicense, ) @@ -411,7 +415,3 @@ func getTemporalAuthCertificate() ([]tls.Certificate, error) { } return []tls.Certificate{}, nil } - -func getIsNeosyncCloud() bool { - return viper.GetBool("NEOSYNC_CLOUD") -}