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)