diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index db743932a..425ee645e 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -161,6 +161,10 @@ Community limit enforcement. 8. Add or change activation/grant lifecycle or dev-mode capability widening through `pkg/licensing/dev_mode_features.go`, `pkg/licensing/service.go`, `pkg/licensing/grant_refresh.go`, and `pkg/licensing/revocation_poll.go` 9. Add or change license-server transport through `pkg/licensing/license_server_client.go` and `pkg/licensing/quickstart_bootstrap.go` 10. Add or change encrypted activation persistence through `pkg/licensing/persistence.go` and `pkg/licensing/activation_store.go` + That persistence boundary must encrypt new state only with the persistent + random key file and the current HKDF-based derivation. Machine ID may + remain only as a compatibility loader for previously saved state, and + legacy derivations must never become the write path again. 11. Add or change hosted trial or self-hosted purchase-return token semantics through `pkg/licensing/trial_activation.go` and `pkg/licensing/purchase_return.go` diff --git a/internal/api/config_export_import_compat_test.go b/internal/api/config_export_import_compat_test.go index b30c6a512..b34c8a498 100644 --- a/internal/api/config_export_import_compat_test.go +++ b/internal/api/config_export_import_compat_test.go @@ -20,6 +20,11 @@ import ( "golang.org/x/crypto/pbkdf2" ) +const ( + currentExportCompatPBKDF2Iterations = 600000 + legacyExportCompatPBKDF2Iterations = 100000 +) + func TestHandleImportConfigAcceptsLegacyVersion40Bundle(t *testing.T) { targetDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", targetDir) @@ -165,7 +170,7 @@ func encryptExportCompatPayload(plaintext []byte, passphrase string) ([]byte, er return nil, err } - key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New) + key := pbkdf2.Key([]byte(passphrase), salt, legacyExportCompatPBKDF2Iterations, 32, sha256.New) block, err := aes.NewCipher(key) if err != nil { return nil, err @@ -193,22 +198,34 @@ func decryptExportCompatPayload(ciphertext []byte, passphrase string) ([]byte, e salt := ciphertext[:32] cipherbody := ciphertext[32:] - key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New) - block, err := aes.NewCipher(key) - if err != nil { - return nil, err + var lastErr error + for _, iterations := range []int{currentExportCompatPBKDF2Iterations, legacyExportCompatPBKDF2Iterations} { + key := pbkdf2.Key([]byte(passphrase), salt, iterations, 32, sha256.New) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(cipherbody) < gcm.NonceSize() { + return nil, io.ErrUnexpectedEOF + } + + nonce := cipherbody[:gcm.NonceSize()] + payload := cipherbody[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, payload, nil) + if err == nil { + return plaintext, nil + } + lastErr = err } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err + if lastErr != nil { + return nil, lastErr } - - if len(cipherbody) < gcm.NonceSize() { - return nil, io.ErrUnexpectedEOF - } - - nonce := cipherbody[:gcm.NonceSize()] - payload := cipherbody[gcm.NonceSize():] - return gcm.Open(nil, nonce, payload, nil) + return nil, io.ErrUnexpectedEOF } diff --git a/internal/config/export.go b/internal/config/export.go index fe4771ee4..f0415d9a4 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -17,6 +17,11 @@ import ( "golang.org/x/crypto/pbkdf2" ) +const ( + currentExportPBKDF2Iterations = 600000 + legacyExportPBKDF2Iterations = 100000 +) + // ExportData contains all configuration data for export type ExportData struct { Version string `json:"version"` @@ -241,7 +246,7 @@ func encryptWithPassphrase(plaintext []byte, passphrase string) ([]byte, error) } // Derive key from passphrase using PBKDF2 - key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New) + key := pbkdf2.Key([]byte(passphrase), salt, currentExportPBKDF2Iterations, 32, sha256.New) // Create cipher block, err := aes.NewCipher(key) @@ -283,33 +288,35 @@ func decryptWithPassphrase(ciphertext []byte, passphrase string) ([]byte, error) ciphertext = ciphertext[32:] // Derive key from passphrase - key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New) + var lastErr error + for _, iterations := range []int{currentExportPBKDF2Iterations, legacyExportPBKDF2Iterations} { + key := pbkdf2.Key([]byte(passphrase), salt, iterations, 32, sha256.New) - // Create cipher - block, err := aes.NewCipher(key) - if err != nil { - return nil, err + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, payload := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, payload, nil) + if err == nil { + return plaintext, nil + } + lastErr = err } - // Use GCM mode - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err + if lastErr != nil { + return nil, lastErr } - - // Extract nonce - nonceSize := gcm.NonceSize() - if len(ciphertext) < nonceSize { - return nil, fmt.Errorf("ciphertext too short") - } - - nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] - - // Decrypt - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, err - } - - return plaintext, nil + return nil, fmt.Errorf("failed to decrypt export payload") } diff --git a/internal/config/persistence_test.go b/internal/config/persistence_test.go index f13157dba..a621e87c2 100644 --- a/internal/config/persistence_test.go +++ b/internal/config/persistence_test.go @@ -1230,7 +1230,7 @@ func encryptExportPayload(plaintext []byte, passphrase string) ([]byte, error) { return nil, err } - key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New) + key := pbkdf2.Key([]byte(passphrase), salt, 600000, 32, sha256.New) block, err := aes.NewCipher(key) if err != nil { @@ -1260,7 +1260,7 @@ func decryptExportPayload(ciphertext []byte, passphrase string) ([]byte, error) salt := ciphertext[:32] cipherbody := ciphertext[32:] - key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New) + key := pbkdf2.Key([]byte(passphrase), salt, 600000, 32, sha256.New) block, err := aes.NewCipher(key) if err != nil { diff --git a/pkg/licensing/activation_store.go b/pkg/licensing/activation_store.go index c87c4f98a..d1234190b 100644 --- a/pkg/licensing/activation_store.go +++ b/pkg/licensing/activation_store.go @@ -76,13 +76,7 @@ func (p *Persistence) LoadActivationState() (*ActivationState, error) { migratedPlaintext := false if encrypted, err := base64.StdEncoding.DecodeString(string(encoded)); err == nil { - // Try to decrypt with current encryption key. - decrypted, decErr := p.decrypt(encrypted) - - // Fall back to machine-id if the current key doesn't work. - if decErr != nil && p.machineID != p.encryptionKey { - decrypted, decErr = p.decryptWithKey(encrypted, p.deriveKeyFrom(p.machineID)) - } + decrypted, decErr := p.decryptWithCompatibleKeys(encrypted) if decErr != nil { return nil, fmt.Errorf("decrypt activation state: %w", decErr) } diff --git a/pkg/licensing/persistence.go b/pkg/licensing/persistence.go index 98c9a7071..e52b7cf55 100644 --- a/pkg/licensing/persistence.go +++ b/pkg/licensing/persistence.go @@ -3,6 +3,7 @@ package licensing import ( "crypto/aes" "crypto/cipher" + "crypto/hkdf" "crypto/rand" "crypto/sha256" "encoding/base64" @@ -33,6 +34,11 @@ const ( maxLicenseFileSize = 1 << 20 // 1 MiB ) +var ( + licenseKeyDerivationSalt = []byte("pulse-license-persistence-v2") + licenseKeyDerivationInfo = []byte("license-encryption") +) + var ( errUnsafeLicensePersistencePath = errors.New("unsafe license persistence path") errInvalidLicensePersistentKey = errors.New("invalid persistent license key") @@ -145,13 +151,13 @@ func writeOwnerOnlyPersistenceFileAtomic(path string, data []byte) error { // Persistence handles encrypted storage of license keys. type Persistence struct { configDir string - encryptionKey string // Primary key for encryption (persistent or machine-id) - machineID string // Fallback for backwards compatibility + encryptionKey string // Primary persistent key for encryption + machineID string // Legacy-only fallback for backwards compatibility } // NewPersistence creates a new license persistence handler. -// It tries to use a persistent key stored in configDir first, then falls back -// to machine-id for backwards compatibility with existing installations. +// New writes always use a persistent random key; machine-id is retained only +// as a compatibility fallback when loading older encrypted state. func NewPersistence(configDir string) (*Persistence, error) { normalizedConfigDir, err := normalizePersistenceConfigDir(configDir) if err != nil { @@ -164,21 +170,15 @@ func NewPersistence(configDir string) (*Persistence, error) { return nil, fmt.Errorf("failed to load persistent key: %w", err) } - // Get machine-id as fallback for backwards compatibility + // Machine ID is legacy-only fallback material for existing installations. machineID, err := getMachineID() if err != nil { - machineID = "pulse-dev-fallback-machine-id" - } - - // Use persistent key if available, otherwise machine-id - encryptionKey := persistentKey - if encryptionKey == "" { - encryptionKey = machineID + machineID = "" } return &Persistence{ configDir: normalizedConfigDir, - encryptionKey: encryptionKey, + encryptionKey: persistentKey, machineID: machineID, }, nil } @@ -322,8 +322,8 @@ func (p *Persistence) Load() (string, error) { } // LoadWithMetadata reads and decrypts a license with metadata from disk. -// It tries to decrypt with the current encryption key first, then falls back -// to machine-id for backwards compatibility with existing installations. +// It first tries the current HKDF-derived persistent key and then legacy +// derivations for compatibility with older installations. func (p *Persistence) LoadWithMetadata() (PersistedLicense, error) { licensePath, err := resolvePersistencePath(p.configDir, LicenseFileName) if err != nil { @@ -342,16 +342,7 @@ func (p *Persistence) LoadWithMetadata() (PersistedLicense, error) { migratedPlaintext := false if encrypted, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encoded))); err == nil { - // Try to decrypt with current encryption key. - decrypted, decErr := p.decrypt(encrypted) - - // If decryption failed and we have a different machine-id, try that as fallback. - // This handles the case where an existing license was encrypted with machine-id - // before the persistent key feature was added. - if decErr != nil && p.machineID != p.encryptionKey { - decrypted, decErr = p.decryptWithKey(encrypted, p.deriveKeyFrom(p.machineID)) - } - + decrypted, decErr := p.decryptWithCompatibleKeys(encrypted) if decErr != nil { return PersistedLicense{}, fmt.Errorf("failed to decrypt license: %w", decErr) } @@ -467,12 +458,71 @@ func (p *Persistence) deriveKey() []byte { return p.deriveKeyFrom(p.encryptionKey) } -// deriveKeyFrom derives a 32-byte key from a given key material. +// deriveKeyFrom derives a 32-byte key from a given key material using HKDF. func (p *Persistence) deriveKeyFrom(keyMaterial string) []byte { + if strings.TrimSpace(keyMaterial) == "" { + return nil + } + key, err := hkdf.Key(sha256.New, []byte(keyMaterial), licenseKeyDerivationSalt, string(licenseKeyDerivationInfo), 32) + if err != nil { + return nil + } + return key +} + +func (p *Persistence) deriveLegacyKeyFrom(keyMaterial string) []byte { + if strings.TrimSpace(keyMaterial) == "" { + return nil + } hash := sha256.Sum256([]byte("pulse-license-" + keyMaterial)) return hash[:] } +func (p *Persistence) compatibleKeyCandidates() [][]byte { + materials := []string{} + addMaterial := func(candidate string) { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return + } + for _, existing := range materials { + if existing == candidate { + return + } + } + materials = append(materials, candidate) + } + + addMaterial(p.encryptionKey) + addMaterial(p.machineID) + + keys := make([][]byte, 0, len(materials)*2) + for _, material := range materials { + if key := p.deriveKeyFrom(material); len(key) > 0 { + keys = append(keys, key) + } + if key := p.deriveLegacyKeyFrom(material); len(key) > 0 { + keys = append(keys, key) + } + } + return keys +} + +func (p *Persistence) decryptWithCompatibleKeys(ciphertext []byte) ([]byte, error) { + var lastErr error + for _, key := range p.compatibleKeyCandidates() { + plaintext, err := p.decryptWithKey(ciphertext, key) + if err == nil { + return plaintext, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("no compatible decryption key available") +} + // getMachineID attempts to get a stable machine identifier. func getMachineID() (string, error) { // Try Linux machine-id @@ -491,11 +541,5 @@ func getMachineID() (string, error) { } } - // Try hostname as fallback - hostname, err := os.Hostname() - if err == nil { - return hostname, nil - } - return "", errors.New("could not determine machine ID") } diff --git a/pkg/licensing/persistence_test.go b/pkg/licensing/persistence_test.go index 3fa5fdff2..7001d8cab 100644 --- a/pkg/licensing/persistence_test.go +++ b/pkg/licensing/persistence_test.go @@ -2,8 +2,12 @@ package licensing import ( "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" "encoding/base64" "encoding/json" + "io" "os" "path/filepath" "strings" @@ -165,7 +169,7 @@ func TestPersistence(t *testing.T) { // Manually encrypt and save without creating persistent key persisted := PersistedLicense{LicenseKey: testKey} jsonData, _ := json.Marshal(persisted) - encrypted, err := pOld.encrypt(jsonData) + encrypted, err := encryptWithKey(jsonData, pOld.deriveLegacyKeyFrom(machineID)) if err != nil { t.Fatalf("Failed to encrypt license: %v", err) } @@ -202,6 +206,48 @@ func TestPersistence(t *testing.T) { } }) + t.Run("Backwards compatibility with legacy persistent-key derivation", func(t *testing.T) { + tmpDirCompat, _ := os.MkdirTemp("", "pulse-license-persistent-compat-*") + defer os.RemoveAll(tmpDirCompat) + + testKey := "compat-persistent-key" + persistentKey := "persisted-random-key-material" + + if err := os.WriteFile(filepath.Join(tmpDirCompat, PersistentKeyFileName), []byte(persistentKey), 0600); err != nil { + t.Fatalf("Failed to write persistent key file: %v", err) + } + + pOld := &Persistence{ + configDir: tmpDirCompat, + encryptionKey: persistentKey, + } + persisted := PersistedLicense{LicenseKey: testKey} + jsonData, _ := json.Marshal(persisted) + encrypted, err := encryptWithKey(jsonData, pOld.deriveLegacyKeyFrom(persistentKey)) + if err != nil { + t.Fatalf("Failed to encrypt license: %v", err) + } + + licensePath := filepath.Join(tmpDirCompat, LicenseFileName) + encoded := base64.StdEncoding.EncodeToString(encrypted) + if err := os.WriteFile(licensePath, []byte(encoded), 0600); err != nil { + t.Fatalf("Failed to write legacy persistent-key license: %v", err) + } + + pNew, err := NewPersistence(tmpDirCompat) + if err != nil { + t.Fatalf("Failed to create persistence: %v", err) + } + + loaded, err := pNew.LoadWithMetadata() + if err != nil { + t.Fatalf("Failed to load license with legacy persistent-key derivation: %v", err) + } + if loaded.LicenseKey != testKey { + t.Errorf("Expected license key %s, got %s", testKey, loaded.LicenseKey) + } + }) + t.Run("plaintext license file rewrites encrypted storage on load", func(t *testing.T) { plainDir, err := os.MkdirTemp("", "pulse-license-plaintext-*") if err != nil { @@ -255,6 +301,25 @@ func TestPersistence(t *testing.T) { }) } +func encryptWithKey(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + func TestPersistenceEnforcesOwnerOnlyPermissions(t *testing.T) { tmpDir := t.TempDir() if err := os.Chmod(tmpDir, 0755); err != nil {