package licensing import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/hex" "errors" "fmt" "net" "net/url" "os" "strings" "time" "github.com/golang-jwt/jwt/v5" ) const ( // 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" // TrialActivationAudience is the JWT audience for hosted trial activation tokens. TrialActivationAudience = "pulse-pro-trial-activation" ) var ( ErrTrialActivationPrivateKeyMissing = errors.New("trial activation private key is required") ErrTrialActivationPrivateKeyInvalid = errors.New("invalid trial activation private key") ErrTrialActivationPublicKeyMissing = errors.New("trial activation public key is not configured") ErrTrialActivationPublicKeyInvalid = errors.New("invalid trial activation public key") ErrTrialActivationOrgIDMissing = errors.New("trial activation org_id is required") ErrTrialActivationInstanceMissing = errors.New("trial activation instance host is required") 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 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"` InstanceHost string `json:"instance_host"` InstanceToken string `json:"instance_token,omitempty"` ReturnURL string `json:"return_url,omitempty"` jwt.RegisteredClaims } // DecodeEd25519PrivateKey decodes a base64-encoded Ed25519 private key. // Supports 64-byte private keys and 32-byte seeds. func DecodeEd25519PrivateKey(encoded string) (ed25519.PrivateKey, error) { encoded = strings.TrimSpace(encoded) if encoded == "" { return nil, ErrTrialActivationPrivateKeyMissing } decoded, err := decodeBase64Flexible(encoded) if err != nil { return nil, fmt.Errorf("%w: %v", ErrTrialActivationPrivateKeyInvalid, err) } switch len(decoded) { case ed25519.PrivateKeySize: return ed25519.PrivateKey(decoded), nil case ed25519.SeedSize: return ed25519.NewKeyFromSeed(decoded), nil default: return nil, fmt.Errorf("%w: expected %d or %d bytes, got %d", ErrTrialActivationPrivateKeyInvalid, ed25519.PrivateKeySize, ed25519.SeedSize, len(decoded)) } } // 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(HostedEntitlementPublicKeyEnvVar)); env != "" { key, err := DecodePublicKey(env) if err != nil { return nil, fmt.Errorf("%w: %v", ErrHostedEntitlementPublicKeyInvalid, err) } return key, nil } } embedded := strings.TrimSpace(EmbeddedPublicKey) if embedded == "" { return nil, ErrHostedEntitlementPublicKeyMissing } key, err := DecodePublicKey(embedded) if err != nil { return nil, fmt.Errorf("%w: %v", ErrHostedEntitlementPublicKeyInvalid, err) } return key, nil } func allowTrialActivationPublicKeyEnvOverride() bool { return allowPublicKeyEnvOverride() || allowHostedTrialActivationPublicKeyEnvOverride() } // SignTrialActivationToken signs a hosted trial activation JWT. func SignTrialActivationToken(privateKey ed25519.PrivateKey, claims TrialActivationClaims) (string, error) { if len(privateKey) != ed25519.PrivateKeySize { return "", ErrTrialActivationPrivateKeyInvalid } claims.OrgID = strings.TrimSpace(claims.OrgID) if claims.OrgID == "" { return "", ErrTrialActivationOrgIDMissing } claims.InstanceHost = normalizeHost(claims.InstanceHost) if claims.InstanceHost == "" { return "", ErrTrialActivationInstanceMissing } claims.ReturnURL = strings.TrimSpace(claims.ReturnURL) returnHost, err := ValidateTrialActivationReturnURL(claims.ReturnURL, claims.InstanceHost) if err != nil { return "", err } claims.InstanceHost = returnHost now := time.Now().UTC() if claims.IssuedAt == nil { claims.IssuedAt = jwt.NewNumericDate(now) } if claims.ExpiresAt == nil { claims.ExpiresAt = jwt.NewNumericDate(now.Add(10 * time.Minute)) } if strings.TrimSpace(claims.ID) == "" { jti, err := randomJTI() if err != nil { return "", fmt.Errorf("generate trial activation jti: %w", err) } claims.ID = jti } if strings.TrimSpace(claims.Issuer) == "" { claims.Issuer = TrialActivationIssuer } if len(claims.Audience) == 0 { claims.Audience = jwt.ClaimStrings{TrialActivationAudience} } token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) signed, err := token.SignedString(privateKey) if err != nil { return "", fmt.Errorf("sign trial activation token: %w", err) } return signed, nil } // VerifyTrialActivationToken verifies a hosted trial activation JWT. func VerifyTrialActivationToken(token string, publicKey ed25519.PublicKey, expectedInstanceHost string, now time.Time) (*TrialActivationClaims, error) { if len(publicKey) != ed25519.PublicKeySize { return nil, ErrTrialActivationPublicKeyInvalid } token = strings.TrimSpace(token) if token == "" { return nil, errors.New("trial activation token is required") } if now.IsZero() { now = time.Now().UTC() } claims := &TrialActivationClaims{} parsed, err := jwt.ParseWithClaims( token, claims, func(t *jwt.Token) (any, error) { if t.Method.Alg() != jwt.SigningMethodEdDSA.Alg() { return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) } return publicKey, nil }, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), jwt.WithIssuer(TrialActivationIssuer), jwt.WithAudience(TrialActivationAudience), jwt.WithTimeFunc(func() time.Time { return now }), ) if err != nil { return nil, err } if !parsed.Valid { return nil, errors.New("trial activation token is invalid") } claims.OrgID = strings.TrimSpace(claims.OrgID) if claims.OrgID == "" { return nil, ErrTrialActivationOrgIDMissing } claims.InstanceHost = normalizeHost(claims.InstanceHost) claims.ReturnURL = strings.TrimSpace(claims.ReturnURL) if claims.InstanceHost == "" { return nil, ErrTrialActivationInstanceMissing } returnHost, err := ValidateTrialActivationReturnURL(claims.ReturnURL, claims.InstanceHost) if err != nil { return nil, err } claims.InstanceHost = returnHost expected := normalizeHost(expectedInstanceHost) if expected != "" && !strings.EqualFold(claims.InstanceHost, expected) { return nil, ErrTrialActivationHostMismatch } return claims, nil } // ValidateTrialActivationReturnURL validates the hosted-trial callback target and // returns its normalized hostname. expectedInstanceHost, when non-empty, must // match the callback URL host. func ValidateTrialActivationReturnURL(raw, expectedInstanceHost string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", ErrTrialActivationReturnURLMissing } parsed, err := url.Parse(raw) if err != nil || parsed == nil { return "", ErrTrialActivationReturnURLInvalid } if !parsed.IsAbs() || strings.TrimSpace(parsed.Host) == "" { return "", ErrTrialActivationReturnURLInvalid } if parsed.EscapedPath() != "/auth/trial-activate" { return "", ErrTrialActivationReturnURLInvalid } if strings.TrimSpace(parsed.RawQuery) != "" || strings.TrimSpace(parsed.Fragment) != "" { return "", ErrTrialActivationReturnURLInvalid } host := normalizeHost(parsed.Hostname()) if host == "" { return "", ErrTrialActivationReturnURLInvalid } switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { case "https": case "http": if !isAllowedInsecureTrialActivationReturnHost(host) { return "", ErrTrialActivationReturnURLInvalid } default: return "", ErrTrialActivationReturnURLInvalid } expected := normalizeHost(expectedInstanceHost) if expected != "" && !strings.EqualFold(host, expected) { return "", ErrTrialActivationHostMismatch } return host, nil } func randomJTI() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func decodeBase64Flexible(encoded string) ([]byte, error) { decoded, err := base64.StdEncoding.DecodeString(encoded) if err == nil { return decoded, nil } decoded, err = base64.RawStdEncoding.DecodeString(encoded) if err == nil { return decoded, nil } decoded, err = base64.URLEncoding.DecodeString(encoded) if err == nil { return decoded, nil } return base64.RawURLEncoding.DecodeString(encoded) } func normalizeHost(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if strings.Contains(raw, "://") { if parsed, err := url.Parse(raw); err == nil { raw = parsed.Host } } host := raw if h, p, err := net.SplitHostPort(raw); err == nil { if p != "" { host = h } } host = strings.Trim(host, "[]") return strings.ToLower(strings.TrimSpace(host)) } func isAllowedInsecureTrialActivationReturnHost(host string) bool { host = strings.ToLower(strings.TrimSpace(host)) if host == "" { return false } if host == "localhost" || strings.HasSuffix(host, ".local") { return true } if !strings.Contains(host, ".") { return true } ip := net.ParseIP(host) if ip == nil { return false } return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() }