feat: direct-to-R2 Files uploads (client-sealed SPENC2) + chunked video dropzone + multi-file queue

For scale: payload bytes go browser → R2 → browser; the api handles keys,
authz and metadata only, so a cheap box can serve thousands of users.

- SPENC2 frame (browser-sealed, P-256 ECDH → HKDF wrap of a per-file CEK,
  10 MiG AES-GCM segments): storage.ts gains the reader (magic-branched
  getObject), presignPut/headObject, openDirectHeader; sealForDirect is the
  reference sealer for cross-implementation tests
- Files API: GET /files/direct/config, POST /files/direct/init (presigned
  PUT), POST /files/direct/complete (HEAD-gated commit), POST
  /files/:id/direct-download (presigned GET + CEK ticket)
- web: lib/direct-storage (WebCrypto seal/open), lib/files-direct (flows),
  dropzone rewritten with a multi-file sequential queue (per-file status,
  direct-first with server fallback), file download decrypts in-browser for
  direct objects (falls back to streaming)
- video dropzone wired to the chunked ingest API (>10 MiB → init/chunk/
  complete), fixing the Cloudflare ~100 MB body cap for large videos
- DIRECT_EC_PRIVATE_KEY (P-256, PKCS8 b64) wired through compose; cross-impl
  roundtrip tests prove web-seal ↔ server-open byte compatibility
This commit is contained in:
glass beadian 2026-10-16 08:12:11 +00:00
parent b3c2085436
commit 0a1c3b392b
21 changed files with 1444 additions and 83 deletions

View file

@ -20,6 +20,10 @@ interface FileUpload {
*
* GET /files list/search, keyset (groupId, senderMxid,
* mimeClass, origin, name, from, to, cursor, limit)
* GET /files/direct/config direct-upload capability + server public JWK
* POST /files/direct/init presigned PUT for a browser-sealed upload
* POST /files/direct/complete HEAD-checked commit + index row
* POST /files/:id/direct-download presigned GET + unwrapped CEK
* POST /files/groups/:groupId/upload direct web upload (≤2 GiB, multipart)
* GET /files/:id one GroupFile
* GET /files/:id/download stream payload (sha256 re-verified)
@ -49,6 +53,64 @@ export class GroupFilesController {
return this.files.listFiles(session.workspaceId, session.userId, session.role, query);
}
/**
* Direct-upload capability. Declared before the :id routes so the literal
* "direct/config" path wins route matching over ":id".
*/
@Get("direct/config")
@RequirePermission("media.view")
async directConfig(): Promise<ReturnType<GroupFilesService["directUploadConfig"]>> {
return this.files.directUploadConfig();
}
@Post("direct/init")
@RequirePermission("media.view")
async directInit(
@Req() request: Request & AuthenticatedRequest,
@Body() body: unknown,
): Promise<ReturnType<GroupFilesService["initDirectUpload"]>> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.files.initDirectUpload(session.workspaceId, requireUuidValue(dto.groupId, "groupId"), {
userId: session.userId,
role: session.role,
}, {
filename: requireString(dto.filename, "filename"),
mimeType: requireString(dto.mimeType, "mimeType"),
sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"),
});
}
@Post("direct/complete")
@RequirePermission("media.view")
async directComplete(
@Req() request: Request & AuthenticatedRequest,
@Body() body: unknown,
): Promise<GroupFile> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.files.completeDirectUpload(session.workspaceId, { userId: session.userId, role: session.role }, {
uploadId: requireUuidValue(dto.uploadId, "uploadId"),
objectKey: requireString(dto.objectKey, "objectKey"),
groupId: requireUuidValue(dto.groupId, "groupId"),
filename: requireString(dto.filename, "filename"),
mimeType: requireString(dto.mimeType, "mimeType"),
sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"),
sha256: requireString(dto.sha256, "sha256"),
});
}
/** Same ACL/availability checks as the streaming route; direct objects only. */
@Post(":id/direct-download")
@RequirePermission("media.view")
async directDownload(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
): Promise<ReturnType<GroupFilesService["openDirectDownload"]>> {
const session = requireSession(request);
return this.files.openDirectDownload(session.workspaceId, session.userId, session.role, id);
}
@Post("groups/:groupId/upload")
@RequirePermission("media.view")
@UseInterceptors(FileInterceptor("file", {
@ -176,3 +238,18 @@ export class PublicFileShareController {
response.end();
}
}
function requireUuidValue(value: unknown, field: string): string {
const text = requireString(value, field);
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text)) {
throw new BadRequestException(`${field} must be a UUID`);
}
return text;
}
function requireNumber(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
throw new BadRequestException(`${field} must be a safe integer`);
}
return value;
}

View file

