/**
* @module utils/credentialEncryption
* @description AES-256-GCM encryption for project credentials at rest.
*
* Credentials (login username, password, CSS selectors) are encrypted before
* being persisted to the JSON database and decrypted when needed by the
* crawl/test pipeline.
*
* The encryption key is derived from `CREDENTIAL_SECRET` env var (or the
* JWT_SECRET as fallback). In development, a deterministic key is derived
* from the project directory — acceptable for local use but NOT for production.
*
* ### Exports
* - {@link encryptCredentials} — Encrypt a credentials object.
* - {@link decryptCredentials} — Decrypt a credentials object.
*/
import crypto from "crypto";
import { formatLogLine } from "./logFormatter.js";
/**
* Cached encryption key — derived once per process to avoid repeated
* synchronous scryptSync calls on the event loop.
* @type {Buffer|null}
* @private
*/
let _cachedKey = null;
/**
* Derive a 32-byte AES key from the configured secret.
* Result is cached for the process lifetime.
* @returns {Buffer}
* @private
*/
function getEncryptionKey() {
if (_cachedKey) return _cachedKey;
const secret = process.env.CREDENTIAL_SECRET || process.env.JWT_SECRET;
if (secret && secret.length >= 16) {
_cachedKey = crypto.scryptSync(secret, "sentri-credentials-salt", 32);
return _cachedKey;
}
// Dev fallback — deterministic but not secure for production
const seed = `dev-credential-key:${process.cwd()}`;
_cachedKey = crypto.createHash("sha256").update(seed).digest();
return _cachedKey;
}
/**
* Encrypt a plaintext string using AES-256-GCM.
* @param {string} plaintext
* @returns {string} Format: `"<iv-hex>:<authTag-hex>:<ciphertext-hex>"`
* @private
*/
function encrypt(plaintext) {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return `${iv.toString("hex")}:${authTag}:${encrypted}`;
}
/**
* Decrypt a string encrypted by {@link encrypt}.
* @param {string} encryptedStr - Format: `"<iv-hex>:<authTag-hex>:<ciphertext-hex>"`
* @returns {string} The original plaintext.
* @private
*/
function decrypt(encryptedStr) {
const key = getEncryptionKey();
const [ivHex, authTagHex, ciphertext] = encryptedStr.split(":");
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
/**
* Encrypt sensitive fields in a credentials object before storage.
* Non-sensitive fields (CSS selectors) are stored as-is.
*
* @param {Object|null} creds - `{ usernameSelector, username, passwordSelector, password, submitSelector }`
* @returns {Object|null} Encrypted credentials object, or `null`.
*/
export function encryptCredentials(creds) {
if (!creds) return null;
return {
usernameSelector: creds.usernameSelector || "",
username: creds.username ? encrypt(creds.username) : "",
passwordSelector: creds.passwordSelector || "",
password: creds.password ? encrypt(creds.password) : "",
submitSelector: creds.submitSelector || "",
_encrypted: true,
};
}
/**
* Decrypt sensitive fields in a credentials object for use by the pipeline.
* If the credentials are not encrypted (legacy data), returns them as-is.
*
* @param {Object|null} creds - Stored credentials (possibly encrypted).
* @returns {Object|null} Decrypted credentials object, or `null`.
*/
export function decryptCredentials(creds) {
if (!creds) return null;
if (!creds._encrypted) return creds; // legacy unencrypted data
try {
return {
usernameSelector: creds.usernameSelector || "",
username: creds.username ? decrypt(creds.username) : "",
passwordSelector: creds.passwordSelector || "",
password: creds.password ? decrypt(creds.password) : "",
submitSelector: creds.submitSelector || "",
};
} catch (err) {
console.error(formatLogLine("error", null, `[credentialEncryption] Decryption failed: ${err.message}`));
return null;
}
}