From a7c3f26a4fc8ace36a935f400383ea5b1d8bf587 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Fri, 16 Oct 2026 08:30:25 +0000 Subject: [PATCH] fix: direct-download reads the sealed object for its CEK header getObject decrypts by magic, so feeding its plaintext into openDirectHeader always failed. getSealedObject exposes raw bytes for the header read; DirectStorageMembers and the test fakes carry the new member. --- apps/api/src/group-files/group-files.service.ts | 2 +- apps/api/src/storage/storage.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/api/src/group-files/group-files.service.ts b/apps/api/src/group-files/group-files.service.ts index 7201326..20ac517 100644 --- a/apps/api/src/group-files/group-files.service.ts +++ b/apps/api/src/group-files/group-files.service.ts @@ -665,7 +665,7 @@ export class GroupFilesService { 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)), + storage.getSealedObject(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 }; } diff --git a/apps/api/src/storage/storage.ts b/apps/api/src/storage/storage.ts index 5c6d100..7fb94f9 100644 --- a/apps/api/src/storage/storage.ts +++ b/apps/api/src/storage/storage.ts @@ -48,12 +48,14 @@ export interface StorageService { 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; + /** Direct-to-R2: raw still-sealed object bytes — the CEK header lives only in the sealed form. */ + getSealedObject(key: string): Promise; } /** The direct-only members of StorageService, as one spreadable group. */ export type DirectStorageMembers = Pick< StorageService, - "directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" + "directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "getSealedObject" >; /** @@ -70,6 +72,7 @@ export function directStorageDisabled(): DirectStorageMembers { presignPut: unavailable, headObject: unavailable, openDirectHeader: unavailable, + getSealedObject: unavailable, }; } @@ -514,6 +517,12 @@ export class S3Storage implements StorageService { if (this.directKey === null) throw missingDirectKey(); return unwrapDirectCek(stored, this.directKey.privateKey); } + + async getSealedObject(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}`); + return Buffer.from(await res.Body.transformToByteArray()); + } } /** Build a DirectKeyPair from an already-loaded private key (config + tests). */