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