Fix activation fingerprint and license period status

This commit is contained in:
rcourtman 2026-07-09 11:19:40 +01:00
parent f850099134
commit 847cf98544
8 changed files with 439 additions and 35 deletions

View file

@ -1500,10 +1500,15 @@ That same local-persistence boundary also owns the filesystem path contract for
commercial secrets at rest. `pkg/licensing/persistence.go` and
`pkg/licensing/activation_store.go` must normalize the owned config directory
once and resolve only the fixed `.license-key`, `license.enc`, and
`activation.enc` leaves through the shared storage-path helper before any
filesystem read, write, rename, stat, or delete. Future licensing persistence
changes must not bypass that resolver with raw `filepath.Join(configDir, ...)`
joins or introduce caller-controlled persistence filenames.
`activation.enc`, and `instance-fingerprint` leaves through the shared
storage-path helper before any filesystem read, write, rename, stat, or delete.
Future licensing persistence changes must not bypass that resolver with raw
`filepath.Join(configDir, ...)` joins or introduce caller-controlled
persistence filenames. The `instance-fingerprint` file is not an activation
credential and must survive clear-license flows: native activation and legacy
exchange must reuse it as the stable local installation identity so one machine
does not consume another paid installation slot after a local activation clear,
container recreation, or retry.
That same local-persistence boundary also owns writable-but-not-owned runtime
storage semantics for commercial state. `pkg/licensing/persistence.go` may
harden directories it owns to `0700`, but it must not assume it can chmod the
@ -1551,7 +1556,15 @@ unmetered.
Activation-grant translation is part of the same boundary: when relay/license
server grants enter the local claims model, Cloud plan keys and lifecycle state
must still resolve through the canonical entitlement claim accessors rather
than becoming a parallel truth path.
than becoming a parallel truth path. The activation grant `exp` remains the
short-lived enforcement and refresh lease; recurring self-hosted license period
display must come from the optional grant `current_period_end` mapped into the
local claims model and exposed through `/api/license/status` `expires_at` and
`days_remaining`. Older grants that omit `current_period_end` may fall back to
the grant/JWT expiry for compatibility, but new recurring grants must not make
the UI present the 72-hour relay lease as the customer's subscription end date.
The shared grant wire shape must stay aligned across the Pulse client, the
`pulse-pro` license server issuer, and the relay-server grant validator.
The legacy-license exchange transport is part of that same activation boundary:
`pkg/licensing/activation_types.go` and `pkg/licensing/license_server_client.go`
must treat `legacy_license_token` as the canonical v6 request field for

View file

