T-HUB/packages/connector-runtime/src/vault.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
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/.
2026-10-10 11:56:42 +00:00

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();