Strengthen export and license persistence encryption

This commit is contained in:
rcourtman 2026-04-22 01:03:10 +01:00
parent 513399b004
commit 70acd663bd
7 changed files with 215 additions and 84 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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