sc-postiz-poster/lib/apiKey.js
2026-10-19 01:20:42 -05:00

122 lines
4.3 KiB
JavaScript

// The Postiz API key at rest: sealed in _sc_plugins.configuration (Saltcorn
// stores plugin configuration plaintext, so a DB dump, a backup or a dev-deploy
// sync would otherwise expose it), opened only at the point of use. The key is
// derived from the session secret (env / config file, NOT the database), see
// lib/common/aesGcm.js + sessionKey.js. The configuration workflow seals a newly
// typed key before Saltcorn saves it (lib/config.js); onLoad seals a plaintext
// key left by plugin versions before 0.0.3. Rotating the session secret means
// re-entering the key.
const { createSealer, isSealedString, STRING_PREFIX } = require("./common/aesGcm");
const { getSessionSecret } = require("./common/sessionKey");
const { PLUGIN_NAME } = require("./constants");
// Domain label that ties the sealed apiKey to this plugin + field (key
// derivation). Changing it would orphan already-sealed keys.
const API_KEY_INFO = `${PLUGIN_NAME}:apiKey`;
// What the stored apiKey is: not set, sealed (and it opens), sealed but it does
// not open (session secret missing or rotated), or plaintext (legacy, or no
// session secret to seal under).
const API_KEY_STATES = {
UNSET: "unset",
SEALED: "sealed",
SEALED_UNOPENABLE: "sealed_unopenable",
PLAINTEXT: "plaintext"
};
// The sealer of one configuration field, keyed by its domain label: HKDF salt
// "scs1:<label>", info "<label>" -- byte-identical to the derivation every
// already-sealed key was made with.
const fieldSealer = (info) => {
return createSealer({ owner: PLUGIN_NAME, salt: STRING_PREFIX + info, info: info });
};
const apiKeySealer = fieldSealer(API_KEY_INFO);
// The saved key's state (API_KEY_STATES) and its usable plaintext ("" when it
// is unset or does not open).
const readApiKey = (pluginCfg) => {
const raw = pluginCfg && pluginCfg.apiKey;
if (!raw) {
return { state: API_KEY_STATES.UNSET, key: "" };
}
if (!isSealedString(raw)) {
return { state: API_KEY_STATES.PLAINTEXT, key: String(raw) };
}
const opened = apiKeySealer.openString(raw);
if (opened === null) {
return { state: API_KEY_STATES.SEALED_UNOPENABLE, key: "" };
}
return { state: API_KEY_STATES.SEALED, key: opened };
};
// The usable plaintext key from saved config, sealed or (legacy / no session
// secret) plaintext. Throws an actionable error if a sealed key cannot be opened.
const resolveApiKey = (pluginCfg) => {
const stored = readApiKey(pluginCfg);
if (stored.state === API_KEY_STATES.SEALED_UNOPENABLE) {
throw new Error(`${PLUGIN_NAME}: stored Postiz API key could not be decrypted (SALTCORN_SESSION_SECRET / the config-file session_secret is missing or was rotated). Re-enter it in the plugin config.`);
}
return stored.key;
};
// Whether an acceptable session secret is configured, so a key is sealed on
// save and a sealed key can be opened.
const sessionSecretConfigured = () => {
try {
getSessionSecret(PLUGIN_NAME);
return true;
} catch (e) {
return false;
}
};
// A plaintext key sealed; a sealed blob or an empty value unchanged. Without an
// acceptable session secret the key stays plaintext (with a warning) rather than
// being sealed under a secret Saltcorn generated for this process only.
const sealApiKey = (value) => {
if (!value || isSealedString(value)) {
return value;
}
try {
return apiKeySealer.sealString(value);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(`[${PLUGIN_NAME}] API key left unsealed: ${e.message}`);
return value;
}
};
const sealStoredApiKey = async () => {
const Plugin = require("@saltcorn/data/models/plugin");
const plugin = await Plugin.findOne({ name: PLUGIN_NAME });
if (!plugin || !plugin.configuration) {
return;
}
const current = plugin.configuration.apiKey;
const sealed = sealApiKey(current);
if (sealed === current) {
return;
}
plugin.configuration = { ...plugin.configuration, apiKey: sealed };
await plugin.upsert();
};
module.exports = {
API_KEY_INFO,
fieldSealer,
readApiKey,
resolveApiKey,
sealApiKey,
sealStoredApiKey,
sessionSecretConfigured
};