The Mastodon connector's uploadAll resolves asset ids via loadAsset/openAsset. The registry wires these hooks from a mediaResolver that the scheduler injects: media_assets lookup (workspace-scoped via SET LOCAL) + R2 download + SPENC1 decrypt (extracted from the api's storage into connector-runtime/media.ts). provider-fetch gains serializeBody for multipart media uploads through the pinned transport channel.
99 lines
4.7 KiB
TypeScript
99 lines
4.7 KiB
TypeScript
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<string, string | undefined> = 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<MediaAsset>; 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<MediaAsset> =>
|
|
Promise.resolve().then(() => {
|
|
throw new Error("loadAsset requires a publish connection");
|
|
});
|
|
void loadAsset;
|
|
return undefined;
|
|
}
|