import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; import { CreateBucketCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, S3ServiceException, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; /** * S3-compatible object storage — the ONLY media I/O path (user directive: * all archive/grabber content lives on object storage, ENCRYPTED — user * directive 2026-10-06). Client-side envelope encryption: every object body * is AES-256-GCM sealed with a random CEK; the CEK is wrapped by the media * KEK (env S3_KEK). The endpoint never sees plaintext. Content addressing * stays on the PLAINTEXT hash (dedup survives); getObject re-verifies the * hash, so storage-level tampering is detected. SSE-at-rest headers are set * additionally for endpoints that honor them (belt-and-braces). */ export interface StoredObject { key: string; hash: string; sizeBytes: number; contentType: string; } export interface StorageService { putContent(buffer: Buffer, contentType: string): Promise; getObject(key: string): Promise; hasContent(hash: string, contentType: string): Promise; presign(key: string, ttlSeconds: number): Promise; } const EXTENSIONS: Record = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov", "application/pdf": "pdf", }; export function extensionFor(contentType: string): string { return EXTENSIONS[contentType] ?? "bin"; } export function keyForHash(hash: string, contentType: string): string { return `sha256/${hash}.${extensionFor(contentType)}`; } export function sha256(buffer: Buffer): string { return createHash("sha256").update(buffer).digest("hex"); } const FRAME_MAGIC = Buffer.from("SPENC1"); /** Seal plaintext under the KEK: returns [iv(12) | wrappedCEK | ciphertext+tag]. */ export function seal(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. */ export function open(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 ciphertext = body.subarray(0, body.length - 16); const decipher = createDecipheriv("aes-256-gcm", cek, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]); } 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()]); } 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)"); } return createHash("sha256").update(`dev-media-kek:${process.env.HOSTNAME ?? "local"}`).digest(); } export interface S3StorageConfig { endpoint?: string; region?: string; accessKeyId?: string; secretAccessKey?: string; bucket?: string; forcePathStyle?: boolean; mediaKek?: Buffer; } export function s3ConfigFromEnv(): S3StorageConfig { 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", }; } export class S3Storage implements StorageService { private readonly client: S3Client; private readonly bucket: string; private readonly kek: Buffer; private bucketReady = false; constructor(config: S3StorageConfig) { 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, }); } private async ensureBucket(): Promise { if (this.bucketReady) return; try { await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })); } catch (err) { const status = err instanceof S3ServiceException ? err.$metadata.httpStatusCode : undefined; const name = err instanceof S3ServiceException ? err.name : ""; // 403: least-privilege object tokens (e.g. R2 keys scoped to existing // buckets) cannot create buckets. The bucket is pre-provisioned, so // proceed — a genuinely missing bucket fails loudly on the real operation. if (status !== 409 && name !== "BucketAlreadyOwnedByYou" && status !== 403) throw err; } this.bucketReady = true; } private async headExists(key: string): Promise { try { await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); return true; } catch (err) { const status = err instanceof S3ServiceException ? err.$metadata.httpStatusCode : undefined; const name = err instanceof S3ServiceException ? err.name : ""; if (status === 404 || name === "NotFound") return false; throw err; } } async putContent(buffer: Buffer, contentType: string): Promise { await this.ensureBucket(); const hash = sha256(buffer); const key = keyForHash(hash, contentType); if (await this.headExists(key)) { return { key, hash, sizeBytes: buffer.byteLength, contentType, deduplicated: true }; } await this.client.send( new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: seal(buffer, this.kek), ContentType: contentType, ServerSideEncryption: "AES256", }), ); return { key, hash, sizeBytes: buffer.byteLength, contentType, deduplicated: false }; } 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 = open(stored, this.kek); const hashFromKey = key.split("/")[1]?.split(".")[0]; if (hashFromKey !== undefined && sha256(plaintext) !== hashFromKey) { throw new Error(`storage: integrity failure for ${key} (plaintext hash mismatch)`); } return plaintext; } async hasContent(hash: string, contentType: string): Promise { await this.ensureBucket(); return this.headExists(keyForHash(hash, contentType)); } async presign(key: string, ttlSeconds: number): Promise { return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: ttlSeconds }); } }