/** * Worker-side storage adapter: the same envelope encryption the API's * `apps/api/src/storage/storage.ts` uses (SPENC1 frames, KEK from S3_KEK), so a * `getObject` here opens bytes a `putContent` there sealed — and vice versa. * * The worker must never reimplement the API's policy (dedup HEAD, presign, bucket * provisioning); it needs only the two operations a produce job uses: download * source/logo/voiceover bytes, publish the produced mp4. Reusing the seal/open * frame format (rather than routing through the API) keeps the worker off the * API's request path, which is the point of a queue. */ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import type { ProduceObjectStorage } from "./store"; const FRAME_MAGIC = Buffer.from("SPENC1"); export interface WorkerStorageConfig { endpoint?: string; region?: string; accessKeyId?: string; secretAccessKey?: string; bucket?: string; forcePathStyle?: boolean; /** 32-byte KEK; when omitted, the dev mirror of the API's loadMediaKek(). */ mediaKek?: Buffer; } function loadMediaKek(): Buffer { const env = process.env.S3_KEK; if (env !== undefined && env !== "") { const kek = Buffer.from(env, "base64"); if (kek.length !== 32) throw new Error("S3_KEK must be 32 bytes base64"); return kek; } if (process.env.NODE_ENV === "production") { throw new Error("S3_KEK is required in production (media encryption is mandatory)"); } // Same derivation as the API's dev fallback, so a dev worker reads dev writes. return createHash("sha256").update(`dev-media-kek:${process.env.HOSTNAME ?? "local"}`).digest(); } export function workerStorageConfigFromEnv(): WorkerStorageConfig { return { endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION ?? "us-east-1", accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY, bucket: process.env.S3_BUCKET ?? "tactical-media-broadcast-hub-media", forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false", }; } function aesGcmSeal(key: Buffer, iv: Buffer, plaintext: Buffer): Buffer { const cipher = createCipheriv("aes-256-gcm", key, iv); return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); } function aesGcmOpen(key: Buffer, iv: Buffer, sealed: Buffer): Buffer { const tag = sealed.subarray(sealed.length - 16); const decipher = createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]); } /** Seal plaintext under the KEK: [magic | iv(12) | wrappedCEK | ciphertext+tag] — the API's exact frame. */ function sealFrame(plaintext: Buffer, kek: Buffer): Buffer { const cek = randomBytes(32); const iv = randomBytes(12); const sealedCek = aesGcmSeal(kek, iv, cek); const cipher = createCipheriv("aes-256-gcm", cek, iv); const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); return Buffer.concat([FRAME_MAGIC, iv, sealedCek, body]); } /** Open a sealed frame; verifies GCM tags (CEK wrap + body) and returns plaintext. */ function openFrame(sealed: Buffer, kek: Buffer): Buffer { if (sealed.subarray(0, FRAME_MAGIC.length).equals(FRAME_MAGIC) === false) { throw new Error("storage: not an encrypted frame (magic mismatch)"); } let offset = FRAME_MAGIC.length; const iv = sealed.subarray(offset, offset + 12); offset += 12; const cekLen = 32 + 16; const sealedCek = sealed.subarray(offset, offset + cekLen); offset += cekLen; const body = sealed.subarray(offset); const cek = aesGcmOpen(kek, iv, sealedCek); const tag = body.subarray(body.length - 16); const decipher = createDecipheriv("aes-256-gcm", cek, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]); } function keyForHash(hash: string): string { return `sha256/${hash}.mp4`; } /** * Envelope-encrypted S3 storage for the produce worker. Object bodies are sealed * exactly like the API's `S3Storage` (magic | iv | wrapped CEK | body+tag), so * the worker and the API interoperate on one bucket. */ export class WorkerS3Storage implements ProduceObjectStorage { private readonly client: S3Client; private readonly bucket: string; private readonly kek: Buffer; constructor(config: WorkerStorageConfig) { this.bucket = config.bucket ?? "tactical-media-broadcast-hub-media"; this.kek = config.mediaKek ?? loadMediaKek(); this.client = new S3Client({ endpoint: config.endpoint, region: config.region ?? "us-east-1", credentials: config.accessKeyId !== undefined && config.secretAccessKey !== undefined ? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey } : undefined, forcePathStyle: config.forcePathStyle ?? true, }); } async getObject(key: string): Promise { const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); if (res.Body === undefined) throw new Error(`empty body for ${key}`); const stored = Buffer.from(await res.Body.transformToByteArray()); const plaintext = openFrame(stored, this.kek); // Content addressing: the key names the plaintext hash, so tampering is detected. const hashFromKey = key.split("/")[1]?.split(".")[0]; if (hashFromKey !== undefined && /^[0-9a-f]{64}$/.test(hashFromKey)) { if (createHash("sha256").update(plaintext).digest("hex") !== hashFromKey) { throw new Error("storage: integrity failure (plaintext hash mismatch)"); } } return plaintext; } async putContent(body: Buffer, contentType: "video/mp4"): Promise<{ key: string }> { if (contentType !== "video/mp4") throw new Error("produce storage publishes video/mp4 only"); const hash = createHash("sha256").update(body).digest("hex"); const key = keyForHash(hash); await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: sealFrame(body, this.kek), ContentType: contentType, ServerSideEncryption: "AES256", })); return { key }; } }