mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
cce1b3c783
v5.0.0 derived the license.enc AES key as sha256("pulse-license-" +
material) where material was the raw /etc/machine-id file content —
trailing newline included — with hostname and a fixed dev fallback as
alternates. v6 trims machine-id before hashing and dropped both
fallbacks, so licenses activated on v5.0.0 (before .license-key shipped
in v5.0.1) could never be decrypted and the December 2025 lifetime
cohort silently booted as Community.
Collect the raw v5 key materials at construction and try them verbatim
as legacy sha256 candidates during decrypt, alongside the existing
trimmed HKDF/legacy derivations. The write path is unchanged: persistent
random key + HKDF only. Pins the cloud-paid persistence contract to the
full v5.0.x compatibility-loader material set.
511 lines
16 KiB
Go
511 lines
16 KiB
Go
package licensing
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPersistence(t *testing.T) {
|
|
// Create a temporary directory for config
|
|
tmpDir, err := os.MkdirTemp("", "pulse-license-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
p, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create persistence: %v", err)
|
|
}
|
|
|
|
testLicenseKey := "test-license-key-123"
|
|
|
|
t.Run("Save and Load", func(t *testing.T) {
|
|
err := p.Save(testLicenseKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save license: %v", err)
|
|
}
|
|
|
|
if !p.Exists() {
|
|
t.Error("License file should exist")
|
|
}
|
|
|
|
loadedKey, err := p.Load()
|
|
if err != nil {
|
|
t.Fatalf("Failed to load license: %v", err)
|
|
}
|
|
|
|
if loadedKey != testLicenseKey {
|
|
t.Errorf("Expected license key %s, got %s", testLicenseKey, loadedKey)
|
|
}
|
|
})
|
|
|
|
t.Run("Save and Load with Grace Period", func(t *testing.T) {
|
|
gracePeriodEnd := time.Now().Add(7 * 24 * time.Hour).Unix()
|
|
err := p.SaveWithGracePeriod(testLicenseKey, &gracePeriodEnd)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save license with grace period: %v", err)
|
|
}
|
|
|
|
persisted, err := p.LoadWithMetadata()
|
|
if err != nil {
|
|
t.Fatalf("Failed to load license with metadata: %v", err)
|
|
}
|
|
|
|
if persisted.LicenseKey != testLicenseKey {
|
|
t.Errorf("Expected license key %s, got %s", testLicenseKey, persisted.LicenseKey)
|
|
}
|
|
|
|
if persisted.GracePeriodEnd == nil || *persisted.GracePeriodEnd != gracePeriodEnd {
|
|
t.Errorf("Expected grace period end %v, got %v", gracePeriodEnd, persisted.GracePeriodEnd)
|
|
}
|
|
})
|
|
|
|
t.Run("Load non-existent", func(t *testing.T) {
|
|
tmpDirEmpty, _ := os.MkdirTemp("", "pulse-license-test-empty-*")
|
|
defer os.RemoveAll(tmpDirEmpty)
|
|
|
|
pEmpty, _ := NewPersistence(tmpDirEmpty)
|
|
key, err := pEmpty.Load()
|
|
if err != nil {
|
|
t.Fatalf("Expected no error for non-existent license, got %v", err)
|
|
}
|
|
if key != "" {
|
|
t.Errorf("Expected empty key, got %s", key)
|
|
}
|
|
})
|
|
|
|
t.Run("Delete", func(t *testing.T) {
|
|
err := p.Save(testLicenseKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save license: %v", err)
|
|
}
|
|
|
|
if !p.Exists() {
|
|
t.Fatal("License should exist before delete")
|
|
}
|
|
|
|
err = p.Delete()
|
|
if err != nil {
|
|
t.Fatalf("Failed to delete license: %v", err)
|
|
}
|
|
|
|
if p.Exists() {
|
|
t.Error("License should not exist after delete")
|
|
}
|
|
})
|
|
|
|
t.Run("Encryption check", func(t *testing.T) {
|
|
err := p.Save(testLicenseKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save license: %v", err)
|
|
}
|
|
|
|
// Read the file directly - it should be base64 encoded and encrypted
|
|
licensePath := filepath.Join(tmpDir, LicenseFileName)
|
|
data, err := os.ReadFile(licensePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read license file: %v", err)
|
|
}
|
|
|
|
if string(data) == testLicenseKey {
|
|
t.Error("License file should be encrypted, not raw text")
|
|
}
|
|
|
|
// Ensure it's not JSON either in raw form
|
|
if data[0] == '{' {
|
|
t.Error("License file should be encrypted, not raw JSON")
|
|
}
|
|
})
|
|
|
|
t.Run("Decrypt with wrong key material", func(t *testing.T) {
|
|
err := p.Save(testLicenseKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save license: %v", err)
|
|
}
|
|
|
|
// Create a new persistence with different encryption key
|
|
pWrong := &Persistence{
|
|
configDir: tmpDir,
|
|
encryptionKey: "different-encryption-key",
|
|
machineID: "different-machine-id",
|
|
}
|
|
|
|
_, err = pWrong.Load()
|
|
if err == nil {
|
|
t.Error("Expected error when decrypting with wrong key material")
|
|
}
|
|
})
|
|
|
|
t.Run("Backwards compatibility with machine-id", func(t *testing.T) {
|
|
// This tests the scenario where a license was saved with machine-id
|
|
// (old behavior before persistent key feature) and we're now loading it
|
|
// with a different primary key but same machine-id as fallback
|
|
|
|
tmpDirCompat, _ := os.MkdirTemp("", "pulse-license-compat-*")
|
|
defer os.RemoveAll(tmpDirCompat)
|
|
|
|
testKey := "compat-test-key"
|
|
machineID := "test-machine-id-12345"
|
|
|
|
// Simulate old behavior: directly encrypt with machine-id (without calling Save
|
|
// which would create a persistent key). This is what old installations have.
|
|
pOld := &Persistence{
|
|
configDir: tmpDirCompat,
|
|
encryptionKey: machineID, // Old behavior used machine-id as encryption key
|
|
machineID: machineID,
|
|
}
|
|
|
|
// Manually encrypt and save without creating persistent key
|
|
persisted := PersistedLicense{LicenseKey: testKey}
|
|
jsonData, _ := json.Marshal(persisted)
|
|
encrypted, err := encryptWithKey(jsonData, pOld.deriveLegacyKeyFrom(machineID))
|
|
if err != nil {
|
|
t.Fatalf("Failed to encrypt license: %v", err)
|
|
}
|
|
|
|
// Write directly to simulate old installation
|
|
_ = os.MkdirAll(tmpDirCompat, 0700)
|
|
licensePath := filepath.Join(tmpDirCompat, LicenseFileName)
|
|
encoded := base64.StdEncoding.EncodeToString(encrypted)
|
|
_ = os.WriteFile(licensePath, []byte(encoded), 0600)
|
|
|
|
// Verify no persistent key file exists (simulating old installation)
|
|
keyPath := filepath.Join(tmpDirCompat, PersistentKeyFileName)
|
|
if _, err := os.Stat(keyPath); err == nil {
|
|
t.Fatal("Persistent key should not exist for this test")
|
|
}
|
|
|
|
// Now try to load with a new primary key but same machine-id as fallback
|
|
// This simulates what happens when a Docker user upgrades and gets a
|
|
// persistent key file, but their old license was encrypted with machine-id
|
|
pNew := &Persistence{
|
|
configDir: tmpDirCompat,
|
|
encryptionKey: "new-persistent-key", // Different primary key (simulating new container)
|
|
machineID: machineID, // Same machine-id for fallback
|
|
}
|
|
|
|
loaded, err := pNew.LoadWithMetadata()
|
|
if err != nil {
|
|
t.Fatalf("Failed to load license with machine-id fallback: %v\n"+
|
|
"This means backwards compatibility is broken for existing Docker users", err)
|
|
}
|
|
|
|
if loaded.LicenseKey != testKey {
|
|
t.Errorf("Expected license key %s, got %s", testKey, loaded.LicenseKey)
|
|
}
|
|
})
|
|
|
|
t.Run("Backwards compatibility with v5.0.0 raw key materials", func(t *testing.T) {
|
|
// v5.0.0 sealed license.enc with sha256("pulse-license-" + material)
|
|
// where material was the raw machine-id file content (trailing
|
|
// newline included), the hostname fallback, or the fixed dev
|
|
// fallback. Licenses activated on v5.0.0 were never re-saved, so v6
|
|
// must be able to open files sealed under each of those materials.
|
|
rawMachineID := "0123456789abcdef0123456789abcdef\n"
|
|
hostname := "pulse-host-01"
|
|
|
|
for name, material := range map[string]string{
|
|
"raw machine-id with trailing newline": rawMachineID,
|
|
"hostname fallback": hostname,
|
|
"dev fallback": legacyV5MachineIDFallback,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
tmpDirCompat := t.TempDir()
|
|
testKey := "v5-lifetime-key"
|
|
|
|
sealer := &Persistence{configDir: tmpDirCompat}
|
|
persisted := PersistedLicense{LicenseKey: testKey}
|
|
jsonData, _ := json.Marshal(persisted)
|
|
encrypted, err := encryptWithKey(jsonData, sealer.deriveLegacyKeyFrom(material))
|
|
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 license file: %v", err)
|
|
}
|
|
|
|
// v6 trims machine-id and generates a fresh persistent key,
|
|
// so only the raw legacy materials can open the v5.0.0 file.
|
|
pNew := &Persistence{
|
|
configDir: tmpDirCompat,
|
|
encryptionKey: "new-persistent-key",
|
|
machineID: strings.TrimSpace(rawMachineID),
|
|
legacyKeyMaterials: []string{rawMachineID, hostname, legacyV5MachineIDFallback},
|
|
}
|
|
|
|
loaded, err := pNew.LoadWithMetadata()
|
|
if err != nil {
|
|
t.Fatalf("Failed to load v5.0.0-era license with raw key material %q: %v\n"+
|
|
"This breaks migration for licenses activated on v5.0.0 before .license-key existed", name, err)
|
|
}
|
|
if loaded.LicenseKey != testKey {
|
|
t.Errorf("Expected license key %s, got %s", testKey, loaded.LicenseKey)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("NewPersistence collects v5.0.0 legacy key materials", func(t *testing.T) {
|
|
p, err := NewPersistence(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Failed to create persistence: %v", err)
|
|
}
|
|
|
|
contains := func(materials []string, want string) bool {
|
|
for _, m := range materials {
|
|
if m == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if !contains(p.legacyKeyMaterials, legacyV5MachineIDFallback) {
|
|
t.Errorf("legacyKeyMaterials missing dev fallback %q: %v", legacyV5MachineIDFallback, p.legacyKeyMaterials)
|
|
}
|
|
if hostname, err := os.Hostname(); err == nil && hostname != "" {
|
|
if !contains(p.legacyKeyMaterials, hostname) {
|
|
t.Errorf("legacyKeyMaterials missing hostname %q: %v", hostname, p.legacyKeyMaterials)
|
|
}
|
|
}
|
|
for _, path := range machineIDPaths {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || len(data) == 0 {
|
|
continue
|
|
}
|
|
if !contains(p.legacyKeyMaterials, string(data)) {
|
|
t.Errorf("legacyKeyMaterials missing raw content of %s: %v", path, p.legacyKeyMaterials)
|
|
}
|
|
}
|
|
})
|
|
|
|
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 {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(plainDir)
|
|
|
|
pPlain, err := NewPersistence(plainDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create persistence: %v", err)
|
|
}
|
|
|
|
gracePeriodEnd := time.Now().Add(24 * time.Hour).Unix()
|
|
plaintext := PersistedLicense{
|
|
LicenseKey: "pit-license-plaintext-test",
|
|
GracePeriodEnd: &gracePeriodEnd,
|
|
}
|
|
|
|
raw, err := json.Marshal(plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal plaintext license: %v", err)
|
|
}
|
|
|
|
licensePath := filepath.Join(plainDir, LicenseFileName)
|
|
if err := os.WriteFile(licensePath, raw, 0600); err != nil {
|
|
t.Fatalf("Failed to write plaintext license file: %v", err)
|
|
}
|
|
|
|
loaded, err := pPlain.LoadWithMetadata()
|
|
if err != nil {
|
|
t.Fatalf("LoadWithMetadata plaintext migration failed: %v", err)
|
|
}
|
|
|
|
if loaded.LicenseKey != plaintext.LicenseKey {
|
|
t.Fatalf("LicenseKey = %q, want %q", loaded.LicenseKey, plaintext.LicenseKey)
|
|
}
|
|
if loaded.GracePeriodEnd == nil || *loaded.GracePeriodEnd != gracePeriodEnd {
|
|
t.Fatalf("GracePeriodEnd = %v, want %v", loaded.GracePeriodEnd, gracePeriodEnd)
|
|
}
|
|
|
|
rewritten, err := os.ReadFile(licensePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read rewritten license file: %v", err)
|
|
}
|
|
if bytes.Equal(rewritten, raw) {
|
|
t.Fatal("expected plaintext license file to be rewritten encrypted")
|
|
}
|
|
if bytes.Contains(rewritten, []byte(plaintext.LicenseKey)) {
|
|
t.Fatal("license key should not remain in plaintext after migration rewrite")
|
|
}
|
|
})
|
|
}
|
|
|
|
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 {
|
|
t.Fatalf("failed to relax temp dir perms: %v", err)
|
|
}
|
|
|
|
p, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create persistence: %v", err)
|
|
}
|
|
if err := p.Save("owner-only-perm-test"); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
dirInfo, err := os.Stat(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat config dir: %v", err)
|
|
}
|
|
if got := dirInfo.Mode().Perm(); got != 0700 {
|
|
t.Fatalf("config dir perms = %o, want 700", got)
|
|
}
|
|
|
|
for _, file := range []string{LicenseFileName, PersistentKeyFileName} {
|
|
path := filepath.Join(tmpDir, file)
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat %s: %v", file, err)
|
|
}
|
|
if got := info.Mode().Perm(); got != 0600 {
|
|
t.Fatalf("%s perms = %o, want 600", file, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewPersistence_HardensExistingPersistentKeyFileOnLoad(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
keyPath := filepath.Join(tmpDir, PersistentKeyFileName)
|
|
if err := os.WriteFile(keyPath, []byte("persistent-key-material"), 0o644); err != nil {
|
|
t.Fatalf("failed to write persistent key: %v", err)
|
|
}
|
|
if err := os.Chmod(keyPath, 0o644); err != nil {
|
|
t.Fatalf("failed to loosen persistent key perms: %v", err)
|
|
}
|
|
|
|
p, err := NewPersistence(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("NewPersistence() error: %v", err)
|
|
}
|
|
if p.encryptionKey != "persistent-key-material" {
|
|
t.Fatalf("encryptionKey = %q, want persistent key contents", p.encryptionKey)
|
|
}
|
|
|
|
info, err := os.Stat(keyPath)
|
|
if err != nil {
|
|
t.Fatalf("stat %s: %v", keyPath, err)
|
|
}
|
|
if got := info.Mode().Perm(); got != 0o600 {
|
|
t.Fatalf("%s perms = %o, want 600", filepath.Base(keyPath), got)
|
|
}
|
|
}
|
|
|
|
func TestResolvePersistencePathCanonicalizesConfigDir(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
inputDir := " " + filepath.Join(baseDir, "nested", "..", "licensing") + string(os.PathSeparator) + ". "
|
|
|
|
path, err := resolvePersistencePath(inputDir, LicenseFileName)
|
|
if err != nil {
|
|
t.Fatalf("resolvePersistencePath() error = %v", err)
|
|
}
|
|
|
|
wantDir := filepath.Clean(filepath.Join(baseDir, "nested", "..", "licensing") + string(os.PathSeparator) + ".")
|
|
if path != filepath.Join(wantDir, LicenseFileName) {
|
|
t.Fatalf("resolvePersistencePath() = %q, want %q", path, filepath.Join(wantDir, LicenseFileName))
|
|
}
|
|
}
|
|
|
|
func TestResolvePersistencePathRejectsBlankConfigDir(t *testing.T) {
|
|
if _, err := resolvePersistencePath(" \t ", LicenseFileName); err == nil {
|
|
t.Fatal("expected blank config dir to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestNewPersistenceRejectsSymlinkPersistentKey(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
target := filepath.Join(tmpDir, "key-target")
|
|
if err := os.WriteFile(target, []byte("test-key"), 0600); err != nil {
|
|
t.Fatalf("failed to write target file: %v", err)
|
|
}
|
|
|
|
keyPath := filepath.Join(tmpDir, PersistentKeyFileName)
|
|
if err := os.Symlink(target, keyPath); err != nil {
|
|
t.Skipf("symlink unsupported on this platform: %v", err)
|
|
}
|
|
|
|
_, err := NewPersistence(tmpDir)
|
|
if err == nil {
|
|
t.Fatal("expected error for symlink persistent key path")
|
|
}
|
|
if !strings.Contains(err.Error(), "symlink") {
|
|
t.Fatalf("expected symlink error, got: %v", err)
|
|
}
|
|
}
|