fix(licensing): decrypt v5.0.0-era license.enc sealed with raw machine-id material

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.
This commit is contained in:
rcourtman 2026-06-11 08:36:11 +01:00
parent 6929c1c8fb
commit cce1b3c783
3 changed files with 152 additions and 20 deletions

View file

@ -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`

View file

@ -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)
}

View file

@ -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)