Pulse/internal/license/persistence.go

227 lines
5.5 KiB
Go

package license
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
const (
// LicenseFileName is the name of the encrypted license file.
LicenseFileName = "license.enc"
)
// Persistence handles encrypted storage of license keys.
type Persistence struct {
configDir string
machineID string // Used as encryption key material
}
// NewPersistence creates a new license persistence handler.
func NewPersistence(configDir string) (*Persistence, error) {
machineID, err := getMachineID()
if err != nil {
// Fall back to a fixed key for development
machineID = "pulse-dev-fallback-machine-id"
}
return &Persistence{
configDir: configDir,
machineID: machineID,
}, nil
}
// PersistedLicense contains the license key and metadata for storage.
type PersistedLicense struct {
LicenseKey string `json:"license_key"`
GracePeriodEnd *int64 `json:"grace_period_end,omitempty"` // Unix timestamp
}
// Save encrypts and saves a license key to disk.
func (p *Persistence) Save(licenseKey string) error {
return p.SaveWithGracePeriod(licenseKey, nil)
}
// SaveWithGracePeriod encrypts and saves a license with optional grace period.
func (p *Persistence) SaveWithGracePeriod(licenseKey string, gracePeriodEnd *int64) error {
if licenseKey == "" {
return errors.New("license key cannot be empty")
}
// Store as JSON with metadata
persisted := PersistedLicense{
LicenseKey: licenseKey,
GracePeriodEnd: gracePeriodEnd,
}
jsonData, err := json.Marshal(persisted)
if err != nil {
return fmt.Errorf("failed to marshal license: %w", err)
}
encrypted, err := p.encrypt(jsonData)
if err != nil {
return fmt.Errorf("failed to encrypt license: %w", err)
}
// Ensure config directory exists
if err := os.MkdirAll(p.configDir, 0700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
licensePath := filepath.Join(p.configDir, LicenseFileName)
encoded := base64.StdEncoding.EncodeToString(encrypted)
if err := os.WriteFile(licensePath, []byte(encoded), 0600); err != nil {
return fmt.Errorf("failed to write license file: %w", err)
}
return nil
}
// Load reads and decrypts a license key from disk.
func (p *Persistence) Load() (string, error) {
persisted, err := p.LoadWithMetadata()
if err != nil {
return "", err
}
return persisted.LicenseKey, nil
}
// LoadWithMetadata reads and decrypts a license with metadata from disk.
func (p *Persistence) LoadWithMetadata() (PersistedLicense, error) {
licensePath := filepath.Join(p.configDir, LicenseFileName)
encoded, err := os.ReadFile(licensePath)
if err != nil {
if os.IsNotExist(err) {
return PersistedLicense{}, nil // No license saved
}
return PersistedLicense{}, fmt.Errorf("failed to read license file: %w", err)
}
encrypted, err := base64.StdEncoding.DecodeString(string(encoded))
if err != nil {
return PersistedLicense{}, fmt.Errorf("failed to decode license file: %w", err)
}
decrypted, err := p.decrypt(encrypted)
if err != nil {
return PersistedLicense{}, fmt.Errorf("failed to decrypt license: %w", err)
}
// Try to parse as new JSON format
var persisted PersistedLicense
if err := json.Unmarshal(decrypted, &persisted); err != nil {
// Fall back to old format (just raw key)
persisted.LicenseKey = string(decrypted)
}
return persisted, nil
}
// Delete removes the saved license file.
func (p *Persistence) Delete() error {
licensePath := filepath.Join(p.configDir, LicenseFileName)
err := os.Remove(licensePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete license file: %w", err)
}
return nil
}
// Exists checks if a saved license exists.
func (p *Persistence) Exists() bool {
licensePath := filepath.Join(p.configDir, LicenseFileName)
_, err := os.Stat(licensePath)
return err == nil
}
// encrypt uses AES-GCM to encrypt data.
func (p *Persistence) encrypt(plaintext []byte) ([]byte, error) {
key := p.deriveKey()
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
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// decrypt uses AES-GCM to decrypt data.
func (p *Persistence) decrypt(ciphertext []byte) ([]byte, error) {
key := p.deriveKey()
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(ciphertext) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce := ciphertext[:gcm.NonceSize()]
ciphertext = ciphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
// deriveKey derives a 32-byte key from the machine ID.
func (p *Persistence) deriveKey() []byte {
hash := sha256.Sum256([]byte("pulse-license-" + p.machineID))
return hash[:]
}
// getMachineID attempts to get a stable machine identifier.
func getMachineID() (string, error) {
// Try Linux machine-id
paths := []string{
"/etc/machine-id",
"/var/lib/dbus/machine-id",
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err == nil && len(data) > 0 {
return string(data), nil
}
}
// Try hostname as fallback
hostname, err := os.Hostname()
if err == nil {
return hostname, nil
}
return "", errors.New("could not determine machine ID")
}