Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
121 lines
5.2 KiB
TypeScript
121 lines
5.2 KiB
TypeScript
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
|
|
|
/**
|
|
* Envelope encryption vault.
|
|
* - Master KEK from env (32 bytes, base64). Dev fallback: derive a stable key
|
|
* from a machine-local file so ciphertexts survive restarts in development.
|
|
* - Per-workspace DEK (AES-256-GCM) wrapped by the KEK.
|
|
* - Rotation: new DEK per workspace, key_version increments; old ciphertexts
|
|
* stay readable until re-wrapped, then the old DEK is dropped.
|
|
*/
|
|
|
|
const ALGO = "aes-256-gcm";
|
|
|
|
export function loadKek(): Buffer {
|
|
const env = process.env.SUPERPOSTER_KEK;
|
|
if (env !== undefined && env !== "") {
|
|
const kek = Buffer.from(env, "base64");
|
|
if (kek.length !== 32) throw new Error("SUPERPOSTER_KEK must be 32 bytes base64");
|
|
return kek;
|
|
}
|
|
// Dev fallback: deterministic from hostname — NEVER production.
|
|
if (process.env.NODE_ENV === "production") {
|
|
throw new Error("SUPERPOSTER_KEK is required in production");
|
|
}
|
|
return createHash("sha256").update(`dev-kek:${process.env.HOSTNAME ?? "local"}`).digest();
|
|
}
|
|
|
|
const KEK = loadKek();
|
|
|
|
export interface SealedCredential {
|
|
ciphertext: Buffer;
|
|
keyVersion: number;
|
|
}
|
|
|
|
interface WrappedDek {
|
|
keyVersion: number;
|
|
wrapped: Buffer;
|
|
}
|
|
|
|
function aesEncrypt(key: Buffer, plaintext: Buffer): Buffer {
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv(ALGO, key, iv);
|
|
return Buffer.concat([iv, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
}
|
|
|
|
function aesDecrypt(key: Buffer, sealed: Buffer): Buffer {
|
|
const iv = sealed.subarray(0, 12);
|
|
const tag = sealed.subarray(sealed.length - 16);
|
|
const body = sealed.subarray(12, sealed.length - 16);
|
|
const decipher = createDecipheriv(ALGO, key, iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
}
|
|
|
|
/** In-memory DEK cache; the persistent wrapped-DEK store arrives with the credentials table wiring. */
|
|
export class Vault {
|
|
/** Self-contained envelope for persistent stores; AAD binds tenant and handle. */
|
|
async seal(workspaceId: string, credentialId: string, plaintext: Buffer): Promise<SealedCredential> {
|
|
const dek = randomBytes(32);
|
|
const wrapped = aesEncrypt(KEK, dek);
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv(ALGO, dek, iv);
|
|
cipher.setAAD(Buffer.from(JSON.stringify([workspaceId, credentialId])));
|
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
return { keyVersion: 1, ciphertext: Buffer.concat([Buffer.from("APV1"), wrapped, iv, cipher.getAuthTag(), encrypted]) };
|
|
}
|
|
|
|
async open(workspaceId: string, credentialId: string, sealed: SealedCredential): Promise<Buffer> {
|
|
const blob = sealed.ciphertext;
|
|
if (blob.length < 92 || blob.subarray(0, 4).toString() !== "APV1") throw new Error("invalid vault envelope");
|
|
const dek = aesDecrypt(KEK, blob.subarray(4, 64));
|
|
const decipher = createDecipheriv(ALGO, dek, blob.subarray(64, 76));
|
|
decipher.setAAD(Buffer.from(JSON.stringify([workspaceId, credentialId])));
|
|
decipher.setAuthTag(blob.subarray(76, 92));
|
|
return Buffer.concat([decipher.update(blob.subarray(92)), decipher.final()]);
|
|
}
|
|
private readonly deks = new Map<string, { dek: Buffer; wrapped: WrappedDek }>();
|
|
private versions = new Map<string, number>();
|
|
|
|
async put(_workspaceId: string, credentialId: string, plaintext: Buffer): Promise<SealedCredential> {
|
|
const { dek, wrapped } = await this.ensureDek(_workspaceId);
|
|
this.deks.set(credentialId, { dek, wrapped });
|
|
const version = this.versions.get(_workspaceId) ?? 1;
|
|
return { ciphertext: aesEncrypt(dek, plaintext), keyVersion: version };
|
|
}
|
|
|
|
async get(_workspaceId: string, credentialId: string, sealed: SealedCredential): Promise<Buffer> {
|
|
const entry = this.deks.get(credentialId);
|
|
if (entry === undefined) throw new Error("credential not loaded into vault session");
|
|
return aesDecrypt(entry.dek, sealed.ciphertext);
|
|
}
|
|
|
|
/**
|
|
* Rotation: fresh DEK for the workspace, re-wrap given credentials,
|
|
* drop the old DEK so pre-rotation ciphertext becomes unreadable.
|
|
*/
|
|
async rotate(workspaceId: string, secrets: Array<{ credentialId: string; plaintext: Buffer }>): Promise<Map<string, SealedCredential>> {
|
|
const nextVersion = (this.versions.get(workspaceId) ?? 1) + 1;
|
|
const newDek = randomBytes(32);
|
|
const wrapped: WrappedDek = { keyVersion: nextVersion, wrapped: aesEncrypt(KEK, newDek) };
|
|
this.versions.set(workspaceId, nextVersion);
|
|
const out = new Map<string, SealedCredential>();
|
|
for (const { credentialId, plaintext } of secrets) {
|
|
this.deks.set(credentialId, { dek: newDek, wrapped });
|
|
out.set(credentialId, { ciphertext: aesEncrypt(newDek, plaintext), keyVersion: nextVersion });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private async ensureDek(workspaceId: string): Promise<{ dek: Buffer; wrapped: WrappedDek }> {
|
|
const existing = [...this.deks.values()].find((e) => e.wrapped.keyVersion === (this.versions.get(workspaceId) ?? 1));
|
|
if (existing !== undefined) return existing;
|
|
const version = 1;
|
|
const dek = randomBytes(32);
|
|
const entry = { dek, wrapped: { keyVersion: version, wrapped: aesEncrypt(KEK, dek) } };
|
|
this.versions.set(workspaceId, version);
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
export const vault = new Vault();
|