JM Tech Power

jm-tech-power-official/Flash Download

A desktop download manager that opens up to 32 connections against one file and can send each of them out a different proxy route.

Public Uncategorized
Clone jm-tech-power-official/flash-download.jmproj
src/main/engine/secrets.js
'use strict';

const fs = require('fs/promises');
const path = require('path');

/**
 * Encrypted side-store for proxy credentials, so they never sit in state.json as
 * plaintext. Values are encrypted with a codec whose key is held by the OS —
 * Electron's safeStorage: DPAPI on Windows, Keychain on macOS, libsecret/kwallet
 * on Linux. The ciphertext blobs live in their own file; state.json carries only
 * the non-secret route fields.
 *
 * The codec is injected rather than imported so the engine stays electron-free
 * and testable. When no codec is available (e.g. a Linux session with no
 * keyring), nothing is written — credentials are kept in memory for the session
 * only and never degraded to plaintext on disk.
 *
 * The live route records in Manager are the single source of truth; this store
 * reads credentials onto them at load and rebuilds its whole file from them at
 * save, so a removed route's secret disappears with no extra bookkeeping.
 */
class SecretStore {
  constructor({ filePath, codec }) {
    this.filePath = filePath;
    this.codec = codec || null;
  }

  available() {
    try { return !!(this.codec && this.codec.available()); }
    catch { return false; }
  }

  /** Decrypt the stored blobs and assign username/password onto matching routes. */
  async loadInto(routes) {
    if (!this.available()) return;
    let raw;
    try { raw = JSON.parse(await fs.readFile(this.filePath, 'utf8')); }
    catch { return; }   // missing or unreadable — nothing to restore

    for (const [id, blob] of Object.entries(raw.secrets || {})) {
      const route = routes.find((r) => r.id === id);
      if (!route) continue;
      try {
        const creds = JSON.parse(this.codec.decrypt(blob));
        route.username = creds.username || null;
        route.password = creds.password || null;
      } catch {
        // Written on another machine, or the OS key changed: leave the route
        // without credentials rather than crashing. The user re-enters them.
      }
    }
  }

  /** Rebuild the encrypted file from whatever credentials the routes now hold. */
  async saveFrom(routes) {
    if (!this.available()) {
      // Never leave stale ciphertext that no longer matches, and never write
      // plaintext. Best effort removal; in-memory credentials still work.
      await fs.rm(this.filePath, { force: true }).catch(() => {});
      return;
    }

    const secrets = {};
    for (const route of routes) {
      if (route.type === 'direct') continue;
      if (!route.username && !route.password) continue;
      try {
        secrets[route.id] = this.codec.encrypt(JSON.stringify({
          username: route.username || null,
          password: route.password || null,
        }));
      } catch {
        // Encryption failed for this one — skip it rather than fall back to
        // plaintext. The credential stays in memory for the session.
      }
    }

    await fs.mkdir(path.dirname(this.filePath), { recursive: true });
    const body = JSON.stringify({ v: 1, secrets }, null, 2);
    await fs.writeFile(this.filePath, body, { mode: 0o600 }).catch(() => {});
  }
}

/** Build the Electron-backed codec. Returns null when safeStorage is absent. */
function electronCodec(safeStorage) {
  if (!safeStorage) return null;
  return {
    available: () => safeStorage.isEncryptionAvailable(),
    encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
    decrypt: (b64) => safeStorage.decryptString(Buffer.from(b64, 'base64')),
  };
}

module.exports = { SecretStore, electronCodec };