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