create-qa-architect 5.0.7 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,13 +10,72 @@ const fs = require('fs')
10
10
  const path = require('path')
11
11
  const os = require('os')
12
12
  const crypto = require('crypto')
13
+ const {
14
+ LICENSE_KEY_PATTERN,
15
+ buildLicensePayload,
16
+ hashEmail,
17
+ verifyPayload,
18
+ stableStringify,
19
+ loadKeyFromEnv,
20
+ } = require('./license-signing')
21
+
22
+ /**
23
+ * TD10 fix: Timing-safe string comparison to prevent timing attacks
24
+ * on hash/signature verification.
25
+ *
26
+ * Security enhancement: Uses constant-time operations for length check
27
+ * to avoid leaking length information through timing side-channels.
28
+ */
29
+ function timingSafeEqual(a, b) {
30
+ if (typeof a !== 'string' || typeof b !== 'string') return false
31
+
32
+ // Use constant-time length comparison to avoid timing leaks
33
+ const maxLen = Math.max(a.length, b.length, 1)
34
+ const bufA = Buffer.alloc(maxLen, 0)
35
+ const bufB = Buffer.alloc(maxLen, 0)
36
+
37
+ // Write actual values - this is constant time regardless of length
38
+ Buffer.from(a, 'utf8').copy(bufA)
39
+ Buffer.from(b, 'utf8').copy(bufB)
40
+
41
+ // Both comparisons happen regardless of results (no early return)
42
+ const lengthsMatch = a.length === b.length
43
+ const contentsMatch = crypto.timingSafeEqual(bufA, bufB)
44
+
45
+ return lengthsMatch && contentsMatch
46
+ }
47
+
48
+ /**
49
+ * Validate that a license directory path is safe (no path traversal)
50
+ * Security: Prevents attackers from using QAA_LICENSE_DIR to access arbitrary paths
51
+ */
52
+ function validateLicenseDir(dirPath) {
53
+ const resolved = path.resolve(dirPath)
54
+ const home = os.homedir()
55
+ const tmp = os.tmpdir()
56
+
57
+ // Must be within home directory or temp directory (for tests)
58
+ const isInHome = resolved.startsWith(home + path.sep) || resolved === home
59
+ const isInTmp = resolved.startsWith(tmp + path.sep) || resolved === tmp
60
+
61
+ if (!isInHome && !isInTmp) {
62
+ console.warn(
63
+ `⚠️ QAA_LICENSE_DIR must be within home or temp directory, ignoring: ${dirPath}`
64
+ )
65
+ return path.join(home, '.create-qa-architect')
66
+ }
67
+
68
+ return resolved
69
+ }
13
70
 
