fix: oidc hardening — sid-less consent refused, rotation race closed, iss on errors, logout honesty

From the logic audit:
- consent rejects sid-less bearers and requires a LIVE hub session
  (sessionAlive) before minting codes — a logged-out bearer can no longer
  mint downstream tokens inside its TTL
- refresh rotation race closed: consume returns whether THIS call won;
  the loser revokes the family (reuse detection now holds under
  concurrency)
- error redirects carry iss (RFC 9207) on error responses too
- logout awaits the revocation and no longer returns ok for missing or
  forged bearers
This commit is contained in:
glass beadian 2026-10-18 11:54:59 +00:00
parent e486e62220
commit 74f0802f1a
10 changed files with 355 additions and 24 deletions

View file

@ -163,6 +163,46 @@ export class GroupFilesController {
});
}
/** Segment-aligned multipart upload; upload IDs come from S3. */
@Post("direct/multipart/init")
@RequirePermission("media.view")
async directMultipartInit(
@Req() request: Request & AuthenticatedRequest,
@Body() body: unknown,
): Promise<ReturnType<GroupFilesService["initDirectMultipartUpload"]>> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.files.initDirectMultipartUpload(session.workspaceId,
dto.groupId == null ? null : 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"),
personal: dto.personal, folderId: dto.folderId,
});
}
@Post("direct/multipart/complete")
@RequirePermission("media.view")
async directMultipartComplete(
@Req() request: Request & AuthenticatedRequest,
@Body() body: unknown,
): Promise<GroupFile> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.files.completeDirectMultipartUpload(session.workspaceId,
{ userId: session.userId, role: session.role }, {
uploadId: requireString(dto.uploadId, "uploadId"),
objectKey: requireString(dto.objectKey, "objectKey"),
groupId: dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"),
personal: dto.personal, folderId: dto.folderId,
filename: requireString(dto.filename, "filename"),
mimeType: requireString(dto.mimeType, "mimeType"),
plainSizeBytes: requireNumber(dto.plainSizeBytes, "plainSizeBytes"),
sha256: requireString(dto.sha256, "sha256"), parts: dto.parts,
});
}
/** Same ACL/availability checks as the streaming route; direct objects only. */
@Post(":id/direct-download")
@RequirePermission("media.view")

View file

