diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b04474884..4bb378d52 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1632,6 +1632,12 @@ self-hosted callback must also stay absent from lifecycle retry and backoff behavior. Lifecycle-adjacent setup and install surfaces must also treat `trial_eligible` and `trial_eligibility_reason` as retired compatibility fields, not as prompt state or setup transport state. +Legacy-named hosted entitlement verifier plumbing under shared `internal/api/` +is boundary-only commercial compatibility, not lifecycle setup state: +agent-lifecycle surfaces may consume the resolved entitlement outcome, but +must not treat `TrialActivation*` names or the retained +`PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` literal as permission to recreate trial +acquisition, setup retry, or install-progress prompts. That same shared `internal/api/` dependency also assumes session-carried OIDC refresh tokens stay fail-closed at rest: `session_store.go` may only persist or recover those tokens through encrypted-at-rest session payloads, and any diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 3bb75b4a3..dc38eea05 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -379,6 +379,12 @@ the canonical monitored-system blocked payload. target before refresh, persistence, and evaluator rewiring, so tenant- scoped hosted routes cannot refresh against an empty non-default org while the machine's real hosted lease still lives on `default`. + The hosted verifier bridge may keep the legacy + `PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` environment literal for deployed tenant + compatibility, but API call sites that validate hosted entitlement leases + must route through the `HostedEntitlement*` licensing aliases rather than + treating the retired trial-activation callback as the active acquisition + model. 33. Keep public demo bootstrap posture on the shared security-status contract. `internal/api/router_routes_auth_security.go`, `internal/api/security_status_capabilities.go`, frontend security-status diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index fc80f9750..76d2579da 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1000,6 +1000,13 @@ The retired `/auth/trial-activate` return path must also stay out of the ordinary self-hosted router and Pro settings UI. Hosted/cloud entitlement lease refresh may still validate signed leases for already-approved hosted state, but ordinary self-hosted Pulse must not create a local trial acquisition callback. +The remaining `TrialActivation*` verifier names and +`PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` environment literal are boundary-only +compatibility for hosted entitlement lease signing and already-deployed tenant +configuration. New entitlement runtime call sites must use the +`HostedEntitlement*` aliases unless they are explicitly proving the retired +callback boundary, and changing the environment literal requires a separately +governed credential rollout. The matching control-plane acquisition routes are retired too: `/start-pro-trial`, `/trial-signup/*`, and `/api/trial-signup/*` must remain unregistered. `/api/entitlements/refresh` remains the only hosted entitlement diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7bb817dbb..7705a4b9b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -751,6 +751,12 @@ Shared licensing routes under `internal/api/` may retain legacy recovery surfaces must continue to consume the presentation policy instead of using those route names as a cue to render paid history prompts in ordinary self-hosted sessions. +Legacy-named hosted entitlement verifier wiring under shared `internal/api/` +is the same kind of boundary-only compatibility: storage and recovery surfaces +may consume the resolved hosted entitlement, but they must not infer trial +acquisition, restore identity, or recovery-progress state from +`TrialActivation*` names or the retained +`PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` literal. That same shared boundary now also owns the one runtime-safe exception: storage and recovery may inherit demo-safe `/api/license/runtime-capabilities` reads for capability and history-retention truth, but diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 0ce9c8f89..0a2a08e95 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -5240,6 +5240,27 @@ func TestContract_HostedTenantEntitlementRefreshFallsBackToDefaultBillingState(t } } +func TestContract_HostedEntitlementVerifierBridgeUsesCompatibilityEnvAlias(t *testing.T) { + if pkglicensing.HostedEntitlementPublicKeyEnvVar != pkglicensing.TrialActivationPublicKeyEnvVar { + t.Fatalf("hosted entitlement verifier env=%q, want legacy env alias %q", pkglicensing.HostedEntitlementPublicKeyEnvVar, pkglicensing.TrialActivationPublicKeyEnvVar) + } + + pub, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + t.Setenv("PULSE_HOSTED_MODE", "true") + t.Setenv(pkglicensing.HostedEntitlementPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub)) + + got, err := trialActivationPublicKeyFromLicensing() + if err != nil { + t.Fatalf("trialActivationPublicKeyFromLicensing: %v", err) + } + if !bytes.Equal(got, pub) { + t.Fatal("hosted entitlement verifier bridge did not resolve the compatibility env key") + } +} + func TestContract_EntitlementPayloadMonitoredSystemUsageJSONSnapshot(t *testing.T) { payload := buildEntitlementPayloadWithUsage(&licenseStatus{ Valid: true, diff --git a/internal/api/licensing_bridge.go b/internal/api/licensing_bridge.go index ae34bd463..2daee5725 100644 --- a/internal/api/licensing_bridge.go +++ b/internal/api/licensing_bridge.go @@ -303,7 +303,7 @@ func parseOptionalTimeParamFromLicensing(raw string, defaultValue time.Time) (ti } func trialActivationPublicKeyFromLicensing() (ed25519.PublicKey, error) { - return pkglicensing.TrialActivationPublicKey() + return pkglicensing.HostedEntitlementPublicKey() } func signPurchaseReturnTokenFromLicensing(signingKey []byte, claims purchaseReturnClaimsModel) (string, error) { diff --git a/pkg/licensing/entitlement_lease.go b/pkg/licensing/entitlement_lease.go index bac77385a..3a3451c42 100644 --- a/pkg/licensing/entitlement_lease.go +++ b/pkg/licensing/entitlement_lease.go @@ -11,11 +11,17 @@ import ( ) const ( - // TrialEntitlementLeaseIssuer is the JWT issuer for hosted entitlement cache leases. - TrialEntitlementLeaseIssuer = "pulse-pro-entitlement-lease" + // HostedEntitlementLeaseIssuer is the JWT issuer for hosted entitlement cache leases. + HostedEntitlementLeaseIssuer = "pulse-pro-entitlement-lease" - // TrialEntitlementLeaseAudience is the JWT audience for hosted entitlement cache leases. - TrialEntitlementLeaseAudience = "pulse-pro-entitlement-cache" + // HostedEntitlementLeaseAudience is the JWT audience for hosted entitlement cache leases. + HostedEntitlementLeaseAudience = "pulse-pro-entitlement-cache" + + // TrialEntitlementLeaseIssuer is the legacy exported name for hosted entitlement leases. + TrialEntitlementLeaseIssuer = HostedEntitlementLeaseIssuer + + // TrialEntitlementLeaseAudience is the legacy exported name for hosted entitlement leases. + TrialEntitlementLeaseAudience = HostedEntitlementLeaseAudience ) var ( @@ -88,10 +94,10 @@ func SignEntitlementLeaseToken(privateKey ed25519.PrivateKey, claims Entitlement claims.ExpiresAt = jwt.NewNumericDate(expiresAt) } if strings.TrimSpace(claims.Issuer) == "" { - claims.Issuer = TrialEntitlementLeaseIssuer + claims.Issuer = HostedEntitlementLeaseIssuer } if len(claims.Audience) == 0 { - claims.Audience = jwt.ClaimStrings{TrialEntitlementLeaseAudience} + claims.Audience = jwt.ClaimStrings{HostedEntitlementLeaseAudience} } token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) @@ -127,8 +133,8 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec claims := &EntitlementLeaseClaims{} parseOpts := []jwt.ParserOption{ jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), - jwt.WithIssuer(TrialEntitlementLeaseIssuer), - jwt.WithAudience(TrialEntitlementLeaseAudience), + jwt.WithIssuer(HostedEntitlementLeaseIssuer), + jwt.WithAudience(HostedEntitlementLeaseAudience), jwt.WithTimeFunc(func() time.Time { return now }), } if skipTimeValidation { @@ -162,7 +168,7 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec } expected := normalizeHost(expectedInstanceHost) if expected != "" && !strings.EqualFold(claims.InstanceHost, expected) { - return nil, ErrTrialActivationHostMismatch + return nil, ErrHostedEntitlementHostMismatch } normalizeEntitlementLeaseClaims(claims) return claims, nil @@ -175,7 +181,7 @@ func ResolveEntitlementLeaseBillingState(state BillingState, expectedInstanceHos if token == "" { return normalizeTrialExpiry(state, now) } - publicKey, err := TrialActivationPublicKey() + publicKey, err := HostedEntitlementPublicKey() if err != nil { return entitlementLeaseFallbackState(state, now) } diff --git a/pkg/licensing/entitlement_lease_test.go b/pkg/licensing/entitlement_lease_test.go index 38b43e38a..1d6d887c0 100644 --- a/pkg/licensing/entitlement_lease_test.go +++ b/pkg/licensing/entitlement_lease_test.go @@ -120,8 +120,8 @@ func TestVerifyEntitlementLeaseTokenHostMismatch(t *testing.T) { } _, err = VerifyEntitlementLeaseToken(token, pub, "pulse-b.example.com", time.Now()) - if !errors.Is(err, ErrTrialActivationHostMismatch) { - t.Fatalf("VerifyEntitlementLeaseToken() error=%v, want %v", err, ErrTrialActivationHostMismatch) + if !errors.Is(err, ErrHostedEntitlementHostMismatch) { + t.Fatalf("VerifyEntitlementLeaseToken() error=%v, want %v", err, ErrHostedEntitlementHostMismatch) } } diff --git a/pkg/licensing/service_activate_test.go b/pkg/licensing/service_activate_test.go index 5f3b8a693..04e9484fa 100644 --- a/pkg/licensing/service_activate_test.go +++ b/pkg/licensing/service_activate_test.go @@ -1,3 +1,5 @@ +//go:build !release + package licensing import ( diff --git a/pkg/licensing/trial_activation.go b/pkg/licensing/trial_activation.go index 70ee97441..912655e6b 100644 --- a/pkg/licensing/trial_activation.go +++ b/pkg/licensing/trial_activation.go @@ -17,10 +17,18 @@ import ( ) const ( - // TrialActivationPublicKeyEnvVar overrides the public key used to validate - // hosted trial activation tokens. + // TrialActivationPublicKeyEnvVar is the legacy environment variable name + // for the hosted entitlement signing public key. The literal value stays + // stable for deployed-tenant compatibility; do not use it to reintroduce + // self-hosted trial acquisition. TrialActivationPublicKeyEnvVar = "PULSE_TRIAL_ACTIVATION_PUBLIC_KEY" + // HostedEntitlementPublicKeyEnvVar is the canonical name for the hosted + // entitlement signing public-key source. It intentionally aliases the + // legacy environment variable until a separately governed credential + // migration exists. + HostedEntitlementPublicKeyEnvVar = TrialActivationPublicKeyEnvVar + // TrialActivationIssuer is the JWT issuer for hosted trial activation tokens. TrialActivationIssuer = "pulse-pro-trial-signup" @@ -38,10 +46,16 @@ var ( ErrTrialActivationReturnURLMissing = errors.New("trial activation return_url is required") ErrTrialActivationReturnURLInvalid = errors.New("trial activation return_url is invalid") ErrTrialActivationHostMismatch = errors.New("trial activation token host mismatch") + + ErrHostedEntitlementPublicKeyMissing = ErrTrialActivationPublicKeyMissing + ErrHostedEntitlementPublicKeyInvalid = ErrTrialActivationPublicKeyInvalid + ErrHostedEntitlementHostMismatch = ErrTrialActivationHostMismatch ) -// TrialActivationClaims are signed by the hosted signup service and consumed by -// self-hosted Pulse instances to start a Pro trial after registration/checkout. +// TrialActivationClaims model the retired self-hosted trial-acquisition +// callback. They remain for compatibility tests around the closed +// /auth/trial-activate boundary; normal v6 hosted entitlement state uses +// EntitlementLeaseClaims. type TrialActivationClaims struct { OrgID string `json:"org_id"` Email string `json:"email,omitempty"` @@ -75,16 +89,29 @@ func DecodeEd25519PrivateKey(encoded string) (ed25519.PrivateKey, error) { } } -// TrialActivationPublicKey resolves the verification key for hosted trial -// activation tokens. Environment override is only allowed in local/dev builds. -// Release builds must use the build-time embedded verification key instead of -// reopening that trust boundary through hosted-mode environment wiring. +// HostedEntitlementPublicKey resolves the verification key for hosted +// entitlement leases. It currently reads the legacy trial-activation +// environment name for deployed-tenant compatibility. Environment override is +// only allowed in local/dev builds. Release builds must use the build-time +// embedded verification key instead of reopening that trust boundary through +// hosted-mode environment wiring. +func HostedEntitlementPublicKey() (ed25519.PublicKey, error) { + return trialActivationPublicKey() +} + +// TrialActivationPublicKey resolves the same legacy verification source for +// retired trial-activation compatibility. New hosted entitlement code should +// call HostedEntitlementPublicKey instead. func TrialActivationPublicKey() (ed25519.PublicKey, error) { + return trialActivationPublicKey() +} + +func trialActivationPublicKey() (ed25519.PublicKey, error) { if allowTrialActivationPublicKeyEnvOverride() { - if env := strings.TrimSpace(os.Getenv(TrialActivationPublicKeyEnvVar)); env != "" { + if env := strings.TrimSpace(os.Getenv(HostedEntitlementPublicKeyEnvVar)); env != "" { key, err := DecodePublicKey(env) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTrialActivationPublicKeyInvalid, err) + return nil, fmt.Errorf("%w: %v", ErrHostedEntitlementPublicKeyInvalid, err) } return key, nil } @@ -92,11 +119,11 @@ func TrialActivationPublicKey() (ed25519.PublicKey, error) { embedded := strings.TrimSpace(EmbeddedPublicKey) if embedded == "" { - return nil, ErrTrialActivationPublicKeyMissing + return nil, ErrHostedEntitlementPublicKeyMissing } key, err := DecodePublicKey(embedded) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTrialActivationPublicKeyInvalid, err) + return nil, fmt.Errorf("%w: %v", ErrHostedEntitlementPublicKeyInvalid, err) } return key, nil } diff --git a/pkg/licensing/trial_activation_test.go b/pkg/licensing/trial_activation_test.go index 9b6046e4f..fffb4e217 100644 --- a/pkg/licensing/trial_activation_test.go +++ b/pkg/licensing/trial_activation_test.go @@ -194,3 +194,36 @@ func TestTrialActivationPublicKey_SelectsAllowedVerificationSource(t *testing.T) t.Fatalf("TrialActivationPublicKey mismatch") } } + +func TestHostedEntitlementPublicKey_AliasesLegacyVerificationSource(t *testing.T) { + if HostedEntitlementPublicKeyEnvVar != TrialActivationPublicKeyEnvVar { + t.Fatalf("HostedEntitlementPublicKeyEnvVar=%q, want legacy env var %q", HostedEntitlementPublicKeyEnvVar, TrialActivationPublicKeyEnvVar) + } + + envPub, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + embeddedPub, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + t.Setenv("PULSE_HOSTED_MODE", "true") + t.Setenv(HostedEntitlementPublicKeyEnvVar, base64.StdEncoding.EncodeToString(envPub)) + embeddedBefore := EmbeddedPublicKey + t.Cleanup(func() { EmbeddedPublicKey = embeddedBefore }) + EmbeddedPublicKey = base64.StdEncoding.EncodeToString(embeddedPub) + + got, err := HostedEntitlementPublicKey() + if err != nil { + t.Fatalf("HostedEntitlementPublicKey: %v", err) + } + want := embeddedPub + if allowTrialActivationPublicKeyEnvOverride() { + want = envPub + } + if !bytes.Equal(got, want) { + t.Fatalf("HostedEntitlementPublicKey mismatch") + } +}