diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 99e11c9..a677d9f 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,7 +1,6 @@ import type { ClientInit } from "@sveltejs/kit"; import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB"; import { prepareFileCache } from "$lib/modules/file"; -import { prepareOpfs } from "$lib/modules/opfs"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores"; const requestPersistentStorage = async () => { @@ -46,7 +45,6 @@ export const init: ClientInit = async () => { prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore(), - prepareOpfs(), ]); cleanupDanglingInfos(); // Intended diff --git a/src/lib/constants/index.ts b/src/lib/constants/index.ts index ab6125a..4983846 100644 --- a/src/lib/constants/index.ts +++ b/src/lib/constants/index.ts @@ -1 +1,2 @@ +export * from "./serviceWorker"; export * from "./upload"; diff --git a/src/lib/constants/serviceWorker.ts b/src/lib/constants/serviceWorker.ts new file mode 100644 index 0000000..8c09d05 --- /dev/null +++ b/src/lib/constants/serviceWorker.ts @@ -0,0 +1 @@ +export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decrypted-file/"; diff --git a/src/lib/constants/upload.ts b/src/lib/constants/upload.ts index 337700d..99d94bb 100644 --- a/src/lib/constants/upload.ts +++ b/src/lib/constants/upload.ts @@ -1,5 +1,6 @@ -export const CHUNK_SIZE = 4 * 1024 * 1024; - export const AES_GCM_IV_SIZE = 12; export const AES_GCM_TAG_SIZE = 16; export const ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE; + +export const CHUNK_SIZE = 4 * 1024 * 1024; +export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD; diff --git a/src/lib/modules/crypto/aes.ts b/src/lib/modules/crypto/aes.ts index 67f6a9f..fe11afb 100644 --- a/src/lib/modules/crypto/aes.ts +++ b/src/lib/modules/crypto/aes.ts @@ -9,7 +9,7 @@ import { export const generateMasterKey = async () => { return { - masterKey: await window.crypto.subtle.generateKey( + masterKey: await crypto.subtle.generateKey( { name: "AES-KW", length: 256, @@ -22,7 +22,7 @@ export const generateMasterKey = async () => { export const generateDataKey = async () => { return { - dataKey: await window.crypto.subtle.generateKey( + dataKey: await crypto.subtle.generateKey( { name: "AES-GCM", length: 256, @@ -35,9 +35,9 @@ export const generateDataKey = async () => { }; export const makeAESKeyNonextractable = async (key: CryptoKey) => { - return await window.crypto.subtle.importKey( + return await crypto.subtle.importKey( "raw", - await window.crypto.subtle.exportKey("raw", key), + await crypto.subtle.exportKey("raw", key), key.algorithm, false, key.usages, @@ -45,12 +45,12 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => { }; export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => { - return encodeToBase64(await window.crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW")); + return encodeToBase64(await crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW")); }; export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => { return { - dataKey: await window.crypto.subtle.unwrapKey( + dataKey: await crypto.subtle.unwrapKey( "raw", decodeFromBase64(dataKeyWrapped), masterKey, @@ -63,12 +63,12 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey }; export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => { - return encodeToBase64(await window.crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW")); + return encodeToBase64(await crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW")); }; export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => { return { - hmacSecret: await window.crypto.subtle.unwrapKey( + hmacSecret: await crypto.subtle.unwrapKey( "raw", decodeFromBase64(hmacSecretWrapped), masterKey, @@ -84,8 +84,8 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry }; export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await window.crypto.subtle.encrypt( + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv, @@ -101,7 +101,7 @@ export const decryptData = async ( iv: string | BufferSource, dataKey: CryptoKey, ) => { - return await window.crypto.subtle.decrypt( + return await crypto.subtle.decrypt( { name: "AES-GCM", iv: typeof iv === "string" ? decodeFromBase64(iv) : iv, diff --git a/src/lib/modules/crypto/rsa.ts b/src/lib/modules/crypto/rsa.ts index 13dfd46..11e136f 100644 --- a/src/lib/modules/crypto/rsa.ts +++ b/src/lib/modules/crypto/rsa.ts @@ -1,7 +1,7 @@ import { encodeString, encodeToBase64, decodeFromBase64 } from "./util"; export const generateEncryptionKeyPair = async () => { - const keyPair = await window.crypto.subtle.generateKey( + const keyPair = await crypto.subtle.generateKey( { name: "RSA-OAEP", modulusLength: 4096, @@ -18,7 +18,7 @@ export const generateEncryptionKeyPair = async () => { }; export const generateSigningKeyPair = async () => { - const keyPair = await window.crypto.subtle.generateKey( + const keyPair = await crypto.subtle.generateKey( { name: "RSA-PSS", modulusLength: 4096, @@ -37,7 +37,7 @@ export const generateSigningKeyPair = async () => { export const exportRSAKey = async (key: CryptoKey) => { const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const); return { - key: await window.crypto.subtle.exportKey(format, key), + key: await crypto.subtle.exportKey(format, key), format, }; }; @@ -54,14 +54,14 @@ export const importEncryptionKeyPairFromBase64 = async ( name: "RSA-OAEP", hash: "SHA-256", }; - const encryptKey = await window.crypto.subtle.importKey( + const encryptKey = await crypto.subtle.importKey( "spki", decodeFromBase64(encryptKeyBase64), algorithm, true, ["encrypt", "wrapKey"], ); - const decryptKey = await window.crypto.subtle.importKey( + const decryptKey = await crypto.subtle.importKey( "pkcs8", decodeFromBase64(decryptKeyBase64), algorithm, @@ -79,14 +79,14 @@ export const importSigningKeyPairFromBase64 = async ( name: "RSA-PSS", hash: "SHA-256", }; - const signKey = await window.crypto.subtle.importKey( + const signKey = await crypto.subtle.importKey( "pkcs8", decodeFromBase64(signKeyBase64), algorithm, true, ["sign"], ); - const verifyKey = await window.crypto.subtle.importKey( + const verifyKey = await crypto.subtle.importKey( "spki", decodeFromBase64(verifyKeyBase64), algorithm, @@ -98,17 +98,11 @@ export const importSigningKeyPairFromBase64 = async ( export const makeRSAKeyNonextractable = async (key: CryptoKey) => { const { key: exportedKey, format } = await exportRSAKey(key); - return await window.crypto.subtle.importKey( - format, - exportedKey, - key.algorithm, - false, - key.usages, - ); + return await crypto.subtle.importKey(format, exportedKey, key.algorithm, false, key.usages); }; export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => { - return await window.crypto.subtle.decrypt( + return await crypto.subtle.decrypt( { name: "RSA-OAEP", } satisfies RsaOaepParams, @@ -119,7 +113,7 @@ export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => { return encodeToBase64( - await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, { + await crypto.subtle.wrapKey("raw", masterKey, encryptKey, { name: "RSA-OAEP", } satisfies RsaOaepParams), ); @@ -131,7 +125,7 @@ export const unwrapMasterKey = async ( extractable = false, ) => { return { - masterKey: await window.crypto.subtle.unwrapKey( + masterKey: await crypto.subtle.unwrapKey( "raw", decodeFromBase64(masterKeyWrapped), decryptKey, @@ -146,7 +140,7 @@ export const unwrapMasterKey = async ( }; export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => { - return await window.crypto.subtle.sign( + return await crypto.subtle.sign( { name: "RSA-PSS", saltLength: 32, // SHA-256 @@ -161,7 +155,7 @@ export const verifySignatureRSA = async ( signature: BufferSource, verifyKey: CryptoKey, ) => { - return await window.crypto.subtle.verify( + return await crypto.subtle.verify( { name: "RSA-PSS", saltLength: 32, // SHA-256 diff --git a/src/lib/modules/crypto/sha.ts b/src/lib/modules/crypto/sha.ts index 3acb258..9bf2dea 100644 --- a/src/lib/modules/crypto/sha.ts +++ b/src/lib/modules/crypto/sha.ts @@ -1,10 +1,10 @@ export const digestMessage = async (message: BufferSource) => { - return await window.crypto.subtle.digest("SHA-256", message); + return await crypto.subtle.digest("SHA-256", message); }; export const generateHmacSecret = async () => { return { - hmacSecret: await window.crypto.subtle.generateKey( + hmacSecret: await crypto.subtle.generateKey( { name: "HMAC", hash: "SHA-256", @@ -16,5 +16,5 @@ export const generateHmacSecret = async () => { }; export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => { - return await window.crypto.subtle.sign("HMAC", hmacSecret, message); + return await crypto.subtle.sign("HMAC", hmacSecret, message); }; diff --git a/src/lib/server/modules/http.ts b/src/lib/modules/http.ts similarity index 100% rename from src/lib/server/modules/http.ts rename to src/lib/modules/http.ts diff --git a/src/lib/modules/opfs.ts b/src/lib/modules/opfs.ts index 41f1f72..a367aae 100644 --- a/src/lib/modules/opfs.ts +++ b/src/lib/modules/opfs.ts @@ -1,13 +1,5 @@ -let rootHandle: FileSystemDirectoryHandle | null = null; - -export const prepareOpfs = async () => { - rootHandle = await navigator.storage.getDirectory(); -}; - const getFileHandle = async (path: string, create = true) => { - if (!rootHandle) { - throw new Error("OPFS not prepared"); - } else if (path[0] !== "/") { + if (path[0] !== "/") { throw new Error("Path must be absolute"); } @@ -17,7 +9,7 @@ const getFileHandle = async (path: string, create = true) => { } try { - let directoryHandle = rootHandle; + let directoryHandle = await navigator.storage.getDirectory(); for (const part of parts.slice(0, -1)) { if (!part) continue; directoryHandle = await directoryHandle.getDirectoryHandle(part, { create }); @@ -34,12 +26,15 @@ const getFileHandle = async (path: string, create = true) => { } }; -export const readFile = async (path: string) => { +export const getFile = async (path: string) => { const { fileHandle } = await getFileHandle(path, false); if (!fileHandle) return null; - const file = await fileHandle.getFile(); - return await file.arrayBuffer(); + return await fileHandle.getFile(); +}; + +export const readFile = async (path: string) => { + return (await getFile(path))?.arrayBuffer() ?? null; }; export const writeFile = async (path: string, data: ArrayBuffer) => { @@ -61,9 +56,7 @@ export const deleteFile = async (path: string) => { }; const getDirectoryHandle = async (path: string) => { - if (!rootHandle) { - throw new Error("OPFS not prepared"); - } else if (path[0] !== "/") { + if (path[0] !== "/") { throw new Error("Path must be absolute"); } @@ -73,7 +66,7 @@ const getDirectoryHandle = async (path: string) => { } try { - let directoryHandle = rootHandle; + let directoryHandle = await navigator.storage.getDirectory(); let parentHandle; for (const part of parts.slice(1)) { if (!part) continue; diff --git a/src/lib/serviceWorker/client.ts b/src/lib/serviceWorker/client.ts new file mode 100644 index 0000000..771c15e --- /dev/null +++ b/src/lib/serviceWorker/client.ts @@ -0,0 +1,39 @@ +import { DECRYPTED_FILE_URL_PREFIX } from "$lib/constants"; +import type { FileMetadata, ServiceWorkerMessage, ServiceWorkerResponse } from "./types"; + +const PREPARE_TIMEOUT_MS = 5000; + +const getServiceWorker = async () => { + const registration = await navigator.serviceWorker.ready; + const sw = registration.active; + if (!sw) { + throw new Error("Service worker not activated"); + } + return sw; +}; + +export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => { + const sw = await getServiceWorker(); + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Service worker timeout")), + PREPARE_TIMEOUT_MS, + ); + const handler = (event: MessageEvent) => { + if (event.data.type === "decryption-ready" && event.data.fileId === id) { + clearTimeout(timeout); + navigator.serviceWorker.removeEventListener("message", handler); + resolve(); + } + }; + navigator.serviceWorker.addEventListener("message", handler); + + sw.postMessage({ + type: "decryption-prepare", + fileId: id, + ...metadata, + } satisfies ServiceWorkerMessage); + }); +}; + +export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`; diff --git a/src/lib/serviceWorker/index.ts b/src/lib/serviceWorker/index.ts new file mode 100644 index 0000000..d2ec230 --- /dev/null +++ b/src/lib/serviceWorker/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./types"; diff --git a/src/lib/serviceWorker/types.ts b/src/lib/serviceWorker/types.ts new file mode 100644 index 0000000..97edd6d --- /dev/null +++ b/src/lib/serviceWorker/types.ts @@ -0,0 +1,19 @@ +export interface FileMetadata { + isLegacy: boolean; + dataKey: CryptoKey; + encContentSize: number; + contentType: string; +} + +export interface DecryptionPrepareMessage extends FileMetadata { + type: "decryption-prepare"; + fileId: number; +} + +export interface DecryptionReadyMessage { + type: "decryption-ready"; + fileId: number; +} + +export type ServiceWorkerMessage = DecryptionPrepareMessage; +export type ServiceWorkerResponse = DecryptionReadyMessage; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 4aa6b42..674bc22 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -17,6 +17,7 @@ requestFileDownload, requestThumbnailUpload, requestFileAdditionToCategory, + requestVideoStream, } from "./service"; import TopBarMenu from "./TopBarMenu.svelte"; @@ -37,6 +38,7 @@ let viewerType: "image" | "video" | undefined = $state(); let fileBlob: Blob | undefined = $state(); let fileBlobUrl: string | undefined = $state(); + let videoStreamUrl: string | undefined = $state(); let videoElement: HTMLVideoElement | undefined = $state(); const updateViewer = async (buffer: ArrayBuffer, contentType: string) => { @@ -95,12 +97,27 @@ untrack(() => { if (!downloadState && !isDownloadRequested) { isDownloadRequested = true; - requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then(async (buffer) => { - const blob = await updateViewer(buffer, contentType); - if (!viewerType) { - FileSaver.saveAs(blob, info!.name); - } - }); + + if (viewerType === "video" && !info!.isLegacy) { + requestVideoStream(data.id, info!.dataKey!.key, contentType).then((streamUrl) => { + if (streamUrl) { + videoStreamUrl = streamUrl; + } else { + requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then((buffer) => + updateViewer(buffer, contentType), + ); + } + }); + } else { + requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then( + async (buffer) => { + const blob = await updateViewer(buffer, contentType); + if (!viewerType) { + FileSaver.saveAs(blob, info!.name); + } + }, + ); + } } }); } @@ -159,9 +176,10 @@ {@render viewerLoading("이미지를 불러오고 있어요.")} {/if} {:else if viewerType === "video"} - {#if fileBlobUrl} + {#if videoStreamUrl || fileBlobUrl}
- + updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)} diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts index 09ec86f..ea3e49c 100644 --- a/src/routes/(fullscreen)/file/[id]/service.ts +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -1,11 +1,32 @@ import { encryptData } from "$lib/modules/crypto"; import { storeFileThumbnailCache } from "$lib/modules/file"; +import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker"; import { requestFileThumbnailUpload } from "$lib/services/file"; import { trpc } from "$trpc/client"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; export { requestFileDownload } from "$lib/services/file"; +export const requestVideoStream = async ( + fileId: number, + dataKey: CryptoKey, + contentType: string, +) => { + const res = await fetch(`/api/file/${fileId}/download`, { method: "HEAD" }); + if (!res.ok) return null; + + const encContentSize = parseInt(res.headers.get("Content-Length") ?? "0", 10); + if (encContentSize <= 0) return null; + + try { + await prepareFileDecryption(fileId, { isLegacy: false, dataKey, encContentSize, contentType }); + return getDecryptedFileUrl(fileId); + } catch { + // TODO: Error Handling + return null; + } +}; + export const requestThumbnailUpload = async ( fileId: number, thumbnail: Blob, diff --git a/src/routes/api/file/[id]/download/+server.ts b/src/routes/api/file/[id]/download/+server.ts index 974dd54..68191ef 100644 --- a/src/routes/api/file/[id]/download/+server.ts +++ b/src/routes/api/file/[id]/download/+server.ts @@ -1,7 +1,7 @@ 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 { parseRangeHeader, getContentRangeHeader } from "$lib/modules/http"; import { getFileStream } from "$lib/server/services/file"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/file/[id]/thumbnail/download/+server.ts b/src/routes/api/file/[id]/thumbnail/download/+server.ts index 70d4cd3..4fc7c1a 100644 --- a/src/routes/api/file/[id]/thumbnail/download/+server.ts +++ b/src/routes/api/file/[id]/thumbnail/download/+server.ts @@ -1,7 +1,7 @@ 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 { parseRangeHeader, getContentRangeHeader } from "$lib/modules/http"; import { getFileThumbnailStream } from "$lib/server/services/file"; import type { RequestHandler } from "./$types"; diff --git a/src/service-worker/handlers/decryptFile.ts b/src/service-worker/handlers/decryptFile.ts new file mode 100644 index 0000000..e374e5d --- /dev/null +++ b/src/service-worker/handlers/decryptFile.ts @@ -0,0 +1,117 @@ +import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../modules/constants"; +import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto"; +import { parseRangeHeader, getContentRangeHeader } from "../modules/http"; +import { getFile } from "../modules/opfs"; +import { fileMetadataStore } from "../stores"; +import type { FileMetadata } from "../types"; + +const createResponse = ( + stream: ReadableStream, + isRangeRequest: boolean, + range: { start: number; end: number; total: number }, + contentType?: string, +) => { + return new Response(stream, { + status: isRangeRequest ? 206 : 200, + headers: { + "Accept-Ranges": "bytes", + "Content-Length": String(range.end - range.start + 1), + "Content-Type": contentType ?? "application/octet-stream", + ...(isRangeRequest ? getContentRangeHeader(range) : {}), + }, + }); +}; + +const streamFromOpfs = async ( + file: File, + metadata?: FileMetadata, + range?: { start?: number; end?: number }, +) => { + const start = range?.start ?? 0; + const end = range?.end ?? file.size - 1; + if (start > end || start < 0 || end >= file.size) { + return new Response("Invalid range", { status: 416 }); + } + + return createResponse( + file.slice(start, end + 1).stream(), + !!range, + { start, end, total: file.size }, + metadata?.contentType, + ); +}; + +const streamFromServer = async ( + id: number, + metadata: FileMetadata, + range?: { start?: number; end?: number }, +) => { + const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy); + const start = range?.start ?? 0; + const end = + range?.end ?? + (range && !metadata.isLegacy ? Math.min(start + CHUNK_SIZE, totalSize) : totalSize) - 1; + if (start > end || start < 0 || end >= totalSize) { + return new Response("Invalid range", { status: 416 }); + } + + const encryptedRange = getEncryptedRange(start, end, metadata.encContentSize, metadata.isLegacy); + const apiResponse = await fetch(`/api/file/${id}/download`, { + headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` }, + }); + if (apiResponse.status !== 206) { + return new Response("Failed to fetch encrypted file", { status: 502 }); + } + + const fileEncrypted = await apiResponse.arrayBuffer(); + return createResponse( + new ReadableStream({ + async start(controller) { + if (metadata.isLegacy) { + const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey); + controller.enqueue(new Uint8Array(decrypted.slice(start, end + 1))); + controller.close(); + return; + } + + const chunks = encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1; + + for (let i = 0; i < chunks; i++) { + const chunk = await decryptChunk( + fileEncrypted.slice(i * ENCRYPTED_CHUNK_SIZE, (i + 1) * ENCRYPTED_CHUNK_SIZE), + metadata.dataKey, + ); + const sliceStart = i === 0 ? start % CHUNK_SIZE : 0; + const sliceEnd = i === chunks - 1 ? (end % CHUNK_SIZE) + 1 : chunk.byteLength; + controller.enqueue(new Uint8Array(chunk.slice(sliceStart, sliceEnd))); + } + + controller.close(); + }, + }), + !!range, + { start, end, total: totalSize }, + metadata.contentType, + ); +}; + +const decryptFileHandler = async (request: Request) => { + const url = new URL(request.url); + const fileId = parseInt(url.pathname.slice(DECRYPTED_FILE_URL_PREFIX.length), 10); + if (isNaN(fileId)) { + throw new Response("Invalid file id", { status: 400 }); + } + + const metadata = fileMetadataStore.get(fileId); + const range = parseRangeHeader(request.headers.get("Range")); + const cache = await getFile(`/cache/${fileId}`); + if (cache) { + return streamFromOpfs(cache, metadata, range); + } else if (metadata) { + return streamFromServer(fileId, metadata, range); + } else { + return new Response("Decryption not prepared", { status: 400 }); + } +}; + +export default decryptFileHandler; diff --git a/src/service-worker/handlers/index.ts b/src/service-worker/handlers/index.ts new file mode 100644 index 0000000..fe5b0f9 --- /dev/null +++ b/src/service-worker/handlers/index.ts @@ -0,0 +1 @@ +export { default as decryptFile } from "./decryptFile"; diff --git a/src/service-worker/index.ts b/src/service-worker/index.ts new file mode 100644 index 0000000..051f8d9 --- /dev/null +++ b/src/service-worker/index.ts @@ -0,0 +1,43 @@ +/// +/// +/// +/// + +import { DECRYPTED_FILE_URL_PREFIX } from "./modules/constants"; +import { decryptFile } from "./handlers"; +import { fileMetadataStore } from "./stores"; +import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types"; + +const self = globalThis.self as unknown as ServiceWorkerGlobalScope; + +self.addEventListener("message", (event) => { + const message: ServiceWorkerMessage = event.data; + switch (message.type) { + case "decryption-prepare": + fileMetadataStore.set(message.fileId, message); + event.source?.postMessage({ + type: "decryption-ready", + fileId: message.fileId, + } satisfies ServiceWorkerResponse); + break; + default: { + const exhaustive: never = message.type; + return exhaustive; + } + } +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + if (url.pathname.startsWith(DECRYPTED_FILE_URL_PREFIX)) { + event.respondWith(decryptFile(event.request)); + } +}); + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); diff --git a/src/service-worker/modules/constants.ts b/src/service-worker/modules/constants.ts new file mode 100644 index 0000000..cca093e --- /dev/null +++ b/src/service-worker/modules/constants.ts @@ -0,0 +1 @@ +export * from "../../lib/constants"; diff --git a/src/service-worker/modules/crypto.ts b/src/service-worker/modules/crypto.ts new file mode 100644 index 0000000..1afee74 --- /dev/null +++ b/src/service-worker/modules/crypto.ts @@ -0,0 +1,40 @@ +import { ENCRYPTION_OVERHEAD, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "./constants"; + +export * from "../../lib/modules/crypto"; + +export const getEncryptedRange = ( + start: number, + end: number, + totalEncryptedSize: number, + isLegacy: boolean, +) => { + if (isLegacy) { + return { + firstChunkIndex: 0, + lastChunkIndex: 0, + start: 0, + end: totalEncryptedSize - 1, + }; + } + + const firstChunkIndex = Math.floor(start / CHUNK_SIZE); + const lastChunkIndex = Math.floor(end / CHUNK_SIZE); + return { + firstChunkIndex, + lastChunkIndex, + start: firstChunkIndex * ENCRYPTED_CHUNK_SIZE, + end: Math.min((lastChunkIndex + 1) * ENCRYPTED_CHUNK_SIZE - 1, totalEncryptedSize - 1), + }; +}; + +export const getDecryptedSize = (encryptedSize: number, isLegacy: boolean) => { + if (isLegacy) { + return encryptedSize - ENCRYPTION_OVERHEAD; + } + + const fullChunks = Math.floor(encryptedSize / ENCRYPTED_CHUNK_SIZE); + const lastChunkEncSize = encryptedSize % ENCRYPTED_CHUNK_SIZE; + return ( + fullChunks * CHUNK_SIZE + (lastChunkEncSize > 0 ? lastChunkEncSize - ENCRYPTION_OVERHEAD : 0) + ); +}; diff --git a/src/service-worker/modules/http.ts b/src/service-worker/modules/http.ts new file mode 100644 index 0000000..0d1bf5e --- /dev/null +++ b/src/service-worker/modules/http.ts @@ -0,0 +1 @@ +export * from "../../lib/modules/http"; diff --git a/src/service-worker/modules/opfs.ts b/src/service-worker/modules/opfs.ts new file mode 100644 index 0000000..0ef5769 --- /dev/null +++ b/src/service-worker/modules/opfs.ts @@ -0,0 +1 @@ +export * from "../../lib/modules/opfs"; diff --git a/src/service-worker/stores.ts b/src/service-worker/stores.ts new file mode 100644 index 0000000..22d899e --- /dev/null +++ b/src/service-worker/stores.ts @@ -0,0 +1,3 @@ +import type { FileMetadata } from "./types"; + +export const fileMetadataStore = new Map(); diff --git a/src/service-worker/types.ts b/src/service-worker/types.ts new file mode 100644 index 0000000..f04ed39 --- /dev/null +++ b/src/service-worker/types.ts @@ -0,0 +1 @@ +export * from "../lib/serviceWorker/types";