SafeUpdater is designed to provide a secure model and reliable update mechanisms for Electron applications. We welcome contributions from developers of all levels, whether it's fixing bugs, improving documentation, adding new features, or enhancing security.
This guide will walk you through everything you need to know to set up your development environment and understand the architecture.
- CONTRIBUTING
┌─────────────────────────────────────────────────────────────────┐
│ Electron App │
├─────────────────────────────────────────────────────────────────┤
│ Main Process │
│ ┌───────────────────┐ │
│ │ SafeUpdater.start() │──► Initializes updater system │
│ └───────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ Updater (Base) │──► Abstract class with core logic │
│ └───────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ MacOSUpdater │──► Platform-specific implementation │
│ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
│ HTTPS (TLS)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Update Server │
├─────────────────────────────────────────────────────────────────┤
│ releases/ │
│ ├── versions.json (signed) │
│ ├── versions.json.sig │
│ ├── 2.0.0/ │
│ │ ├── 2.0.0.yml (metadata, signed) │
│ │ ├── 2.0.0.yml.sig │
│ │ ├── my-app-2.0.0-mac.zip (signed) │
│ │ ├── my-app-2.0.0-mac.zip.sig │
│ │ └── my-app-2.0.0.dmg │
│ └── 1.0.0/ │
│ └── ... │
└─────────────────────────────────────────────────────────────────┘
Updater (Abstract Base Class)
├── Core update logic
├── Download management
├── Signature verification
├── Error handling & retry
├── IPC communication
└── Abstract methods:
├── deletePreviousInstallers()
└── installUpdate(path, isSilent)
MacOSUpdater (Platform Implementation)
├── Extends Updater
├── Delegates to Electron's autoUpdater
├── DMG/ZIP installation
└── macOS-specific error handling
1. Start
├─► Schedule polling (30 min + random jitter)
└─► Trigger initial update check
2. Check for Updates
├─► Fetch versions.json from server
├─► Verify versions.json signature
├─► Parse available versions
├─► Show version selection UI (if downgrade enabled)
├─► Fetch version YAML (e.g., 2.0.0.yml)
├─► Verify YAML signature
├─► Parse metadata (files, SHA-512 hashes, vendor info)
├─► Validate version constraints
│ ├─► OS version compatibility
│ ├─► Manual update required flag
│ └─► Version comparison (newer/same/older)
└─► Return update info or null
3. Download Update
├─► Check cache for existing file
├─► Verify cached file SHA-512 (if exists)
├─► Download from server (if not cached or invalid)
│ ├─► Download ZIP file
│ ├─► Download signature file
│ └─► Show progress (if interactive)
├─► Write to temporary directory
├─► Move to cache directory (atomic)
└─► Return { updateFilePath, signature }
4. Verify Signature
├─► Load public key from config
├─► Compute SHA-256 of update file
├─► Create message: `${sha256Hex}-${version}`
├─► Verify Ed25519 signature
└─► Throw error if invalid
5. Install Update
├─► Determine silent or interactive mode
├─► Call platform-specific installUpdate()
│ └─► MacOS: Write feed JSON, call autoUpdater
├─► Show update dialog (if interactive)
├─► Wait for user confirmation or auto-install
└─► Restart application
6. Error Handling
├─► Transient errors: Schedule retry (1 day delay)
├─► Rollback version on download failure
└─► Log all errors with stack traces
| Component | Purpose | Location |
|---|---|---|
index.js |
Entry point, initialization | src/index.js |
common.js |
Core updater logic (Updater class) | src/common.js |
macos.js |
macOS platform implementation | src/macos.js |
signature.js |
Ed25519 signature generation/verification | src/signature.js |
downloadandverify.js |
Download with signature verification | src/downloadandverify.js |
util.js |
File integrity, graceful operations | src/util.js |
pathAndDir.js |
Path validation, temp directories | util/pathAndDir.js |
got.js |
HTTP client configuration | src/got.js |
SafeUpdater defends against:
- Man-in-the-Middle (MitM) Attacks Interference: While MitM attacks cannot be prevented at the network level, cryptographic signature verification ensures that intercepted or modified updates are rejected
- Compromised CDN/Server: Signed releases prevent serving malicious updates
- Tampered Downloads: SHA-512 integrity checks detect corruption or modification
- Path Traversal Attacks: Strict path validation prevents escaping cache directories
- Downgrade Attacks: Version comparison prevents unintended downgrades (unless explicitly enabled)
- Replay Attacks: Version-specific signatures prevent reusing old signatures
- Algorithm: Ed25519 (Edwards-curve Digital Signature Algorithm)
- Library:
@noble/ed25519with SHA-512 hashing - Key Size: 32 bytes (256 bits) for both private and public keys
- Signature Size: 64 bytes (512 bits)
Signing Process:
// 1. Hash the file with SHA-256
const fileHash = SHA256(fileContents);
// 2. Create message: "<hex_hash>-<version>"
const message = `${fileHash.toString('hex')}-${version}`;
// 3. Sign with Ed25519 private key
const signature = ed25519.sign(message, privateKey);
// 4. Save as hex-encoded .sig file
fs.writeFileSync(`${file}.sig`, signature.toString('hex'));Verification Process:
// 1. Load public key from config
const publicKey = hexToBinary(config.get('updatesPublicKey'));
// 2. Compute SHA-256 of downloaded file
const fileHash = SHA256(downloadedFile);
// 3. Reconstruct message
const message = `${fileHash.toString('hex')}-${version}`;
// 4. Verify signature
const isValid = ed25519.verify(signature, message, publicKey);
if (!isValid) {
throw new Error('Signature verification failed');
}- Algorithm: SHA-512 (Secure Hash Algorithm 512-bit)
- Encoding: Base64
- Purpose: Detect file corruption or tampering during download
// Verify file integrity
const computedHash = createHash('sha512').update(fileContents).digest('base64');
if (computedHash !== expectedSHA512) {
throw new Error('Integrity check failed');
}Prevents directory traversal attacks:
// Validates that targetPath is inside basePath
function validatePath(basePath, targetPath) {
const normalizedBase = normalize(basePath);
const normalizedTarget = normalize(targetPath);
if (!isPathInside(normalizedTarget, normalizedBase)) {
throw new Error('Path traversal detected');
}
}
// Used for:
// - Cache directory operations
// - Temporary file creation
// - Update file placement- HTTPS Required: All update requests use TLS encryption
- Custom CA Support: Pin specific certificate authorities via config
- Self-Signed Support: Useful for development/testing with
allowInsecureTLS - Certificate Validation: Configurable
rejectUnauthorizedoption
{
"certificateAuthority": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
"allowInsecureTLS": false
}- Never commit private keys: Keep
private.keyout of version control - Distribute public keys securely: Embed in app config, not fetched from server
- Use HTTPS in production: Never allow
allowInsecureTLSin production builds - Rotate keys periodically: Plan for key rotation with versioned public keys
- Monitor signature failures: Log and alert on verification failures
- Validate all inputs: YAML parsing uses
FAILSAFE_SCHEMAto prevent code injection - Secure file permissions: Temp directories created with 0o700 (owner-only)
Initializes the SafeUpdater system.
Parameters:
interface StartOptions {
canRunSilently: boolean; // If true, updates install without user prompt
getMainWindow: () => BrowserWindow; // Function returning main window
logger: Logger; // Logger implementation
}
interface Logger {
fatal(...args: any[]): void;
error(...args: any[]): void;
warn(...args: any[]): void;
info(...args: any[]): void;
debug(...args: any[]): void;
trace(...args: any[]): void;
writeBufferInto(output: Logger): void; // Optional: replay buffered logs
}Example:
await updater.start({
canRunSilently: true,
getMainWindow: () => mainWindow,
logger: consoleLogger,
});Behavior:
- Checks if updates are disabled (via config or platform)
- Handles "install on next launch" scenario
- Creates platform-specific updater (MacOSUpdater)
- Triggers version selection UI if
downgradeEnabled - Begins update polling
Throws:
Errorif called multiple timesErrorif logger not providedErrorif platform is not macOS
Forces an update check, bypassing cached "no update" state.
// Trigger manual update check
await updater.force();Use cases:
- "Check for Updates" menu item
- Manual update button
- Development/testing
Handles user-cancelled restart scenarios.
// Call when user cancels restart dialog
updater.onRestartCancelled();Behavior:
- Resets
#isRestartingflag - Triggers new update check with
AllowSameVersion
Main updater entry point that shows version selection UI.
Flow:
- Loads available versions from server
- Shows version selection dialog (if
downgradeEnabled) - Compares selected version to latest
- Calls
start()for updates orstart_downgrade()for downgrades
Starts update process for specified version.
Parameters:
version(string): Target version
Behavior:
- Schedules periodic polling
- Deletes previous installers
- Checks for updates
- Downloads and installs if available
Starts downgrade process to older version.
Parameters:
version(string): Target version (must be older)
Behavior:
- Deletes previous installers
- Checks for valid downgrade
- Downloads and installs specified version
Shows UI dialog for version selection.
Returns: Promise<string | null> - Selected version or null if cancelled
Behavior:
- Loads available versions from server
- Displays version picker window
- Waits for user selection via IPC
- Returns selected version string
Registers handler for user-initiated update installation.
Parameters:
performUpdateCallback(function): Async function to execute on install
Usage:
this.setUpdateListener(async () => {
await this.installUpdate(updateFilePath, false);
});Marks updater as unable to proceed due to error.
Parameters:
error(Error): Error that caused failure
Behavior:
- Sets
#noUpdateflag - Shows "Cannot Update" dialog to user
- Registers retry handler for user action
- Logs error with full details
Flags that application restart is imminent.
Behavior:
- Sets
#isRestartingflag - Prepares for quit-and-install
Platform-specific cleanup of old installer files.
Example (macOS):
async deletePreviousInstallers() {
// macOS: Empty implementation (no cleanup needed)
}Platform-specific installation logic.
Parameters:
updateFilePath(string): Path to downloaded updateisSilent(boolean): Whether to skip user confirmation
Returns: Promise<function> - Handler function for installation
Example (macOS):
async installUpdate(updateFilePath, isSilent) {
return async () => {
// Write feed JSON
// Call autoUpdater.setFeedURL()
// Call autoUpdater.checkForUpdates()
// Wait for update-downloaded event
// Call autoUpdater.quitAndInstall()
};
}Extends Updater with macOS-specific implementation.
Installs DMG/ZIP update on macOS.
Implementation:
- Creates temporary feed JSON file
- Configures Electron's autoUpdater with feed URL
- Calls
autoUpdater.checkForUpdates() - Returns handler that waits for download completion
- Calls
autoUpdater.quitAndInstall()to restart
Triggers manual update check.
Renderer:
ipcRenderer.invoke('updater/force-update');Main:
// Handled automatically by Updater constructor
ipcMain.handle('updater/force-update', () => this.force());User confirmation to install downloaded update.
Renderer:
ipcRenderer.send('start-update');Main:
// Set via setUpdateListener()
ipcMain.handleOnce('start-update', performUpdateCallback);Version selection from user dialog.
Renderer:
ipcRenderer.send('user-input', selectedVersion);Main:
ipcMain.once('user-input', (event, version) => {
// Process selected version
});Displays update dialog to user.
Main:
mainWindow.webContents.send('show-update-dialog', dialogType, data);Dialog Types:
None- Hide dialogAutoUpdate- Automatic update startingCannot_Update- Update failedCannot_Update_Require_Manual- Manual update requiredUnsupportedOS- OS version too oldMacOS_Read_Only- macOS volume is read-onlyDownloadReady- Update ready to installFullDownloadReady- Full update readyDownloading- Download in progressDownloadedUpdate- Download complete
Renderer:
In the renderer process, the developer is responsible for defining how update-related events are presented to the user. The renderer listens for IPC messages such as show-update-dialog and, based on the dialogType and associated data sent from the main process, determines what UI elements or messages should be displayed. This design delegates all presentation logic to the renderer.
ipcRenderer.on('show-update-dialog', (event, dialogType, data) => {
switch (dialogType) {
case 'Downloading':
console.log(`Downloaded ${data.downloadedSize} of ${data.downloadSize}`);
break;
case 'DownloadReady':
// Show install button
break;
// ...
}
});1. app.whenReady()
└─► updater.start()
├─► Check if updates disabled (platform, config, packaging)
├─► Check for "install on next launch" flag
│ └─► If set: Install pending update and quit
├─► Create MacOSUpdater instance
└─► Call updater.run()
├─► getUserVersion() - Show version selection UI
├─► Determine if update or downgrade
└─► Call start() or start_downgrade()
schedulePoll()
├─► Calculate next poll time
│ ├─► Base: 30 minutes from now
│ └─► Jitter: Random 0-30 minutes added
├─► setTimeout(safePoll, timeoutMs)
└─► safePoll()
├─► Check if retry delay active
├─► Call checkForUpdatesMaybeInstall()
├─► Handle errors gracefully
└─► schedulePoll() (recursive)
checkForUpdatesMaybeInstall(checkType, version)
├─► Fetch versions.json from server
├─► Verify versions.json signature
├─► Parse available versions
├─► Select version to check
│ ├─► If version provided: Use that version
│ └─► Else: Use latest version
├─► Fetch version YAML (e.g., 2.0.0.yml)
├─► Verify YAML signature
├─► Parse metadata
│ ├─► Extract files, hashes, vendor info
│ ├─► Validate OS version compatibility
│ ├─► Check manual update flag
│ └─► Validate version constraints
├─► Compare versions based on checkType
│ ├─► Normal: newVersion > currentVersion
│ ├─► AllowSameVersion: newVersion >= currentVersion
│ ├─► ForceDownload: Skip comparison
│ └─► AllowDowngrade: Allow older versions
└─► Return updateInfo or null
downloadUpdate(updateInfo)
├─► Build update URLs
│ ├─► ZIP: /releases/2.0.0/my-app-2.0.0-mac.zip
│ ├─► Signature: /releases/2.0.0/my-app-2.0.0-mac.zip.sig
│ └─► Blockmap (optional): /releases/2.0.0/my-app-2.0.0-mac.zip.blockmap
├─► Create cache and temp directories
├─► Check for cached file
│ ├─► If exists: Verify SHA-512
│ └─► If valid: Move to temp dir (skip download)
├─► Download signature file
├─► Download update file (if not cached)
│ ├─► Stream to temp file
│ ├─► Report progress (if interactive)
│ └─► Wait for completion
├─► Move temp dir to cache dir (atomic)
│ ├─► Success: Update cache
│ ├─► Failure: Restore backup
│ └─► Fallback: Install from temp dir
└─► Return { updateFilePath, signature }
verifySignature(updateFilePath, version, signature, publicKey)
├─► Compute SHA-256 of update file
├─► Create message: `${sha256Hex}-${version}`
├─► Verify Ed25519 signature with public key
└─► Return true/false
If verification fails:
- Error logged with details
- "Cannot Update" event sent
- Retry available via user action
installUpdate(updateFilePath, isSilent)
├─► Determine mode
│ ├─► Silent: vendor.requireUserConfirmation !== 'true' && canRunSilent
│ └─► Interactive: Show dialog, wait for user
├─► Call platform-specific installation
│ └─► MacOS: autoUpdater.quitAndInstall()
├─► If silent or force: Execute immediately
└─► If interactive: Wait for user confirmation
└─► IPC event 'start-update' triggers execution
macOS Installation:
installUpdate()
├─► Create feed JSON file
│ └─► { url: 'file:///path/to/update.zip' }
├─► Call autoUpdater.setFeedURL()
├─► Call autoUpdater.checkForUpdates()
├─► Wait for 'update-downloaded' event
└─► Call autoUpdater.quitAndInstall()
├─► App quits
├─► Update installs
└─► App restarts with new version
Download/Install Error
├─► Transient errors (network, timeout)
│ ├─► autoRetryAttempts < MAX_AUTO_RETRY_ATTEMPTS?
│ │ ├─► Yes: Schedule retry after 1 day
│ │ └─► No: Mark cannot update
│ └─► Log retry schedule
├─► Fatal errors (signature failure, invalid YAML)
│ ├─► Mark cannot update
│ ├─► Show error dialog
│ └─► Register user retry handler
└─► Rollback version number if download failed
Retry configuration:
MAX_AUTO_RETRY_ATTEMPTS= 1AUTO_RETRY_DELAY= 1 day
SafeUpdater supports verified downgrades to previous versions with the same cryptographic guarantees as upgrades.
Configuration:
{
"downgradeEnabled": true
}When downgradeEnabled is true, SafeUpdater shows a version picker on startup:
┌─────────────────────────────────────┐
│ Select Version │
├─────────────────────────────────────┤
│ ○ 2.0.0 (Latest) │
│ ○ 1.5.0 │
│ ○ 1.0.0 │
│ │
│ [ Select ] [ Cancel ] │
└─────────────────────────────────────┘
Implementation: src/input.html
1. getUserVersion()
├─► Fetch versions.json
├─► Parse available versions
├─► Show version selection UI
└─► Return selected version
2. Compare selected to latest
├─► If same: Proceed with update (start())
└─► If older: Proceed with downgrade (start_downgrade())
3. start_downgrade(version)
├─► Delete previous installers
└─► checkForDowngradeMaybeInstall(AllowDowngrade, version)
├─► Fetch version YAML for selected version
├─► Verify signature
├─► Download update files
├─► Verify integrity
└─► Install
4. Installation
└─► Same process as upgrade
Downgrades have identical security to upgrades:
- Ed25519 signature verification
- SHA-512 integrity checks
- TLS encryption in transit
- Path validation
- Same error handling
-
Rollback after bad release:
- User experiences bugs in new version
- Downgrade to stable version
- Developer fixes issues
- User updates again when ready
-
Testing specific versions:
- QA tests against multiple versions
- Developers reproduce version-specific bugs
- Beta testers compare versions
-
Enterprise deployments:
- IT controls which version users run
- Staged rollouts with controlled downgrades
- Compliance with specific version requirements
For production apps, consider disabling downgrades:
{
"downgradeEnabled": false
}Benefits:
- Simpler UX (no version picker)
- Faster startup (no version fetch)
- Prevents accidental downgrades
- Reduces support complexity
SafeUpdater includes integration tests using Vitest.
Location: SafeUpdatePackage/tests/integration/
Test files:
downloadAndVerify.integration.test.js- Download and signature verificationsignature.integration.test.js- Ed25519 signing and verificationutil.integration.test.js- File utilities and integrity checks
cd SafeUpdatePackage
npm install
npm testdescribe('downloadAndVerify', () => {
it('downloads and parses text');
it('downloads and parses JSON');
it('downloads as buffer');
it('throws if signature verification fails');
it('handles network errors gracefully');
});describe('signature module', () => {
it('generates and verifies signatures end-to-end');
it('rejects invalid signatures');
it('handles corrupted files');
it('getSignatureFileName appends .sig');
it('hexToBinary and binaryToHex are inverses');
});describe('util integration', () => {
it('creates unique temp directories');
it('checks file integrity correctly');
it('gracefulRmRecursive removes directories');
it('gracefulRename handles permission errors');
it('parseYaml validates structure');
});npx vitest runstderr | tests/integration/downloadAndVerify.integration.test.js
WARNING: NODE_ENV value of 'test' did not match any deployment config file names.
WARNING: See https://github.com/node-config/node-config/wiki/Strict-Mode
stderr | tests/integration/util.integration.test.js
WARNING: NODE_ENV value of 'test' did not match any deployment config file names.
WARNING: See https://github.com/node-config/node-config/wiki/Strict-Mode
✓ tests/integration/signature.integration.test.js (2 tests) 80ms
✓ tests/integration/downloadAndVerify.integration.test.js (4 tests) 12ms
✓ tests/integration/util.integration.test.js (5 tests) 18ms
Test Files 3 passed (3)
Tests 11 passed (11)
Start at 13:16:19
Duration 739ms (transform 308ms, setup 0ms, collect 749ms, tests 110ms, environment 0ms, prepare 262ms)Made with ❤️ for secure Electron applications