Skip to content

Latest commit

 

History

History
982 lines (770 loc) · 30.5 KB

File metadata and controls

982 lines (770 loc) · 30.5 KB

CONTRIBUTING

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.

Table of Contents


Architecture & Design

High-Level Architecture

┌─────────────────────────────────────────────────────────────────┐
│                         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/                                                    │
│      └── ...                                                    │
└─────────────────────────────────────────────────────────────────┘

Class Hierarchy

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

Update Flow Sequence

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

Key Components

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

Security Model

Threat Model

SafeUpdater defends against:

  1. 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
  2. Compromised CDN/Server: Signed releases prevent serving malicious updates
  3. Tampered Downloads: SHA-512 integrity checks detect corruption or modification
  4. Path Traversal Attacks: Strict path validation prevents escaping cache directories
  5. Downgrade Attacks: Version comparison prevents unintended downgrades (unless explicitly enabled)
  6. Replay Attacks: Version-specific signatures prevent reusing old signatures

Cryptographic Implementation

Ed25519 Digital Signatures

  • Algorithm: Ed25519 (Edwards-curve Digital Signature Algorithm)
  • Library: @noble/ed25519 with 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');
}

SHA-512 Integrity Checks

  • 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');
}

Path Validation

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

TLS/Certificate Handling

  • 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 rejectUnauthorized option
{
  "certificateAuthority": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
  "allowInsecureTLS": false
}

Security Best Practices

  1. Never commit private keys: Keep private.key out of version control
  2. Distribute public keys securely: Embed in app config, not fetched from server
  3. Use HTTPS in production: Never allow allowInsecureTLS in production builds
  4. Rotate keys periodically: Plan for key rotation with versioned public keys
  5. Monitor signature failures: Log and alert on verification failures
  6. Validate all inputs: YAML parsing uses FAILSAFE_SCHEMA to prevent code injection
  7. Secure file permissions: Temp directories created with 0o700 (owner-only)

API Reference

Entry Point

start(options)

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:

  • Error if called multiple times
  • Error if logger not provided
  • Error if platform is not macOS

Updater Class (Abstract Base)

Public Methods

async force()

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
onRestartCancelled()

Handles user-cancelled restart scenarios.

// Call when user cancels restart dialog
updater.onRestartCancelled();

Behavior:

  • Resets #isRestarting flag
  • Triggers new update check with AllowSameVersion
async run()

Main updater entry point that shows version selection UI.

Flow:

  1. Loads available versions from server
  2. Shows version selection dialog (if downgradeEnabled)
  3. Compares selected version to latest
  4. Calls start() for updates or start_downgrade() for downgrades
async start(version)

Starts update process for specified version.

Parameters:

  • version (string): Target version

Behavior:

  1. Schedules periodic polling
  2. Deletes previous installers
  3. Checks for updates
  4. Downloads and installs if available
async start_downgrade(version)

Starts downgrade process to older version.

Parameters:

  • version (string): Target version (must be older)

Behavior:

  1. Deletes previous installers
  2. Checks for valid downgrade
  3. Downloads and installs specified version
async getUserVersion()

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

Protected Methods

setUpdateListener(performUpdateCallback)

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);
});
markCannotUpdate(error)

Marks updater as unable to proceed due to error.

Parameters:

  • error (Error): Error that caused failure

Behavior:

  • Sets #noUpdate flag
  • Shows "Cannot Update" dialog to user
  • Registers retry handler for user action
  • Logs error with full details
markRestarting()

Flags that application restart is imminent.

Behavior:

  • Sets #isRestarting flag
  • Prepares for quit-and-install

Abstract Methods (Must Implement in Subclass)

async deletePreviousInstallers()

Platform-specific cleanup of old installer files.

Example (macOS):

async deletePreviousInstallers() {
  // macOS: Empty implementation (no cleanup needed)
}
async installUpdate(updateFilePath, isSilent)

Platform-specific installation logic.

Parameters:

  • updateFilePath (string): Path to downloaded update
  • isSilent (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()
  };
}

MacOSUpdater Class

Extends Updater with macOS-specific implementation.

async installUpdate(updateFilePath, isSilent)

Installs DMG/ZIP update on macOS.

Implementation:

  1. Creates temporary feed JSON file
  2. Configures Electron's autoUpdater with feed URL
  3. Calls autoUpdater.checkForUpdates()
  4. Returns handler that waits for download completion
  5. Calls autoUpdater.quitAndInstall() to restart

IPC Events

From Renderer to Main

updater/force-update

Triggers manual update check.

Renderer:

ipcRenderer.invoke('updater/force-update');

Main:

// Handled automatically by Updater constructor
ipcMain.handle('updater/force-update', () => this.force());
start-update

User confirmation to install downloaded update.

Renderer:

ipcRenderer.send('start-update');

Main:

// Set via setUpdateListener()
ipcMain.handleOnce('start-update', performUpdateCallback);
user-input

Version selection from user dialog.

Renderer:

ipcRenderer.send('user-input', selectedVersion);

Main:

ipcMain.once('user-input', (event, version) => {
  // Process selected version
});

From Main to Renderer

show-update-dialog

Displays update dialog to user.

Main:

mainWindow.webContents.send('show-update-dialog', dialogType, data);

Dialog Types:

  • None - Hide dialog
  • AutoUpdate - Automatic update starting
  • Cannot_Update - Update failed
  • Cannot_Update_Require_Manual - Manual update required
  • UnsupportedOS - OS version too old
  • MacOS_Read_Only - macOS volume is read-only
  • DownloadReady - Update ready to install
  • FullDownloadReady - Full update ready
  • Downloading - Download in progress
  • DownloadedUpdate - 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;
    // ...
  }
});

Update Flow

Startup Sequence

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()

Polling Mechanism

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)

Version Checking

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

Download Process

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 }

Signature Verification

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

Installation & Restart

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

Error Handling & Retries

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 = 1
  • AUTO_RETRY_DELAY = 1 day

Downgrade Support

How Downgrades Work

SafeUpdater supports verified downgrades to previous versions with the same cryptographic guarantees as upgrades.

Enabling Downgrades

Configuration:

{
  "downgradeEnabled": true
}

Version Selection UI

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

Downgrade Flow

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

Security Guarantees

Downgrades have identical security to upgrades:

  • Ed25519 signature verification
  • SHA-512 integrity checks
  • TLS encryption in transit
  • Path validation
  • Same error handling

Use Cases

  1. Rollback after bad release:

    • User experiences bugs in new version
    • Downgrade to stable version
    • Developer fixes issues
    • User updates again when ready
  2. Testing specific versions:

    • QA tests against multiple versions
    • Developers reproduce version-specific bugs
    • Beta testers compare versions
  3. Enterprise deployments:

    • IT controls which version users run
    • Staged rollouts with controlled downgrades
    • Compliance with specific version requirements

Disabling for Production

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

Testing

Integration Tests

SafeUpdater includes integration tests using Vitest.

Location: SafeUpdatePackage/tests/integration/

Test files:

  • downloadAndVerify.integration.test.js - Download and signature verification
  • signature.integration.test.js - Ed25519 signing and verification
  • util.integration.test.js - File utilities and integrity checks

Running Tests

cd SafeUpdatePackage
npm install
npm test

Test Coverage

Download & Verification Tests

describe('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');
});

Signature Tests

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');
});

Utility Tests

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');
});

Output

npx vitest run
stderr | 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