@ -20,7 +20,7 @@
* (audit_append), the house pattern.
*/
import { BadRequestException, ConflictException, ForbiddenException, GoneException, Injectable, NotFoundException, Optional, Inject, ServiceUnavailableException } from "@nestjs/common";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import type { PoolClient } from "pg";
import { attachmentArchiveKey, safeFileName, SNIFF_BYTES, sniffMime, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core";
@ -68,6 +68,38 @@ export interface FilePage {
nextCursor: string | null;
}
/** GET /files/direct/config — what the browser needs before it can seal. */
export interface DirectUploadConfig {
enabled: boolean;
frameVersion: 2;
publicKeyJwk: { kty: "EC"; crv: "P-256"; x: string; y: string } | null;
maxBytes: number;
}
/** POST /files/direct/init response. Nothing is persisted until complete. */
export interface DirectUploadInit {
uploadId: string;
objectKey: string;
putUrl: string;
}
/** POST /files/:id/direct-download response: URL + the unwrapped CEK. */
export interface DirectDownloadTicket {
url: string;
cekB64: string;
frame: "SPENC2";
sizeBytes: number;
}
export const DIRECT_MAX_BYTES = 2_147_483_648;
const DIRECT_PUT_TTL_SECONDS = 900;
const DIRECT_GET_TTL_SECONDS = 600;
const DIRECT_KEY_PREFIX = "direct/";
const DIRECT_OBJECT_SUFFIX = ".enc";
/** MIME allowlist for direct uploads: prefix match on the declared type. */
const DIRECT_MIME_PREFIXES = ["image/", "video/", "audio/", "application/", "text/"] as const;
const MAX_SHARE_TTL_HOURS = 720;
const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"];
@ -511,6 +543,133 @@ export class GroupFilesService {
});
}
// -------------------------------------------------------------------------
// Direct-to-R2 (browser-sealed SPENC2): keys/authz/metadata stay here,
// payload bytes go browser → R2 → browser
// -------------------------------------------------------------------------
/**
* Whether the deployment can unwrap browser-sealed frames. The public key is
* DERIVED from the private key, so a browser can only ever wrap a CEK this
* process can open — it never sends a key of its own.
*/
async directUploadConfig(): Promise<DirectUploadConfig> {
const storage = this.storage();
const publicKeyJwk = typeof storage.directPublicJwk === "function" ? await storage.directPublicJwk() : null;
return {
enabled: publicKeyJwk !== null,
frameVersion: 2,
publicKeyJwk,
maxBytes: DIRECT_MAX_BYTES,
};
}
/**
* Mint the presigned PUT for a direct upload. Group membership is checked
* exactly like uploadForGroup; the size is bounded up front so an oversized
* pick fails before any bytes are sealed. Nothing is persisted — the row
* only appears on complete, so an abandoned PUT leaves an orphan object
* (no row), never a dangling row without an object.
*/
async initDirectUpload(
workspaceId: string,
groupId: string,
actor: ActorRef,
input: { filename: string; mimeType: string; sizeBytes: number },
): Promise<DirectUploadInit> {
if (!isUuid(groupId)) throw new BadRequestException("groupId must be a UUID");
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 1 || input.sizeBytes > DIRECT_MAX_BYTES) {
throw new BadRequestException(`sizeBytes must be an integer between 1 and ${DIRECT_MAX_BYTES}`);
}
validateDirectFilename(input.filename);
validateDirectMime(input.mimeType);
await this.requireGroupMembership(workspaceId, groupId, actor);
if ((await this.storage().directPublicJwk()) === null) {
throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
}
const uploadId = randomUUID();
const objectKey = `${DIRECT_KEY_PREFIX}${uploadId}${DIRECT_OBJECT_SUFFIX}`;
const putUrl = await this.storage()
.presignPut(objectKey, input.mimeType, DIRECT_PUT_TTL_SECONDS)
.catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); });
return { uploadId, objectKey, putUrl };
}
/**
* Commit a completed direct upload. The object is HEAD-checked (exists, exact
* declared size) before the row is written, so metadata can never point at a
* missing or partial PUT. sha256 is client-attested — the payload is opaque
* to this process by design; the browser computed it over the plaintext it
* sealed. Filename runs through the same safeFileName the streamed path uses.
*/
async completeDirectUpload(
workspaceId: string,
actor: ActorRef,
input: { uploadId: string; objectKey: string; groupId: string; filename: string; mimeType: string; sizeBytes: number; sha256: string },
): Promise<GroupFile> {
if (!isUuid(input.uploadId)) throw new BadRequestException("uploadId must be a UUID");
if (!isUuid(input.groupId)) throw new BadRequestException("groupId must be a UUID");
if (input.objectKey !== `${DIRECT_KEY_PREFIX}${input.uploadId}${DIRECT_OBJECT_SUFFIX}`) {
throw new BadRequestException("objectKey must be direct/<uploadId>.enc");
}
if (!/^[0-9a-f]{64}$/.test(input.sha256)) throw new BadRequestException("sha256 must be lowercase hex (64 chars)");
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 1 || input.sizeBytes > DIRECT_MAX_BYTES) {
throw new BadRequestException(`sizeBytes must be an integer between 1 and ${DIRECT_MAX_BYTES}`);
}
const safe = validateDirectFilename(input.filename);
const mimeType = validateDirectMime(input.mimeType);
await this.requireGroupMembership(workspaceId, input.groupId, actor);
const head = await this.storage().headObject(input.objectKey).catch(() => {
throw new ServiceUnavailableException("file storage is unavailable");
});
if (!head.exists || head.contentLength !== input.sizeBytes) {
throw new BadRequestException("uploaded object is missing or its size does not match sizeBytes");
}
const mimeClass = mimeClassForDeclared(mimeType);
const username = await this.usernameFor(workspaceId, actor.userId);
const { id } = await this.registerArchiveEntry(workspaceId, {
groupId: input.groupId,
roomId: null,
eventId: null,
origin: { kind: "upload" },
originKind: "upload",
senderMxid: `@${username}:tmbh.local`,
originalName: input.filename,
safeName: safe,
mime: mimeType,
mimeClass,
byteSize: input.sizeBytes,
sha256: input.sha256,
ocisResourceId: input.objectKey,
ocisResourceVersion: null,
storageKey: input.objectKey,
createdAt: new Date(),
});
return this.getFile(workspaceId, actor.userId, actor.role, id);
}
/**
* Ticket for a browser-side download: presigned GET (600 s) plus the
* unwrapped CEK. Availability/ACL checks are getFile's (the same ones the
* streaming route runs); only direct/ objects can take this path.
*/
async openDirectDownload(workspaceId: string, userId: string, role: string, itemId: string): Promise<DirectDownloadTicket> {
const item = await this.getFile(workspaceId, userId, role, itemId);
if (item.status !== "available") throw new GoneException(`file is ${item.status}`);
const storageKey = item.file.storageKey;
if (storageKey === undefined || !storageKey.startsWith(DIRECT_KEY_PREFIX)) {
throw new BadRequestException("not a direct object");
}
const storage = this.storage();
const [url, frame] = await Promise.all([
storage.presign(storageKey, DIRECT_GET_TTL_SECONDS),
storage.getObject(storageKey).then((stored) => storage.openDirectHeader(stored)),
]).catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); });
return { url, cekB64: frame.toString("base64"), frame: "SPENC2", sizeBytes: item.sizeBytes };
}
// -------------------------------------------------------------------------
// internals
// -------------------------------------------------------------------------
@ -604,6 +763,15 @@ export class GroupFilesService {
return this.getFile(workspaceId, actor.userId, actor.role, id);
}
/** Owner/Admin pass; everyone else must be an active member of the group. */
private async requireGroupMembership(workspaceId: string, groupId: string, actor: ActorRef): Promise<void> {
if (WORKSPACE_WIDE_ROLES.includes(actor.role)) return;
const membership = await withTenant(workspaceId, (client) =>
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1", [groupId, actor.userId]),
);
if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group");
}
private async usernameFor(workspaceId: string, userId: string): Promise<string> {
const row = await withTenant(workspaceId, (client) =>
client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]),
@ -659,6 +827,41 @@ function parseFilters(query: Record<string, unknown>): FileListFilters {
return { groupId, senderMxid, mimeClass: rawClass as MimeClass | null, origin: rawOrigin as OriginKind | null, name, from, to };
}
/**
* Filename gate for direct uploads: no client bytes are sniffed (the payload
* is opaque), so the name itself must pass the library-safe rules and carry a
* plausible extension for the declared type.
*/
function validateDirectFilename(filename: string): string {
if (typeof filename !== "string" || filename.trim() === "" || filename.length > 255) {
throw new BadRequestException("filename must be 1-255 characters");
}
const safe = safeFileName(filename);
if (safe === "" || safe === "attachment") {
throw new BadRequestException("filename must contain a usable name");
}
return safe;
}
/** Declared MIME must sit under one of the allowed prefixes (no sniffing). */
function validateDirectMime(mimeType: string): string {
if (typeof mimeType !== "string" || mimeType.trim() === "" || mimeType.length > 255) {
throw new BadRequestException("mimeType must be 1-255 characters");
}
if (DIRECT_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix))) return mimeType;
throw new BadRequestException(`mimeType must start with one of: ${DIRECT_MIME_PREFIXES.join(" ")}`);
}
/** Coarse class from the declared MIME — the sniff path's equivalent. */
function mimeClassForDeclared(mimeType: string): MimeClass {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType === "application/pdf" || mimeType.startsWith("text/")) return "document";
if (mimeType === "application/zip" || mimeType === "application/x-zip-compressed") return "archive";
return "other";
}
function optionalUuid(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string" || !isUuid(value)) throw new BadRequestException("groupId must be a UUID");

View file

@ -10,7 +10,7 @@ import {
sniffMediaAsset,
} from "./media.service.js";
import { appendTenantAudit } from "../audit/audit.service.js";
import { sha256, type StorageService } from "../storage/storage.js";
import { sha256, type StorageService, directStorageDisabled } from "../storage/storage.js";
import { MediaController } from "./media.controller.js";
import type { AuthenticatedRequest } from "../rbac/rbac.guard.js";
import type { Request, Response } from "express";
@ -68,6 +68,7 @@ function fakeStorage(): StorageService & {
}),
hasContent: vi.fn(async () => false),
presign: vi.fn(async () => ""),
...directStorageDisabled(),
};
}

