IV를 암호화된 파일 및 썸네일 앞에 합쳐서 전송하도록 변경

This commit is contained in:
static
2026-01-11 00:29:59 +09:00
parent 5d130204a6
commit b9e6f17b0c
13 changed files with 161 additions and 76 deletions

View File

@@ -89,11 +89,15 @@ export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
return { ciphertext, iv: encodeToBase64(iv.buffer) };
};
export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => {
export const decryptData = async (
ciphertext: BufferSource,
iv: string | BufferSource,
dataKey: CryptoKey,
) => {
return await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: decodeFromBase64(iv),
iv: typeof iv === "string" ? decodeFromBase64(iv) : iv,
} satisfies AesGcmParams,
dataKey,
ciphertext,

View File

@@ -62,15 +62,14 @@ const requestFileDownload = limitFunction(
);
const decryptFile = limitFunction(
async (
state: FileDownloadState,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
async (state: FileDownloadState, fileEncrypted: ArrayBuffer, dataKey: CryptoKey) => {
state.status = "decrypting";
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
const fileBuffer = await decryptData(
fileEncrypted.slice(12),
fileEncrypted.slice(0, 12),
dataKey,
);
state.status = "decrypted";
state.result = fileBuffer;
@@ -79,7 +78,7 @@ const decryptFile = limitFunction(
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
export const downloadFile = async (id: number, dataKey: CryptoKey) => {
downloadingFiles.push({
id,
status: "download-pending",
@@ -87,7 +86,7 @@ export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey:
const state = downloadingFiles.at(-1)!;
try {
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
return await decryptFile(state, await requestFileDownload(state, id), dataKey);
} catch (e) {
state.status = "error";
throw e;

View File

@@ -5,7 +5,6 @@ import { decryptData } from "$lib/modules/crypto";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import { isTRPCClientError, trpc } from "$trpc/client";
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
@@ -18,25 +17,18 @@ const fetchFromOpfs = async (fileId: number) => {
};
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
try {
const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([
fetch(`/api/file/${fileId}/thumbnail/download`),
trpc().file.thumbnail.query({ id: fileId }),
]);
const thumbnailBuffer = await decryptData(
await thumbnailEncrypted.arrayBuffer(),
thumbnailEncryptedIv,
dataKey,
);
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
return null;
}
throw e;
}
const thumbnailEncrypted = await res.arrayBuffer();
const thumbnailBuffer = await decryptData(
thumbnailEncrypted.slice(12),
thumbnailEncrypted.slice(0, 12),
dataKey,
);
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer);
};
export const getFileThumbnail = (file: SummarizedFileInfo) => {

View File

@@ -50,7 +50,6 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
parentId: file.parent,
dataKey: metadata.dataKey,
contentType: file.contentType,
contentIv: file.contentIv,
name: metadata.name,
createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt,
@@ -118,7 +117,6 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
exists: true as const,
parentId: metadataRaw.parent,
contentType: metadataRaw.contentType,
contentIv: metadataRaw.contentIv,
categories,
...metadata,
};

View File

@@ -31,7 +31,6 @@ export interface FileInfo {
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
@@ -42,7 +41,7 @@ export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "contentIv" | "categories">;
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo {

View File

@@ -0,0 +1,14 @@
export const parseRangeHeader = (rangeHeader: string | null) => {
if (!rangeHeader) return undefined;
const firstRange = rangeHeader.split(",")[0]!.trim();
const parts = firstRange.replace(/bytes=/, "").split("-");
return {
start: parts[0] ? parseInt(parts[0], 10) : undefined,
end: parts[1] ? parseInt(parts[1], 10) : undefined,
};
};
export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => {
return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
};

View File

@@ -10,30 +10,69 @@ import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
import env from "$lib/server/loadenv";
import { safeUnlink } from "$lib/server/modules/filesystem";
export const getFileStream = async (userId: number, fileId: number) => {
const createEncContentStream = async (
path: string,
iv: Buffer,
range?: { start?: number; end?: number },
) => {
const { size: fileSize } = await stat(path);
const ivSize = iv.byteLength;
const totalSize = fileSize + ivSize;
const start = range?.start ?? 0;
const end = range?.end ?? totalSize - 1;
if (start > end || start < 0 || end >= totalSize) {
error(416, "Invalid range");
}
return {
encContentStream: Readable.toWeb(
Readable.from(
(async function* () {
if (start < ivSize) {
yield iv.subarray(start, Math.min(end + 1, ivSize));
}
if (end >= ivSize) {
yield* createReadStream(path, {
start: Math.max(0, start - ivSize),
end: end - ivSize,
});
}
})(),
),
),
range: { start, end, total: totalSize },
};
};
export const getFileStream = async (
userId: number,
fileId: number,
range?: { start?: number; end?: number },
) => {
const file = await FileRepo.getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
const { size } = await stat(file.path);
return {
encContentStream: Readable.toWeb(createReadStream(file.path)),
encContentSize: size,
};
return createEncContentStream(file.path, Buffer.from(file.encContentIv, "base64"), range);
};
export const getFileThumbnailStream = async (userId: number, fileId: number) => {
export const getFileThumbnailStream = async (
userId: number,
fileId: number,
range?: { start?: number; end?: number },
) => {
const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId);
if (!thumbnail) {
error(404, "File or its thumbnail not found");
}
const { size } = await stat(thumbnail.path);
return {
encContentStream: Readable.toWeb(createReadStream(thumbnail.path)),
encContentSize: size,
};
return createEncContentStream(
thumbnail.path,
Buffer.from(thumbnail.encContentIv, "base64"),
range,
);
};
export const uploadFileThumbnail = async (

View File

@@ -9,15 +9,11 @@ import {
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { trpc } from "$trpc/client";
export const requestFileDownload = async (
fileId: number,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
export const requestFileDownload = async (fileId: number, dataKey: CryptoKey) => {
const cache = await getFileCache(fileId);
if (cache) return cache;
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
const fileBuffer = await downloadFile(fileId, dataKey);
storeFileCache(fileId, fileBuffer); // Intended
return fileBuffer;
};

View File

@@ -5,7 +5,7 @@
import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { getFileDownloadState } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores";
@@ -95,14 +95,12 @@
untrack(() => {
if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true;
requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then(
async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, info!.name);
}
},
);
requestFileDownload(data.id, info!.dataKey!.key).then(async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, info!.name);
}
});
}
});
}
@@ -110,7 +108,9 @@
$effect(() => {
if (info?.exists && downloadState?.status === "decrypted") {
untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!));
untrack(
() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType!),
);
}
});

View File

@@ -77,7 +77,7 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
await scheduler.schedule(
async () => {
statuses.set(fileInfo.id, "generation-pending");
file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!);
file = await requestFileDownload(fileInfo.id, fileInfo.dataKey?.key!);
return file.byteLength;
},
async () => {

View File

@@ -1,10 +1,15 @@
import { error } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http";
import { getFileStream } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => {
const downloadHandler = async (
locals: App.Locals,
params: Record<string, string>,
request: Request,
) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
@@ -15,11 +20,29 @@ export const GET: RequestHandler = async ({ locals, params }) => {
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { encContentStream, encContentSize } = await getFileStream(userId, id);
return new Response(encContentStream as ReadableStream, {
const { encContentStream, range } = await getFileStream(
userId,
id,
parseRangeHeader(request.headers.get("Range")),
);
return {
stream: encContentStream,
headers: {
"Accept-Ranges": "bytes",
"Content-Length": (range.end - range.start + 1).toString(),
"Content-Type": "application/octet-stream",
"Content-Length": encContentSize.toString(),
...getContentRangeHeader(range),
},
});
isRangeRequest: !!range,
};
};
export const GET: RequestHandler = async ({ locals, params, request }) => {
const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers });
};
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
const { headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(null, { status: isRangeRequest ? 206 : 200, headers });
};

View File

@@ -1,10 +1,15 @@
import { error } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http";
import { getFileThumbnailStream } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => {
const downloadHandler = async (
locals: App.Locals,
params: Record<string, string>,
request: Request,
) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
@@ -15,11 +20,29 @@ export const GET: RequestHandler = async ({ locals, params }) => {
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { encContentStream, encContentSize } = await getFileThumbnailStream(userId, id);
return new Response(encContentStream as ReadableStream, {
const { encContentStream, range } = await getFileThumbnailStream(
userId,
id,
parseRangeHeader(request.headers.get("Range")),
);
return {
stream: encContentStream,
headers: {
"Accept-Ranges": "bytes",
"Content-Length": (range.end - range.start + 1).toString(),
"Content-Type": "application/octet-stream",
"Content-Length": encContentSize.toString(),
...getContentRangeHeader(range),
},
});
isRangeRequest: !!range,
};
};
export const GET: RequestHandler = async ({ locals, params, request }) => {
const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers });
};
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
const { headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(null, { status: isRangeRequest ? 206 : 200, headers });
};

View File

@@ -24,7 +24,6 @@ const fileRouter = router({
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
contentIv: file.encContentIv,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
@@ -58,7 +57,6 @@ const fileRouter = router({
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
contentIv: file.encContentIv,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
@@ -158,7 +156,7 @@ const fileRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" });
}
return { updatedAt: thumbnail.updatedAt, contentIv: thumbnail.encContentIv };
return { updatedAt: thumbnail.updatedAt };
}),
});