fix: prevent encryption key conflicts and add license backup

- Remove eager key generation from crypto-utils module load (was baking
  keys into Docker images that conflicted with mounted production keys)
- Add license backup to config.json (survives credential store failures)
- LicenseManager.load() falls back to config.json backup if credential
  store decryption fails (e.g. after container rebuild with new key)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 14:17:25 -07:00
parent 64b3534c7d
commit abd54d4b99
2 changed files with 44 additions and 9 deletions

View File

@@ -37,14 +37,16 @@ class LicenseManager {
}
/**
* Load license state from storage on startup
* Load license state from storage on startup.
* Primary: encrypted credential store. Fallback: config.json backup.
* If the credential store fails (e.g. encryption key changed after rebuild),
* restores from the config.json backup automatically.
*/
async load() {
try {
const stored = await this.credentialManager.retrieve(LICENSE_CRED_KEY);
if (stored) {
this.activation = JSON.parse(stored);
// Check if expired
if (this.isExpired()) {
this.log.info?.('license', 'License has expired', {
code: this._maskCode(this.activation.code),
@@ -57,13 +59,40 @@ class LicenseManager {
daysRemaining: this.daysRemaining()
});
}
} else {
this.log.info?.('license', 'No active license');
this._loaded = true;
return;
}
} catch (error) {
this.log.error?.('license', 'Failed to load license state', { error: error.message });
this.activation = null;
this.log.warn?.('license', 'Failed to load from credential store, trying config backup', { error: error.message });
}
// Fallback: restore from config.json backup
try {
const fsp = require('fs').promises;
const data = await fsp.readFile(this.configFile, 'utf8');
const config = JSON.parse(data);
if (config.licenseBackup) {
this.activation = config.licenseBackup;
this.log.info?.('license', 'License restored from config backup', {
code: this._maskCode(this.activation.code),
lifetime: this.activation.lifetime
});
// Re-store in credential manager so future loads succeed
try {
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation));
this.log.info?.('license', 'License re-stored in credential manager');
} catch (storeErr) {
this.log.warn?.('license', 'Could not re-store license in credential manager', { error: storeErr.message });
}
this._loaded = true;
return;
}
} catch (_) {
// Config doesn't exist or no backup — continue
}
this.log.info?.('license', 'No active license');
this.activation = null;
this._loaded = true;
}
@@ -412,7 +441,9 @@ class LicenseManager {
}
/**
* Update config.json with non-sensitive license info
* Update config.json with license info and full activation backup.
* The backup ensures the license survives encryption key changes
* (e.g. container rebuilds that generate new keys).
*/
async _updateConfig() {
try {
@@ -433,8 +464,11 @@ class LicenseManager {
daysRemaining: this.daysRemaining(),
features: this.activation.features || Object.keys(PREMIUM_FEATURES)
};
// Full backup of activation data (config.json is volume-mounted and persists)
config.licenseBackup = this.activation;
} else {
config.license = { active: false, tier: 'free' };
delete config.licenseBackup;
}
config.updatedAt = new Date().toISOString();