@ -5,11 +5,82 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
)
// ActivationStateFileName is the name of the encrypted activation state file.
const ActivationStateFileName = "activation.enc"
// InstanceFingerprintFileName is the durable installation identity used for
// activation-key installs. It is intentionally separate from activation.enc so
// clearing a license does not make one machine consume another install slot.
const InstanceFingerprintFileName = "instance-fingerprint"
const maxInstanceFingerprintFileSize = 512
// LoadOrCreateInstanceFingerprint returns the stable local installation
// fingerprint, creating it if this config directory has not activated before.
func (p *Persistence) LoadOrCreateInstanceFingerprint() (string, error) {
fingerprintPath, err := resolvePersistencePath(p.configDir, InstanceFingerprintFileName)
if err != nil {
return "", fmt.Errorf("resolve instance fingerprint path: %w", err)
}
data, err := readBoundedPersistenceRegularFile(fingerprintPath, maxInstanceFingerprintFileSize)
if err == nil {
fingerprint := strings.TrimSpace(string(data))
if fingerprint == "" {
return "", fmt.Errorf("instance fingerprint file is empty")
}
if err := os.Chmod(fingerprintPath, persistencePrivateFilePerm); err != nil {
return "", fmt.Errorf("secure instance fingerprint file: %w", err)
}
return fingerprint, nil
}
if !isMissingPersistencePathError(err) {
return "", fmt.Errorf("read instance fingerprint file: %w", err)
}
state, err := p.LoadActivationState()
if err != nil {
return "", fmt.Errorf("load activation state for instance fingerprint: %w", err)
}
if state != nil {
fingerprint := strings.TrimSpace(state.InstanceFingerprint)
if fingerprint != "" {
if err := p.SaveInstanceFingerprint(fingerprint); err != nil {
return "", err
}
return fingerprint, nil
}
}
fingerprint, err := generateFingerprint()
if err != nil {
return "", fmt.Errorf("generate instance fingerprint: %w", err)
}
if err := p.SaveInstanceFingerprint(fingerprint); err != nil {
return "", err
}
return fingerprint, nil
}
// SaveInstanceFingerprint persists the stable local installation fingerprint.
func (p *Persistence) SaveInstanceFingerprint(fingerprint string) error {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == "" {
return fmt.Errorf("instance fingerprint cannot be empty")
}
fingerprintPath, err := resolvePersistencePath(p.configDir, InstanceFingerprintFileName)
if err != nil {
return fmt.Errorf("resolve instance fingerprint path: %w", err)
}
if err := writeOwnerOnlyPersistenceFileAtomic(fingerprintPath, []byte(fingerprint+"\n")); err != nil {
return fmt.Errorf("write instance fingerprint file: %w", err)
}
return nil
}
// SaveActivationState encrypts and persists the activation state to disk.
func (p *Persistence) SaveActivationState(state *ActivationState) error {
if state == nil {

View file

@ -253,3 +253,119 @@ func TestActivationStatePersistence(t *testing.T) {
}
})
}
func TestInstanceFingerprintPersistenceSurvivesActivationClear(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "pulse-fingerprint-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
p, err := NewPersistence(tmpDir)
if err != nil {
t.Fatalf("create persistence: %v", err)
}
first, err := p.LoadOrCreateInstanceFingerprint()
if err != nil {
t.Fatalf("LoadOrCreateInstanceFingerprint first: %v", err)
}
if first == "" {
t.Fatal("expected non-empty fingerprint")
}
second, err := p.LoadOrCreateInstanceFingerprint()
if err != nil {
t.Fatalf("LoadOrCreateInstanceFingerprint second: %v", err)
}
if second != first {
t.Fatalf("fingerprint changed between reads: %q then %q", first, second)
}
state := &ActivationState{
InstallationID: "inst_abc",
InstallationToken: "pit_live_secret",
LicenseID: "lic_123",
GrantJWT: "header.payload.signature",
GrantJTI: "grant_xyz",
GrantExpiresAt: 1700000000,
InstanceFingerprint: first,
LicenseServerURL: "https://license.example.com",
ActivatedAt: 1699000000,
LastRefreshedAt: 1699500000,
}
if err := p.SaveActivationState(state); err != nil {
t.Fatalf("SaveActivationState: %v", err)
}
if err := p.ClearActivationState(); err != nil {
t.Fatalf("ClearActivationState: %v", err)
}
afterClear, err := p.LoadOrCreateInstanceFingerprint()
if err != nil {
t.Fatalf("LoadOrCreateInstanceFingerprint after clear: %v", err)
}
if afterClear != first {
t.Fatalf("fingerprint changed after activation clear: %q then %q", first, afterClear)
}
fingerprintPath := filepath.Join(tmpDir, InstanceFingerprintFileName)
info, err := os.Stat(fingerprintPath)
if err != nil {
t.Fatalf("stat fingerprint file: %v", err)
}
if got := info.Mode().Perm(); got != 0600 {
t.Fatalf("fingerprint file perms = %o, want 600", got)
}
if p.ActivationStateExists() {
t.Fatal("activation state should still be cleared")
}
}
func TestInstanceFingerprintPersistenceSeedsFromExistingActivationState(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "pulse-fingerprint-seed-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
p, err := NewPersistence(tmpDir)
if err != nil {
t.Fatalf("create persistence: %v", err)
}
state := &ActivationState{
InstallationID: "inst_existing",
InstallationToken: "pit_live_existing",
LicenseID: "lic_existing",
GrantJWT: "header.payload.signature",
GrantJTI: "grant_existing",
GrantExpiresAt: 1700000000,
InstanceFingerprint: "existing-fingerprint",
LicenseServerURL: "https://license.example.com",
ActivatedAt: 1699000000,
LastRefreshedAt: 1699500000,
}
if err := p.SaveActivationState(state); err != nil {
t.Fatalf("SaveActivationState: %v", err)
}
fingerprint, err := p.LoadOrCreateInstanceFingerprint()
if err != nil {
t.Fatalf("LoadOrCreateInstanceFingerprint: %v", err)
}
if fingerprint != state.InstanceFingerprint {
t.Fatalf("fingerprint=%q want existing activation fingerprint %q", fingerprint, state.InstanceFingerprint)
}
if err := p.ClearActivationState(); err != nil {
t.Fatalf("ClearActivationState: %v", err)
}
afterClear, err := p.LoadOrCreateInstanceFingerprint()
if err != nil {
t.Fatalf("LoadOrCreateInstanceFingerprint after clear: %v", err)
}
if afterClear != state.InstanceFingerprint {
t.Fatalf("fingerprint after clear=%q want %q", afterClear, state.InstanceFingerprint)
}
}

