UNPKG

spamir-updater

Version:

Secure automatic update client for Node.js applications with encrypted communication and package verification

696 lines (599 loc) 26.7 kB
const fs = require('fs'); const path = require('path'); const os = require('os'); const AdmZip = require('adm-zip'); const NetworkHandler = require('./network'); const EncryptionHandler = require('./encryption'); const { loadVersionFromConfig, saveVersionToConfig, getSystemInfo, generateInstanceSignature, logToFile } = require('./utils'); // Constants - these remain the same for all product implementations const SHARED_AUTH_TOKEN = "0leCsb1QQNcswtnuOZdQ8zlqgYKSz0RMd9ZKMSCA76A="; const ORIGIN_ENDPOINT_BASE = "https://spamir.io"; const CORE_HANDLER_VERSION = "1.0"; const GLOBAL_ENCRYPTION_ITERATIONS = 100000; const API_BASE_PATH_SEGMENT = "/endpoint/v1/updater/"; /** * Main updater client class */ class UpdaterClient { constructor(options = {}) { // Required configuration if (!options.productIdentifier) { throw new Error('productIdentifier is required'); } this.productIdentifier = options.productIdentifier; this.currentVersion = options.currentVersion || null; // Optional overrides (mainly for testing) this.authToken = options.authToken || SHARED_AUTH_TOKEN; this.endpointBase = options.endpointBase || ORIGIN_ENDPOINT_BASE; this.handlerVersion = options.handlerVersion || CORE_HANDLER_VERSION; this.encryptionIterations = options.encryptionIterations || GLOBAL_ENCRYPTION_ITERATIONS; // Generate unique instance signature this.instanceMarker = generateInstanceSignature(this.authToken); this.systemInfo = getSystemInfo(); // Initialize handlers this.encryption = new EncryptionHandler(this.authToken, this.encryptionIterations); this.network = new NetworkHandler( this.endpointBase, API_BASE_PATH_SEGMENT, this.handlerVersion, this.instanceMarker ); // Session variables this.sessionToken = null; this.clientNonce = null; this.directiveResults = []; // Track if updater is running this.isRunning = false; } /** * Initialize version from config or default */ async initializeVersion() { if (!this.currentVersion) { // Try to load from config.json const configVersion = await loadVersionFromConfig(); if (configVersion) { this.currentVersion = configVersion; logToFile(`Loaded version from config.json: ${this.currentVersion}`); } else { // Default to 1.0 if no version found this.currentVersion = '1.0'; logToFile(`No version in config.json, using default: ${this.currentVersion}`); } } } /** * Establish secure control channel with server * @returns {Promise<Object|null>} Sync response or null on error */ async establishControlChannel() { // Generate client nonce this.clientNonce = this.encryption.generateNonce(); const payload = { client_version: this.instanceMarker, current_version: this.currentVersion, product_identifier: this.productIdentifier, system_info: this.systemInfo, client_nonce_b64: this.clientNonce }; const payloadJson = JSON.stringify(payload, null, 0); const signature = this.encryption.signData(payloadJson); const response = await this.network.sendRequest( 'sync_check', payloadJson, signature, true ); if (!response) { logToFile('Failed to establish control channel', 'ERROR'); return null; } // Extract session token and server nonce const serverNonce = response.server_nonce; this.sessionToken = response.session_token; if (serverNonce && this.sessionToken) { // Negotiate encryption const success = this.encryption.negotiateSecureLayer(this.clientNonce, serverNonce); if (!success) { logToFile('Failed to negotiate secure layer', 'ERROR'); this.sessionToken = null; return null; } } return response; } /** * Download update package from server * @param {Object} assetDetails - Asset details from server * @returns {Promise<Buffer|null>} Package data or null on error */ async downloadPackage(assetDetails) { if (!this.sessionToken) { logToFile('No session token for asset download', 'ERROR'); return null; } const downloadToken = assetDetails.download_token; if (!downloadToken) { logToFile('No download token in asset details', 'ERROR'); return null; } const payload = { version: assetDetails.version, current_version: this.currentVersion, instance_marker: this.instanceMarker, product_identifier: this.productIdentifier, session_token: this.sessionToken, download_token: downloadToken }; // Create signature without session_token const hmacPayload = { version: assetDetails.version, current_version: this.currentVersion, instance_marker: this.instanceMarker, product_identifier: this.productIdentifier, download_token: downloadToken }; // Must match Python's json.dumps with sort_keys=True, separators=(',', ':') const sortedKeys = Object.keys(hmacPayload).sort(); const sortedPayload = {}; sortedKeys.forEach(key => sortedPayload[key] = hmacPayload[key]); const hmacJson = JSON.stringify(sortedPayload, null, 0); const signature = this.encryption.signData(hmacJson); const response = await this.network.downloadAsset(payload, signature); // Enhanced debugging if (!response) { logToFile('Asset download failed: No response from server', 'ERROR'); return null; } if (!response.package) { logToFile(`Asset download failed: Missing package field. Response keys: ${Object.keys(response).join(', ')}`, 'ERROR'); return null; } if (!response.hash) { logToFile(`Asset download failed: Missing hash field. Response keys: ${Object.keys(response).join(', ')}`, 'ERROR'); return null; } let packageData; const isEncrypted = response.encrypted || false; if (isEncrypted) { // Decrypt package packageData = this.encryption.decryptPayload(response.package); if (!packageData) { logToFile('Failed to decrypt package', 'ERROR'); return null; } } else { // Decode base64 packageData = Buffer.from(response.package, 'base64'); } // Verify hash const computedHash = this.encryption.computeHash(packageData); if (computedHash !== response.hash) { logToFile(`Package hash mismatch:`, 'ERROR'); logToFile(` Expected: ${response.hash}`, 'ERROR'); logToFile(` Computed: ${computedHash}`, 'ERROR'); logToFile(` Package size: ${packageData.length} bytes`, 'ERROR'); logToFile(` Is encrypted: ${isEncrypted}`, 'ERROR'); return null; } logToFile(`Package downloaded successfully: ${packageData.length} bytes, hash verified`, 'INFO'); return packageData; } /** * Extract update package to current directory * @param {Buffer} packageData - ZIP package data * @param {string} newVersion - New version string * @returns {Promise<string>} Status: 'success', 'failed_extraction', or 'failed_bad_format' */ async extractPackage(packageData, newVersion) { // Check if it's a ZIP file (starts with PK) if (!packageData.slice(0, 4).equals(Buffer.from([0x50, 0x4B, 0x03, 0x04]))) { return 'failed_bad_format'; } try { const zip = new AdmZip(packageData); const extractPath = process.cwd(); // Extract all files zip.extractAllTo(extractPath, true); // Update version this.currentVersion = newVersion; logToFile(`Package extracted successfully, version updated to ${newVersion}`); return 'success'; } catch (error) { logToFile(`Extraction failed: ${error.message}`, 'ERROR'); return 'failed_extraction'; } } /** * Process directive from server * @param {string} downloadToken - Directive download token * @param {string} directiveName - Name for logging * @returns {Promise<Object>} Result object */ async processDirective(downloadToken, directiveName = 'Directive') { if (!this.sessionToken) { return { status: 'error', message: 'No session token' }; } const payload = { download_token: downloadToken, version: this.currentVersion, instance_marker: this.instanceMarker, product_identifier: this.productIdentifier, session_token: this.sessionToken }; const hmacPayload = { download_token: downloadToken, version: this.currentVersion, instance_marker: this.instanceMarker, product_identifier: this.productIdentifier }; // Sort keys for consistent HMAC const sortedKeys = Object.keys(hmacPayload).sort(); const sortedPayload = {}; sortedKeys.forEach(key => sortedPayload[key] = hmacPayload[key]); const hmacJson = JSON.stringify(sortedPayload, null, 0); const signature = this.encryption.signData(hmacJson); const response = await this.network.sendRequest( 'fetch_directive', payload, signature, false ); if (!response || !response.code || !response.hmac) { return { status: 'error', message: 'Invalid directive response' }; } let directiveCode; const isEncrypted = response.encrypted || false; if (isEncrypted) { const decrypted = this.encryption.decryptPayload(response.code); if (!decrypted) { return { status: 'error', message: 'Failed to decrypt directive' }; } directiveCode = decrypted.toString('utf8'); } else { directiveCode = Buffer.from(response.code, 'base64').toString('utf8'); } // Verify HMAC if (!this.encryption.verifySignature(directiveCode, response.hmac)) { return { status: 'error', message: 'Directive HMAC verification failed' }; } // Execute the directive like Python version does try { // Create temporary directory (cross-platform) const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'directive-')); const tempFilePath = path.join(tempDir, 'directive.js'); // Write directive code to temporary file fs.writeFileSync(tempFilePath, directiveCode, 'utf8'); // Parameters to pass to the directive const serviceParams = { instance_marker: this.instanceMarker, asset_version: this.currentVersion }; let result = { status: 'error', message: 'Request could not be processed.' }; try { // Clear require cache to ensure fresh execution delete require.cache[require.resolve(tempFilePath)]; // Load and execute the directive module const directiveModule = require(tempFilePath); // Check if main function exists and is callable if (directiveModule.main && typeof directiveModule.main === 'function') { const moduleResponse = directiveModule.main(serviceParams); if (typeof moduleResponse === 'object' && moduleResponse !== null) { result = moduleResponse; } else { result = { status: 'ok', message: 'Request completed.', return_value: String(moduleResponse) }; } } else { result = { status: 'error', message: "Module 'main' interface not found." }; } } catch (execError) { logToFile(`Directive execution error: ${execError.message}`, 'ERROR'); result = { status: 'error', message: 'Directive execution failed', error: execError.message }; } finally { // Clean up temporary files try { fs.unlinkSync(tempFilePath); fs.rmdirSync(tempDir); } catch (cleanupError) { // Ignore cleanup errors } } return result; } catch (error) { logToFile(`Directive processing error: ${error.message}`, 'ERROR'); return { status: 'error', message: 'Failed to process directive', error: error.message }; } } /** * Report directive outcome to server * @param {string} directiveName - Directive name * @param {string} directiveVersion - Directive version * @param {Object} resultDict - Result data * @param {Object} options - Additional options for immediate directives * @returns {Promise<boolean>} Success status */ async reportDirectiveOutcome(directiveName, directiveVersion, resultDict, options = {}) { if (!this.sessionToken) { return false; } // Different field names for immediate vs queued directives const isQueued = options.queuedId !== undefined; const payload = isQueued ? { // For queued directives (update_queued_directive_status.php) instance_marker: this.instanceMarker, product_identifier: this.productIdentifier, queued_id: String(options.queuedId), directive_name: directiveName, directive_version: directiveVersion, session_token: this.sessionToken, result_is_encrypted: 'False' } : { // For immediate directives (report_directive_result.php) client_version: this.instanceMarker, // API expects client_version for the UUID product_identifier: this.productIdentifier, directive_name: directiveName, directive_version: directiveVersion, session_token: this.sessionToken, result_is_encrypted: 'False' }; // Add immediate directive specific fields if (!isQueued && options.isImmediate && options.scriptId !== undefined) { payload.script_id = String(options.scriptId); payload.is_recurring = options.isRecurring ? '1' : '0'; } const resultJson = JSON.stringify(resultDict); let finalPayload = resultJson; // Encrypt if we have a channel key if (this.encryption.currentChannelKey) { const encrypted = this.encryption.encryptPayload(Buffer.from(resultJson, 'utf8')); if (encrypted) { finalPayload = encrypted; payload.result_is_encrypted = 'True'; } } // Different field name for immediate vs queued if (isQueued) { payload.outcome_data = finalPayload; // Queued uses outcome_data } else { payload.execution_result = finalPayload; // Immediate uses execution_result } // Must match Python's json.dumps with sort_keys=True, separators=(',', ':') const sortedKeys = Object.keys(payload).sort(); const sortedPayload = {}; sortedKeys.forEach(key => sortedPayload[key] = payload[key]); const hmacJson = JSON.stringify(sortedPayload, null, 0); const signature = this.encryption.signData(hmacJson); const actionKey = options.queuedId !== undefined ? 'update_directive_status' : 'report_outcome'; const response = await this.network.sendRequest( actionKey, payload, signature, false ); if (response && response.message) { logToFile(`Directive Execution Log: ${response.message}`, 'INFO'); } return response && response.success; } /** * Perform complete update cycle * @returns {Promise<Object>} Update result */ async performUpdateCycle() { // Clear previous directive results this.directiveResults = []; // Establish secure channel const syncResponse = await this.establishControlChannel(); if (!syncResponse) { return { status: 'sync_failed', message: 'Failed to establish control channel', version: this.currentVersion }; } let overallStatus = 'no_update'; let newVersionDetails = null; // Check for update package const updatePackage = syncResponse.update_package; logToFile(`Sync response received. Has update_package: ${!!updatePackage}`, 'INFO'); if (updatePackage && updatePackage.new_version) { const offeredVersion = updatePackage.new_version; logToFile(`Update available: ${this.currentVersion} -> ${offeredVersion}`, 'INFO'); if (offeredVersion !== this.currentVersion) { overallStatus = 'update_started'; newVersionDetails = offeredVersion; // Prepare asset details for download const assetDetails = { ...updatePackage }; assetDetails.version = offeredVersion; delete assetDetails.new_version; logToFile(`Asset details prepared. Has download_token: ${!!assetDetails.download_token}`, 'INFO'); // Download package const packageData = await this.downloadPackage(assetDetails); if (packageData) { // Extract package const extractResult = await this.extractPackage(packageData, offeredVersion); if (extractResult === 'success') { overallStatus = 'update_success'; // Save new version to config await saveVersionToConfig(offeredVersion); } else { overallStatus = 'update_failed'; } } else { overallStatus = 'update_failed'; logToFile('Package download or extraction failed', 'ERROR'); } } else { logToFile(`Version ${offeredVersion} is same as current version`, 'INFO'); } } else { if (!updatePackage) { logToFile('No update package in sync response', 'INFO'); } else if (!updatePackage.new_version) { logToFile('Update package missing new_version field', 'WARNING'); } } // Process immediate directive if present const immediateDirective = syncResponse.immediate_directive; if (immediateDirective && immediateDirective.download_token) { const result = await this.processDirective( immediateDirective.download_token, immediateDirective.directive_name || 'UnknownDirective' ); // Store directive result for the application this.directiveResults.push({ type: 'immediate', name: immediateDirective.directive_name || 'UnknownDirective', version: immediateDirective.version || 'N/A', result: result }); await this.reportDirectiveOutcome( immediateDirective.directive_name || 'UnknownDirective', immediateDirective.version || 'N/A', result, { isImmediate: true, scriptId: immediateDirective.script_id, isRecurring: immediateDirective.is_recurring || false } ); if (overallStatus === 'no_update') { overallStatus = 'directives_processed'; } } // Process queued directives const queuedDirectives = syncResponse.queued_directives || []; for (const directive of queuedDirectives) { if (directive.download_token) { const result = await this.processDirective( directive.download_token, directive.directive_name || 'UnknownDirective' ); // Store directive result for the application this.directiveResults.push({ type: 'queued', name: directive.directive_name || 'UnknownDirective', version: directive.version || 'N/A', result: result }); await this.reportDirectiveOutcome( directive.directive_name || 'UnknownDirective', directive.version || 'N/A', result, { queuedId: directive.queued_id } ); if (overallStatus === 'no_update') { overallStatus = 'directives_processed'; } } } return { status: overallStatus, message: `Cycle completed. Final version: ${this.currentVersion}`, version: this.currentVersion, new_version: overallStatus === 'update_success' ? this.currentVersion : newVersionDetails }; } /** * Main method to check for updates * @returns {Promise<Object>} Update status */ async checkForUpdates() { if (this.isRunning) { return { status: 'error', message: 'Update check already in progress', version: this.currentVersion || '1.0' }; } this.isRunning = true; try { // Initialize version if not set await this.initializeVersion(); // Perform update cycle const result = await this.performUpdateCycle(); // Format response based on result if (result.status === 'update_success') { return { status: 'update_available', message: `Update completed successfully to version ${result.version}`, version: this.currentVersion, new_version: result.version }; } else if (result.status === 'no_update') { const response = { status: 'no_update', message: 'Application is up to date', version: this.currentVersion }; // Include directive results if any were processed if (this.directiveResults.length > 0) { response.directiveResults = this.directiveResults; } return response; } else if (result.status === 'directives_processed') { return { status: 'no_update', message: 'Directives processed. Update cycle completed.', version: this.currentVersion, directiveResults: this.directiveResults }; } else if (result.status === 'sync_failed') { return { status: 'sync_failed', message: 'Failed to connect to update server', version: this.currentVersion }; } else { return { status: 'error', message: result.message || 'Update check failed', version: this.currentVersion }; } } catch (error) { logToFile(`Update check error: ${error.message}`, 'ERROR'); // Report error to server if possible if (this.instanceMarker && this.productIdentifier) { const errorData = { instance_marker: this.instanceMarker, product_identifier: this.productIdentifier, agent_version: this.handlerVersion, error_message: error.message, stack_trace: error.stack || '' }; const errorJson = JSON.stringify(errorData, null, 0); const signature = this.encryption.signData(errorJson); await this.network.reportError(errorData, signature); } return { status: 'error', message: `Update check failed: ${error.message}`, version: this.currentVersion || '1.0' }; } finally { this.isRunning = false; } } } module.exports = UpdaterClient;