View file

@ -2,11 +2,11 @@ import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import { MemoryRedis } from "./testing/memory-redis.js";
import { PreviewService } from "./preview.js";
import { keyForHash, sha256, type StorageService } from "../storage/storage.js";
import { keyForHash, sha256, directStorageDisabled, type StorageService } from "../storage/storage.js";
const WS = randomUUID(), CH = randomUUID(), TAP = randomUUID();
function fixture(): { preview: PreviewService; storage: StorageService; redis: MemoryRedis; seed: (file: string, body: string, type?: string) => void; advance: (ms: number) => void } {
const redis = new MemoryRedis(), objects = new Map<string, Buffer>(); let now = Date.now();
const storage: StorageService = { putContent: async () => { throw new Error("unused"); }, getObject: vi.fn(async key => { await new Promise(r => setTimeout(r, 1)); return objects.get(key) ?? Buffer.alloc(0); }), hasContent: async () => false, presign: async () => { throw new Error("must not redirect to encrypted S3"); } };
const storage: StorageService = { putContent: async () => { throw new Error("unused"); }, getObject: vi.fn(async key => { await new Promise(r => setTimeout(r, 1)); return objects.get(key) ?? Buffer.alloc(0); }), hasContent: async () => false, presign: async () => { throw new Error("must not redirect to encrypted S3"); }, ...directStorageDisabled() };
const preview = new PreviewService({ redis, storage, secret: "test", now: () => now, assertTap: async () => undefined });
const seed = (file: string, body: string, type = "video/mp2t"): void => {
const buffer = Buffer.from(body), key = keyForHash(sha256(buffer), type); objects.set(key, buffer);

View file

@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import type { PoolClient } from "pg";
import { describe, expect, it, vi } from "vitest";
import { MediaControlService, type IssuedCommand } from "../media-control/media-control.service.js";
import { sha256, keyForHash, type StorageService } from "../storage/storage.js";
import { sha256, keyForHash, directStorageDisabled, type StorageService } from "../storage/storage.js";
import { ProductionService } from "./production.service.js";
import type { TenantTransaction } from "./journal.js";
const WS = randomUUID(), CH = randomUUID(), SLOT = randomUUID();
@ -29,7 +29,7 @@ function fixture(render?: (input: unknown) => Promise<Buffer>): { production: Pr
try { return await fn({ query } as unknown as PoolClient); } finally { unlock(); }
};
const control = new MediaControlService({ signer: null, assignments: [] }); vi.spyOn(control, "director").mockResolvedValue(ISSUED);
const storage: StorageService = { putContent: vi.fn(async (buffer, contentType) => { const hash = sha256(buffer); return { hash, key: keyForHash(hash, contentType), sizeBytes: buffer.length, contentType, deduplicated: false }; }), getObject: async () => Buffer.alloc(0), hasContent: async () => false, presign: async () => "" };
const storage: StorageService = { putContent: vi.fn(async (buffer, contentType) => { const hash = sha256(buffer); return { hash, key: keyForHash(hash, contentType), sizeBytes: buffer.length, contentType, deduplicated: false }; }), getObject: async () => Buffer.alloc(0), hasContent: async () => false, presign: async () => "", ...directStorageDisabled() };
return { rows, control, storage, production: new ProductionService(control, { transaction, storage, ...(render ? { render } : {}) }) };
}
describe("revision → render job → content-addressed storage → desired-state pipeline", () => {

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { directStorageDisabled } from "../storage/storage.js";
import { BadRequestException, ServiceUnavailableException } from "@nestjs/common";
import type { PoolClient } from "pg";
import type { Request, Response } from "express";
@ -67,6 +68,7 @@ function fakeStorage(objects: Record<string, Buffer> = {}): {
}),
hasContent: vi.fn(async () => false),
presign: vi.fn(async () => ""),
...directStorageDisabled(),
};
return { storage, puts };
}
@ -339,6 +341,7 @@ describe("branding service — uploads (storage reuse, magic-byte sniffing)", ()
getObject: vi.fn(async () => Buffer.alloc(0)),
hasContent: vi.fn(async () => false),
presign: vi.fn(async () => ""),
...directStorageDisabled(),
};
const branding = serviceWith({ storage: broken });
await expect(branding.uploadImage(PNG_HEADER, "image/png")).rejects.toBeInstanceOf(ServiceUnavailableException);