View file

@ -54,22 +54,23 @@ func grantClaimsUseUncappedCoreMonitoring(gc *GrantClaims) bool {
// GrantClaims are the claims parsed from a relay grant JWT payload.
// The grant is a short-lived JWT (72h TTL) issued by the license server.
type GrantClaims struct {
Issuer string `json:"iss"`
Audience string `json:"aud"`
LicenseID string `json:"lid"`
InstallationID string `json:"iid"`
LicenseVersion int64 `json:"lv"`
State string `json:"st"` // active|past_due|grace
Tier string `json:"tier"` // matches Tier constants: "relay", "pro", "pro_plus", etc.
PlanKey string `json:"plan"`
Features []string `json:"feat"`
MaxGuests int `json:"max_guests"`
MaxUsers int `json:"max_users"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
GraceUntil int64 `json:"grace_until"`
JTI string `json:"jti"` // unique grant ID
Email string `json:"email"` // license owner email
Issuer string `json:"iss"`
Audience string `json:"aud"`
LicenseID string `json:"lid"`
InstallationID string `json:"iid"`
LicenseVersion int64 `json:"lv"`
State string `json:"st"` // active|past_due|grace
Tier string `json:"tier"` // matches Tier constants: "relay", "pro", "pro_plus", etc.
PlanKey string `json:"plan"`
Features []string `json:"feat"`
MaxGuests int `json:"max_guests"`
MaxUsers int `json:"max_users"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
CurrentPeriodEnd int64 `json:"current_period_end,omitempty"`
GraceUntil int64 `json:"grace_until"`
JTI string `json:"jti"` // unique grant ID
Email string `json:"email"` // license owner email
}
func (g *GrantClaims) UnmarshalJSON(data []byte) error {
@ -94,6 +95,7 @@ func grantClaimsToClaimsWithContinuity(gc *GrantClaims, _ ActivationContinuity)
Tier: Tier(gc.Tier),
IssuedAt: gc.IssuedAt,
ExpiresAt: gc.ExpiresAt,
LicensePeriodEnd: gc.CurrentPeriodEnd,
Features: gc.Features,
MaxGuests: gc.MaxGuests,
MaxUsers: gc.MaxUsers,
@ -218,12 +220,13 @@ type ActivateInstallationResponse struct {
// ActivateResponseLicense is the license portion of the activation response.
type ActivateResponseLicense struct {
LicenseID string `json:"license_id"`
State string `json:"state"`
Tier string `json:"tier"`
MaxGuests int `json:"max_guests"`
Features []string `json:"features"`
LicenseVersion int64 `json:"license_version"`
LicenseID string `json:"license_id"`
State string `json:"state"`
Tier string `json:"tier"`
MaxGuests int `json:"max_guests"`
Features []string `json:"features"`
LicenseVersion int64 `json:"license_version"`
CurrentPeriodEnd string `json:"current_period_end,omitempty"`
}
func (l *ActivateResponseLicense) UnmarshalJSON(data []byte) error {

View file

@ -174,6 +174,7 @@ var grantContractJSONTags = []string{
"feat",
"max_guests",
"max_users",
"current_period_end",
"grace_until",
"email",
"jti",

View file

@ -22,6 +22,11 @@ type Claims struct {
// Expires at (Unix timestamp, 0 for lifetime)
ExpiresAt int64 `json:"exp,omitempty"`
// LicensePeriodEnd is the paid entitlement period end. Activation grants use
// a short ExpiresAt lease for enforcement; this field preserves the billing
// period shown in status responses when the grant carries it.
LicensePeriodEnd int64 `json:"license_period_end,omitempty"`
// Features explicitly granted (optional, tier implies features)
Features []string `json:"features,omitempty"`
@ -154,6 +159,38 @@ func (l *License) ExpiresAt() *time.Time {
return &t
}
// LicensePeriodDaysRemaining returns days remaining for user-facing license
// status. It uses the paid entitlement period when present and falls back to
// the grant/JWT expiration for legacy licenses and older grants.
func (l *License) LicensePeriodDaysRemaining() int {
if l.IsLifetime() {
return -1
}
expiresAt := l.Claims.LicensePeriodEnd
if expiresAt == 0 {
expiresAt = l.Claims.ExpiresAt
}
remaining := time.Until(time.Unix(expiresAt, 0))
if remaining < 0 {
return 0
}
return int(remaining.Hours() / 24)
}
// LicensePeriodExpiresAt returns the user-facing license period end. Short
// activation grant expiry remains available through ExpiresAt for enforcement.
func (l *License) LicensePeriodExpiresAt() *time.Time {
if l.IsLifetime() {
return nil
}
expiresAt := l.Claims.LicensePeriodEnd
if expiresAt == 0 {
expiresAt = l.Claims.ExpiresAt
}
t := time.Unix(expiresAt, 0)
return &t
}
// HasFeature checks if the license grants a specific feature.
func (l *License) HasFeature(feature string) bool {
for _, capability := range l.Claims.EffectiveCapabilities() {

View file

@ -248,10 +248,9 @@ func (s *Service) ActivateWithKey(activationKey string) (*License, error) {
return nil, fmt.Errorf("activation unavailable: license server client not configured")
}
// Generate a stable fingerprint for this installation.
fingerprint, err := generateFingerprint()
fingerprint, err := activationInstanceFingerprint(persistence)
if err != nil {
return nil, fmt.Errorf("generate instance fingerprint: %w", err)
return nil, err
}
hostname, _ := os.Hostname()
@ -287,9 +286,9 @@ func (s *Service) ActivateLegacyLicense(legacyLicenseKey string) (*License, erro
return nil, fmt.Errorf("activation unavailable: license server client not configured")
}
fingerprint, err := generateFingerprint()
fingerprint, err := activationInstanceFingerprint(persistence)
if err != nil {
return nil, fmt.Errorf("generate instance fingerprint: %w", err)
return nil, err
}
hostname, _ := os.Hostname()
@ -314,6 +313,21 @@ func (s *Service) ActivateLegacyLicense(legacyLicenseKey string) (*License, erro
})
}
func activationInstanceFingerprint(persistence *Persistence) (string, error) {
if persistence != nil {
fingerprint, err := persistence.LoadOrCreateInstanceFingerprint()
if err != nil {
return "", fmt.Errorf("load instance fingerprint: %w", err)
}
return fingerprint, nil
}
fingerprint, err := generateFingerprint()
if err != nil {
return "", fmt.Errorf("generate instance fingerprint: %w", err)
}
return fingerprint, nil
}
func (s *Service) applyActivationResponse(
resp *ActivateInstallationResponse,
fingerprint string,
@ -447,12 +461,19 @@ func (s *Service) RestoreActivation(state *ActivationState) error {
source := NewTokenSource(&s.license.Claims)
s.evaluator = NewEvaluator(source)
s.activationState = &stateCopy
persistence := s.persistence
cb := s.onLicenseChange
activationCB := s.onActivationStateChange
snapshot := cloneLicense(s.license)
stateSnapshot := cloneActivationState(&stateCopy)
s.mu.Unlock()
if persistence != nil && stateCopy.InstanceFingerprint != "" {
if err := persistence.SaveInstanceFingerprint(stateCopy.InstanceFingerprint); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to persist instance fingerprint: %v\n", err)
}
}
if cb != nil {
cb(snapshot)
}
@ -475,6 +496,10 @@ func (s *Service) Clear() {
s.evaluator = nil
persistence := s.persistence
hadActivation := s.activationState != nil
var activationFingerprint string
if s.activationState != nil {
activationFingerprint = s.activationState.InstanceFingerprint
}
s.activationState = nil
cb := s.onLicenseChange
activationCB := s.onActivationStateChange
@ -482,6 +507,11 @@ func (s *Service) Clear() {
// Clear persisted activation state if it existed.
if hadActivation && persistence != nil {
if activationFingerprint != "" {
if err := persistence.SaveInstanceFingerprint(activationFingerprint); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to persist instance fingerprint before clearing activation: %v\n", err)
}
}
if err := persistence.ClearActivationState(); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to clear activation state: %v\n", err)
}
@ -703,15 +733,15 @@ func (s *Service) Status() *LicenseStatus {
status.Tier = s.license.Claims.Tier
status.PlanVersion = s.license.Claims.EntitlementPlanVersion()
status.IsLifetime = s.license.IsLifetime()
status.DaysRemaining = s.license.DaysRemaining()
status.DaysRemaining = s.license.LicensePeriodDaysRemaining()
status.Features = s.license.AllFeatures()
if maxGuests, ok := s.license.Claims.EffectiveLimits()["max_guests"]; ok {
status.MaxGuests = safeIntFromInt64(maxGuests)
}
if s.license.ExpiresAt() != nil {
exp := s.license.ExpiresAt().Format(time.RFC3339)
if s.license.LicensePeriodExpiresAt() != nil {
exp := s.license.LicensePeriodExpiresAt().Format(time.RFC3339)
status.ExpiresAt = &exp
}

View file

@ -179,6 +179,139 @@ func TestServiceActivateWithKey_SendsClientVersion(t *testing.T) {
}
}
func TestServiceActivateWithKey_ReusesPersistentFingerprintAfterClear(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
setupTestPublicKey(t)
tmpDir, err := os.MkdirTemp("", "pulse-service-fingerprint-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
p, err := NewPersistence(tmpDir)
if err != nil {
t.Fatalf("create persistence: %v", err)
}
var fingerprints []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/activate" {
t.Fatalf("Path = %q, want /v1/activate", r.URL.Path)
}
var req ActivateInstallationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
fingerprints = append(fingerprints, req.InstanceFingerprint)
if req.InstanceFingerprint == "" {
t.Fatal("expected non-empty instance fingerprint")
}
grantJWT := makeTestGrantJWT(t, &GrantClaims{
LicenseID: "lic_fingerprint",
Tier: "pro",
State: "active",
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
})
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(ActivateInstallationResponse{
License: ActivateResponseLicense{
LicenseID: "lic_fingerprint",
State: "active",
Tier: "pro",
},
Installation: ActivateResponseInstallation{
InstallationID: "inst_fingerprint",
InstallationToken: "pit_live_fingerprint",
Status: "active",
},
Grant: GrantEnvelope{
JWT: grantJWT,
JTI: "grant_fingerprint",
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
},
})
}))
defer server.Close()
svc := NewService()
svc.SetPersistence(p)
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
if _, err := svc.Activate("ppk_live_fingerprint_test"); err != nil {
t.Fatalf("first Activate: %v", err)
}
svc.Clear()
persistedAfterClear, err := p.LoadOrCreateInstanceFingerprint()
if err != nil {
t.Fatalf("LoadOrCreateInstanceFingerprint after clear: %v", err)
}
if persistedAfterClear != fingerprints[0] {
t.Fatalf("clear did not preserve activation fingerprint: got %q want %q", persistedAfterClear, fingerprints[0])
}
if _, err := svc.Activate("ppk_live_fingerprint_test"); err != nil {
t.Fatalf("second Activate: %v", err)
}
if len(fingerprints) != 2 {
t.Fatalf("expected two activation requests, got %d", len(fingerprints))
}
if fingerprints[1] != fingerprints[0] {
t.Fatalf("activation fingerprint changed after clear: %q then %q", fingerprints[0], fingerprints[1])
}
}
func TestServiceStatusUsesLicensePeriodEndForActivationGrantDisplay(t *testing.T) {
now := time.Now().UTC()
grantExpiresAt := now.Add(72 * time.Hour).Unix()
periodEnd := now.Add(365 * 24 * time.Hour).Unix()
lic := grantClaimsToLicense(&GrantClaims{
LicenseID: "lic_period_display",
Tier: "pro",
State: "active",
IssuedAt: now.Unix(),
ExpiresAt: grantExpiresAt,
CurrentPeriodEnd: periodEnd,
Email: "period@example.com",
Features: []string{"relay"},
LicenseVersion: 1,
InstallationID: "inst_period_display",
JTI: "grant_period_display",
}, "header.payload.signature")
if lic.Claims.ExpiresAt != grantExpiresAt {
t.Fatalf("grant expiry changed: got %d want %d", lic.Claims.ExpiresAt, grantExpiresAt)
}
if lic.Claims.LicensePeriodEnd != periodEnd {
t.Fatalf("license period end = %d, want %d", lic.Claims.LicensePeriodEnd, periodEnd)
}
svc := NewService()
svc.SetCurrentForTesting(lic)
status := svc.Status()
if !status.Valid {
t.Fatal("expected active grant to be valid")
}
if status.ExpiresAt == nil {
t.Fatal("expected status expires_at")
}
wantExpires := time.Unix(periodEnd, 0).Format(time.RFC3339)
if *status.ExpiresAt != wantExpires {
t.Fatalf("status expires_at = %q, want %q", *status.ExpiresAt, wantExpires)
}
if status.DaysRemaining < 360 {
t.Fatalf("status days_remaining = %d, want billing-period scale", status.DaysRemaining)
}
if gotGrantDays := lic.DaysRemaining(); gotGrantDays > 3 {
t.Fatalf("grant DaysRemaining() = %d, want short grant lease", gotGrantDays)
}
}
func TestServiceActivate_AllowsLegacyJWTInDevMode(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "true")