Skip to content

Commit 5336a69

Browse files
committed
fix: trial data handling logic for promo and pro activation
1. Corrected trial activation logic to properly detect corrupted data, handle existing trials, and avoid granting trials on tampering or invalid state. 2. Enhanced robustness by explicitly handling failure cases (e.g., JSON parse errors, invalid signatures).
1 parent 801310a commit 5336a69

File tree

2 files changed

+181
-56
lines changed

2 files changed

+181
-56
lines changed

src/phoenix/trust_ring.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,17 @@ function _selectKeys() {
179179
}
180180

181181
const CRED_KEY_API = "API_KEY";
182-
const CRED_KEY_ENTITLEMENTS = "ENTITLEMENTS_GRANT_KEY";
182+
const CRED_KEY_PROMO = "PROMO_GRANT_KEY";
183+
const SIGNATURE_SALT_KEY = "SIGNATURE_SALT_KEY";
183184
const { key, iv } = _selectKeys();
184185
// this key is set at boot time as a truct base for all the core components before any extensions are loaded.
185186
// just before extensions are loaded, this key is blanked. This can be used by core modules to talk with other
186187
// core modules securely without worrying about interception by extensions.
187188
// KernalModeTrust should only be available within all code that loads before the first default/any extension.
188189
window.KernalModeTrust = {
189190
CRED_KEY_API,
190-
CRED_KEY_ENTITLEMENTS,
191+
CRED_KEY_PROMO,
192+
SIGNATURE_SALT_KEY,
191193
aesKeys: { key, iv },
192194
setCredential,
193195
getCredential,

src/services/promotions.js

Lines changed: 177 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,96 @@ define(function (require, exports, module) {
4646

4747
// Constants
4848
const EVENT_PRO_UPGRADE_ON_INSTALL = "pro_upgrade_on_install";
49+
const PROMO_LOCAL_FILE = "entitlements_promo.json";
4950
const TRIAL_POLL_MS = 10 * 1000; // 10 seconds after start, we assign a free trial if possible
5051
const FIRST_INSTALL_TRIAL_DAYS = 30;
5152
const SUBSEQUENT_TRIAL_DAYS = 7;
5253
const MS_PER_DAY = 24 * 60 * 60 * 1000;
54+
// the fallback salt is always a constant as this will only fail in rare circumstatnces and it needs to
55+
// be exactly same across versions of the app. Changing this will not breal the large majority of users and
56+
// for the ones who are affected, the app will reset the signed data with new salt but will not grant ant trial
57+
// when tampering is detected.
58+
const FALLBACK_SALT = 'fallback-salt-2f309322-b32d-4d59-85b4-2baef666a9f4';
59+
60+
// Error constants for _getTrialData
61+
const ERR_CORRUPTED = "corrupted";
62+
63+
/**
64+
* Async wrapper for fs.readFile in browser
65+
*/
66+
function _readFileAsync(filePath) {
67+
return new Promise((resolve) => {
68+
window.fs.readFile(filePath, 'utf8', function (err, data) {
69+
resolve(err ? null : data);
70+
});
71+
});
72+
}
73+
74+
/**
75+
* Async wrapper for fs.writeFile in browser
76+
*/
77+
function _writeFileAsync(filePath, data) {
78+
return new Promise((resolve, reject) => {
79+
window.fs.writeFile(filePath, data, 'utf8', (err) => {
80+
if (err) {
81+
reject(err);
82+
} else {
83+
resolve();
84+
}
85+
});
86+
});
87+
}
88+
89+
/**
90+
* Clear trial data from storage (reusable function)
91+
*/
92+
async function _clearTrialData() {
93+
try {
94+
if (Phoenix.isNativeApp) {
95+
await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_PROMO);
96+
} else {
97+
const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE;
98+
await new Promise((resolve) => {
99+
window.fs.unlink(filePath, () => resolve()); // Always resolve, ignore errors
100+
});
101+
}
102+
} catch (error) {
103+
console.log("Error clearing trial data:", error);
104+
}
105+
}
106+
107+
/**
108+
* Get per-user salt for signature generation, creating and persisting one if it doesn't exist
109+
*/
110+
async function _getSalt() {
111+
try {
112+
if (Phoenix.isNativeApp) {
113+
// Native app: use KernalModeTrust credential store
114+
let salt = await KernalModeTrust.getCredential(KernalModeTrust.SIGNATURE_SALT_KEY);
115+
if (!salt) {
116+
// Generate and store new salt
117+
salt = crypto.randomUUID();
118+
await KernalModeTrust.setCredential(KernalModeTrust.SIGNATURE_SALT_KEY, salt);
119+
}
120+
return salt;
121+
}
122+
// in browser app, there is no way to securely store salt without the extensions also being able to
123+
// read it. So we will just return a static salt. In future, we will need to vend trials strongly tied
124+
// to user logins for the browser app, and for desktop app, the current cred storage would work as is.
125+
return FALLBACK_SALT;
126+
} catch (error) {
127+
console.error("Error getting signature salt:", error);
128+
Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "corrupt", "saltErr");
129+
// Return a fallback salt to prevent crashes
130+
return FALLBACK_SALT;
131+
}
132+
}
53133

54134
/**
55135
* Generate SHA-256 signature for trial data integrity
56136
*/
57137
async function _generateSignature(proVersion, endDate) {
58-
const salt = window.AppConfig ? window.AppConfig.version : "default-salt";
138+
const salt = await _getSalt();
59139
const data = proVersion + "|" + endDate + "|" + salt;
60140

61141
// Use browser crypto API for SHA-256 hashing
@@ -79,42 +159,51 @@ define(function (require, exports, module) {
79159
}
80160

81161
/**
82-
* Get stored trial data with validation
162+
* Get stored trial data with validation and corruption detection
163+
* Returns: {data: {...}} for valid data, {error: ERR_CORRUPTED} for errors, or null for no data
83164
*/
84165
async function _getTrialData() {
85166
try {
86167
if (Phoenix.isNativeApp) {
87168
// Native app: use KernalModeTrust credential store
88-
const data = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS);
169+
const data = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_PROMO);
89170
if (!data) {
90-
return null;
171+
return null; // No data exists - genuine first install
172+
}
173+
try {
174+
const trialData = JSON.parse(data);
175+
const isValid = await _isValidSignature(trialData);
176+
if (isValid) {
177+
return { data: trialData }; // Valid trial data
178+
}
179+
return { error: ERR_CORRUPTED }; // Data exists but signature invalid
180+
} catch (e) {
181+
return { error: ERR_CORRUPTED }; // JSON parse error
91182
}
92-
const parsed = JSON.parse(data);
93-
return (await _isValidSignature(parsed)) ? parsed : null;
94183
} else {
95-
// Browser app: use virtual filesystem
96-
return new Promise((resolve) => {
97-
// app support dir in browser is /fs/app/
98-
const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json";
99-
window.fs.readFile(filePath, 'utf8', function (err, data) {
100-
if (err || !data) {
101-
resolve(null);
102-
return;
103-
}
104-
try {
105-
const parsed = JSON.parse(data);
106-
_isValidSignature(parsed).then(isValid => {
107-
resolve(isValid ? parsed : null);
108-
}).catch(() => resolve(null));
109-
} catch (e) {
110-
resolve(null);
111-
}
112-
});
113-
});
184+
// Browser app: use virtual filesystem. in future we need to always fetch from remote about trial
185+
// entitlements for browser app.
186+
const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE;
187+
const fileData = await _readFileAsync(filePath);
188+
189+
if (!fileData) {
190+
return null; // No data exists - genuine first install
191+
}
192+
193+
try {
194+
const trialData = JSON.parse(fileData);
195+
const isValid = await _isValidSignature(trialData);
196+
if (isValid) {
197+
return { data: trialData }; // Valid trial data
198+
}
199+
return { error: ERR_CORRUPTED }; // Data exists but signature invalid
200+
} catch (e) {
201+
return { error: ERR_CORRUPTED }; // JSON parse error
202+
}
114203
}
115204
} catch (error) {
116205
console.error("Error getting trial data:", error);
117-
return null;
206+
return { error: ERR_CORRUPTED }; // Treat error as corrupted/tampered data
118207
}
119208
}
120209

@@ -127,20 +216,11 @@ define(function (require, exports, module) {
127216
try {
128217
if (Phoenix.isNativeApp) {
129218
// Native app: use KernalModeTrust credential store
130-
await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS, JSON.stringify(trialData));
219+
await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(trialData));
131220
} else {
132221
// Browser app: use virtual filesystem
133-
return new Promise((resolve, reject) => {
134-
const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json";
135-
window.fs.writeFile(filePath, JSON.stringify(trialData), 'utf8', (writeErr) => {
136-
if (writeErr) {
137-
console.error("Error storing trial data:", writeErr);
138-
reject(writeErr);
139-
} else {
140-
resolve();
141-
}
142-
});
143-
});
222+
const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE;
223+
await _writeFileAsync(filePath, JSON.stringify(trialData));
144224
}
145225
} catch (error) {
146226
console.error("Error setting trial data:", error);
@@ -191,36 +271,80 @@ define(function (require, exports, module) {
191271
}
192272
}
193273

274+
function _isTrialClosedForCurrentVersion(currentTrialData) {
275+
if(!currentTrialData) {
276+
return false;
277+
}
278+
const currentVersion = window.AppConfig ? window.AppConfig.apiVersion : "1.0.0";
279+
const remainingDays = _calculateRemainingTrialDays(currentTrialData);
280+
const trialVersion = currentTrialData.proVersion;
281+
const isNewerVersion = _isNewerVersion(currentVersion, trialVersion);
282+
const trialClosedDialogShown = currentTrialData.upgradeDialogShownVersion === currentVersion;
283+
// if isCurrentVersionTrialClosed and if remainingDays > 0, it means that user put back system time to
284+
// before trial end. in this case we should not grant any trial.
285+
return trialClosedDialogShown || (remainingDays <= 0 && !isNewerVersion);
286+
}
287+
194288
/**
195289
* Get remaining pro trial days
196290
* Returns 0 if no trial or trial expired
197291
*/
198292
async function getProTrialDaysRemaining() {
199-
const trialData = await _getTrialData();
200-
if (!trialData) {
293+
const result = await _getTrialData();
294+
if (!result || result.error || _isTrialClosedForCurrentVersion(result.data)) {
201295
return 0;
202296
}
203297

204-
return _calculateRemainingTrialDays(trialData);
298+
return _calculateRemainingTrialDays(result.data);
205299
}
206300

207301
async function activateProTrial() {
208302
const currentVersion = window.AppConfig ? window.AppConfig.apiVersion : "1.0.0";
209-
const existingTrialData = await _getTrialData();
303+
const result = await _getTrialData();
210304

211305
let trialDays = FIRST_INSTALL_TRIAL_DAYS;
212306
let endDate;
213307
const now = dateNowFn();
214308
let metricString = `${currentVersion.replaceAll(".", "_")}`; // 3.1.0 -> 3_1_0
215309

310+
// Handle corrupted or parse failed data - reset trial state and deny any trial grants
311+
if (result && result.error) {
312+
console.warn(`Trial data error detected (${result.error}) - resetting trial state without granting trial`);
313+
Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trial", "corrupt");
314+
315+
// Check if user has pro subscription
316+
const hasProSubscription = await _hasProSubscription();
317+
if (hasProSubscription) {
318+
console.log("User has pro subscription - resetting corrupted trial marker");
319+
await _setTrialData({
320+
proVersion: currentVersion,
321+
endDate: now // Expires immediately
322+
});
323+
return;
324+
}
325+
326+
// For corruption, show trial ended dialog and create expired marker
327+
// Do not grant any new trial as possible tampering.
328+
console.warn("trial data corrupted");
329+
ProDialogs.showProEndedDialog(); // Show ended dialog for security
330+
331+
// Create expired trial marker to prevent future trial grants
332+
await _setTrialData({
333+
proVersion: currentVersion,
334+
endDate: now // Expires immediately
335+
});
336+
return;
337+
}
338+
339+
const existingTrialData = result ? result.data : null;
216340
if (existingTrialData) {
217341
// Existing trial found
218342
const remainingDays = _calculateRemainingTrialDays(existingTrialData);
219343
const trialVersion = existingTrialData.proVersion;
220344
const isNewerVersion = _isNewerVersion(currentVersion, trialVersion);
221345

222346
// Check if we should grant any trial
223-
if (remainingDays <= 0 && !isNewerVersion) {
347+
if (_isTrialClosedForCurrentVersion(existingTrialData)) {
224348
// Check if promo ended dialog was already shown for this version
225349
if (existingTrialData.upgradeDialogShownVersion !== currentVersion) {
226350
// Check if user has pro subscription before showing promo dialog
@@ -336,22 +460,18 @@ define(function (require, exports, module) {
336460
ProDialogs: ProDialogs,
337461
_getTrialData: _getTrialData,
338462
_setTrialData: _setTrialData,
339-
_cleanTrialData: async function() {
463+
_getSalt: _getSalt,
464+
_cleanTrialData: _clearTrialData,
465+
_cleanSaltData: async function() {
340466
try {
341467
if (Phoenix.isNativeApp) {
342-
await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS);
343-
} else {
344-
const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json";
345-
return new Promise((resolve) => {
346-
window.fs.unlink(filePath, () => {
347-
// Always resolve, ignore errors since file might not exist
348-
resolve();
349-
});
350-
});
468+
await KernalModeTrust.removeCredential(KernalModeTrust.SIGNATURE_SALT_KEY);
469+
console.log("Salt data cleanup completed");
351470
}
471+
// in browser app we always return a static salt, so no need to clear it
352472
} catch (error) {
353473
// Ignore cleanup errors
354-
console.log("Trial data cleanup completed (ignoring errors)");
474+
console.log("Salt data cleanup completed (ignoring errors)");
355475
}
356476
},
357477
activateProTrial: activateProTrial,
@@ -364,6 +484,9 @@ define(function (require, exports, module) {
364484
FIRST_INSTALL_TRIAL_DAYS,
365485
SUBSEQUENT_TRIAL_DAYS,
366486
MS_PER_DAY
487+
},
488+
ERROR_CONSTANTS: {
489+
ERR_CORRUPTED
367490
}
368491
};
369492
}

0 commit comments

Comments
 (0)