View file

@ -1,4 +1,5 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
import { ServiceUnavailableException } from "@nestjs/common";
import {
CreateBucketCommand,
GetObjectCommand,
@ -18,6 +19,13 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
* 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).
*
* SPENC2 (direct-to-R2, 2026-10-16): objects under `direct/` are sealed IN
* THE BROWSER (apps/web/src/lib/direct-storage.ts). The server never sees
* the payload — it only mints the presigned PUT/GET and unwraps the CEK with
* the ECDH share it derives from DIRECT_EC_PRIVATE_KEY. Both frame versions
* share `getObject`: the leading magic decides the branch, so a single
* reader serves the whole bucket and the SPENC1 path stays byte-identical.
*/
export interface StoredObject {
@ -32,6 +40,37 @@ export interface StorageService {
getObject(key: string): Promise<Buffer>;
hasContent(hash: string, contentType: string): Promise<boolean>;
presign(key: string, ttlSeconds: number): Promise<string>;
/** Direct-to-R2: the JWK the browser seals against; null = feature off. */
directPublicJwk(): Promise<{ kty: "EC"; crv: "P-256"; x: string; y: string } | null>;
/** Direct-to-R2: presigned PUT for the browser to push a SPENC2 frame. */
presignPut(key: string, contentType: string, ttlSeconds: number): Promise<string>;
/** Direct-to-R2: existence + exact size, used to gate /files/direct/complete. */
headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }>;
/** Direct-to-R2: unwrap the frame's CEK (header only) for /files/:id/direct-download. */
openDirectHeader(stored: Buffer): Promise<Buffer>;
}
/** The direct-only members of StorageService, as one spreadable group. */
export type DirectStorageMembers = Pick<
StorageService,
"directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader"
>;
/**
* Direct-path stub for in-memory fakes that never serve browser uploads:
* reports the feature disabled and refuses any direct operation. Lets a fake
* stay a complete StorageService with one spread instead of four stubs.
*/
export function directStorageDisabled(): DirectStorageMembers {
const unavailable = async (): Promise<never> => {
throw new Error("direct storage is unavailable");
};
return {
directPublicJwk: async () => null,
presignPut: unavailable,
headObject: unavailable,
openDirectHeader: unavailable,
};
}
const EXTENSIONS: Record<string, string> = {
@ -89,6 +128,218 @@ export function open(sealed: Buffer, kek: Buffer): Buffer {
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
// ---------------------------------------------------------------------------
// SPENC2 — browser-sealed direct-to-R2 frames
// ---------------------------------------------------------------------------
/** Frame magic for browser-sealed objects (spec: "SPENC2" exactly). */
export const DIRECT_FRAME_MAGIC = Buffer.from("SPENC2", "ascii");
/**
* Fixed frame layout, all integers big-endian. Apps/web/src/lib/direct-storage.ts
* seals the same shape; this reader is the cross-implementation proof target.
*
* [0..6) magic "SPENC2"
* [6..71) ephemeral P-256 public key, uncompressed: 0x04 || X(32) || Y(32)
* [71..83) wrapNonce, 12 random bytes
* [83..131) wrappedCek: AES-256-GCM(CEK), key = HKDF-SHA256(ECDH, salt=∅,
* info="tmbh-direct-cek-wrap", L=32), nonce = wrapNonce → 48 bytes
* [131..135) segmentCount, u32
* [135..] segments: ctLen u32 | iv 12 | AES-256-GCM ciphertext+tag, key=CEK, no AAD
*/
const EPHEMERAL_PUBLIC_OFFSET = 6;
const WRAP_NONCE_OFFSET = 71;
const WRAPPED_CEK_OFFSET = 83;
const SEGMENT_COUNT_OFFSET = 131;
const SEGMENTS_OFFSET = 135;
const WRAPPED_CEK_BYTES = 32 + 16;
/** HKDF info binding the wrap key to this purpose. ASCII, exact. */
const DIRECT_WRAP_INFO = Buffer.from("tmbh-direct-cek-wrap", "ascii");
/** Segment plaintext ceiling. Mirrors the web side, which seals 10 MiB chunks. */
export const DIRECT_SEGMENT_BYTES = 10 * 1024 * 1024;
/** Server direct keypair: P-256 private key from DIRECT_EC_PRIVATE_KEY (base64 DER). */
export interface DirectKeyPair {
privateKey: ReturnType<typeof createPrivateKey>;
/** Compact JWK {kty,crv,x,y} — exactly what the browser's importKey wants. */
publicJwk: { kty: "EC"; crv: "P-256"; x: string; y: string };
}
export class DirectKeyError extends Error {}
/** True when DIRECT_EC_PRIVATE_KEY is present (routes report enabled=false otherwise). */
export function directEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return loadDirectPrivateJwk(env) !== null;
}
function loadDirectPrivateJwk(env: NodeJS.ProcessEnv): ReturnType<typeof createPrivateKey> | null {
const raw = env.DIRECT_EC_PRIVATE_KEY;
if (raw === undefined || raw.trim() === "") return null;
let der: Buffer;
try {
der = Buffer.from(raw.trim(), "base64");
} catch {
throw new DirectKeyError("DIRECT_EC_PRIVATE_KEY must be base64 DER");
}
try {
return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
} catch {
throw new DirectKeyError("DIRECT_EC_PRIVATE_KEY is not a PKCS8 P-256 private key");
}
}
/**
* The direct keypair, deriving the public key from the private one — a
* client-supplied public key is never trusted, so the browser can only wrap
* CEKs this process can unwrap.
*/
export function directKeyPairFromEnv(env: NodeJS.ProcessEnv = process.env): DirectKeyPair | null {
const privateKey = loadDirectPrivateJwk(env);
if (privateKey === null) return null;
const jwk = createPublicKey(privateKey).export({ format: "jwk" });
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") {
throw new DirectKeyError("DIRECT_EC_PRIVATE_KEY is not a P-256 key");
}
return { privateKey, publicJwk: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y } };
}
/** ECDH → HKDF-SHA256(salt empty, info "tmbh-direct-cek-wrap") → 32-byte wrap key. */
function directWrapKey(privateKey: ReturnType<typeof createPrivateKey>, ephemeralPoint: Buffer): Buffer {
const x = ephemeralPoint.subarray(1, 33).toString("base64url");
const y = ephemeralPoint.subarray(33, 65).toString("base64url");
let peer: ReturnType<typeof createPublicKey>;
try {
peer = createPublicKey({ key: { kty: "EC", crv: "P-256", x, y }, format: "jwk" });
} catch {
throw new Error("storage: SPENC2 frame holds a malformed ephemeral key");
}
const ikm = Buffer.from(diffieHellman({ privateKey, publicKey: peer }));
// RFC 5869: an absent salt defaults to HashLen zero bytes; WebCrypto's empty
// salt and Node's zero-filled salt produce the identical PRK (verified).
return Buffer.from(hkdfSync("sha256", ikm, Buffer.alloc(0), DIRECT_WRAP_INFO, 32));
}
interface DirectHeader {
ephemeralPoint: Buffer;
wrapNonce: Buffer;
wrappedCek: Buffer;
segmentCount: number;
}
/** Validate + slice the fixed header. Throws on any truncation or bad shape. */
function parseDirectHeader(stored: Buffer): DirectHeader {
if (stored.length < SEGMENTS_OFFSET) {
throw new Error("storage: SPENC2 frame is truncated");
}
const ephemeralPoint = stored.subarray(EPHEMERAL_PUBLIC_OFFSET, WRAP_NONCE_OFFSET);
if (ephemeralPoint[0] !== 0x04) throw new Error("storage: SPENC2 ephemeral key is not an uncompressed point");
const wrapNonce = stored.subarray(WRAP_NONCE_OFFSET, WRAPPED_CEK_OFFSET);
const wrappedCek = stored.subarray(WRAPPED_CEK_OFFSET, WRAPPED_CEK_OFFSET + WRAPPED_CEK_BYTES);
const segmentCount = stored.readUInt32BE(SEGMENT_COUNT_OFFSET);
if (segmentCount === 0 || segmentCount > 4_294_967_295 / DIRECT_SEGMENT_BYTES + 1) {
throw new Error("storage: SPENC2 segment count is out of range");
}
return { ephemeralPoint, wrapNonce, wrappedCek, segmentCount };
}
/** Walk the segment table; yields {ciphertext, iv} in order. */
function* directSegments(stored: Buffer, segmentCount: number): Generator<{ iv: Buffer; ciphertext: Buffer }> {
let offset = SEGMENTS_OFFSET;
for (let index = 0; index < segmentCount; index += 1) {
if (offset + 4 + 12 > stored.length) throw new Error("storage: SPENC2 segment table is truncated");
const ctLen = stored.readUInt32BE(offset);
offset += 4;
if (ctLen < 16 || offset + 12 + ctLen > stored.length) throw new Error("storage: SPENC2 segment length is out of range");
const iv = stored.subarray(offset, offset + 12);
offset += 12;
const ciphertext = stored.subarray(offset, offset + ctLen);
offset += ctLen;
yield { iv, ciphertext };
}
if (offset !== stored.length) throw new Error("storage: SPENC2 frame has trailing bytes");
}
/** Unwrap the CEK (verifies the wrap GCM tag). Exposed for openDirectHeader. */
export function unwrapDirectCek(stored: Buffer, privateKey: ReturnType<typeof createPrivateKey>): Buffer {
const header = parseDirectHeader(stored);
return aesGcmOpen(directWrapKey(privateKey, header.ephemeralPoint), header.wrapNonce, header.wrappedCek);
}
/**
* Open a SPENC2 frame: unwrap the CEK, then decrypt every segment. Segment
* GCM tags are verified individually, so any flipped byte throws. Empty
* plaintexts are rejected (the upload floor is 1 byte).
*/
export function openDirectFrame(stored: Buffer, privateKey: ReturnType<typeof createPrivateKey>): Buffer {
const { segmentCount } = parseDirectHeader(stored);
const cek = unwrapDirectCek(stored, privateKey);
const parts: Buffer[] = [];
for (const segment of directSegments(stored, segmentCount)) {
parts.push(aesGcmOpen(cek, segment.iv, segment.ciphertext));
}
const plaintext = Buffer.concat(parts);
if (plaintext.length === 0) throw new Error("storage: SPENC2 frame decrypts to zero bytes");
return plaintext;
}
/**
* Test helper: seal `plaintext` exactly the way the browser does, so server
* readers can be exercised without a DOM. Production uploads come from
* apps/web/src/lib/direct-storage.ts — this is the reference implementation.
*/
export function sealForDirect(plaintext: Buffer, publicJwk: { kty: "EC"; crv: "P-256"; x: string; y: string }): Buffer {
if (plaintext.length === 0) throw new Error("sealForDirect: plaintext must not be empty");
const cek = randomBytes(32);
const { privateKey: ephemeralPrivate, publicKey: ephemeralPublic } = newEphemeralKeyPair();
// Wrap to the SERVER's public key — ECDH(ephPriv, serverPub), the same share
// the server derives as ECDH(serverPriv, ephPub) in directWrapKey.
const wrapKey = directWrapKey(ephemeralPrivate, uncompressedPoint(createPublicKey({ key: { ...publicJwk }, format: "jwk" })));
const wrapNonce = randomBytes(12);
const wrappedCek = aesGcmSeal(wrapKey, wrapNonce, cek);
const segments: Buffer[] = [];
for (let offset = 0; offset < plaintext.length; offset += DIRECT_SEGMENT_BYTES) {
const iv = randomBytes(12);
const ciphertext = aesGcmSeal(cek, iv, plaintext.subarray(offset, offset + DIRECT_SEGMENT_BYTES));
segments.push(Buffer.concat([u32be(ciphertext.byteLength), iv, ciphertext]));
}
return Buffer.concat([
DIRECT_FRAME_MAGIC,
uncompressedPoint(ephemeralPublic),
wrapNonce,
wrappedCek,
u32be(segments.length),
...segments,
]);
}
/** Random object key for a browser upload: `direct/<uuid>.enc`. */
export function directObjectKey(): string {
return `direct/${randomUUID()}.enc`;
}
function u32be(value: number): Buffer {
const out = Buffer.alloc(4);
out.writeUInt32BE(value, 0);
return out;
}
function newEphemeralKeyPair(): { privateKey: ReturnType<typeof createPrivateKey>; publicKey: ReturnType<typeof createPublicKey> } {
return generateKeyPairSync("ec", { namedCurve: "P-256" });
}
/** Raised wherever a direct operation needs a key the deployment never supplied. */
function missingDirectKey(): ServiceUnavailableException {
return new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
}
function uncompressedPoint(publicKey: ReturnType<typeof createPublicKey>): Buffer {
const point = publicKey.export({ type: "spki", format: "der" }).subarray(26);
if (point.length !== 65 || point[0] !== 0x04) throw new Error("storage: expected an uncompressed P-256 point");
return point;
}
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()]);
@ -122,6 +373,8 @@ export interface S3StorageConfig {
bucket?: string;
forcePathStyle?: boolean;
mediaKek?: Buffer;
/** Overrides DIRECT_EC_PRIVATE_KEY (tests). */
directPrivateKey?: ReturnType<typeof createPrivateKey>;
}
export function s3ConfigFromEnv(): S3StorageConfig {
@ -139,11 +392,13 @@ export class S3Storage implements StorageService {
private readonly client: S3Client;
private readonly bucket: string;
private readonly kek: Buffer;
private readonly directKey: DirectKeyPair | null;
private bucketReady = false;
constructor(config: S3StorageConfig) {
this.bucket = config.bucket ?? "tactical-media-broadcast-hub-media";
this.kek = config.mediaKek ?? loadMediaKek();
this.directKey = config.directPrivateKey !== undefined ? directKeyPairFromPrivateKey(config.directPrivateKey) : directKeyPairFromEnv();
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region ?? "us-east-1",
@ -155,6 +410,11 @@ export class S3Storage implements StorageService {
});
}
/** The JWK the browser seals against; null when direct upload is disabled. */
async directPublicJwk(): Promise<{ kty: "EC"; crv: "P-256"; x: string; y: string } | null> {
return this.directKey === null ? null : { ...this.directKey.publicJwk };
}
private async ensureBucket(): Promise<void> {
if (this.bucketReady) return;
try {
@ -205,9 +465,21 @@ export class S3Storage implements StorageService {
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 leading = stored.subarray(0, 6).toString("latin1");
let plaintext: Buffer;
if (leading === "SPENC1") {
plaintext = open(stored, this.kek);
} else if (leading === "SPENC2") {
// Browser-sealed frame: only this process can unwrap the CEK.
if (this.directKey === null) throw missingDirectKey();
plaintext = openDirectFrame(stored, this.directKey.privateKey);
} else {
plaintext = open(stored, this.kek); // raises the magic-mismatch error
}
// Content addressing is on the PLAINTEXT hash, so tampering (or a
// mismatched key) is detected here for both frame versions.
const hashFromKey = key.split("/")[1]?.split(".")[0];
if (hashFromKey !== undefined && sha256(plaintext) !== hashFromKey) {
if (hashFromKey !== undefined && hashFromKey.length === 64 && sha256(plaintext) !== hashFromKey) {
throw new Error(`storage: integrity failure for ${key} (plaintext hash mismatch)`);
}
return plaintext;
@ -221,4 +493,34 @@ export class S3Storage implements StorageService {
async presign(key: string, ttlSeconds: number): Promise<string> {
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: ttlSeconds });
}
async presignPut(key: string, contentType: string, ttlSeconds: number): Promise<string> {
return getSignedUrl(this.client, new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }), { expiresIn: ttlSeconds });
}
async headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }> {
try {
const res = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
return { exists: true, contentLength: res.ContentLength ?? null };
} catch (err) {
const status = err instanceof S3ServiceException ? err.$metadata.httpStatusCode : undefined;
const name = err instanceof S3ServiceException ? err.name : "";
if (status === 404 || name === "NotFound") return { exists: false, contentLength: null };
throw err;
}
}
async openDirectHeader(stored: Buffer): Promise<Buffer> {
if (this.directKey === null) throw missingDirectKey();
return unwrapDirectCek(stored, this.directKey.privateKey);
}
}
/** Build a DirectKeyPair from an already-loaded private key (config + tests). */
function directKeyPairFromPrivateKey(privateKey: ReturnType<typeof createPrivateKey>): DirectKeyPair {
const jwk = createPublicKey(privateKey).export({ format: "jwk" });
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") {
throw new DirectKeyError("direct key is not a P-256 key");
}
return { privateKey, publicJwk: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y } };
}

