Fixes.
This commit is contained in:
parent
9e776ded42
commit
3b55965c15
2 changed files with 59 additions and 10 deletions
22
README.md
22
README.md
|
|
@ -43,16 +43,26 @@ in `_sc_plugins.configuration`, which would otherwise expose the key to DB
|
||||||
backups, admin reads, and dev-deploy's journal sync. Instead:
|
backups, admin reads, and dev-deploy's journal sync. Instead:
|
||||||
|
|
||||||
- `lib/secretsAtRest.js` seals the key with AES-256-GCM under a key derived
|
- `lib/secretsAtRest.js` seals the key with AES-256-GCM under a key derived
|
||||||
(HKDF-SHA256) from Saltcorn's `session_secret` -- which lives in the env /
|
(HKDF-SHA256) from Saltcorn's `session_secret`, which it resolves from the
|
||||||
config file, not the database. Ciphertext in the DB is useless without it.
|
Saltcorn installation itself -- no extra env var required. `getSessionSecret`
|
||||||
|
tries, in order: `SALTCORN_SESSION_SECRET` (an optional override, checked
|
||||||
|
first and wins when present), `db.connectObj.session_secret` (Saltcorn's
|
||||||
|
resolved value, which already merges the env var and the `~/.config/.saltcorn`
|
||||||
|
config file), `getConfigFile()` reading `~/.config/.saltcorn` directly (for
|
||||||
|
when the db module is not yet initialised), and finally the `session_secret`
|
||||||
|
DB config. It throws (`secretsAtRest: session_secret unavailable ...`) only if
|
||||||
|
all four miss. The derived KEK is cached keyed by a hash of the resolved
|
||||||
|
secret, so changing the secret re-derives it. Ciphertext in the DB is useless
|
||||||
|
without it.
|
||||||
- Because Saltcorn's config form autosaves raw values, `onLoad` re-seals any
|
- Because Saltcorn's config form autosaves raw values, `onLoad` re-seals any
|
||||||
plaintext key the moment the plugin (re)loads after a save; the action
|
plaintext key the moment the plugin (re)loads after a save; the action
|
||||||
decrypts only at the point of use.
|
decrypts only at the point of use.
|
||||||
- **Threat model:** this protects DB-only exposure (backups, sync, config-table
|
- **Threat model:** this protects DB-only exposure (backups, sync, config-table
|
||||||
reads). It does not protect a host that leaks both the database and the env /
|
reads). It does not protect a host that leaks both the database and the
|
||||||
config file. **Rotating `session_secret` invalidates the sealed key** -- you
|
resolved `session_secret` (config file, env override, or DB config).
|
||||||
must re-enter it in the plugin config (the action raises a clear error saying
|
**Rotating `session_secret` invalidates the sealed key** (the hash-keyed KEK
|
||||||
so).
|
cache re-derives) -- you must re-enter it in the plugin config (the action
|
||||||
|
raises a clear error saying so).
|
||||||
- For the same reason, the sealed key is environment-specific: it will not
|
- For the same reason, the sealed key is environment-specific: it will not
|
||||||
decrypt on a peer with a different `session_secret`, so exclude it from any
|
decrypt on a peer with a different `session_secret`, so exclude it from any
|
||||||
dev-deploy sync and set it per environment.
|
dev-deploy sync and set it per environment.
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,45 @@ const IV_BYTES = 12;
|
||||||
const TAG_BYTES = 16;
|
const TAG_BYTES = 16;
|
||||||
const KEY_BYTES = 32;
|
const KEY_BYTES = 32;
|
||||||
|
|
||||||
const kekCache = new Map(); // info label -> 32-byte derived key
|
const kekCache = new Map(); // info label -> 32-byte derived key
|
||||||
|
let cachedSecretHash = null; // last session-secret hash; a change clears kekCache
|
||||||
|
|
||||||
|
|
||||||
|
// Resolve the session secret from the Saltcorn installation, mirroring the
|
||||||
|
// precedence Saltcorn itself uses in db/connect.ts (env var -> ~/.config/.saltcorn
|
||||||
|
// -> generated). Leaning on Saltcorn's own resolution rather than requiring
|
||||||
|
// SALTCORN_SESSION_SECRET in the plugin's process environment lets the plugin
|
||||||
|
// install cleanly on an instance configured purely through the config file.
|
||||||
const getSessionSecret = () => {
|
const getSessionSecret = () => {
|
||||||
|
// 1. Explicit environment override (also what Saltcorn checks first).
|
||||||
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
|
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
|
||||||
if (fromEnv && fromEnv.length > 0) {
|
if (fromEnv && fromEnv.length > 0) {
|
||||||
return fromEnv;
|
return fromEnv;
|
||||||
}
|
}
|
||||||
// Fall back to the value Saltcorn loaded into its config state.
|
// 2. Saltcorn's resolved connection object. session_secret here has already
|
||||||
|
// been merged from the env var and the .saltcorn config file (and is the
|
||||||
|
// exact value Saltcorn signs its own session cookies with), so this is the
|
||||||
|
// single source of truth whenever the db module is initialised.
|
||||||
|
try {
|
||||||
|
const db = require("@saltcorn/data/db");
|
||||||
|
if (db.connectObj && db.connectObj.session_secret) {
|
||||||
|
return db.connectObj.session_secret;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// db module not loadable in this context; fall through
|
||||||
|
}
|
||||||
|
// 3. Read the Saltcorn config file (~/.config/.saltcorn) directly, in case
|
||||||
|
// the db module is not yet initialised when we are called.
|
||||||
|
try {
|
||||||
|
const { getConfigFile } = require("@saltcorn/data/db/connect");
|
||||||
|
const cfg = getConfigFile();
|
||||||
|
if (cfg && cfg.session_secret) {
|
||||||
|
return cfg.session_secret;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// connect module not available; fall through
|
||||||
|
}
|
||||||
|
// 4. DB-stored config value Saltcorn loaded into its config state, if any.
|
||||||
try {
|
try {
|
||||||
const { getState } = require("@saltcorn/data/db/state");
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
const v = getState().getConfig("session_secret");
|
const v = getState().getConfig("session_secret");
|
||||||
|
|
@ -42,16 +72,25 @@ const getSessionSecret = () => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// state not available (e.g. unit tests) -> fall through to throw
|
// state not available (e.g. unit tests) -> fall through to throw
|
||||||
}
|
}
|
||||||
throw new Error("secretsAtRest: SALTCORN_SESSION_SECRET unavailable; cannot derive key");
|
throw new Error("secretsAtRest: session_secret unavailable from environment, the Saltcorn config file, or DB config; cannot derive key");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const getKek = (info) => {
|
const getKek = (info) => {
|
||||||
|
// Re-derive if the session secret changes so a rotated secret never keeps
|
||||||
|
// serving a stale KEK. session_secret is global, so a change invalidates
|
||||||
|
// every per-label KEK at once.
|
||||||
|
const sessionSecret = getSessionSecret();
|
||||||
|
const secretHash = crypto.createHash(HASH).update(sessionSecret, "utf8").digest("hex");
|
||||||
|
if (cachedSecretHash !== secretHash) {
|
||||||
|
kekCache.clear();
|
||||||
|
cachedSecretHash = secretHash;
|
||||||
|
}
|
||||||
const cached = kekCache.get(info);
|
const cached = kekCache.get(info);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
const ikm = Buffer.from(getSessionSecret(), "utf8");
|
const ikm = Buffer.from(sessionSecret, "utf8");
|
||||||
const salt = Buffer.from(`${BLOB_PREFIX}${info}`, "utf8");
|
const salt = Buffer.from(`${BLOB_PREFIX}${info}`, "utf8");
|
||||||
const kek = Buffer.from(crypto.hkdfSync(HASH, ikm, salt, Buffer.from(info, "utf8"), KEY_BYTES));
|
const kek = Buffer.from(crypto.hkdfSync(HASH, ikm, salt, Buffer.from(info, "utf8"), KEY_BYTES));
|
||||||
kekCache.set(info, kek);
|
kekCache.set(info, kek);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue