Files
claudemesh/apps/broker/src/broker-crypto.ts
Alejandro Gutiérrez 2be5e9dccb
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
fix(security): resolve all 17 codex findings — auth, grants, crypto, ops
Critical: broker HTTP auth via cli_session bearer token on all /cli/*;
file download requires auth+membership; v2 claim gated; duplicate
claimInviteV2Core removed; grant enforcement tries member then
session pubkey; audit hash uses canonical sorted-keys JSON.

High: rate limit args fixed (burst 10, 60/min) + both buckets swept;
BROKER_ENCRYPTION_KEY fail-fast in prod; migrate uses pg_try + lock_
timeout; hello validates sessionPubkey hex; blocked DMs rejected pre-
queue; watch timers cleaned on disconnect.

Medium: inbound pushes serialized; reconnect jitter + timer guard;
hardcoded URLs through env; v2 claim path configurable.

Low: WSHelloMessage optional protocolVersion+capabilities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:18:25 +01:00

83 lines
3.1 KiB
TypeScript

/**
* Broker-side symmetric encryption for persisting resolved env vars.
*
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
* a random key is generated and logged on first use — operator should
* persist it to survive broker restarts.
*
* This is NOT the same as peer-side E2E crypto (libsodium). This is
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
*/
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { env } from "./env";
import { log } from "./logger";
const ALGO = "aes-256-gcm";
const IV_LEN = 12;
const TAG_LEN = 16;
let _key: Buffer | null = null;
function getKey(): Buffer {
if (_key) return _key;
if (env.BROKER_ENCRYPTION_KEY && /^[0-9a-f]{64}$/i.test(env.BROKER_ENCRYPTION_KEY)) {
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
return _key;
}
// In production, refuse to start without a persistent key. Silently
// generating a random one meant every restart invalidated all encrypted
// rows on disk — and the ephemeral key was logged in clear, which is
// itself a leak.
if (process.env.NODE_ENV === "production") {
log.error("BROKER_ENCRYPTION_KEY is missing or malformed (need 64 hex chars) — refusing to start in production");
process.exit(1);
}
// Dev only: generate a stable per-process key. Never log the value.
_key = randomBytes(32);
log.warn("BROKER_ENCRYPTION_KEY not set — using ephemeral key for this dev process (encrypted data WILL NOT survive restarts). Set BROKER_ENCRYPTION_KEY to a 64-hex-char value for persistence.");
return _key;
}
/**
* Encrypt a JSON-serializable value. Returns a base64 string containing
* IV + ciphertext + auth tag.
*/
export function encryptForStorage(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Pack: IV (12) + tag (16) + ciphertext
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
/**
* Decrypt a value produced by encryptForStorage. Returns the plaintext
* string, or null if decryption fails (wrong key, tampered).
*/
export function decryptFromStorage(packed: string): string | null {
try {
const key = getKey();
const buf = Buffer.from(packed, "base64");
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString("utf8");
} catch (e) {
// Loud failure: if a stored row fails to decrypt the key changed or
// data is corrupt — don't silently return null and let downstream
// code assume "no value".
log.error("decryptFromStorage failed", { err: e instanceof Error ? e.message : String(e) });
return null;
}
}