Pulse/pkg/licensing/trial_activation.go

346 lines
11 KiB
Go

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