Clarify hosted entitlement signing compatibility

This commit is contained in:
rcourtman 2026-04-28 18:47:19 +01:00
parent 99e65d7e68
commit 1d189d3343
11 changed files with 139 additions and 25 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -1,3 +1,5 @@
//go:build !release
package licensing
import (

View file

@ -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
}

View file

@ -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")
}
}