@ -5,7 +5,7 @@ import { GUARDS_METADATA } from "@nestjs/common/constants.js";
import { FilePlaybackController, GroupFilesController } from "./group-files.controller.js";
import { RbacGuard } from "../rbac/rbac.guard.js";
import { FileRangeNotSatisfiableException } from "./playback-range.js";
import { directStorageDisabled, type StorageService } from "../storage/storage.js";
import { DIRECT_SEGMENT_BYTES, directSealedSize, directStorageDisabled, type StorageService } from "../storage/storage.js";
import { hashShareToken } from "./share-token.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BadRequestException, ConflictException, ForbiddenException, GoneException, NotFoundException } from "@nestjs/common";
@ -363,6 +363,102 @@ describe("GroupFilesService invariants", () => {
});
describe("direct multipart uploads", () => {
const actor = { userId: "user-1", role: "Publisher" };
const objectKey = "direct/multipart/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.enc";
const plainSizeBytes = DIRECT_SEGMENT_BYTES + 1;
const input = {
uploadId: "opaque-s3-upload-id+/=", objectKey, groupId: row.group_id,
filename: "clip.mp4", mimeType: "video/mp4", plainSizeBytes, sha256: "a".repeat(64),
parts: [{ ETag: '"etag-1"', PartNumber: 1 }, { ETag: '"etag-2"', PartNumber: 2 }],
};
let storage: StorageService;
let service: GroupFilesService;
beforeEach(() => {
behavior = () => ({ rows: [row], rowCount: 1 });
storage = {
...directStorageDisabled(), putContent: vi.fn(), getObject: vi.fn(), hasContent: vi.fn(), presign: vi.fn(),
directPublicJwk: vi.fn(async () => ({ kty: "EC", crv: "P-256", x: "x", y: "y" } as const)),
createMultipart: vi.fn(async () => ({ uploadId: input.uploadId })),
presignPart: vi.fn(async (_key, _upload, part) => `https://storage.example/part/${part}`),
completeMultipart: vi.fn(async () => undefined), abortMultipart: vi.fn(async () => undefined),
headObject: vi.fn(async () => ({ exists: true, contentLength: directSealedSize(plainSizeBytes) })),
};
service = new GroupFilesService({ storage });
vi.spyOn(service, "registerArchiveEntry").mockResolvedValue({ id: row.id, claimKey: "claim" });
});
it("initializes the full 2 GiB as 205 ordered part URLs", async () => {
const result = await service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, {
filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 2 * 1024 ** 3,
});
expect(result.partCount).toBe(205);
expect(result.partUrls).toHaveLength(205);
expect(result.uploadId).toBe(input.uploadId);
expect(result.objectKey).toMatch(/^direct\/multipart\/[a-f0-9-]+\.enc$/);
expect(storage.createMultipart).toHaveBeenCalledWith(result.objectKey, "application/octet-stream");
expect(storage.presignPart).toHaveBeenNthCalledWith(205, result.objectKey, input.uploadId, 205, 900);
});
it("completes before HEAD and registers plaintext bytes after exact size verification", async () => {
await service.completeDirectMultipartUpload(row.workspace_id, actor, input);
expect(storage.completeMultipart).toHaveBeenCalledWith(objectKey, input.uploadId, input.parts);
expect(vi.mocked(storage.completeMultipart).mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(storage.headObject).mock.invocationCallOrder[0]!);
expect(service.registerArchiveEntry).toHaveBeenCalledWith(row.workspace_id, expect.objectContaining({
byteSize: plainSizeBytes, storageKey: objectKey, groupId: row.group_id, sha256: input.sha256,
}));
expect(storage.abortMultipart).not.toHaveBeenCalled();
});
it.each([
[], [input.parts[0]], [...input.parts, { ETag: "extra", PartNumber: 3 }],
[{ ETag: "", PartNumber: 1 }, input.parts[1]],
[{ ETag: "one", PartNumber: 1 }, { ETag: "two", PartNumber: 1 }],
[input.parts[1], input.parts[0]], null,
].map((parts) => ({ parts })))("rejects an invalid ETag list and aborts before completion: $parts", async ({ parts }) => {
await expect(service.completeDirectMultipartUpload(row.workspace_id, actor, { ...input, parts }))
.rejects.toBeInstanceOf(BadRequestException);
expect(storage.abortMultipart).toHaveBeenCalledWith(objectKey, input.uploadId);
expect(storage.completeMultipart).not.toHaveBeenCalled();
expect(service.registerArchiveEntry).not.toHaveBeenCalled();
});
it.each([null, directSealedSize(plainSizeBytes) - 1, directSealedSize(plainSizeBytes) + 1, plainSizeBytes])(
"rejects missing or inexact sealed size %s without registering", async (contentLength) => {
vi.mocked(storage.headObject).mockResolvedValue({ exists: contentLength !== null, contentLength });
await expect(service.completeDirectMultipartUpload(row.workspace_id, actor, input)).rejects.toMatchObject({ status: 400 });
expect(storage.abortMultipart).toHaveBeenCalledWith(objectKey, input.uploadId);
expect(service.registerArchiveEntry).not.toHaveBeenCalled();
});
it("aborts when presigning a part fails", async () => {
vi.mocked(storage.presignPart).mockRejectedValue(new Error("unavailable"));
await expect(service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, {
filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: plainSizeBytes,
})).rejects.toMatchObject({ status: 503 });
expect(storage.abortMultipart).toHaveBeenCalledWith(expect.stringMatching(/^direct\/multipart\//), input.uploadId);
});
it("rejects an oversize file before allocating an upload", async () => {
await expect(service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, {
filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 2 * 1024 ** 3 + 1,
})).rejects.toMatchObject({ status: 400 });
expect(storage.createMultipart).not.toHaveBeenCalled();
});
it("keeps multipart plaintext metadata intact for range playback", async () => {
behavior = () => ({ rows: [{ ...row, byte_size: String(plainSizeBytes), storage_key: objectKey }], rowCount: 1 });
const result = await service.openRangePlayback(row.workspace_id, actor.userId, actor.role, row.id, plainSizeBytes - 1, 1);
const { token } = result.ticket;
storage.readDirectRange = vi.fn(async () => Buffer.from("x"));
const slice = await service.readPlaybackTicket(token, String(plainSizeBytes - 1), String(plainSizeBytes - 1));
expect(slice.total).toBe(plainSizeBytes);
expect(storage.readDirectRange).toHaveBeenCalledWith(objectKey, plainSizeBytes - 1, 1);
});
});
describe("range playback capabilities", () => {
const payload = Buffer.from("0123456789");
let service: GroupFilesService;

View file

@ -28,7 +28,7 @@ import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
import { withSystem, withTenant } from "../db/client.js";
import { isUuid, parseLimit } from "../http/validation.js";
import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js";
import { S3Storage, directPlaintextSize, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
import { S3Storage, DIRECT_SEGMENT_BYTES, directSealedSize, directPlaintextSize, s3ConfigFromEnv, type MultipartPart, type StorageService } from "../storage/storage.js";
import type { PayloadDownload } from "./verify-stream.js";
import { hashShareToken, mintShareToken } from "./share-token.js";
import { playbackRange, requestedPlaybackRange } from "./playback-range.js";
@ -86,6 +86,13 @@ export interface DirectUploadInit {
putUrl: string;
}
export interface DirectMultipartInit {
uploadId: string;
objectKey: string;
partCount: number;
partUrls: string[];
}
/** POST /files/:id/direct-download response: URL + the unwrapped CEK. */
export interface DirectDownloadTicket {
url: string;
@ -122,6 +129,8 @@ 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/";
// This namespace records plaintext byte_size, unlike legacy single-PUT entries.
const DIRECT_MULTIPART_KEY_PREFIX = "direct/multipart/";
const DIRECT_OBJECT_SUFFIX = ".enc";
/** MIME allowlist for direct uploads: prefix match on the declared type. */
@ -694,7 +703,8 @@ export class GroupFilesService {
if (!item.file.storageKey?.startsWith(DIRECT_KEY_PREFIX)) {
throw new BadRequestException("range reads are only available for direct objects");
}
// /direct/complete stores the HEAD-verified sealed frame size. Its fixed
if (item.file.storageKey.startsWith(DIRECT_MULTIPART_KEY_PREFIX)) return item;
// Legacy /direct/complete stores the HEAD-verified sealed frame size. Its fixed
// 135-byte header and 32-byte segment overhead must not count as media.
try {
return { ...item, sizeBytes: directPlaintextSize(item.sizeBytes) };
@ -996,6 +1006,99 @@ export class GroupFilesService {
return { uploadId, objectKey, putUrl };
}
async initDirectMultipartUpload(
workspaceId: string,
groupId: string | null,
actor: ActorRef,
input: { filename: string; mimeType: string; sizeBytes: number; personal?: unknown; folderId?: unknown },
): Promise<DirectMultipartInit> {
const scope = parseWriteScope({ groupId, personal: input.personal }, actor.userId);
validateDirectPlainSize(input.sizeBytes);
validateDirectFilename(input.filename);
validateDirectMime(input.mimeType);
await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
const storage = this.storage();
if ((await storage.directPublicJwk()) === null) {
throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
}
const objectKey = `${DIRECT_MULTIPART_KEY_PREFIX}${randomUUID()}${DIRECT_OBJECT_SUFFIX}`;
let uploadId: string | undefined;
try {
({ uploadId } = await storage.createMultipart(objectKey, "application/octet-stream"));
const partCount = Math.ceil(input.sizeBytes / DIRECT_SEGMENT_BYTES);
const partUrls = await Promise.all(Array.from({ length: partCount }, (_, i) =>
storage.presignPart(objectKey, uploadId!, i + 1, DIRECT_PUT_TTL_SECONDS)));
return { uploadId, objectKey, partCount, partUrls };
} catch {
if (uploadId !== undefined) await storage.abortMultipart(objectKey, uploadId).catch(() => undefined);
throw new ServiceUnavailableException("file storage is unavailable");
}
}
async completeDirectMultipartUpload(
workspaceId: string,
actor: ActorRef,
input: { uploadId: string; objectKey: string; groupId?: string | null; personal?: unknown; folderId?: unknown; filename: string; mimeType: string; plainSizeBytes: number; sha256: string; parts: unknown },
): Promise<GroupFile> {
// S3 upload IDs are opaque strings, not UUIDs. Only our generated object namespace is accepted.
if (typeof input.uploadId !== "string" || input.uploadId.length < 1 || input.uploadId.length > 2048) {
throw new BadRequestException("uploadId must be a nonempty S3 upload ID");
}
if (typeof input.objectKey !== "string" || !input.objectKey.startsWith(DIRECT_MULTIPART_KEY_PREFIX)
|| !input.objectKey.endsWith(DIRECT_OBJECT_SUFFIX)
|| !isUuid(input.objectKey.slice(DIRECT_MULTIPART_KEY_PREFIX.length, -DIRECT_OBJECT_SUFFIX.length))) {
throw new BadRequestException("objectKey must be direct/multipart/<uuid>.enc");
}
const scope = parseWriteScope(input, actor.userId);
const folderId = await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
const storage = this.storage();
try {
validateDirectPlainSize(input.plainSizeBytes);
const safe = validateDirectFilename(input.filename);
const mimeType = validateDirectMime(input.mimeType);
if (typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.sha256)) {
throw new BadRequestException("sha256 must be lowercase hex (64 chars)");
}
const partCount = Math.ceil(input.plainSizeBytes / DIRECT_SEGMENT_BYTES);
if (!Array.isArray(input.parts) || input.parts.length !== partCount) {
throw new BadRequestException("ETag count does not match expected segment count");
}
const parts: MultipartPart[] = input.parts.map((part: unknown, i: number) => {
if (typeof part !== "object" || part === null) throw new BadRequestException("invalid multipart part");
const { ETag, PartNumber } = part as Record<string, unknown>;
if (typeof ETag !== "string" || ETag.trim() === "" || ETag.length > 256 || PartNumber !== i + 1) {
throw new BadRequestException("parts must have nonempty ETags and consecutive PartNumbers");
}
return { ETag, PartNumber };
});
await storage.completeMultipart(input.objectKey, input.uploadId, parts).catch((err: unknown) => {
if (err instanceof Error && ["InvalidPart", "InvalidPartOrder", "EntityTooSmall"].includes(err.name)) {
throw new BadRequestException("multipart ETags or part sizes do not match");
}
throw new ServiceUnavailableException("file storage is unavailable");
});
const head = await storage.headObject(input.objectKey).catch(() => {
throw new ServiceUnavailableException("file storage is unavailable");
});
if (!head.exists || head.contentLength !== directSealedSize(input.plainSizeBytes)) {
throw new BadRequestException("uploaded object is missing or its sealed size does not match plainSizeBytes");
}
const username = await this.usernameFor(workspaceId, actor.userId);
const { id } = await this.registerArchiveEntry(workspaceId, {
groupId: scope.groupId, ownerId: scope.ownerId, folderId, createdBy: actor.userId,
roomId: null, eventId: null, origin: { kind: "upload" }, originKind: "upload",
senderMxid: `@${username}:tmbh.local`, originalName: input.filename, safeName: safe,
mime: mimeType, mimeClass: mimeClassForDeclared(mimeType), byteSize: input.plainSizeBytes,
sha256: input.sha256, ocisResourceId: input.objectKey, ocisResourceVersion: null,
storageKey: input.objectKey, createdAt: new Date(),
});
return await this.getFile(workspaceId, actor.userId, actor.role, id);
} catch (err) {
await storage.abortMultipart(input.objectKey, input.uploadId).catch(() => undefined);
throw err;
}
}
/**
* 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
@ -1264,6 +1367,12 @@ function validateDirectFilename(filename: string): string {
return safe;
}
function validateDirectPlainSize(size: number): void {
if (!Number.isSafeInteger(size) || size < 1 || size > DIRECT_MAX_BYTES) {
throw new BadRequestException(`sizeBytes must be an integer between 1 and ${DIRECT_MAX_BYTES}`);
}
}
/** 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) {

View file

@ -50,8 +50,16 @@ export function sniffMediaAsset(bytes: Buffer): SniffedMedia | null {
if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") {
return { kind: "image", mime: "image/webp" };
}
// mp4: "ftyp" at offset 4
if (bytes.subarray(4, 8).toString("latin1") === "ftyp") return { kind: "video", mime: "video/mp4" };
// mp4: "ftyp" at offset 4, but only ISOBMFF video brands — HEIC/AVIF photos
// and QuickTime .mov share the box and would be mislabelled as video/mp4.
if (bytes.subarray(4, 8).toString("latin1") === "ftyp") {
const brand = bytes.subarray(8, 12).toString("latin1").toLowerCase();
if (brand.startsWith("hei") || brand.startsWith("avif") || brand.startsWith("avis") || brand.startsWith("mif1") || brand.startsWith("msf1")) {
return { kind: "image", mime: "image/avif" };
}
if (brand.startsWith("qt")) return { kind: "video", mime: "video/quicktime" };
return { kind: "video", mime: "video/mp4" };
}
// webm/mkv: EBML magic 1A 45 DF A3
if (bytes.subarray(0, 4).toString("hex") === "1a45dfa3") return { kind: "video", mime: "video/webm" };
return null;

View file

@ -161,11 +161,14 @@ export function validateAuthorize(params: AuthorizeParams): AuthorizeValidation
}
/** `redirectUri?error=…&error_description=…&state=…` for a redirectable failure. */
export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string): string {
export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string, issuer?: string): string {
const url = new URL(redirectUri);
url.searchParams.set("error", error);
url.searchParams.set("error_description", description);
if (state !== null) url.searchParams.set("state", state);
// RFC 9207 §2: iss is required on error responses too when the server
// advertises authorization_response_iss_parameter_supported.
if (issuer !== undefined) url.searchParams.set("iss", issuer);
return url.toString();
}

View file

@ -223,7 +223,7 @@ export class OidcService implements OnModuleInit {
const params = authorizeParamsFrom(query);
const result = validateAuthorize(params);
if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`);
if (!result.ok) return errorRedirect(result.redirectUri, result.state, result.error, result.description);
if (!result.ok) return errorRedirect(result.redirectUri, result.state, result.error, result.description, this.config().issuer);
const consent = new URL(config.consentUrl);
for (const name of AUTHORIZE_PARAM_NAMES) {
const value = params[name];
@ -235,9 +235,14 @@ export class OidcService implements OnModuleInit {
/** POST /oidc/authorize/consent — the session's user approves (auto, first-party) and gets a code. */
async consent(workspaceId: string, userId: string, body: unknown, sid: string | null = null): Promise<{ redirectTo: string }> {
const config = this.config();
// The session must still be alive: a logged-out (or sid-less) bearer must
// never mint codes or downstream tokens, even inside its TTL.
if (sid === null || !(await this.deps.refreshStore.sessionAlive(workspaceId, sid))) {
throw new UnauthorizedException("the hub session has ended — sign in again");
}
const result = validateAuthorize(authorizeParamsFrom(body));
if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`);
if (!result.ok) return { redirectTo: errorRedirect(result.redirectUri, result.state, result.error, result.description) };
if (!result.ok) return { redirectTo: errorRedirect(result.redirectUri, result.state, result.error, result.description, this.config().issuer) };
const request = result.request;
if (!isFirstPartyClient(request.clientId)) {
// No consent UI exists yet: anything but a first-party client fails closed.

View file

@ -79,6 +79,8 @@ function harness(): { service: OidcService; users: FakeUsers; clock: { now: numb
const vault = new Vault();
const store = new MemoryRefreshStore();
const sessions = store.liveSessions;
store.liveSessions.add(`${WORKSPACE}:test-sid`);
store.liveSessions.add(`${WORKSPACE}:test-sid`);
const service = new OidcService({
config: () => config,
keyring: (c) => OidcKeyring.open(new FileKeyStore(c.keyDir), vault),
@ -91,7 +93,7 @@ function harness(): { service: OidcService; users: FakeUsers; clock: { now: numb
}
async function issueCode(service: OidcService, challenge: string, extra: Record<string, string> = {}): Promise<string> {
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra));
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra), "test-sid");
const url = new URL(redirectTo);
const code = url.searchParams.get("code");
if (code === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`);
@ -228,7 +230,8 @@ describe("OidcService", () => {
it("auto-consents for client_id=web: the session gets a code straight back, no consent screen", async () => {
const { service } = harness();
const { challenge } = pkce();
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge));
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException); // sid-less bearers cannot mint
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid");
const url = new URL(redirectTo);
expect(`${url.origin}${url.pathname}`).toBe(WEB_REDIRECT_URI);
expect(url.searchParams.get("code")).toMatch(/^[A-Za-z0-9_-]{43}$/);
@ -239,19 +242,19 @@ describe("OidcService", () => {
it("only issues codes to active members with a usable, unreserved username", async () => {
const { service, users } = harness();
const { challenge } = pkce();
await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(UnauthorizedException); // cross-workspace sid is not live
users.members.set(ADA.id, { ...ADA, username: "admin" });
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
users.members.set(ADA.id, { ...ADA, username: "Ada" });
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
});
it("re-validates relayed params: a forged redirect_uri from the consent page is refused", async () => {
const { service } = harness();
const { challenge } = pkce();
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }))).rejects.toBeInstanceOf(BadRequestException);
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }));
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }), "test-sid")).rejects.toBeInstanceOf(BadRequestException);
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }), "test-sid");
expect(new URL(redirectTo).searchParams.has("code")).toBe(false);
});
@ -388,11 +391,10 @@ describe("OidcService refresh tokens (phase 2)", () => {
expect(rows.filter((row) => row.consumedAt === null)).toHaveLength(1);
});
it("a code from a pre-phase2 session (no sid) mints no refresh token", async () => {
const h = harness();
const { verifier, challenge } = pkce();
const tokens = await exchange(h.service, await issueCode(h.service, challenge), verifier);
expect(tokens.refresh_token).toBeUndefined();
it("a consent without a session id (pre-phase2 client) is rejected outright", async () => {
const { service } = harness();
const { challenge } = pkce();
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException);
});
it("rejects a revoked (logged-out) session's refresh token and revokes the chain", async () => {

View file

@ -44,10 +44,27 @@ vi.mock("@aws-sdk/client-s3", () => {
};
});
import { DIRECT_SEGMENT_BYTES, S3Storage, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
import { DIRECT_SEGMENT_BYTES, S3Storage, directSealedSize, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
const TEST_KEK = createHash("sha256").update("test-kek").digest();
describe("SPENC2 sealed size", () => {
it.each([
[1, 168],
[DIRECT_SEGMENT_BYTES - 1, 135 + DIRECT_SEGMENT_BYTES - 1 + 32],
[DIRECT_SEGMENT_BYTES, 135 + DIRECT_SEGMENT_BYTES + 32],
[DIRECT_SEGMENT_BYTES + 1, 135 + DIRECT_SEGMENT_BYTES + 1 + 64],
[2 * 1024 ** 3, 135 + 2 * 1024 ** 3 + 205 * 32],
])("computes exact size for %i plaintext bytes", (plain, sealed) => {
expect(directSealedSize(plain)).toBe(sealed);
expect(directPlaintextSize(sealed)).toBe(plain);
});
it.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])("rejects invalid plaintext size %s", (size) => {
expect(() => directSealedSize(size)).toThrow(RangeError);
});
});
describe("storage key scheme (pure)", () => {
it("maps content types to extensions, unknown -> bin", () => {
expect(extensionFor("image/png")).toBe("png");

View file

@ -1,12 +1,16 @@
import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
import { ServiceUnavailableException } from "@nestjs/common";
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateBucketCommand,
CreateMultipartUploadCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
S3ServiceException,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@ -44,6 +48,10 @@ export interface StorageService {
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>;
createMultipart(key: string, contentType: string): Promise<{ uploadId: string }>;
presignPart(key: string, uploadId: string, partNumber: number, ttlSeconds: number): Promise<string>;
completeMultipart(key: string, uploadId: string, parts: MultipartPart[]): Promise<void>;
abortMultipart(key: string, uploadId: string): Promise<void>;
/** 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. */
@ -56,10 +64,15 @@ export interface StorageService {
readDirectRange(key: string, start: number, length: number): Promise<Buffer>;
}
export interface MultipartPart {
ETag: string;
PartNumber: number;
}
/** The direct-only members of StorageService, as one spreadable group. */
export type DirectStorageMembers = Pick<
StorageService,
"directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange"
"directPublicJwk" | "presignPut" | "createMultipart" | "presignPart" | "completeMultipart" | "abortMultipart" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange"
>;
/**
@ -74,6 +87,10 @@ export function directStorageDisabled(): DirectStorageMembers {
return {
directPublicJwk: async () => null,
presignPut: unavailable,
createMultipart: unavailable,
presignPart: unavailable,
completeMultipart: unavailable,
abortMultipart: unavailable,
headObject: unavailable,
openDirectHeader: unavailable,
openDirectHeaderBlob: unavailable,
@ -169,6 +186,16 @@ 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;
/** Exact SPENC2 size: fixed header plus one length, nonce and tag per segment. */
export function directSealedSize(plainSizeBytes: number): number {
if (!Number.isSafeInteger(plainSizeBytes) || plainSizeBytes < 1) {
throw new RangeError("storage: invalid SPENC2 plaintext size");
}
const size = SEGMENTS_OFFSET + plainSizeBytes + Math.ceil(plainSizeBytes / DIRECT_SEGMENT_BYTES) * 32;
if (!Number.isSafeInteger(size)) throw new RangeError("storage: invalid SPENC2 sealed size");
return size;
}
/** Direct upload metadata records sealed bytes; HTTP ranges address plaintext. */
export function directPlaintextSize(sealedBytes: number): number {
const count = Math.ceil((sealedBytes - SEGMENTS_OFFSET) / (DIRECT_SEGMENT_BYTES + 32));
@ -521,6 +548,30 @@ export class S3Storage implements StorageService {
return getSignedUrl(this.client, new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }), { expiresIn: ttlSeconds });
}
async createMultipart(key: string, contentType: string): Promise<{ uploadId: string }> {
const result = await this.client.send(new CreateMultipartUploadCommand({
Bucket: this.bucket, Key: key, ContentType: contentType,
}));
if (!result.UploadId) throw new Error("storage: multipart upload has no upload ID");
return { uploadId: result.UploadId };
}
async presignPart(key: string, uploadId: string, partNumber: number, ttlSeconds: number): Promise<string> {
return getSignedUrl(this.client, new UploadPartCommand({
Bucket: this.bucket, Key: key, UploadId: uploadId, PartNumber: partNumber,
}), { expiresIn: ttlSeconds });
}
async completeMultipart(key: string, uploadId: string, parts: MultipartPart[]): Promise<void> {
await this.client.send(new CompleteMultipartUploadCommand({
Bucket: this.bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts },
}));
}
async abortMultipart(key: string, uploadId: string): Promise<void> {
await this.client.send(new AbortMultipartUploadCommand({ Bucket: this.bucket, Key: key, UploadId: uploadId }));
}
async headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }> {
try {
const res = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));

View file

@ -170,7 +170,7 @@ describe("video byte ingest and processing-job state machine", () => {
const { service, storage } = serviceWithDatabase({}, async () => ({}));
await expect(service.uploadVideoBytes(workspaceId, Buffer.alloc(0), "video/mp4")).rejects.toMatchObject({ status: 400 });
await expect(service.uploadVideoBytes(workspaceId, Buffer.from("image"), "image/png")).rejects.toMatchObject({ status: 400 });
await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("100 MiB upload limit");
await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("video exceeds the chunked upload limit");
expect(storage.putContent).not.toHaveBeenCalled();
});
});