From cce1b3c7831e4f4abea8211313006c5216effeeb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 08:36:11 +0100 Subject: [PATCH] fix(licensing): decrypt v5.0.0-era license.enc sealed with raw machine-id material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../v6/internal/subsystems/cloud-paid.md | 7 ++ pkg/licensing/persistence.go | 79 ++++++++++++----- pkg/licensing/persistence_test.go | 86 +++++++++++++++++++ 3 files changed, 152 insertions(+), 20 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 22e9d4f44..de639c3e6 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -541,6 +541,13 @@ or other self-hosted uncapped continuity plans. 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. + The compatibility loader must keep every key material a real v5.0.x + install could have sealed `license.enc` with: the raw machine-id file + content exactly as read (untrimmed, trailing newline included), the + hostname fallback, and the fixed v5 dev fallback, each tried only through + the legacy sha256 derivation on decrypt. Licenses activated on v5.0.0 + predate `.license-key` and are never re-saved, so dropping or trimming + any of those materials silently downgrades paying upgraders to Community. 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/pkg/licensing/persistence.go b/pkg/licensing/persistence.go index 3ef3d3dca..db32dc90e 100644 --- a/pkg/licensing/persistence.go +++ b/pkg/licensing/persistence.go @@ -150,9 +150,10 @@ func writeOwnerOnlyPersistenceFileAtomic(path string, data []byte) error { // Persistence handles encrypted storage of license keys. type Persistence struct { - configDir string - encryptionKey string // Primary persistent key for encryption - machineID string // Legacy-only fallback for backwards compatibility + configDir string + encryptionKey string // Primary persistent key for encryption + machineID string // Legacy-only fallback for backwards compatibility + legacyKeyMaterials []string // v5.0.x raw key materials, tried verbatim (untrimmed) on decrypt } // NewPersistence creates a new license persistence handler. @@ -177,9 +178,10 @@ func NewPersistence(configDir string) (*Persistence, error) { } return &Persistence{ - configDir: normalizedConfigDir, - encryptionKey: persistentKey, - machineID: machineID, + configDir: normalizedConfigDir, + encryptionKey: persistentKey, + machineID: machineID, + legacyKeyMaterials: legacyV5KeyMaterials(), }, nil } @@ -475,23 +477,20 @@ func (p *Persistence) deriveLegacyKeyFrom(keyMaterial string) []byte { func (p *Persistence) compatibleKeyCandidates() [][]byte { materials := []string{} + seen := map[string]bool{} addMaterial := func(candidate string) { candidate = strings.TrimSpace(candidate) - if candidate == "" { + if candidate == "" || seen[candidate] { return } - for _, existing := range materials { - if existing == candidate { - return - } - } + seen[candidate] = true materials = append(materials, candidate) } addMaterial(p.encryptionKey) addMaterial(p.machineID) - keys := make([][]byte, 0, len(materials)*2) + keys := make([][]byte, 0, len(materials)*2+len(p.legacyKeyMaterials)) for _, material := range materials { if key := p.deriveKeyFrom(material); len(key) > 0 { keys = append(keys, key) @@ -500,6 +499,20 @@ func (p *Persistence) compatibleKeyCandidates() [][]byte { keys = append(keys, key) } } + + // v5.0.x sealed license.enc with sha256("pulse-license-" + material) over + // untrimmed material: the raw machine-id file content (trailing newline + // included), the hostname fallback, or a fixed dev fallback. Those keys + // are only reachable by hashing the material verbatim, so never trim here. + for _, material := range p.legacyKeyMaterials { + if material == "" || seen[material] { + continue + } + seen[material] = true + if key := p.deriveLegacyKeyFrom(material); len(key) > 0 { + keys = append(keys, key) + } + } return keys } @@ -518,15 +531,20 @@ func (p *Persistence) decryptWithCompatibleKeys(ciphertext []byte) ([]byte, erro return nil, fmt.Errorf("no compatible decryption key available") } +// machineIDPaths are the machine-id sources shared by current and v5-era key +// derivation. +var machineIDPaths = []string{ + "/etc/machine-id", + "/var/lib/dbus/machine-id", +} + +// legacyV5MachineIDFallback mirrors v5.0.0's NewPersistence fallback when no +// machine-id source was readable. +const legacyV5MachineIDFallback = "pulse-dev-fallback-machine-id" + // 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 { + for _, path := range machineIDPaths { data, err := os.ReadFile(path) if err == nil && len(data) > 0 { trimmed := strings.TrimSpace(string(data)) @@ -538,3 +556,24 @@ func getMachineID() (string, error) { return "", errors.New("could not determine machine ID") } + +// legacyV5KeyMaterials reproduces the key-material set v5.0.x used to seal +// license.enc: the raw machine-id file content exactly as read (v5 never +// trimmed it, so a standard newline-terminated /etc/machine-id keyed the +// cipher with the newline included), then the hostname fallback, then the +// fixed dev fallback. Licenses activated on v5.0.0 — before .license-key +// shipped in v5.0.1 — are never re-saved, so these are the only keys that +// can open them. +func legacyV5KeyMaterials() []string { + materials := []string{} + for _, path := range machineIDPaths { + data, err := os.ReadFile(path) + if err == nil && len(data) > 0 { + materials = append(materials, string(data)) + } + } + if hostname, err := os.Hostname(); err == nil && hostname != "" { + materials = append(materials, hostname) + } + return append(materials, legacyV5MachineIDFallback) +} diff --git a/pkg/licensing/persistence_test.go b/pkg/licensing/persistence_test.go index 7001d8cab..62cb7be28 100644 --- a/pkg/licensing/persistence_test.go +++ b/pkg/licensing/persistence_test.go @@ -206,6 +206,92 @@ func TestPersistence(t *testing.T) { } }) + 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)