diff --git a/deploy/provider-msp/.env.example b/deploy/provider-msp/.env.example index 74b4c16f4..5fb5c1cff 100644 --- a/deploy/provider-msp/.env.example +++ b/deploy/provider-msp/.env.example @@ -32,7 +32,7 @@ CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt # `./setup.sh --print-lease-signing-public-key` and include it in your # license request. The control plane refuses to start if license and key # do not match. -CP_TRIAL_ACTIVATION_PRIVATE_KEY= +CP_ENTITLEMENT_SIGNING_PRIVATE_KEY= CP_TENANT_MEMORY_LIMIT=536870912 CP_ALLOW_DOCKERLESS_PROVISIONING=false CP_STORAGE_GUARDRAILS_ENABLED=true diff --git a/deploy/provider-msp/docker-compose.yml b/deploy/provider-msp/docker-compose.yml index eaa671429..5bbf13bde 100644 --- a/deploy/provider-msp/docker-compose.yml +++ b/deploy/provider-msp/docker-compose.yml @@ -76,7 +76,7 @@ services: - CP_PROOF_TENANT_MAX_AGE=${CP_PROOF_TENANT_MAX_AGE:-24h} - CP_PROOF_TENANT_MATCHERS=${CP_PROOF_TENANT_MATCHERS:-proof,canary,rehearsal,msp_prod,ownerseed,owner_seed} - CP_REQUIRE_EMAIL_PROVIDER=${CP_REQUIRE_EMAIL_PROVIDER:-false} - - CP_TRIAL_ACTIVATION_PRIVATE_KEY=${CP_TRIAL_ACTIVATION_PRIVATE_KEY} + - CP_ENTITLEMENT_SIGNING_PRIVATE_KEY=${CP_ENTITLEMENT_SIGNING_PRIVATE_KEY} - RESEND_API_KEY=${RESEND_API_KEY} - PULSE_EMAIL_FROM=${PULSE_EMAIL_FROM} - PULSE_EMAIL_REPLY_TO=${PULSE_EMAIL_REPLY_TO} diff --git a/deploy/provider-msp/setup.sh b/deploy/provider-msp/setup.sh index 1319601b6..b5d1b226c 100755 --- a/deploy/provider-msp/setup.sh +++ b/deploy/provider-msp/setup.sh @@ -290,15 +290,15 @@ ensure_generated_secrets() { log "generating CP_ADMIN_KEY" set_env_value CP_ADMIN_KEY "$(openssl rand -hex 32)" "${env_path}" fi - if [[ -z "$(env_value CP_TRIAL_ACTIVATION_PRIVATE_KEY "${env_path}")" ]]; then - log "generating CP_TRIAL_ACTIVATION_PRIVATE_KEY" - set_env_value CP_TRIAL_ACTIVATION_PRIVATE_KEY "$(openssl rand -base64 32 | tr -d '\n')" "${env_path}" + if [[ -z "$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")" ]]; then + log "generating CP_ENTITLEMENT_SIGNING_PRIVATE_KEY" + set_env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "$(openssl rand -base64 32 | tr -d '\n')" "${env_path}" fi chmod 0600 "${env_path}" } # derive_lease_signing_public_key prints the base64 Ed25519 public key for -# CP_TRIAL_ACTIVATION_PRIVATE_KEY. The provider MSP license must bind this +# CP_ENTITLEMENT_SIGNING_PRIVATE_KEY. The provider MSP license must bind this # exact key (entitlement_signing_public_key) or the control plane will refuse # to start; include it when requesting your license. The private key never # leaves this host. @@ -308,8 +308,8 @@ derive_lease_signing_public_key() { have openssl || die "openssl is required to derive the lease signing public key" local key_b64 key_len tmp_der - key_b64="$(env_value CP_TRIAL_ACTIVATION_PRIVATE_KEY "${env_path}")" - [[ -n "${key_b64}" ]] || die "CP_TRIAL_ACTIVATION_PRIVATE_KEY is not set; run setup.sh first" + key_b64="$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")" + [[ -n "${key_b64}" ]] || die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY is not set; run setup.sh first" key_len="$(printf '%s' "${key_b64}" | base64 -d 2>/dev/null | wc -c | tr -d ' ')" case "${key_len}" in 64) @@ -328,7 +328,7 @@ derive_lease_signing_public_key() { rm -f "${tmp_der}" ;; *) - die "CP_TRIAL_ACTIVATION_PRIVATE_KEY must decode to a 32-byte seed or 64-byte Ed25519 key (got ${key_len} bytes)" + die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY must decode to a 32-byte seed or 64-byte Ed25519 key (got ${key_len} bytes)" ;; esac } @@ -379,7 +379,7 @@ Edit it now and set required values: - CP_TRUSTED_PROXY_CIDRS - CP_PROVIDER_MSP_LICENSE_FILE -setup.sh will generate CP_ADMIN_KEY and CP_TRIAL_ACTIVATION_PRIVATE_KEY if they +setup.sh will generate CP_ADMIN_KEY and CP_ENTITLEMENT_SIGNING_PRIVATE_KEY if they are still blank. EOF @@ -404,7 +404,7 @@ validate_env_file() { local missing=() local k v - for k in DOMAIN ACME_EMAIL CF_DNS_API_TOKEN CP_ENV TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_NETWORK PULSE_PROVIDER_MSP_DOCKER_SUBNET PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR CP_TRUSTED_PROXY_CIDRS CP_PROVIDER_MSP_LICENSE_FILE CP_TRIAL_ACTIVATION_PRIVATE_KEY CP_TENANT_MEMORY_LIMIT CP_ALLOW_DOCKERLESS_PROVISIONING CP_STORAGE_GUARDRAILS_ENABLED CP_STORAGE_MIN_ROOT_AVAILABLE CP_STORAGE_MIN_DATA_AVAILABLE CP_STORAGE_MIN_DOCKER_AVAILABLE CP_STORAGE_MAX_DOCKER_BUILD_CACHE CP_PROOF_TENANT_MAX_AGE CP_PROOF_TENANT_MATCHERS CP_REQUIRE_EMAIL_PROVIDER PULSE_EMAIL_FROM PULSE_EMAIL_REPLY_TO; do + for k in DOMAIN ACME_EMAIL CF_DNS_API_TOKEN CP_ENV TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_NETWORK PULSE_PROVIDER_MSP_DOCKER_SUBNET PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR CP_TRUSTED_PROXY_CIDRS CP_PROVIDER_MSP_LICENSE_FILE CP_ENTITLEMENT_SIGNING_PRIVATE_KEY CP_TENANT_MEMORY_LIMIT CP_ALLOW_DOCKERLESS_PROVISIONING CP_STORAGE_GUARDRAILS_ENABLED CP_STORAGE_MIN_ROOT_AVAILABLE CP_STORAGE_MIN_DATA_AVAILABLE CP_STORAGE_MIN_DOCKER_AVAILABLE CP_STORAGE_MAX_DOCKER_BUILD_CACHE CP_PROOF_TENANT_MAX_AGE CP_PROOF_TENANT_MATCHERS CP_REQUIRE_EMAIL_PROVIDER PULSE_EMAIL_FROM PULSE_EMAIL_REPLY_TO; do v="$(env_value "${k}" "${env_path}")" if [[ -z "${v}" ]]; then missing+=("${k}") @@ -458,9 +458,9 @@ validate_env_file() { if [[ "${#admin_key}" -lt 32 ]]; then die "CP_ADMIN_KEY must be at least 32 characters" fi - trial_key="$(env_value CP_TRIAL_ACTIVATION_PRIVATE_KEY "${env_path}")" + trial_key="$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")" if ! printf '%s' "${trial_key}" | base64 -d >/dev/null 2>&1; then - die "CP_TRIAL_ACTIVATION_PRIVATE_KEY must be valid base64" + die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY must be valid base64" fi docker_subnet="$(env_value PULSE_PROVIDER_MSP_DOCKER_SUBNET "${env_path}")" trusted_cidrs="$(env_value CP_TRUSTED_PROXY_CIDRS "${env_path}" | tr -d '[:space:]')" diff --git a/docs/MSP.md b/docs/MSP.md index 5ab85c6d0..0df96312f 100644 --- a/docs/MSP.md +++ b/docs/MSP.md @@ -182,7 +182,7 @@ In the provider-hosted model the licence is a signed file (`CP_PROVIDER_MSP_LICENSE_FILE`) that also binds your control plane's entitlement lease signing key: -1. `setup.sh` generates `CP_TRIAL_ACTIVATION_PRIVATE_KEY` locally; the +1. `setup.sh` generates `CP_ENTITLEMENT_SIGNING_PRIVATE_KEY` locally; the private key never leaves your host. 2. Send the derived public key (`./setup.sh --print-lease-signing-public-key`) with your licence request. diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 27aa8474b..d3e68d4e7 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -262,11 +262,14 @@ func LoadConfig() (*CPConfig, error) { ProviderMSPLicenseEmail: providerMSPLicenseEmail, LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"), LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")), - TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")), - RequireEmailProvider: envOrDefaultBool("CP_REQUIRE_EMAIL_PROVIDER", true), - ResendAPIKey: strings.TrimSpace(os.Getenv("RESEND_API_KEY")), - EmailFrom: envOrDefault("PULSE_EMAIL_FROM", "noreply@pulserelay.pro"), - EmailReplyTo: envOrDefault("PULSE_EMAIL_REPLY_TO", "support@pulserelay.pro"), + // Canonical name first; CP_TRIAL_ACTIVATION_PRIVATE_KEY is the + // legacy spelling from the retired trial-activation era, kept as a + // fallback for already-deployed control planes. + TrialActivationPrivateKey: envFirstNonEmpty("CP_ENTITLEMENT_SIGNING_PRIVATE_KEY", "CP_TRIAL_ACTIVATION_PRIVATE_KEY"), + RequireEmailProvider: envOrDefaultBool("CP_REQUIRE_EMAIL_PROVIDER", true), + ResendAPIKey: strings.TrimSpace(os.Getenv("RESEND_API_KEY")), + EmailFrom: envOrDefault("PULSE_EMAIL_FROM", "noreply@pulserelay.pro"), + EmailReplyTo: envOrDefault("PULSE_EMAIL_REPLY_TO", "support@pulserelay.pro"), } if strings.TrimSpace(cfg.TrialActivationPrivateKey) != "" { publicKey, err := deriveTrialActivationPublicKey(cfg.TrialActivationPrivateKey) @@ -300,7 +303,7 @@ func (c *CPConfig) validateProviderMSPLeaseSigningBinding() error { } boundKey := strings.TrimSpace(c.ProviderMSPLeaseSigningPublicKey) if boundKey == "" { - return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE does not bind an entitlement lease signing key (entitlement_signing_public_key); request an updated provider MSP license bound to this control plane's CP_TRIAL_ACTIVATION_PRIVATE_KEY public key %q", c.TrialActivationPublicKey) + return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE does not bind an entitlement lease signing key (entitlement_signing_public_key); request an updated provider MSP license bound to this control plane's CP_ENTITLEMENT_SIGNING_PRIVATE_KEY public key %q", c.TrialActivationPublicKey) } bound, err := pkglicensing.DecodePublicKey(boundKey) if err != nil { @@ -311,7 +314,7 @@ func (c *CPConfig) validateProviderMSPLeaseSigningBinding() error { return fmt.Errorf("derive trial activation public key: %w", err) } if !bound.Equal(derived) { - return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY does not match the entitlement lease signing key bound in CP_PROVIDER_MSP_LICENSE_FILE (license binds %q, control plane derives %q); use the keypair the license was issued for, or request a license bound to this key", boundKey, c.TrialActivationPublicKey) + return fmt.Errorf("CP_ENTITLEMENT_SIGNING_PRIVATE_KEY does not match the entitlement lease signing key bound in CP_PROVIDER_MSP_LICENSE_FILE (license binds %q, control plane derives %q); use the keypair the license was issued for, or request a license bound to this key", boundKey, c.TrialActivationPublicKey) } return nil } @@ -411,7 +414,7 @@ func (c *CPConfig) validate() error { return fmt.Errorf("CP_MSP_*_PRICE_ID must not be configured when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode) } if strings.TrimSpace(c.TrialActivationPrivateKey) == "" { - return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode) + return fmt.Errorf("CP_ENTITLEMENT_SIGNING_PRIVATE_KEY is required when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode) } if c.Environment == "production" && strings.TrimSpace(c.ProviderMSPLicenseFile) == "" { return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required in production when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode) @@ -464,7 +467,7 @@ func (c *CPConfig) validate() error { return fmt.Errorf("CP_TRIAL_SIGNUP_PRICE_ID is required when STRIPE_API_KEY is configured") } if strings.TrimSpace(c.StripeAPIKey) != "" && strings.TrimSpace(c.TrialActivationPrivateKey) == "" { - return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when STRIPE_API_KEY is configured") + return fmt.Errorf("CP_ENTITLEMENT_SIGNING_PRIVATE_KEY is required when STRIPE_API_KEY is configured") } if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_TRIAL_SIGNUP_PRICE_ID", c.TrialSignupPriceID, "cloud_starter"); err != nil { return err @@ -659,6 +662,17 @@ func stripeSecretKeyMode(raw string) string { } } +// envFirstNonEmpty returns the first environment variable in keys with a +// non-empty trimmed value. +func envFirstNonEmpty(keys ...string) string { + for _, key := range keys { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + } + return "" +} + func envOrDefault(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index 88795c08b..bff70ca73 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -927,3 +927,31 @@ func TestStripeSecretKeyMode(t *testing.T) { }) } } + +func TestLoadConfig_EntitlementSigningKeyNamePrecedence(t *testing.T) { + setRequiredCPEnv(t) + // Canonical name wins over the legacy trial-era spelling. + t.Setenv("CP_ENTITLEMENT_SIGNING_PRIVATE_KEY", "A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=") + t.Setenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY", "not-even-base64") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.TrialActivationPublicKey != base64.StdEncoding.EncodeToString(trialSigningEnvPublicKey(t)) { + t.Fatalf("canonical CP_ENTITLEMENT_SIGNING_PRIVATE_KEY was not preferred; derived %q", cfg.TrialActivationPublicKey) + } +} + +func TestLoadConfig_LegacyTrialActivationKeyNameStillWorks(t *testing.T) { + setRequiredCPEnv(t) + t.Setenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY", "A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.TrialActivationPublicKey == "" { + t.Fatal("legacy CP_TRIAL_ACTIVATION_PRIVATE_KEY fallback no longer derives a key") + } +} diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 20c520631..9e761cdcb 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -75,7 +75,7 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) { "PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR=/var/lib/docker/.pulse-provider-msp-spacecheck", "CP_TRUSTED_PROXY_CIDRS=172.30.0.0/24", "CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt", - "CP_TRIAL_ACTIVATION_PRIVATE_KEY=", + "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY=", "sudo -E ./setup.sh", "docker compose run --rm control-plane provider-msp bootstrap", "docker compose run --rm control-plane provider-msp preflight", @@ -132,7 +132,7 @@ func TestProviderMSPSetupScriptMatchesProviderContract(t *testing.T) { "run-install-proof.sh", "upgrade.sh", "CP_PROVIDER_MSP_LICENSE_FILE", - "CP_TRIAL_ACTIVATION_PRIVATE_KEY", + "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY", "PULSE_PROVIDER_MSP_DATA_DIR", "PULSE_PROVIDER_MSP_DOCKER_NETWORK", "PULSE_PROVIDER_MSP_DOCKER_SUBNET",