secrets.jsjavascript
/**
 * @file Encrypted secrets store for OAuth tokens and API keys. Secrets NEVER go in
 * settings.json; they live here, encrypted at rest via Electron's `safeStorage`, whose
 * master key is protected by the OS credential store (macOS Keychain, Windows Credential
 * Manager / DPAPI, Linux libsecret / kwallet when available).
 *
 * Only the ciphertext (base64) is held in a file alongside settings, at
 * app.getPath('userData')/secrets.json; safeStorage holds the key. So the file by itself
 * is useless to anyone who copies it off the disk — defending against offline file theft
 * and backup leakage. It does NOT defend against code already running as this user (that
 * code can ask safeStorage to decrypt); that's the standard tradeoff of OS-backed app
 * secret storage.
 *
 * Security posture: main process only. The renderer is deliberately given NO way to read
 * or write plaintext secrets (see main.js: only availability/has/delete are exposed over
 * IPC). In the real OAuth flow (D8/D9) the token is obtained and stored entirely in the
 * main process, so plaintext never crosses into the renderer.
 * @module secrets
 */

const { app, safeStorage } = require('electron');
const path = require('path');
const fs = require('fs');

/**
 * In-memory map of key → base64 ciphertext — encrypted blobs, never plaintext. Null
 * until first load.
 * @type {?Object<string, string>}
 */
let cache = null;

/**
 * Absolute path of secrets.json in the Electron userData directory.
 * @returns {string} File path.
 */
function filePath() {
  return path.join(app.getPath('userData'), 'secrets.json');
}

/**
 * Whether safeStorage can actually encrypt. On Linux with no keyring backend this is
 * false; callers must check before set() and degrade gracefully (e.g. "secure storage
 * unavailable — can't connect this account") rather than storing plaintext.
 * @returns {boolean} True when encryption is available.
 */
function isAvailable() {
  return safeStorage.isEncryptionAvailable();
}

/**
 * Read the ciphertext map from disk once, then serve from memory. Missing file = no
 * secrets yet; an unreadable/corrupt file is logged and treated as empty so a bad file
 * can't brick launch (the secrets it held are lost, which is acceptable — the user
 * simply re-connects the affected account).
 * @returns {Object<string, string>} The ciphertext map.
 */
function load() {
  if (cache) return cache;
  try {
    cache = JSON.parse(fs.readFileSync(filePath(), 'utf8'));
    if (cache === null || typeof cache !== 'object' || Array.isArray(cache)) cache = {};
  } catch (err) {
    if (err.code !== 'ENOENT') console.error('[secrets] unreadable, starting empty:', err.message);
    cache = {};
  }
  return cache;
}

/**
 * Atomic write with owner-only permissions (0o600). Even though the contents are already
 * ciphertext, restricting the file mode is cheap defense-in-depth. Temp-file + rename
 * means a crash mid-write can't leave a half-written secrets.json.
 * @param {Object<string, string>} data Ciphertext map to persist.
 * @returns {void}
 */
function persist(data) {
  const target = filePath();
  const tmp = `${target}.${process.pid}.tmp`;
  fs.mkdirSync(path.dirname(target), { recursive: true });
  fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
  fs.renameSync(tmp, target);
}

/**
 * Encrypt `value` and persist it under `key`. Throws if secure storage isn't available —
 * falling back to plaintext is refused.
 * @param {string} key Secret name, e.g. "frameio.accessToken".
 * @param {string} value Plaintext secret to encrypt and store.
 * @returns {void}
 * @throws {Error} When secure storage is unavailable or `value` is not a string.
 */
function setSecret(key, value) {
  if (!isAvailable()) throw new Error('Secure storage is unavailable on this system.');
  if (typeof value !== 'string') throw new Error('Secret value must be a string.');
  const store = load();
  store[key] = safeStorage.encryptString(value).toString('base64');
  persist(store);
}

/**
 * Decrypt and return the secret, or null if no secret is stored under `key`. Throws only
 * when a blob exists but can't be decrypted (storage went unavailable, or key mismatch).
 * Main-process use only — never wire this to the renderer.
 * @param {string} key Secret name.
 * @returns {?string} Plaintext secret, or null when absent.
 * @throws {Error} When a blob exists but secure storage can't decrypt it.
 */
function getSecret(key) {
  const raw = load()[key];
  if (raw === undefined) return null;
  if (!isAvailable()) throw new Error('Secure storage is unavailable; cannot decrypt secret.');
  return safeStorage.decryptString(Buffer.from(raw, 'base64'));
}

/**
 * Whether a secret is stored under `key`. Cheap boolean — no decryption, so it works
 * even when safeStorage is unavailable. This is what the renderer uses to show connected
 * state.
 * @param {string} key Secret name.
 * @returns {boolean} True when a blob exists for `key`.
 */
function hasSecret(key) {
  return load()[key] !== undefined;
}

/**
 * Remove a secret (account disconnect). No-op if absent.
 * @param {string} key Secret name.
 * @returns {boolean} True if something was removed.
 */
function deleteSecret(key) {
  const store = load();
  if (store[key] === undefined) return false;
  delete store[key];
  persist(store);
  return true;
}

/**
 * The keys currently held (no values). Handy for diagnostics / "which accounts are
 * linked".
 * @returns {string[]} Stored secret names.
 */
function listKeys() {
  return Object.keys(load());
}

module.exports = { isAvailable, setSecret, getSecret, hasSecret, deleteSecret, listKeys, filePath };