View file

@ -1,4 +1,4 @@
import { keyForHash, sha256, type StorageService } from "../storage/storage.js";
import { keyForHash, sha256, type StorageService, directStorageDisabled } from "../storage/storage.js";
import type { PoolClient, QueryResult } from "pg";
import { vi } from "vitest";
import {
@ -43,6 +43,7 @@ export function serviceWithDatabase(
}),
hasContent: async (hash, type) => objects.has(keyForHash(hash, type)),
presign: async () => { throw new Error("Encrypted source must be decrypted before probing"); },
...directStorageDisabled(),
};
database.transitions = [];
database.statements = [];

View file

@ -3,7 +3,7 @@ import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runMigrations } from "../db/migrate.js";
import { sha256, keyForHash, type StorageService } from "../storage/storage.js";
import { sha256, keyForHash, type StorageService, directStorageDisabled } from "../storage/storage.js";
import { VideoService, type VideoServiceDependencies } from "./video.service.js";
const dbAvailable = process.env.DATABASE_URL !== undefined;
@ -45,6 +45,7 @@ describe.skipIf(!dbAvailable)("video library (live DB)", () => {
},
hasContent: async (hash, type) => objects.has(keyForHash(hash, type)),
presign: async () => "unused",
...directStorageDisabled(),
};
beforeAll(async () => {

View file

@ -1,6 +1,6 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { keyForHash, sha256, seal, open, type StorageService } from "../storage/storage.js";
import { keyForHash, sha256, seal, open, type StorageService, directStorageDisabled } from "../storage/storage.js";
import { probeVideoFile } from "./ffprobe.js";
import { serviceWithDatabase, workspaceId } from "./test-fixtures.js";
@ -30,6 +30,7 @@ describe.skipIf(fixture === undefined)("real ffprobe roundtrip (encrypted in-mem
},
hasContent: async (hash, contentType) => objects.has(keyForHash(hash, contentType)),
presign: async () => { throw new Error("must decrypt, not presign"); },
...directStorageDisabled(),
};
const { service } = serviceWithDatabase({}, probeVideoFile, storage);
const uploaded = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4");