14
71
  class LicenseValidator {
15
72
  constructor() {
16
73
  // Support environment variable override for testing (like telemetry/error-reporter)
17
- this.licenseDir =
74
+ // Security: Validate path to prevent traversal attacks
75
+ const requestedDir =
18
76
  process.env.QAA_LICENSE_DIR ||
19
77
  path.join(os.homedir(), '.create-qa-architect')
78
+ this.licenseDir = validateLicenseDir(requestedDir)
20
79
  this.licenseFile = path.join(this.licenseDir, 'license.json')
21
80
  this.legitimateDBFile = path.join(
22
81
  this.licenseDir,
@@ -27,6 +86,21 @@ class LicenseValidator {
27
86
  this.licenseDbUrl =
28
87
  process.env.QAA_LICENSE_DB_URL ||
29
88
  'https://vibebuildlab.com/api/licenses/qa-architect.json'
89
+
90
+ this.licensePublicKey = loadKeyFromEnv(
91
+ process.env.QAA_LICENSE_PUBLIC_KEY,
92
+ process.env.QAA_LICENSE_PUBLIC_KEY_PATH
93
+ )
94
+
95
+ // DR14 fix: Add in-memory cache with 5-minute TTL
96
+ this.dbCache = null
97
+ this.dbCacheTime = 0
98
+ this.cacheTTL = 5 * 60 * 1000 // 5 minutes
99
+ }
100
+
101
+ normalizeLicenseKey(key) {
102
+ if (typeof key !== 'string') return ''
103
+ return key.trim().toUpperCase()
30
104
  }
31
105
 
32
106
  ensureLicenseDir() {
@@ -69,19 +143,43 @@ class LicenseValidator {
69
143
  * Load legitimate licenses from the cached database
70
144
  */
71
145
  loadLegitimateDatabase() {
72
- try {
73
- if (fs.existsSync(this.legitimateDBFile)) {
74
- const data = fs.readFileSync(this.legitimateDBFile, 'utf8')
75
- const parsed = JSON.parse(data)
146
+ // File doesn't exist - expected for first run
147
+ if (!fs.existsSync(this.legitimateDBFile)) {
148
+ return {}
149
+ }
76
150
 
77
- // Remove metadata for license lookup
78
- const { _metadata, ...licenses } = parsed
79
- return licenses
151
+ try {
152
+ const data = fs.readFileSync(this.legitimateDBFile, 'utf8')
153
+ const parsed = JSON.parse(data)
154
+
155
+ try {
156
+ this.verifyRegistrySignature(parsed)
157
+ } catch (error) {
158
+ console.warn(`⚠️ Cached license database invalid: ${error.message}`)
159
+ return {}
80
160
  }
161
+
162
+ // Remove metadata for license lookup
163
+ const { _metadata, ...licenses } = parsed
164
+ return licenses
81
165
  } catch (error) {
82
- console.error('Error loading legitimate license database:', error.message)
166
+ // Silent failure fix: Differentiate error types for actionable messages
167
+ if (error.code === 'EACCES') {
168
+ console.error(
169
+ `❌ Permission denied reading license database: ${this.legitimateDBFile}`
170
+ )
171
+ } else if (error instanceof SyntaxError) {
172
+ console.error(
173
+ `❌ License database is corrupted (invalid JSON): ${error.message}`
174
+ )
175
+ } else {
176
+ console.error(
177
+ 'Error loading legitimate license database:',
178
+ error.message
179
+ )
180
+ }
181
+ return {}
83
182
  }
84
- return {}
85
183
  }
86
184
 
87
185
  /**
@@ -95,12 +193,31 @@ class LicenseValidator {
95
193
  * Fetch latest legitimate licenses from server (if available)
96
194
  */
97
195
  async fetchLegitimateDatabase() {
196
+ // DR14 fix: Check in-memory cache first (5-min TTL)
197
+ const now = Date.now()
198
+ if (this.dbCache && now - this.dbCacheTime < this.cacheTTL) {
199
+ console.log('✅ Using cached license database (fresh)')
200
+ return this.dbCache
201
+ }
202
+
98
203
  try {
99
204
  this.ensureLicenseDir()
100
205
  console.log(
101
206
  `🔄 Fetching latest license database from ${this.licenseDbUrl} ...`
102
207
  )
103
208
 
209
+ const parsedUrl = new URL(this.licenseDbUrl)
210
+ const isTest = process.argv.join(' ').includes('test')
211
+ if (
212
+ parsedUrl.protocol !== 'https:' &&
213
+ !process.env.QAA_ALLOW_INSECURE_LICENSE_DB &&
214
+ !isTest
215
+ ) {
216
+ throw new Error(
217
+ 'license database URL must use HTTPS (set QAA_ALLOW_INSECURE_LICENSE_DB=1 to override)'
218
+ )
219
+ }
220
+
104
221
  const controller = new AbortController()
105
222
  const timeout = setTimeout(() => controller.abort(), 10000)
106
223
  const response = await fetch(this.licenseDbUrl, {
@@ -119,27 +236,48 @@ class LicenseValidator {
119
236
  throw new Error('invalid database format')
120
237
  }
121
238
 
122
- // Mandatory integrity verification
123
- if (!database._metadata.sha256) {
124
- throw new Error('license database missing sha256 checksum')
125
- }
126
-
127
- const { _metadata, ...licenses } = database
128
- const computed = this.computeSha256(JSON.stringify(licenses))
129
- if (computed !== database._metadata.sha256) {
130
- throw new Error('license database integrity check failed')
131
- }
239
+ this.verifyRegistrySignature(database)
132
240
 
133
241
  // Cache locally for offline use
134
242
  fs.writeFileSync(this.legitimateDBFile, JSON.stringify(database, null, 2))
135
243
  console.log('✅ License database updated and cached')
136
244
 
245
+ // Remove metadata for license lookup
246
+ const { _metadata, ...licenses } = database
247
+
248
+ // DR14 fix: Update in-memory cache
249
+ this.dbCache = licenses
250
+ this.dbCacheTime = Date.now()
251
+
137
252
  return licenses
138
253
  } catch (error) {
139
254
  const isTest = process.argv.join(' ').includes('test')
140
255
  const prefix = isTest ? '📋 TEST SCENARIO:' : '⚠️'
141
256
  console.log(`${prefix} Database fetch failed: ${error.message}`)
142
- return this.loadLegitimateDatabase()
257
+
258
+ // DR2 fix: Check if cached database has data before falling back
259
+ const cachedDb = this.loadLegitimateDatabase()
260
+ const hasCachedData = Object.keys(cachedDb || {}).length > 0
261
+
262
+ if (!hasCachedData) {
263
+ console.error('❌ No cached license database available')
264
+ console.error(
265
+ ' You need an internet connection to activate new licenses'
266
+ )
267
+ console.error(' If you recently purchased, please retry when online')
268
+ throw new Error(
269
+ 'Cannot validate license: network unavailable and no cached database. Please connect to the internet and retry.'
270
+ )
271
+ }
272
+
273
+ // Only fall back to cache if it has data
274
+ console.log(
275
+ `✅ Using cached database (${Object.keys(cachedDb).length} licenses)`
276
+ )
277
+ console.log(
278
+ ' Note: Cache may be outdated. Connect to internet for latest licenses.'
279
+ )
280
+ return cachedDb
143
281
  }
144
282
  }
145
283
 
@@ -148,11 +286,12 @@ class LicenseValidator {
148
286
  */
149
287
  async validateLicense(licenseKey, userEmail) {
150
288
  try {
289
+ const normalizedKey = this.normalizeLicenseKey(licenseKey)
151
290
  // Check if already activated locally
152
291
  const localLicense = this.getLocalLicense()
153
292
  if (
154
293
  localLicense &&
155
- localLicense.licenseKey === licenseKey &&
294
+ localLicense.licenseKey === normalizedKey &&
156
295
  localLicense.valid
157
296
  ) {
158
297
  return {
@@ -168,7 +307,7 @@ class LicenseValidator {
168
307
  const legitimateDB = await this.fetchLegitimateDatabase()
169
308
 
170
309
  // If database is empty (no licenses), fail with actionable message
171
- const licenseInfo = legitimateDB[licenseKey]
310
+ const licenseInfo = legitimateDB[normalizedKey]
172
311
  const hasLicenses = Object.keys(legitimateDB || {}).length > 0
173
312
 
174
313
  if (!hasLicenses) {
@@ -187,10 +326,21 @@ class LicenseValidator {
187
326
  }
188
327
  }
189
328
 
329
+ // DR21 fix: Validate email format before hashing to prevent timing attacks on invalid emails
330
+ if (userEmail && !userEmail.includes('@')) {
331
+ return {
332
+ valid: false,
333
+ error: 'Invalid email format. Please provide a valid email address.',
334
+ }
335
+ }
336
+
190
337
  // Verify email matches (if specified in database)
338
+ // TD10 fix: Use timing-safe comparison to prevent timing attacks
339
+ const emailHash = hashEmail(userEmail)
191
340
  if (
192
- licenseInfo.email &&
193
- licenseInfo.email.toLowerCase() !== userEmail.toLowerCase()
341
+ emailHash &&
342
+ licenseInfo.emailHash &&
343
+ !timingSafeEqual(licenseInfo.emailHash, emailHash)
194
344
  ) {
195
345
  return {
196
346
  valid: false,
@@ -199,6 +349,29 @@ class LicenseValidator {
199
349
  }
200
350
  }
201
351
 
352
+ const payload = buildLicensePayload({
353
+ licenseKey: normalizedKey,
354
+ tier: licenseInfo.tier,
355
+ isFounder: licenseInfo.isFounder,
356
+ emailHash: licenseInfo.emailHash,
357
+ issued: licenseInfo.issued,
358
+ })
359
+
360
+ if (!licenseInfo.signature) {
361
+ return {
362
+ valid: false,
363
+ error: 'License entry missing signature. Please contact support.',
364
+ }
365
+ }
366
+
367
+ if (!this.verifySignature(payload, licenseInfo.signature)) {
368
+ return {
369
+ valid: false,
370
+ error:
371
+ 'License entry signature verification failed. Please contact support.',
372
+ }
373
+ }
374
+
202
375
  // License is valid
203
376
  console.log(
204
377
  `✅ License validated: ${licenseInfo.tier} ${licenseInfo.isFounder ? '(Founder)' : ''}`
@@ -210,6 +383,8 @@ class LicenseValidator {
210
383
  isFounder: licenseInfo.isFounder || false,
211
384
  customerId: licenseInfo.customerId,
212
385
  email: userEmail,
386
+ signature: licenseInfo.signature,
387
+ payload,
213
388
  source: 'legitimate_database',
214
389
  }
215
390
  } catch (error) {
@@ -226,26 +401,57 @@ class LicenseValidator {
226
401
  * Get local license file if it exists
227
402
  */
228
403
  getLocalLicense() {
229
- if (fs.existsSync(this.licenseFile)) {
230
- // Let JSON parse errors propagate to caller for proper error handling
231
- const license = JSON.parse(fs.readFileSync(this.licenseFile, 'utf8'))
404
+ if (!fs.existsSync(this.licenseFile)) {
405
+ return null
406
+ }
407
+
408
+ try {
409
+ const content = fs.readFileSync(this.licenseFile, 'utf8')
410
+ const license = JSON.parse(content)
411
+ const normalizedKey = this.normalizeLicenseKey(
412
+ license.licenseKey || license.key
413
+ )
232
414
 
233
- // Check signature if present
234
415
  if (license.payload && license.signature) {
235
416
  const isValid = this.verifySignature(license.payload, license.signature)
236
- return { ...license, valid: isValid }
417
+ return {
418
+ ...license,
419
+ licenseKey: normalizedKey,
420
+ valid: isValid,
421
+ }
237
422
  }
238
423
 
239
- // Legacy format
424
+ // Legacy format (unsigned) is no longer trusted
240
425
  return {
241
426
  ...license,
242
- valid: true,
427
+ licenseKey: normalizedKey,
428
+ valid: false,
243
429
  tier: license.tier,
244
- licenseKey: license.licenseKey || license.key,
245
430
  email: license.email,
246
431
  }
432
+ } catch (error) {
433
+ // DR3 fix: Handle corrupted license files gracefully
434
+ if (error instanceof SyntaxError) {
435
+ console.error('❌ License file is corrupted (invalid JSON)')
436
+ console.error(` File: ${this.licenseFile}`)
437
+ console.error(' Please re-activate your license:')
438
+ console.error(' npx create-qa-architect@latest --activate-license')
439
+
440
+ // Backup corrupted file
441
+ const backupPath = `${this.licenseFile}.corrupted.${Date.now()}`
442
+ try {
443
+ fs.copyFileSync(this.licenseFile, backupPath)
444
+ console.error(` Corrupted file backed up to: ${backupPath}`)
445
+ } catch (_backupError) {
446
+ // Ignore backup failures
447
+ }
448
+
449
+ return null
450
+ }
451
+
452
+ console.error(`❌ Error reading license file: ${error.message}`)
453
+ return null
247
454
  }
248
- return null
249
455
  }
250
456
 
251
457
  /**
@@ -255,25 +461,24 @@ class LicenseValidator {
255
461
  try {
256
462
  this.initialize()
257
463
 
258
- const payload = {
259
- customerId: licenseData.customerId,
260
- tier: licenseData.tier,
261
- isFounder: licenseData.isFounder,
262
- email: licenseData.email,
263
- issued: Date.now(),
264
- version: '1.0',
265
- }
266
-
267
- const signature = this.signPayload(payload)
268
-
269
464
  const licenseRecord = {
270
465
  licenseKey: licenseData.licenseKey,
271
466
  tier: licenseData.tier,
272
467
  isFounder: licenseData.isFounder,
273
468
  email: licenseData.email,
274
469
  activated: new Date().toISOString(),
275
- payload,
276
- signature,
470
+ payload: licenseData.payload,
471
+ signature: licenseData.signature,
472
+ source: licenseData.source || 'legitimate_database',
473
+ verifiedAt: new Date().toISOString(),
474
+ }
475
+
476
+ if (!licenseRecord.payload || !licenseRecord.signature) {
477
+ return {
478
+ success: false,
479
+ error:
480
+ 'Missing license signature payload. Please re-activate online.',
481
+ }
277
482
  }
278
483
 
279
484
  fs.writeFileSync(this.licenseFile, JSON.stringify(licenseRecord, null, 2))
@@ -291,43 +496,123 @@ class LicenseValidator {
291
496
  }
292
497
 
293
498
  /**
294
- * Sign payload for validation
499
+ * Check if developer mode bypass is allowed.
500
+ * Security: In production mode, never allow developer bypass to prevent
501
+ * accidental security misconfiguration.
295
502
  */
296
- signPayload(payload) {
297
- const secret =
298
- process.env.LICENSE_SIGNING_SECRET || 'cqa-dev-secret-change-in-prod'
299
- return crypto
300
- .createHmac('sha256', secret)
301
- .update(JSON.stringify(payload))
302
- .digest('hex')
503
+ isDevBypassAllowed() {
504
+ // Production mode always enforces signature verification
505
+ if (process.env.NODE_ENV === 'production') {
506
+ return false
507
+ }
508
+ return process.env.QAA_DEVELOPER === 'true'
303
509
  }
304
510
 
305
- /**
306
- * Verify signature
307
- */
308
511
  verifySignature(payload, signature) {
309
- const expectedSignature = this.signPayload(payload)
512
+ if (!this.licensePublicKey) {
513
+ // TD12 fix: Log warning when public key is missing in non-dev mode
514
+ if (!this.isDevBypassAllowed()) {
515
+ console.warn(
516
+ '⚠️ License public key not configured - signature verification skipped'
517
+ )
518
+ }
519
+ return this.isDevBypassAllowed()
520
+ }
310
521
  try {
311
- return crypto.timingSafeEqual(
312
- Buffer.from(signature),
313
- Buffer.from(expectedSignature)
314
- )
315
- } catch {
522
+ return verifyPayload(payload, signature, this.licensePublicKey)
523
+ } catch (error) {
524
+ // DR16 fix: Provide specific error types for better debugging
525
+ const msg = error.message?.toLowerCase() || ''
526
+
527
+ if (msg.includes('circular reference')) {
528
+ console.error(
529
+ '❌ Signature verification failed: License payload has circular reference'
530
+ )
531
+ console.error(
532
+ ' This indicates data corruption. Please contact support.'
533
+ )
534
+ } else if (msg.includes('key') || msg.includes('pem')) {
535
+ console.error(
536
+ '❌ Signature verification failed: Invalid public key format'
537
+ )
538
+ console.error(
539
+ ' Check QAA_LICENSE_PUBLIC_KEY or QAA_LICENSE_PUBLIC_KEY_PATH'
540
+ )
541
+ } else if (msg.includes('signature') && msg.includes('invalid')) {
542
+ console.error(
543
+ '❌ Signature verification failed: Corrupted signature data'
544
+ )
545
+ console.error(
546
+ ' License may have been tampered with. Please re-activate.'
547
+ )
548
+ } else if (msg.includes('algorithm')) {
549
+ console.error('❌ Signature verification failed: Algorithm mismatch')
550
+ console.error(
551
+ ' License was signed with different algorithm than expected.'
552
+ )
553
+ } else {
554
+ console.warn(`⚠️ Signature verification failed: ${error.message}`)
555
+ console.warn(
556
+ ' If this persists, please contact support with this error message.'
557
+ )
558
+ }
316
559
  return false
317
560
  }
318
561
  }
319
562
 
563
+ verifyRegistrySignature(database) {
564
+ // DR17 fix: Dev mode should still verify signatures if present, only bypass when missing
565
+ const isDevMode = this.isDevBypassAllowed()
566
+ const signature = database?._metadata?.registrySignature
567
+
568
+ // Missing signature handling
569
+ if (!signature) {
570
+ if (isDevMode) {
571
+ console.warn(
572
+ '⚠️ DEV MODE: License database signature missing (bypassed)'
573
+ )
574
+ return true
575
+ }
576
+ throw new Error('license database missing registry signature')
577
+ }
578
+
579
+ // Missing public key handling
580
+ if (!this.licensePublicKey) {
581
+ if (isDevMode) {
582
+ console.warn(
583
+ '⚠️ DEV MODE: License public key not configured (bypassed)'
584
+ )
585
+ return true
586
+ }
587
+ throw new Error('license public key not configured')
588
+ }
589
+
590
+ // Always verify signature if both signature and key are present
591
+ const { _metadata, ...licenses } = database
592
+ const isValid = verifyPayload(licenses, signature, this.licensePublicKey)
593
+ if (!isValid) {
594
+ throw new Error('license database signature verification failed')
595
+ }
596
+
597
+ // TD10 fix: Use timing-safe comparison for hash verification
598
+ const expectedHash = database?._metadata?.hash
599
+ if (expectedHash) {
600
+ const computed = this.computeSha256(stableStringify(licenses))
601
+ if (!timingSafeEqual(computed, expectedHash)) {
602
+ throw new Error('license database hash mismatch')
603
+ }
604
+ }
605
+ return true
606
+ }
607
+
320
608
  /**
321
609
  * Activate license (main user entry point)
322
610
  */
323
611
  async activateLicense(licenseKey, userEmail) {
324
612
  try {
325
- // Validate license key format
326
- if (
327
- !licenseKey.match(
328
- /^QAA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/
329
- )
330
- ) {
613
+ const normalizedKey = this.normalizeLicenseKey(licenseKey)
614
+ // Validate license key format (TD15 fix: use shared constant)
615
+ if (!LICENSE_KEY_PATTERN.test(normalizedKey)) {
331
616
  return {
332
617
  success: false,
333
618
  error:
@@ -357,11 +642,14 @@ class LicenseValidator {
357
642
 
358
643
  // Save locally
359
644
  const saveResult = this.saveLicense({
360
- licenseKey,
645
+ licenseKey: normalizedKey,
361
646
  tier: validation.tier,
362
647
  isFounder: validation.isFounder,
363
648
  email: userEmail,
364
649
  customerId: validation.customerId,
650
+ source: validation.source,
651
+ payload: validation.payload,
652
+ signature: validation.signature,
365
653
  })
366
654
 
367
655
  if (saveResult.success) {