mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-16 19:50:10 +00:00
Strengthen export and license persistence encryption
This commit is contained in:
parent
513399b004
commit
70acd663bd
7 changed files with 215 additions and 84 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue