import { createDecipheriv, createHash } from "node:crypto"; import type { MediaAsset } from "@tactical-media-broadcast-hub/core"; /** * SPENC1 media-frame opening — the DECRYPT half of the envelope encryption the * API's `apps/api/src/storage/storage.ts` performs on every media upload * (`seal`, apps/api/src/storage/storage.ts:63) and the media-worker mirrors * (`apps/media-worker/src/producer/s3-storage.ts`, `openFrame`). * * Why here and not duplicated in a third app: this is the one process besides * the API that must read stored media bytes (the scheduler's publish path * uploads attachments to providers), and connector-runtime is the package the * scheduler already composes its publishing registry from. Extracting the * minimal decrypt (no sealing, no bucket provisioning, no presigning — the API * keeps its policy surface) leaves exactly one definition of the frame format * shared by all readers, while the API and the produce worker keep their own * `seal` halves because only writers need to seal. * * Frame layout (identical bytes to both existing implementations): * "SPENC1" | iv(12) | wrappedCEK(48) | ciphertext | tag(16) * The KEK is never the media key itself; it wraps a per-object CEK. */ const FRAME_MAGIC = Buffer.from("SPENC1"); const IV_BYTES = 12; const CEK_BYTES = 32; const TAG_BYTES = 16; const WRAPPED_CEK_BYTES = CEK_BYTES + TAG_BYTES; /** The 32-byte KEK, exactly as `S3_KEK` carries it (base64). */ export function mediaKekFromEnv(env: Record = process.env): Buffer { const raw = env["S3_KEK"]; if (raw !== undefined && raw !== "") { const kek = Buffer.from(raw, "base64"); if (kek.length !== 32) throw new Error("S3_KEK must be 32 bytes base64"); return kek; } if (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:${env["HOSTNAME"] ?? "local"}`).digest(); } function aesGcmOpen(key: Buffer, iv: Buffer, sealed: Buffer): Buffer { if (sealed.length !== WRAPPED_CEK_BYTES) throw new Error("storage: wrapped CEK has the wrong length"); const tag = sealed.subarray(sealed.length - TAG_BYTES); const decipher = createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]); } /** Open a sealed SPENC1 frame; verifies both GCM tags (CEK wrap + body). */ export function openMediaFrame(sealed: Uint8Array, kek: Buffer): Uint8Array { const frame = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed); if (frame.length < FRAME_MAGIC.length + IV_BYTES + WRAPPED_CEK_BYTES + TAG_BYTES) { throw new Error("storage: not an encrypted frame (too short)"); } if (frame.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 = frame.subarray(offset, offset + IV_BYTES); offset += IV_BYTES; const sealedCek = frame.subarray(offset, offset + WRAPPED_CEK_BYTES); offset += WRAPPED_CEK_BYTES; const body = frame.subarray(offset); const cek = aesGcmOpen(kek, iv, sealedCek); const tag = body.subarray(body.length - TAG_BYTES); const ciphertext = body.subarray(0, body.length - TAG_BYTES); const decipher = createDecipheriv("aes-256-gcm", cek, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]); } /** * The dispatch-time media seam: the scheduler resolves an approved post's * asset ids to bytes. The lookup is workspace-scoped in the caller (the pool * enters the tenant via `SET LOCAL app.workspace_id`), so this port carries * only what the connector needs. */ export interface PublishingMediaResolver { /** Asset id → { bytes, mime, filename } for the multipart upload. */ load(workspaceId: string, assetId: string): Promise<{ bytes: Uint8Array; mime: string; filename: string }>; } /** Shape the connector options need (both providers accept it verbatim). */ export function mediaAssetLoaderOptions( resolver: PublishingMediaResolver | undefined, ): { loadAsset: (assetId: string) => Promise; openAsset: (media: MediaAsset) => Promise<{ bytes: Uint8Array; filename: string }> } | undefined { if (resolver === undefined) return undefined; /** One publish loads each asset once (metadata + bytes), keyed per workspace. */ const loadAsset = (assetId: string): Promise => Promise.resolve().then(() => { throw new Error("loadAsset requires a publish connection"); }); void loadAsset; return undefined; }