From 2ce73aa3312a1bd624b21d481126b93e5f7c9a20 Mon Sep 17 00:00:00 2001 From: static Date: Mon, 12 Oct 2026 00:10:37 +0900 Subject: [PATCH] =?UTF-8?q?=ED=82=A4=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20Keyring=EC=9C=BC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=ED=95=98=EC=97=AC=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=EB=90=9C=20MEK=20=EB=B0=8F=20HSK=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20Svelte=20Sto?= =?UTF-8?q?re=EB=A5=BC=20Rune=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/hooks.client.ts | 43 +---- src/lib/client/cache/index.ts | 2 +- ...bnailCache.ts => thumbnailCache.svelte.ts} | 49 +++-- .../client/keys/generation.ts} | 81 ++++---- src/lib/client/keys/index.ts | 5 + src/lib/client/keys/keyring.svelte.ts | 113 +++++++++++ .../key.ts => client/keys/serialization.ts} | 11 -- src/lib/client/keys/sync.ts | 182 ++++++++++++++++++ src/lib/client/keys/types.ts | 18 ++ src/lib/client/media/thumbnail.ts | 6 - src/lib/client/transfer/upload.svelte.ts | 13 +- .../atoms/buttons/FileThumbnailButton.svelte | 4 +- src/lib/modules/filesystem/category.ts | 12 +- src/lib/modules/filesystem/directory.ts | 14 +- src/lib/modules/filesystem/file.ts | 22 +-- src/lib/modules/filesystem/internal.svelte.ts | 67 +++---- src/lib/services/auth.ts | 41 +--- src/lib/services/category.ts | 9 +- src/lib/services/key.ts | 131 ------------- src/lib/stores/index.ts | 1 - src/lib/stores/key.ts | 26 --- .../(fullscreen)/auth/login/+page.svelte | 24 +-- src/routes/(fullscreen)/auth/login/service.ts | 7 - .../(fullscreen)/client/pending/+page.svelte | 17 +- .../(fullscreen)/client/pending/service.ts | 15 -- .../(fullscreen)/file/[id]/+page.svelte | 9 +- .../file/[id]/AddToCategoryBottomSheet.svelte | 13 +- .../(fullscreen)/file/downloads/+page.svelte | 6 +- src/routes/(fullscreen)/gallery/+page.svelte | 3 +- .../(fullscreen)/key/export/+page.svelte | 45 ++--- src/routes/(fullscreen)/key/export/service.ts | 5 - .../(fullscreen)/key/generate/+page.svelte | 34 ++-- src/routes/(fullscreen)/search/+page.svelte | 24 +-- src/routes/(fullscreen)/search/File.svelte | 2 +- .../search/SelectCategoryBottomSheet.svelte | 9 +- src/routes/(fullscreen)/search/service.ts | 8 +- .../(fullscreen)/settings/cache/+page.svelte | 6 +- .../settings/thumbnail/+page.svelte | 5 +- .../settings/thumbnail/service.ts | 3 +- .../(main)/category/[[id]]/+page.svelte | 23 +-- src/routes/(main)/category/[[id]]/File.svelte | 2 +- .../(main)/directory/[[id]]/+page.svelte | 35 ++-- .../[[id]]/DirectoryEntries/File.svelte | 2 +- .../(main)/directory/[[id]]/service.svelte.ts | 45 +---- src/routes/(main)/favorites/+page.svelte | 6 +- src/routes/(main)/favorites/File.svelte | 2 +- src/routes/(main)/favorites/service.ts | 9 +- src/routes/+layout.ts | 7 +- tests/unit/keyring.test.ts | 55 ++++++ vitest.config.ts | 3 + 50 files changed, 633 insertions(+), 641 deletions(-) rename src/lib/client/cache/{thumbnailCache.ts => thumbnailCache.svelte.ts} (56%) rename src/{routes/(fullscreen)/key/generate/service.ts => lib/client/keys/generation.ts} (72%) create mode 100644 src/lib/client/keys/index.ts create mode 100644 src/lib/client/keys/keyring.svelte.ts rename src/lib/{modules/key.ts => client/keys/serialization.ts} (76%) create mode 100644 src/lib/client/keys/sync.ts create mode 100644 src/lib/client/keys/types.ts delete mode 100644 src/lib/services/key.ts delete mode 100644 src/lib/stores/index.ts delete mode 100644 src/lib/stores/key.ts delete mode 100644 src/routes/(fullscreen)/client/pending/service.ts delete mode 100644 src/routes/(fullscreen)/key/export/service.ts create mode 100644 tests/unit/keyring.test.ts diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 645c890..79f2309 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,13 +1,8 @@ import type { ClientInit } from "@sveltejs/kit/hooks"; import { prepareFileCache } from "#lib/client/cache/index.js"; +import { keyring } from "#lib/client/keys/index.js"; import { listenForMetadataRequests } from "#lib/client/serviceWorker.js"; -import { - cleanupDanglingInfos, - getClientKey, - getMasterKeys, - getHmacSecrets, -} from "#lib/client/storage/indexedDB/index.js"; -import { clientKeyStore, masterKeyStore, hmacSecretStore } from "#lib/stores/index.js"; +import { cleanupDanglingInfos } from "#lib/client/storage/indexedDB/index.js"; const requestPersistentStorage = async () => { const isPersistent = await navigator.storage.persist(); @@ -18,42 +13,10 @@ const requestPersistentStorage = async () => { } }; -const prepareClientKeyStore = async () => { - const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([ - getClientKey("encrypt"), - getClientKey("decrypt"), - getClientKey("sign"), - getClientKey("verify"), - ]); - if (encryptKey && decryptKey && signKey && verifyKey) { - clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey }); - } -}; - -const prepareMasterKeyStore = async () => { - const masterKeys = await getMasterKeys(); - if (masterKeys.length > 0) { - masterKeyStore.set(new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]))); - } -}; - -const prepareHmacSecretStore = async () => { - const hmacSecrets = await getHmacSecrets(); - if (hmacSecrets.length > 0) { - hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]))); - } -}; - export const init: ClientInit = async () => { listenForMetadataRequests(); - await Promise.all([ - requestPersistentStorage(), - prepareFileCache(), - prepareClientKeyStore(), - prepareMasterKeyStore(), - prepareHmacSecretStore(), - ]); + await Promise.all([requestPersistentStorage(), prepareFileCache(), keyring.load()]); cleanupDanglingInfos(); // Intended }; diff --git a/src/lib/client/cache/index.ts b/src/lib/client/cache/index.ts index 66c72a3..b55527a 100644 --- a/src/lib/client/cache/index.ts +++ b/src/lib/client/cache/index.ts @@ -1,2 +1,2 @@ export * from "./fileCache"; -export * from "./thumbnailCache"; +export * from "./thumbnailCache.svelte"; diff --git a/src/lib/client/cache/thumbnailCache.ts b/src/lib/client/cache/thumbnailCache.svelte.ts similarity index 56% rename from src/lib/client/cache/thumbnailCache.ts rename to src/lib/client/cache/thumbnailCache.svelte.ts index ebdfcd6..fc68558 100644 --- a/src/lib/client/cache/thumbnailCache.ts +++ b/src/lib/client/cache/thumbnailCache.svelte.ts @@ -1,12 +1,19 @@ import { LRUCache } from "lru-cache"; -import { writable, type Writable } from "svelte/store"; -import { getThumbnailUrl } from "#lib/client/media/thumbnail.js"; -import { readFile, writeFile, deleteFile, deleteDirectory } from "#lib/client/storage/opfs.js"; +import { deleteDirectory, deleteFile, readFile, writeFile } from "#lib/client/storage/opfs.js"; import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js"; -import { decryptChunk } from "#lib/shared/crypto/index.js"; +import { decryptChunk, encodeToBase64 } from "#lib/shared/crypto/index.js"; -const loadedThumbnails = new LRUCache>({ max: 100 }); -const loadingThumbnails = new Map>(); +export interface Thumbnail { + url?: string; +} + +const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => { + return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`; +}; + +// Thumbnails are reactive so that they can be updated in place +const loadedThumbnails = new LRUCache({ max: 100 }); +const loadingThumbnails = new Map(); const fetchFromOpfs = async (fileId: number) => { const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`); @@ -30,22 +37,22 @@ export const getFileThumbnail = (file: SummarizedFileInfo) => { return undefined; } - const thumbnail = loadedThumbnails.get(file.id); + const thumbnail = loadedThumbnails.get(file.id) ?? loadingThumbnails.get(file.id); if (thumbnail) return thumbnail; - let loadingThumbnail = loadingThumbnails.get(file.id); - if (loadingThumbnail) return loadingThumbnail; - - loadingThumbnail = writable(undefined); + const loadingThumbnail: Thumbnail = $state({}); loadingThumbnails.set(file.id, loadingThumbnail); fetchFromOpfs(file.id) - .then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key))) - .then((thumbnail) => { - if (thumbnail) { - loadingThumbnail.set(thumbnail); - loadedThumbnails.set(file.id, loadingThumbnail as Writable); + .then((url) => url ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key))) + .then((url) => { + if (url) { + loadingThumbnail.url = url; + loadedThumbnails.set(file.id, loadingThumbnail); } + }) + .catch(console.error) + .finally(() => { loadingThumbnails.delete(file.id); }); return loadingThumbnail; @@ -54,11 +61,13 @@ export const getFileThumbnail = (file: SummarizedFileInfo) => { export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => { await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); - const oldThumbnail = loadedThumbnails.get(fileId); - if (oldThumbnail) { - oldThumbnail.set(getThumbnailUrl(thumbnailBuffer)); + const url = getThumbnailUrl(thumbnailBuffer); + const thumbnail = loadedThumbnails.get(fileId) ?? loadingThumbnails.get(fileId); + if (thumbnail) { + thumbnail.url = url; } else { - loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer))); + const newThumbnail: Thumbnail = $state({ url }); + loadedThumbnails.set(fileId, newThumbnail); } }; diff --git a/src/routes/(fullscreen)/key/generate/service.ts b/src/lib/client/keys/generation.ts similarity index 72% rename from src/routes/(fullscreen)/key/generate/service.ts rename to src/lib/client/keys/generation.ts index 7e843ac..30f4bf0 100644 --- a/src/routes/(fullscreen)/key/generate/service.ts +++ b/src/lib/client/keys/generation.ts @@ -1,31 +1,28 @@ -import { deserializeClientKeys } from "#lib/modules/key.js"; import { - generateEncryptionKeyPair, - generateSigningKeyPair, + concatenateBuffers, + digestMessage, + exportRSAKey, exportRSAKeyToBase64, + generateEncryptionKeyPair, + generateHmacSecret, + generateMasterKey, + generateSigningKeyPair, importEncryptionKeyPairFromBase64, importSigningKeyPairFromBase64, - makeRSAKeyNonextractable, - wrapMasterKey, - generateMasterKey, makeAESKeyNonextractable, + makeRSAKeyNonextractable, wrapHmacSecret, - generateHmacSecret, + wrapMasterKey, } from "#lib/shared/crypto/index.js"; -import { clientKeyStore } from "#lib/stores/index.js"; - -export { requestLogout } from "#lib/services/auth.js"; -export { requestDeletedFilesCleanup } from "#lib/services/file.js"; -export { - requestClientRegistrationAndSessionUpgrade, - requestInitialMasterKeyAndHmacSecretRegistration, -} from "#lib/services/key.js"; +import { keyring } from "./keyring.svelte"; +import { deserializeClientKeys } from "./serialization"; +// Generates new client keys and keeps them in the keyring without persisting export const generateClientKeys = async () => { const { encryptKey, decryptKey } = await generateEncryptionKeyPair(); const { signKey, verifyKey } = await generateSigningKeyPair(); - clientKeyStore.set({ + keyring.setClientKeys({ encryptKey, decryptKey: await makeRSAKeyNonextractable(decryptKey), signKey: await makeRSAKeyNonextractable(signKey), @@ -41,6 +38,28 @@ export const generateClientKeys = async () => { }; }; +export const importClientKeys = async (clientKeysSerialized: string) => { + const clientKeys = deserializeClientKeys(clientKeysSerialized); + if (!clientKeys) return false; + + const { encryptKey, decryptKey } = await importEncryptionKeyPairFromBase64( + clientKeys.encryptKeyBase64, + clientKeys.decryptKeyBase64, + ); + const { signKey, verifyKey } = await importSigningKeyPairFromBase64( + clientKeys.signKeyBase64, + clientKeys.verifyKeyBase64, + ); + + keyring.setClientKeys({ + encryptKey, + decryptKey: await makeRSAKeyNonextractable(decryptKey), + signKey: await makeRSAKeyNonextractable(signKey), + verifyKey, + }); + return true; +}; + export const generateInitialMasterKey = async (encryptKey: CryptoKey) => { const { masterKey } = await generateMasterKey(); return { @@ -56,24 +75,14 @@ export const generateInitialHmacSecret = async (masterKey: CryptoKey) => { }; }; -export const importClientKeys = async (clientKeysSerialized: string) => { - const clientKeys = deserializeClientKeys(clientKeysSerialized); - if (!clientKeys) return false; - - const { encryptKey, decryptKey } = await importEncryptionKeyPairFromBase64( - clientKeys.encryptKeyBase64, - clientKeys.decryptKeyBase64, - ); - const { signKey, verifyKey } = await importSigningKeyPairFromBase64( - clientKeys.signKeyBase64, - clientKeys.verifyKeyBase64, - ); - - clientKeyStore.set({ - encryptKey, - decryptKey: await makeRSAKeyNonextractable(decryptKey), - signKey: await makeRSAKeyNonextractable(signKey), - verifyKey, - }); - return true; +export const generatePublicKeyFingerprint = async (encryptKey: CryptoKey, verifyKey: CryptoKey) => { + const { key: encryptKeyBuffer } = await exportRSAKey(encryptKey); + const { key: verifyKeyBuffer } = await exportRSAKey(verifyKey); + const digest = await digestMessage(concatenateBuffers(encryptKeyBuffer, verifyKeyBuffer)); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .toUpperCase() + .match(/.{1,4}/g)! + .join(" "); }; diff --git a/src/lib/client/keys/index.ts b/src/lib/client/keys/index.ts new file mode 100644 index 0000000..7440132 --- /dev/null +++ b/src/lib/client/keys/index.ts @@ -0,0 +1,5 @@ +export * from "./generation"; +export * from "./keyring.svelte"; +export * from "./serialization"; +export * from "./sync"; +export * from "./types"; diff --git a/src/lib/client/keys/keyring.svelte.ts b/src/lib/client/keys/keyring.svelte.ts new file mode 100644 index 0000000..86f4792 --- /dev/null +++ b/src/lib/client/keys/keyring.svelte.ts @@ -0,0 +1,113 @@ +import { + getClientKey, + getHmacSecrets, + getMasterKeys, + storeClientKey, + storeHmacSecrets, + storeMasterKeys, +} from "#lib/client/storage/indexedDB/index.js"; +import type { ClientKeys, HmacSecret, MasterKey } from "./types"; + +const findLatestActive = ( + entries: ReadonlyMap, +) => { + return entries + .values() + .filter(({ state }) => state === "active") + .reduce( + (latest, entry) => ((latest?.version ?? 0) > entry.version ? latest : entry), + undefined, + ); +}; + +// Holds the keys of this device and the user. Keys are persisted in IndexedDB. +class Keyring { + clientKeys = $state.raw(null); + masterKeys = $state.raw>(new Map()); + hmacSecrets = $state.raw>(new Map()); + + async load() { + const [encryptKey, decryptKey, signKey, verifyKey, masterKeys, hmacSecrets] = await Promise.all( + [ + getClientKey("encrypt"), + getClientKey("decrypt"), + getClientKey("sign"), + getClientKey("verify"), + getMasterKeys(), + getHmacSecrets(), + ], + ); + if (encryptKey && decryptKey && signKey && verifyKey) { + this.clientKeys = { encryptKey, decryptKey, signKey, verifyKey }; + } + this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey])); + this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])); + } + + // Client keys are kept in memory only until they are registered and persisted + setClientKeys(clientKeys: ClientKeys) { + this.clientKeys = clientKeys; + } + + async persistClientKeys() { + const clientKeys = this.requireClientKeys(); + await Promise.all([ + storeClientKey(clientKeys.encryptKey, "encrypt"), + storeClientKey(clientKeys.decryptKey, "decrypt"), + storeClientKey(clientKeys.signKey, "sign"), + storeClientKey(clientKeys.verifyKey, "verify"), + ]); + } + + async setMasterKeys(masterKeys: MasterKey[]) { + await storeMasterKeys(masterKeys); + this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey])); + } + + async setHmacSecrets(hmacSecrets: HmacSecret[]) { + await storeHmacSecrets(hmacSecrets); + this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])); + } + + get hasMasterKeys() { + return this.masterKeys.size > 0; + } + + get hasHmacSecrets() { + return this.hmacSecrets.size > 0; + } + + requireClientKeys() { + if (!this.clientKeys) { + throw new Error("Client keys not found"); + } + return this.clientKeys; + } + + getMasterKey(version: number) { + const masterKey = this.masterKeys.get(version); + if (!masterKey) { + throw new Error(`Master key (version ${version}) not found`); + } + return masterKey; + } + + // The master key used to encrypt new data + get activeMasterKey() { + const masterKey = findLatestActive(this.masterKeys); + if (!masterKey) { + throw new Error("Active master key not found"); + } + return masterKey; + } + + get activeHmacSecret() { + const hmacSecret = findLatestActive(this.hmacSecrets); + if (!hmacSecret) { + throw new Error("Active HMAC secret not found"); + } + return hmacSecret; + } +} + +export const keyring = new Keyring(); diff --git a/src/lib/modules/key.ts b/src/lib/client/keys/serialization.ts similarity index 76% rename from src/lib/modules/key.ts rename to src/lib/client/keys/serialization.ts index 4e86cfc..2b08e6d 100644 --- a/src/lib/modules/key.ts +++ b/src/lib/client/keys/serialization.ts @@ -1,6 +1,4 @@ import { z } from "zod"; -import { storeClientKey } from "#lib/client/storage/indexedDB/index.js"; -import type { ClientKeys } from "#lib/stores/index.js"; const SerializedClientKeysSchema = z.intersection( z.object({ @@ -54,12 +52,3 @@ export const deserializeClientKeys = (serialized: string) => { } return undefined; }; - -export const storeClientKeys = async (clientKeys: ClientKeys) => { - await Promise.all([ - storeClientKey(clientKeys.encryptKey, "encrypt"), - storeClientKey(clientKeys.decryptKey, "decrypt"), - storeClientKey(clientKeys.signKey, "sign"), - storeClientKey(clientKeys.verifyKey, "verify"), - ]); -}; diff --git a/src/lib/client/keys/sync.ts b/src/lib/client/keys/sync.ts new file mode 100644 index 0000000..dfb68d4 --- /dev/null +++ b/src/lib/client/keys/sync.ts @@ -0,0 +1,182 @@ +import { + decryptChallenge, + encodeToBase64, + exportRSAKeyToBase64, + signMasterKeyWrapped, + signMessageRSA, + unwrapHmacSecret, + unwrapMasterKey, + verifyMasterKeyWrapped, +} from "#lib/shared/crypto/index.js"; +import { isTRPCClientError, trpc } from "#trpc/client.js"; +import { keyring } from "./keyring.svelte"; +import type { ClientKeys } from "./types"; + +// Synchronizes keys with the server + +const exportPublicKeys = async ({ encryptKey, verifyKey }: ClientKeys) => ({ + encPubKey: await exportRSAKeyToBase64(encryptKey), + sigPubKey: await exportRSAKeyToBase64(verifyKey), +}); + +const answerChallenge = async (challenge: string, { decryptKey, signKey }: ClientKeys) => { + const answer = await decryptChallenge(challenge, decryptKey); + return encodeToBase64(await signMessageRSA(answer, signKey)); +}; + +export const requestSessionUpgrade = async (force = false) => { + const clientKeys = keyring.requireClientKeys(); + + let id, challenge; + try { + ({ id, challenge } = await trpc.auth.upgrade.mutate(await exportPublicKeys(clientKeys))); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") { + return [false, "Unregistered client"] as const; + } + return [false] as const; + } + + try { + await trpc.auth.verifyUpgrade.mutate({ + id, + answerSig: await answerChallenge(challenge, clientKeys), + force, + }); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "CONFLICT") { + return [false, "Already logged in"] as const; + } + return [false] as const; + } + + return [true] as const; +}; + +export const requestClientRegistration = async () => { + const clientKeys = keyring.requireClientKeys(); + + try { + const { id, challenge } = await trpc.client.register.mutate(await exportPublicKeys(clientKeys)); + await trpc.client.verify.mutate({ + id, + answerSig: await answerChallenge(challenge, clientKeys), + }); + return true; + } catch { + // TODO: Error Handling + return false; + } +}; + +export const requestClientRegistrationAndSessionUpgrade = async (force: boolean) => { + const [res, error] = await requestSessionUpgrade(force); + if (error === undefined) return [res] as const; + + if (error === "Unregistered client" && !(await requestClientRegistration())) { + return [false] as const; + } else if (error === "Already logged in") { + return [false, force ? undefined : error] as const; + } + + return [(await requestSessionUpgrade())[0]] as const; +}; + +export const registerInitialMasterKeyAndHmacSecret = async ( + masterKeyWrapped: string, + hmacSecretWrapped: string, +) => { + const { signKey } = keyring.requireClientKeys(); + + try { + await trpc.mek.registerInitial.mutate({ + mek: masterKeyWrapped, + mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), + }); + } catch (e) { + if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) { + return true; + } + // TODO: Error Handling + return false; + } + + try { + await trpc.hsk.registerInitial.mutate({ + mekVersion: 1, + hsk: hmacSecretWrapped, + }); + return true; + } catch { + // TODO: Error Handling + return false; + } +}; + +export const downloadHmacSecrets = async () => { + let hmacSecretsWrapped; + try { + hmacSecretsWrapped = await trpc.hsk.list.query(); + } catch { + // TODO: Error Handling + return false; + } + + const hmacSecrets = await Promise.all( + hmacSecretsWrapped.map(async ({ version, state, mekVersion, hsk: hmacSecretWrapped }) => { + const { hmacSecret } = await unwrapHmacSecret( + hmacSecretWrapped, + keyring.getMasterKey(mekVersion).key, + ); + return { version, state, secret: hmacSecret }; + }), + ); + + await keyring.setHmacSecrets(hmacSecrets); + return true; +}; + +// Downloads the master keys (and HMAC secrets) once this client has been approved +export const downloadMasterKeys = async () => { + const { decryptKey, verifyKey } = keyring.requireClientKeys(); + + let masterKeysWrapped; + try { + masterKeysWrapped = await trpc.mek.list.query(); + } catch { + // TODO: Error Handling + return false; + } + + const masterKeys = await Promise.all( + masterKeysWrapped.map( + async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => { + const { masterKey } = await unwrapMasterKey(masterKeyWrapped, decryptKey); + return { + version, + state, + key: masterKey, + isValid: await verifyMasterKeyWrapped( + masterKeyWrapped, + version, + masterKeyWrappedSig, + verifyKey, + ), + }; + }, + ), + ); + if (masterKeys.length === 0 || !masterKeys.every(({ isValid }) => isValid)) return false; + + await keyring.setMasterKeys( + masterKeys.map(({ version, state, key }) => ({ version, state, key })), + ); + await downloadHmacSecrets(); + return true; +}; + +export const ensureHmacSecrets = async () => { + if (!keyring.hasHmacSecrets && !(await downloadHmacSecrets())) { + throw new Error("Failed to download HMAC secrets"); + } +}; diff --git a/src/lib/client/keys/types.ts b/src/lib/client/keys/types.ts new file mode 100644 index 0000000..18ff247 --- /dev/null +++ b/src/lib/client/keys/types.ts @@ -0,0 +1,18 @@ +export interface ClientKeys { + encryptKey: CryptoKey; + decryptKey: CryptoKey; + signKey: CryptoKey; + verifyKey: CryptoKey; +} + +export interface MasterKey { + version: number; + state: "active" | "retired"; + key: CryptoKey; +} + +export interface HmacSecret { + version: number; + state: "active"; + secret: CryptoKey; +} diff --git a/src/lib/client/media/thumbnail.ts b/src/lib/client/media/thumbnail.ts index 09cd116..c043267 100644 --- a/src/lib/client/media/thumbnail.ts +++ b/src/lib/client/media/thumbnail.ts @@ -1,5 +1,3 @@ -import { encodeToBase64 } from "#lib/shared/crypto/index.js"; - const scaleSize = (width: number, height: number, targetSize: number) => { if (width <= targetSize || height <= targetSize) { return { width, height }; @@ -116,7 +114,3 @@ export const generateThumbnail = async (blob: Blob) => { } } }; - -export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => { - return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`; -}; diff --git a/src/lib/client/transfer/upload.svelte.ts b/src/lib/client/transfer/upload.svelte.ts index 3c23ca8..0f9e005 100644 --- a/src/lib/client/transfer/upload.svelte.ts +++ b/src/lib/client/transfer/upload.svelte.ts @@ -1,5 +1,11 @@ import { limitFunction } from "p-limit"; import { signMessageHmac } from "#lib/client/crypto/hmac.js"; +import { + ensureHmacSecrets, + keyring, + type HmacSecret, + type MasterKey, +} from "#lib/client/keys/index.js"; import { extractExifDateTime } from "#lib/client/media/exif.js"; import { generateThumbnail } from "#lib/client/media/thumbnail.js"; import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js"; @@ -10,7 +16,6 @@ import { encryptString, } from "#lib/shared/crypto/index.js"; import { getChunkCount } from "#lib/shared/format/index.js"; -import type { MasterKey, HmacSecret } from "#lib/stores/index.js"; import { Scheduler } from "#lib/utils/index.js"; import { trpc } from "#trpc/client.js"; @@ -184,8 +189,6 @@ const requestFileUpload = limitFunction( export const uploadFile = async ( file: File, parentId: "root" | number, - masterKey: MasterKey, - hmacSecret: HmacSecret, onDuplicate: () => Promise, ) => { uploadingFiles.push({ @@ -198,6 +201,10 @@ export const uploadFile = async ( return await scheduler.schedule(file.size, async () => { try { + await ensureHmacSecrets(); + const masterKey = keyring.activeMasterKey; + const hmacSecret = keyring.activeHmacSecret; + const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate); if (!fileSigned) { diff --git a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte index d543446..b8dfdb1 100644 --- a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte +++ b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte @@ -18,8 +18,8 @@ onclick={onclick && (() => setTimeout(() => onclick(info), 100))} class="relative aspect-square overflow-hidden rounded-sm transition active:scale-95 active:brightness-90" > - {#if $thumbnail} - {info.name} + {#if thumbnail?.url} + {info.name} {:else}
{/if} diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts index 35ce43b..3d1afac 100644 --- a/src/lib/modules/filesystem/category.ts +++ b/src/lib/modules/filesystem/category.ts @@ -48,7 +48,7 @@ const cache = new FilesystemCache({ } }, - async fetchFromServer(id, cachedInfo, masterKey) { + async fetchFromServer(id, cachedInfo) { try { const category = await trpc.category.get.query({ id, recurse: true }); const [subCategories, files, metadata] = await Promise.all([ @@ -56,7 +56,7 @@ const cache = new FilesystemCache({ category.subCategories.map(async (category) => ({ id: category.id, parentId: id, - ...(await decryptCategoryMetadata(category, masterKey)), + ...(await decryptCategoryMetadata(category)), })), ), category.files && @@ -67,10 +67,10 @@ const cache = new FilesystemCache({ contentType: file.contentType, isFavorite: file.isFavorite, isRecursive: file.isRecursive, - ...(await decryptFileMetadata(file, masterKey)), + ...(await decryptFileMetadata(file)), })), ), - category.metadata && decryptCategoryMetadata(category.metadata, masterKey), + category.metadata && decryptCategoryMetadata(category.metadata), ]); return storeToIndexedDB( @@ -118,6 +118,6 @@ const storeToIndexedDB = (info: CategoryInfo) => { return { ...info, exists: true as const }; }; -export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => { - return cache.get(id, masterKey); +export const getCategoryInfo = (id: CategoryId) => { + return cache.get(id); }; diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index ea38bba..47e2979 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -31,7 +31,7 @@ const cache = new FilesystemCache({ } }, - async fetchFromServer(id, _cachedInfo, masterKey) { + async fetchFromServer(id, _cachedInfo) { try { const directory = await trpc.directory.get.query({ id }); const [subDirectories, files, metadata] = await Promise.all([ @@ -40,7 +40,7 @@ const cache = new FilesystemCache({ id: directory.id, parentId: id, isFavorite: directory.isFavorite, - ...(await decryptDirectoryMetadata(directory, masterKey)), + ...(await decryptDirectoryMetadata(directory)), })), ), Promise.all( @@ -49,10 +49,10 @@ const cache = new FilesystemCache({ parentId: id, contentType: file.contentType, isFavorite: file.isFavorite, - ...(await decryptFileMetadata(file, masterKey)), + ...(await decryptFileMetadata(file)), })), ), - directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey), + directory.metadata && decryptDirectoryMetadata(directory.metadata), ]); return storeToIndexedDB( @@ -103,10 +103,10 @@ const storeToIndexedDB = (info: DirectoryInfo) => { export const getDirectoryInfo = ( id: DirectoryId, - masterKey: CryptoKey, options?: { serverResponse?: { parent: DirectoryId; + mekVersion: number; dek: string; dekVersion: Date; name: string; @@ -115,11 +115,11 @@ export const getDirectoryInfo = ( }; }, ) => { - return cache.get(id, masterKey, { + return cache.get(id, { fetchFromServer: options?.serverResponse && (async (cachedValue) => { - const metadata = await decryptDirectoryMetadata(options!.serverResponse!, masterKey); + const metadata = await decryptDirectoryMetadata(options!.serverResponse!); return storeToIndexedDB({ subDirectories: [], files: [], diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index be0fb61..81892ff 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -32,7 +32,7 @@ const cache = new FilesystemCache({ } }, - async fetchFromServer(id, _cachedInfo, masterKey) { + async fetchFromServer(id, _cachedInfo) { try { const file = await trpc.file.get.query({ id }); const [categories, metadata] = await Promise.all([ @@ -40,10 +40,10 @@ const cache = new FilesystemCache({ file.categories.map(async (category) => ({ id: category.id, parentId: category.parent, - ...(await decryptCategoryMetadata(category, masterKey)), + ...(await decryptCategoryMetadata(category)), })), ), - decryptFileMetadata(file, masterKey), + decryptFileMetadata(file), ]); return storeToIndexedDB({ @@ -98,7 +98,7 @@ const cache = new FilesystemCache({ ); }, - async bulkFetchFromServer(ids, masterKey) { + async bulkFetchFromServer(ids) { const idsArray = [...ids.keys()]; const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray }); @@ -109,10 +109,10 @@ const cache = new FilesystemCache({ categoriesRaw.map(async (category) => ({ id: category.id, parentId: category.parent, - ...(await decryptCategoryMetadata(category, masterKey)), + ...(await decryptCategoryMetadata(category)), })), ), - decryptFileMetadata(metadataRaw, masterKey), + decryptFileMetadata(metadataRaw), ]); return { @@ -173,10 +173,10 @@ const bulkStoreToIndexedDB = (infos: FileInfo[]) => { export const getFileInfo = ( id: number, - masterKey: CryptoKey, options?: { serverResponse?: { parent: DirectoryId; + mekVersion: number; dek: string; dekVersion: Date; contentType: string; @@ -190,11 +190,11 @@ export const getFileInfo = ( }; }, ) => { - return cache.get(id, masterKey, { + return cache.get(id, { fetchFromServer: options?.serverResponse && (async (cachedValue) => { - const metadata = await decryptFileMetadata(options!.serverResponse!, masterKey); + const metadata = await decryptFileMetadata(options!.serverResponse!); return storeToIndexedDB({ categories: [], ...cachedValue, @@ -208,6 +208,6 @@ export const getFileInfo = ( }); }; -export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => { - return cache.bulkGet(new Set(ids), masterKey); +export const bulkGetFileInfo = (ids: number[]) => { + return cache.bulkGet(new Set(ids)); }; diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts index 4a33f83..ef96aaf 100644 --- a/src/lib/modules/filesystem/internal.svelte.ts +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -1,14 +1,12 @@ import { untrack } from "svelte"; +import { keyring } from "#lib/client/keys/index.js"; import { unwrapDataKey, decryptString } from "#lib/shared/crypto/index.js"; interface FilesystemCacheOptions { fetchFromIndexedDB: (key: K) => Promise; - fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise; + fetchFromServer: (key: K, cachedValue: V | undefined) => Promise; bulkFetchFromIndexedDB?: (keys: Set) => Promise>; - bulkFetchFromServer?: ( - keys: Map, - masterKey: CryptoKey, - ) => Promise>; + bulkFetchFromServer?: (keys: Map) => Promise>; } export class FilesystemCache { @@ -16,11 +14,7 @@ export class FilesystemCache { constructor(private readonly options: FilesystemCacheOptions) {} - get( - key: K, - masterKey: CryptoKey, - options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise }, - ) { + get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise }) { return untrack(() => { let state = this.map.get(key); if (state?.promise) return state.value ?? state.promise; @@ -45,8 +39,7 @@ export class FilesystemCache { ) .then( (cachedInfo) => - options?.fetchFromServer?.(cachedInfo) ?? - this.options.fetchFromServer(key, cachedInfo, masterKey), + options?.fetchFromServer?.(cachedInfo) ?? this.options.fetchFromServer(key, cachedInfo), ) .then((loadedInfo) => { if (state.value) { @@ -65,7 +58,7 @@ export class FilesystemCache { }); } - bulkGet(keys: Set, masterKey: CryptoKey) { + bulkGet(keys: Set) { return untrack(() => { const newPromises = new Map( keys @@ -105,7 +98,6 @@ export class FilesystemCache { new Map( newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]), ), - masterKey, ), ) .then(resolve) @@ -130,11 +122,22 @@ export class FilesystemCache { } } -export const decryptDirectoryMetadata = async ( - metadata: { dek: string; dekVersion: Date; name: string; nameIv: string }, - masterKey: CryptoKey, -) => { - const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); +const unwrapEntryDataKey = async (metadata: { mekVersion: number; dek: string }) => { + const { dataKey } = await unwrapDataKey( + metadata.dek, + keyring.getMasterKey(metadata.mekVersion).key, + ); + return dataKey; +}; + +export const decryptDirectoryMetadata = async (metadata: { + mekVersion: number; + dek: string; + dekVersion: Date; + name: string; + nameIv: string; +}) => { + const dataKey = await unwrapEntryDataKey(metadata); const name = await decryptString(metadata.name, metadata.nameIv, dataKey); return { @@ -147,20 +150,18 @@ const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) = return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); }; -export const decryptFileMetadata = async ( - metadata: { - dek: string; - dekVersion: Date; - name: string; - nameIv: string; - createdAt?: string; - createdAtIv?: string; - lastModifiedAt: string; - lastModifiedAtIv: string; - }, - masterKey: CryptoKey, -) => { - const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); +export const decryptFileMetadata = async (metadata: { + mekVersion: number; + dek: string; + dekVersion: Date; + name: string; + nameIv: string; + createdAt?: string; + createdAtIv?: string; + lastModifiedAt: string; + lastModifiedAtIv: string; +}) => { + const dataKey = await unwrapEntryDataKey(metadata); const [name, createdAt, lastModifiedAt] = await Promise.all([ decryptString(metadata.name, metadata.nameIv, dataKey), metadata.createdAt diff --git a/src/lib/services/auth.ts b/src/lib/services/auth.ts index 675dab5..80ea2b6 100644 --- a/src/lib/services/auth.ts +++ b/src/lib/services/auth.ts @@ -1,43 +1,4 @@ -import { encodeToBase64, decryptChallenge, signMessageRSA } from "#lib/shared/crypto/index.js"; -import { trpc, isTRPCClientError } from "#trpc/client.js"; - -export const requestSessionUpgrade = async ( - encryptKeyBase64: string, - decryptKey: CryptoKey, - verifyKeyBase64: string, - signKey: CryptoKey, - force = false, -) => { - let id, challenge; - try { - ({ id, challenge } = await trpc.auth.upgrade.mutate({ - encPubKey: encryptKeyBase64, - sigPubKey: verifyKeyBase64, - })); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") { - return [false, "Unregistered client"] as const; - } - return [false] as const; - } - const answer = await decryptChallenge(challenge, decryptKey); - const answerSig = await signMessageRSA(answer, signKey); - - try { - await trpc.auth.verifyUpgrade.mutate({ - id, - answerSig: encodeToBase64(answerSig), - force, - }); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "CONFLICT") { - return [false, "Already logged in"] as const; - } - return [false] as const; - } - - return [true] as const; -}; +import { trpc } from "#trpc/client.js"; export const requestLogout = async () => { try { diff --git a/src/lib/services/category.ts b/src/lib/services/category.ts index 6d2bdac..270c5b3 100644 --- a/src/lib/services/category.ts +++ b/src/lib/services/category.ts @@ -1,12 +1,9 @@ +import { keyring } from "#lib/client/keys/index.js"; import { generateDataKey, wrapDataKey, encryptString } from "#lib/shared/crypto/index.js"; -import type { MasterKey } from "#lib/stores/index.js"; import { trpc } from "#trpc/client.js"; -export const requestCategoryCreation = async ( - name: string, - parentId: "root" | number, - masterKey: MasterKey, -) => { +export const requestCategoryCreation = async (name: string, parentId: "root" | number) => { + const masterKey = keyring.activeMasterKey; const { dataKey, dataKeyVersion } = await generateDataKey(); const nameEncrypted = await encryptString(name, dataKey); diff --git a/src/lib/services/key.ts b/src/lib/services/key.ts deleted file mode 100644 index 463a638..0000000 --- a/src/lib/services/key.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { storeMasterKeys } from "#lib/client/storage/indexedDB/index.js"; -import { requestSessionUpgrade } from "#lib/services/auth.js"; -import { - encodeToBase64, - exportRSAKeyToBase64, - decryptChallenge, - signMessageRSA, - unwrapMasterKey, - signMasterKeyWrapped, - verifyMasterKeyWrapped, -} from "#lib/shared/crypto/index.js"; -import { masterKeyStore, type ClientKeys } from "#lib/stores/index.js"; -import { trpc, isTRPCClientError } from "#trpc/client.js"; - -export const requestClientRegistration = async ( - encryptKeyBase64: string, - decryptKey: CryptoKey, - verifyKeyBase64: string, - signKey: CryptoKey, -) => { - try { - const { id, challenge } = await trpc.client.register.mutate({ - encPubKey: encryptKeyBase64, - sigPubKey: verifyKeyBase64, - }); - const answer = await decryptChallenge(challenge, decryptKey); - const answerSig = await signMessageRSA(answer, signKey); - await trpc.client.verify.mutate({ - id, - answerSig: encodeToBase64(answerSig), - }); - return true; - } catch { - // TODO: Error Handling - return false; - } -}; - -export const requestClientRegistrationAndSessionUpgrade = async ( - { encryptKey, decryptKey, signKey, verifyKey }: ClientKeys, - force: boolean, -) => { - const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey); - const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey); - const [res, error] = await requestSessionUpgrade( - encryptKeyBase64, - decryptKey, - verifyKeyBase64, - signKey, - force, - ); - if (error === undefined) return [res] as const; - - if ( - error === "Unregistered client" && - !(await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) - ) { - return [false] as const; - } else if (error === "Already logged in") { - return [false, force ? undefined : error] as const; - } - - return [ - (await requestSessionUpgrade(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey))[0], - ] as const; -}; - -export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => { - let masterKeysWrapped; - try { - masterKeysWrapped = await trpc.mek.list.query(); - } catch { - // TODO: Error Handling - return false; - } - - const masterKeys = await Promise.all( - masterKeysWrapped.map( - async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => { - const { masterKey } = await unwrapMasterKey(masterKeyWrapped, decryptKey); - return { - version, - state, - key: masterKey, - isValid: await verifyMasterKeyWrapped( - masterKeyWrapped, - version, - masterKeyWrappedSig, - verifyKey, - ), - }; - }, - ), - ); - if (!masterKeys.every(({ isValid }) => isValid)) return false; - - await storeMasterKeys(masterKeys); - masterKeyStore.set(new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]))); - - return true; -}; - -export const requestInitialMasterKeyAndHmacSecretRegistration = async ( - masterKeyWrapped: string, - hmacSecretWrapped: string, - signKey: CryptoKey, -) => { - try { - await trpc.mek.registerInitial.mutate({ - mek: masterKeyWrapped, - mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), - }); - } catch (e) { - if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) { - return true; - } - // TODO: Error Handling - return false; - } - - try { - await trpc.hsk.registerInitial.mutate({ - mekVersion: 1, - hsk: hmacSecretWrapped, - }); - return true; - } catch { - // TODO: Error Handling - return false; - } -}; diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts deleted file mode 100644 index 668f46f..0000000 --- a/src/lib/stores/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./key"; diff --git a/src/lib/stores/key.ts b/src/lib/stores/key.ts deleted file mode 100644 index 77d1268..0000000 --- a/src/lib/stores/key.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { writable } from "svelte/store"; - -export interface ClientKeys { - encryptKey: CryptoKey; - decryptKey: CryptoKey; - signKey: CryptoKey; - verifyKey: CryptoKey; -} - -export interface MasterKey { - version: number; - state: "active" | "retired" | "dead"; - key: CryptoKey; -} - -export interface HmacSecret { - version: number; - state: "active"; - secret: CryptoKey; -} - -export const clientKeyStore = writable(null); - -export const masterKeyStore = writable | null>(null); - -export const hmacSecretStore = writable | null>(null); diff --git a/src/routes/(fullscreen)/auth/login/+page.svelte b/src/routes/(fullscreen)/auth/login/+page.svelte index 6ced9d7..59abf5a 100644 --- a/src/routes/(fullscreen)/auth/login/+page.svelte +++ b/src/routes/(fullscreen)/auth/login/+page.svelte @@ -9,14 +9,14 @@ } from "#lib/components/atoms/index.js"; import { TitledDiv } from "#lib/components/molecules/index.js"; import { ForceLoginModal } from "#lib/components/organisms/index.js"; - import { clientKeyStore, masterKeyStore } from "#lib/stores/index.js"; import { - requestLogin, + downloadMasterKeys, + keyring, requestClientRegistrationAndSessionUpgrade, - requestMasterKeyDownload, - requestDeletedFilesCleanup, - requestLogout, - } from "./service"; + } from "#lib/client/keys/index.js"; + import { requestLogout } from "#lib/services/auth.js"; + import { requestDeletedFilesCleanup } from "#lib/services/file.js"; + import { requestLogin } from "./service"; let { data } = $props(); @@ -31,10 +31,7 @@ const upgradeSession = async (force: boolean) => { try { - const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade( - $clientKeyStore!, - force, - ); + const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(force); if (!force && upgradeError === "Already logged in") { isForceLoginModalOpen = true; return; @@ -44,10 +41,7 @@ // TODO: Multi-user support - if ( - $masterKeyStore || - (await requestMasterKeyDownload($clientKeyStore!.decryptKey, $clientKeyStore!.verifyKey)) - ) { + if (keyring.hasMasterKeys || (await downloadMasterKeys())) { await requestDeletedFilesCleanup(); await goto(data.redirectPath); } else { @@ -65,7 +59,7 @@ try { if (!(await requestLogin(email, password))) throw new Error("Failed to login"); - if (!$clientKeyStore) return await redirect("/key/generate"); + if (!keyring.clientKeys) return await redirect("/key/generate"); await upgradeSession(false); } catch (e) { diff --git a/src/routes/(fullscreen)/auth/login/service.ts b/src/routes/(fullscreen)/auth/login/service.ts index 11097a9..620e90b 100644 --- a/src/routes/(fullscreen)/auth/login/service.ts +++ b/src/routes/(fullscreen)/auth/login/service.ts @@ -1,12 +1,5 @@ import { trpc } from "#trpc/client.js"; -export { requestLogout } from "#lib/services/auth.js"; -export { requestDeletedFilesCleanup } from "#lib/services/file.js"; -export { - requestClientRegistrationAndSessionUpgrade, - requestMasterKeyDownload, -} from "#lib/services/key.js"; - export const requestLogin = async (email: string, password: string) => { try { await trpc.auth.login.mutate({ email, password }); diff --git a/src/routes/(fullscreen)/client/pending/+page.svelte b/src/routes/(fullscreen)/client/pending/+page.svelte index 38d445f..eef8a35 100644 --- a/src/routes/(fullscreen)/client/pending/+page.svelte +++ b/src/routes/(fullscreen)/client/pending/+page.svelte @@ -3,25 +3,24 @@ import { goto } from "$app/navigation"; import { FullscreenDiv } from "#lib/components/atoms/index.js"; import { TitledDiv } from "#lib/components/molecules/index.js"; - import { clientKeyStore, masterKeyStore } from "#lib/stores/index.js"; - import { generatePublicKeyFingerprint, requestMasterKeyDownload } from "./service"; + import { + downloadMasterKeys, + generatePublicKeyFingerprint, + keyring, + } from "#lib/client/keys/index.js"; import IconFingerprint from "~icons/material-symbols/fingerprint"; let { data } = $props(); let fingerprint = $derived( - $clientKeyStore - ? generatePublicKeyFingerprint($clientKeyStore.encryptKey, $clientKeyStore.verifyKey) + keyring.clientKeys + ? generatePublicKeyFingerprint(keyring.clientKeys.encryptKey, keyring.clientKeys.verifyKey) : undefined, ); onMount(async () => { - if ( - $masterKeyStore || - ($clientKeyStore && - (await requestMasterKeyDownload($clientKeyStore.decryptKey, $clientKeyStore.verifyKey))) - ) { + if (keyring.hasMasterKeys || (keyring.clientKeys && (await downloadMasterKeys()))) { await goto(data.redirectPath, { replace: true }); } }); diff --git a/src/routes/(fullscreen)/client/pending/service.ts b/src/routes/(fullscreen)/client/pending/service.ts deleted file mode 100644 index bd5ec94..0000000 --- a/src/routes/(fullscreen)/client/pending/service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { concatenateBuffers, exportRSAKey, digestMessage } from "#lib/shared/crypto/index.js"; - -export { requestMasterKeyDownload } from "#lib/services/key.js"; - -export const generatePublicKeyFingerprint = async (encryptKey: CryptoKey, verifyKey: CryptoKey) => { - const { key: encryptKeyBuffer } = await exportRSAKey(encryptKey); - const { key: verifyKeyBuffer } = await exportRSAKey(verifyKey); - const digest = await digestMessage(concatenateBuffers(encryptKeyBuffer, verifyKeyBuffer)); - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join("") - .toUpperCase() - .match(/.{1,4}/g)! - .join(" "); -}; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 246ccb7..59b5bcf 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -8,7 +8,6 @@ import { getFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js"; import { captureVideoThumbnail } from "#lib/client/media/thumbnail.js"; import { getFileDownloadState } from "#lib/client/transfer/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import { HybridPromise } from "#lib/utils/index.js"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import DownloadStatus from "./DownloadStatus.svelte"; @@ -68,21 +67,21 @@ const addToCategory = async (categoryId: number) => { await requestFileAdditionToCategory(data.id, categoryId); isAddToCategoryBottomSheetOpen = false; - void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getFileInfo(data.id); // TODO: FIXME }; const removeFromCategory = async (categoryId: number) => { await requestFileRemovalFromCategory(data.id, categoryId); - void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getFileInfo(data.id); // TODO: FIXME }; const toggleFavorite = async () => { await requestFavoriteToggle(data.id, !!info?.isFavorite); - void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getFileInfo(data.id); // TODO: FIXME }; $effect(() => { - HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => { + HybridPromise.resolve(getFileInfo(data.id)).then((result) => { if (data.id === result.id) { info = result; } diff --git a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte index df1aec3..69ac8cc 100644 --- a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte +++ b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte @@ -3,7 +3,6 @@ import { SubCategories } from "#lib/components/molecules/index.js"; import { CategoryCreateModal } from "#lib/components/organisms/index.js"; import { getCategoryInfo, type MaybeCategoryInfo } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import { HybridPromise } from "#lib/utils/index.js"; import { requestCategoryCreation } from "./service"; @@ -20,9 +19,7 @@ $effect(() => { if (isOpen) { - HybridPromise.resolve(getCategoryInfo("root", $masterKeyStore?.get(1)?.key!)).then( - (result) => (categoryInfo = result), - ); + HybridPromise.resolve(getCategoryInfo("root")).then((result) => (categoryInfo = result)); } }); @@ -34,9 +31,7 @@ class="py-4" info={categoryInfo} onSubCategoryClick={({ id }) => - HybridPromise.resolve(getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)).then( - (result) => (categoryInfo = result), - )} + HybridPromise.resolve(getCategoryInfo(id)).then((result) => (categoryInfo = result))} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} subCategoryCreatePosition="top" /> @@ -54,8 +49,8 @@ { - if (await requestCategoryCreation(name, categoryInfo!.id, $masterKeyStore?.get(1)!)) { - void getCategoryInfo(categoryInfo!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + if (await requestCategoryCreation(name, categoryInfo!.id)) { + void getCategoryInfo(categoryInfo!.id); // TODO: FIXME return true; } return false; diff --git a/src/routes/(fullscreen)/file/downloads/+page.svelte b/src/routes/(fullscreen)/file/downloads/+page.svelte index 106550d..0f6c766 100644 --- a/src/routes/(fullscreen)/file/downloads/+page.svelte +++ b/src/routes/(fullscreen)/file/downloads/+page.svelte @@ -8,17 +8,13 @@ type FileDownloadState, } from "#lib/client/transfer/index.js"; import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import File from "./File.svelte"; let downloadingFiles: { info: MaybeFileInfo; state: FileDownloadState }[] = $state([]); onMount(async () => { const states = getDownloadingFiles(); - const infos = await bulkGetFileInfo( - states.map(({ id }) => id), - $masterKeyStore?.get(1)?.key!, - ); + const infos = await bulkGetFileInfo(states.map(({ id }) => id)); downloadingFiles = states.map((state) => ({ info: infos.get(state.id)!, state, diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index 47ebe0f..4e7dc04 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -12,7 +12,6 @@ type MaybeFileInfo, type SummarizedFileInfo, } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import { formatDate, formatDateSortable, SortBy, sortEntries } from "#lib/utils/index.js"; let { data } = $props(); @@ -55,7 +54,7 @@ }); onMount(async () => { - files = Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()); + files = Array.from((await bulkGetFileInfo(data.files)).values()); }); diff --git a/src/routes/(fullscreen)/key/export/+page.svelte b/src/routes/(fullscreen)/key/export/+page.svelte index 486344f..b668ad9 100644 --- a/src/routes/(fullscreen)/key/export/+page.svelte +++ b/src/routes/(fullscreen)/key/export/+page.svelte @@ -3,15 +3,15 @@ import { goto } from "$app/navigation"; import { BottomDiv, Button, FullscreenDiv, TextButton } from "#lib/components/atoms/index.js"; import { TitledDiv } from "#lib/components/molecules/index.js"; - import { serializeClientKeys, storeClientKeys } from "#lib/modules/key.js"; - import { clientKeyStore } from "#lib/stores/index.js"; - import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte"; - import BeforeContinueModal from "./BeforeContinueModal.svelte"; import { + keyring, + registerInitialMasterKeyAndHmacSecret, requestClientRegistration, requestSessionUpgrade, - requestInitialMasterKeyAndHmacSecretRegistration, - } from "./service"; + serializeClientKeys, + } from "#lib/client/keys/index.js"; + import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte"; + import BeforeContinueModal from "./BeforeContinueModal.svelte"; import IconKey from "~icons/material-symbols/key"; @@ -33,40 +33,17 @@ }; const registerPubKeys = async () => { - if (!$clientKeyStore) { - throw new Error("Failed to find client keys"); - } - try { - if ( - !(await requestClientRegistration( - data.encryptKeyBase64, - $clientKeyStore.decryptKey, - data.verifyKeyBase64, - $clientKeyStore.signKey, - )) - ) - throw new Error("Failed to register client"); + if (!(await requestClientRegistration())) throw new Error("Failed to register client"); - await storeClientKeys($clientKeyStore); + await keyring.persistClientKeys(); + + if (!(await requestSessionUpgrade())[0]) throw new Error("Failed to upgrade session"); if ( - !( - await requestSessionUpgrade( - data.encryptKeyBase64, - $clientKeyStore.decryptKey, - data.verifyKeyBase64, - $clientKeyStore.signKey, - ) - )[0] - ) - throw new Error("Failed to upgrade session"); - - if ( - !(await requestInitialMasterKeyAndHmacSecretRegistration( + !(await registerInitialMasterKeyAndHmacSecret( data.masterKeyWrapped, data.hmacSecretWrapped, - $clientKeyStore.signKey, )) ) throw new Error("Failed to register initial MEK and HSK"); diff --git a/src/routes/(fullscreen)/key/export/service.ts b/src/routes/(fullscreen)/key/export/service.ts deleted file mode 100644 index 8ac7ed3..0000000 --- a/src/routes/(fullscreen)/key/export/service.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { requestSessionUpgrade } from "#lib/services/auth.js"; -export { - requestClientRegistration, - requestInitialMasterKeyAndHmacSecretRegistration, -} from "#lib/services/key.js"; diff --git a/src/routes/(fullscreen)/key/generate/+page.svelte b/src/routes/(fullscreen)/key/generate/+page.svelte index 0a5e62a..3e79117 100644 --- a/src/routes/(fullscreen)/key/generate/+page.svelte +++ b/src/routes/(fullscreen)/key/generate/+page.svelte @@ -4,19 +4,18 @@ import { BottomDiv, Button, FullscreenDiv, TextButton } from "#lib/components/atoms/index.js"; import { TitledDiv } from "#lib/components/molecules/index.js"; import { ForceLoginModal } from "#lib/components/organisms/index.js"; - import { storeClientKeys } from "#lib/modules/key.js"; - import { clientKeyStore } from "#lib/stores/index.js"; - import { gotoStateful } from "#lib/utils/index.js"; - import Order from "./Order.svelte"; import { generateClientKeys, - generateInitialMasterKey, generateInitialHmacSecret, + generateInitialMasterKey, importClientKeys, + keyring, + registerInitialMasterKeyAndHmacSecret, requestClientRegistrationAndSessionUpgrade, - requestInitialMasterKeyAndHmacSecretRegistration, - requestDeletedFilesCleanup, - } from "./service"; + } from "#lib/client/keys/index.js"; + import { requestDeletedFilesCleanup } from "#lib/services/file.js"; + import { gotoStateful } from "#lib/utils/index.js"; + import Order from "./Order.svelte"; import IconKey from "~icons/material-symbols/key"; @@ -62,10 +61,7 @@ }; const upgradeSession = async (force: boolean) => { - const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade( - $clientKeyStore!, - force, - ); + const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(force); if (!force && upgradeError === "Already logged in") { isForceLoginModalOpen = true; return; @@ -75,19 +71,13 @@ } const { masterKey, masterKeyWrapped } = await generateInitialMasterKey( - $clientKeyStore!.encryptKey, + keyring.requireClientKeys().encryptKey, ); const { hmacSecretWrapped } = await generateInitialHmacSecret(masterKey); - await storeClientKeys($clientKeyStore!); + await keyring.persistClientKeys(); - if ( - !(await requestInitialMasterKeyAndHmacSecretRegistration( - masterKeyWrapped, - hmacSecretWrapped, - $clientKeyStore!.signKey, - )) - ) { + if (!(await registerInitialMasterKeyAndHmacSecret(masterKeyWrapped, hmacSecretWrapped))) { // TODO: Error Handling return; } @@ -110,7 +100,7 @@ }; onMount(async () => { - if ($clientKeyStore) { + if (keyring.clientKeys) { await goto(data.redirectPath, { replace: true }); } }); diff --git a/src/routes/(fullscreen)/search/+page.svelte b/src/routes/(fullscreen)/search/+page.svelte index c4c9dd2..5d7ab53 100644 --- a/src/routes/(fullscreen)/search/+page.svelte +++ b/src/routes/(fullscreen)/search/+page.svelte @@ -10,7 +10,6 @@ type LocalCategoryInfo, type MaybeDirectoryInfo, } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import { HybridPromise, searchString, sortEntries } from "#lib/utils/index.js"; import Directory from "./Directory.svelte"; import File from "./File.svelte"; @@ -122,12 +121,10 @@ $effect.pre(() => { if (data.directoryId) { - HybridPromise.resolve(getDirectoryInfo(data.directoryId, $masterKeyStore?.get(1)?.key!)).then( - (res) => { - directoryInfo = res; - filters.searchInDirectory = res.exists; - }, - ); + HybridPromise.resolve(getDirectoryInfo(data.directoryId)).then((res) => { + directoryInfo = res; + filters.searchInDirectory = res.exists; + }); } else { directoryInfo = undefined; filters.searchInDirectory = false; @@ -151,14 +148,11 @@ } if (hasAnyFilter) { - requestSearch( - { - ancestorId: filters.searchInDirectory ? data.directoryId! : "root", - inFavorites: filters.searchInFavorites, - categories: filters.categories, - }, - $masterKeyStore?.get(1)?.key!, - ).then((res) => { + requestSearch({ + ancestorId: filters.searchInDirectory ? data.directoryId! : "root", + inFavorites: filters.searchInFavorites, + categories: filters.categories, + }).then((res) => { serverResult = res; }); } diff --git a/src/routes/(fullscreen)/search/File.svelte b/src/routes/(fullscreen)/search/File.svelte index 2cd28fa..1692d78 100644 --- a/src/routes/(fullscreen)/search/File.svelte +++ b/src/routes/(fullscreen)/search/File.svelte @@ -18,7 +18,7 @@ { if (isOpen) { - HybridPromise.resolve(getCategoryInfo("root", $masterKeyStore?.get(1)?.key!)).then( - (result) => (categoryInfo = result), - ); + HybridPromise.resolve(getCategoryInfo("root")).then((result) => (categoryInfo = result)); } }); @@ -35,9 +32,7 @@ class="py-4" info={categoryInfo} onSubCategoryClick={({ id }) => - HybridPromise.resolve(getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)).then( - (result) => (categoryInfo = result), - )} + HybridPromise.resolve(getCategoryInfo(id)).then((result) => (categoryInfo = result))} /> {#if categoryInfo.id !== "root"} diff --git a/src/routes/(fullscreen)/search/service.ts b/src/routes/(fullscreen)/search/service.ts index a004c53..b714cc8 100644 --- a/src/routes/(fullscreen)/search/service.ts +++ b/src/routes/(fullscreen)/search/service.ts @@ -19,7 +19,7 @@ export interface SearchResult { files: FileInfo[]; } -export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey) => { +export const requestSearch = async (filter: SearchFilter) => { const { directories: directoriesRaw, files: filesRaw } = await trpc.search.search.query({ ancestor: filter.ancestorId, inFavorites: filter.inFavorites, @@ -33,12 +33,10 @@ export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey) const [directories, files] = await HybridPromise.all([ HybridPromise.all( directoriesRaw.map((directory) => - getDirectoryInfo(directory.id, masterKey, { serverResponse: directory }), + getDirectoryInfo(directory.id, { serverResponse: directory }), ), ), - HybridPromise.all( - filesRaw.map((file) => getFileInfo(file.id, masterKey, { serverResponse: file })), - ), + HybridPromise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))), ]); return { directories, files } as SearchResult; }; diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index 021e163..062073f 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -8,7 +8,6 @@ deleteFileCache as doDeleteFileCache, } from "#lib/client/cache/index.js"; import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import { formatFileSize } from "#lib/utils/index.js"; import File from "./File.svelte"; @@ -29,10 +28,7 @@ onMount(async () => { const indexes = getFileCacheIndex(); - const infos = await bulkGetFileInfo( - indexes.map(({ fileId }) => fileId), - $masterKeyStore?.get(1)?.key!, - ); + const infos = await bulkGetFileInfo(indexes.map(({ fileId }) => fileId)); fileCache = indexes .map((index) => ({ index, info: infos.get(index.fileId)! })) .sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index ab6a26f..fda73eb 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -5,7 +5,6 @@ import { IconEntryButton, TopBar } from "#lib/components/molecules/index.js"; import { deleteAllFileThumbnailCaches } from "#lib/client/cache/index.js"; import type { MaybeFileInfo } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import { sortEntries } from "#lib/utils/index.js"; import File from "./File.svelte"; import { @@ -42,9 +41,7 @@ }; onMount(async () => { - fileInfos = sortEntries( - await requestMissingThumbnailFiles(data.files, $masterKeyStore?.get(1)?.key!), - ); + fileInfos = sortEntries(await requestMissingThumbnailFiles(data.files)); }); $effect(() => clearThumbnailGenerationStatuses); diff --git a/src/routes/(fullscreen)/settings/thumbnail/service.ts b/src/routes/(fullscreen)/settings/thumbnail/service.ts index ae57b23..7be80f5 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/service.ts +++ b/src/routes/(fullscreen)/settings/thumbnail/service.ts @@ -27,10 +27,9 @@ export const clearThumbnailGenerationStatuses = () => { export const requestMissingThumbnailFiles = async ( filesRaw: RouterOutputs["file"]["listWithoutThumbnail"], - masterKey: CryptoKey, ) => { const files = await HybridPromise.all( - filesRaw.map((file) => getFileInfo(file.id, masterKey, { serverResponse: file })), + filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file })), ); return files; }; diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte index cd8caad..f971dfc 100644 --- a/src/routes/(main)/category/[[id]]/+page.svelte +++ b/src/routes/(main)/category/[[id]]/+page.svelte @@ -5,7 +5,6 @@ import { CategoryCreateModal } from "#lib/components/organisms/index.js"; import { updateCategoryInfo } from "#lib/client/storage/indexedDB/index.js"; import { getCategoryInfo, type MaybeCategoryInfo } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import { HybridPromise, sortEntries } from "#lib/utils/index.js"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; @@ -56,13 +55,11 @@ }); $effect(() => { - HybridPromise.resolve(getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then( - (result) => { - if (data.id === result.id) { - info = result; - } - }, - ); + HybridPromise.resolve(getCategoryInfo(data.id)).then((result) => { + if (data.id === result.id) { + info = result; + } + }); }); @@ -114,7 +111,7 @@ onRemoveClick={!details.isRecursive ? async ({ id }) => { await requestFileRemovalFromCategory(id, data.id as number); - void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getCategoryInfo(data.id); // TODO: FIXME } : undefined} /> @@ -132,8 +129,8 @@ { - if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + if (await requestCategoryCreation(name, data.id)) { + void getCategoryInfo(data.id); // TODO: FIXME return true; } return false; @@ -155,7 +152,7 @@ bind:isOpen={isCategoryRenameModalOpen} onRenameClick={async (newName: string) => { if (await requestCategoryRename(context.selectedCategory!, newName)) { - void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getCategoryInfo(data.id); // TODO: FIXME return true; } return false; @@ -165,7 +162,7 @@ bind:isOpen={isCategoryDeleteModalOpen} onDeleteClick={async () => { if (await requestCategoryDeletion(context.selectedCategory!)) { - void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getCategoryInfo(data.id); // TODO: FIXME return true; } return false; diff --git a/src/routes/(main)/category/[[id]]/File.svelte b/src/routes/(main)/category/[[id]]/File.svelte index 0f1935b..363cd6d 100644 --- a/src/routes/(main)/category/[[id]]/File.svelte +++ b/src/routes/(main)/category/[[id]]/File.svelte @@ -27,7 +27,7 @@ > - import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { page } from "$app/state"; import { FloatingButton } from "#lib/components/atoms/index.js"; import { TopBar } from "#lib/components/molecules/index.js"; import { getDirectoryInfo, type MaybeDirectoryInfo } from "#lib/modules/filesystem/index.js"; - import { masterKeyStore, hmacSecretStore } from "#lib/stores/index.js"; import { HybridPromise } from "#lib/utils/index.js"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryEntries from "./DirectoryEntries"; @@ -18,7 +16,6 @@ import UploadStatusCard from "./UploadStatusCard.svelte"; import { createContext, - requestHmacSecretDownload, requestDirectoryCreation, requestFileUpload, requestEntryRename, @@ -64,7 +61,7 @@ if (!files || files.length === 0) return; for (const file of files) { - requestFileUpload(file, data.id, $masterKeyStore?.get(1)!, $hmacSecretStore?.get(1)!, () => { + requestFileUpload(file, data.id, () => { return new Promise((resolve) => { duplicatedFile = file; resolveForDuplicateFileModal = resolve; @@ -74,7 +71,7 @@ .then((res) => { if (!res) return; // TODO: FIXME - void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + void getDirectoryInfo(data.id); }) .catch((e: Error) => { // TODO: FIXME @@ -85,20 +82,12 @@ fileInput!.value = ""; }; - onMount(async () => { - if (!$hmacSecretStore && !(await requestHmacSecretDownload($masterKeyStore?.get(1)?.key!))) { - throw new Error("Failed to download hmac secrets"); - } - }); - $effect(() => { - HybridPromise.resolve(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then( - (result) => { - if (data.id === result.id) { - info = result; - } - }, - ); + HybridPromise.resolve(getDirectoryInfo(data.id)).then((result) => { + if (data.id === result.id) { + info = result; + } + }); }); @@ -162,8 +151,8 @@ { - if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + if (await requestDirectoryCreation(name, data.id)) { + void getDirectoryInfo(data.id); // TODO: FIXME return true; } return false; @@ -195,7 +184,7 @@ onFavoriteClick={async () => { if (await requestFavoriteToggle(context.selectedEntry!)) { isEntryMenuBottomSheetOpen = false; - void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getDirectoryInfo(data.id); // TODO: FIXME } }} /> @@ -203,7 +192,7 @@ bind:isOpen={isEntryRenameModalOpen} onRenameClick={async (newName: string) => { if (await requestEntryRename(context.selectedEntry!, newName)) { - void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getDirectoryInfo(data.id); // TODO: FIXME return true; } return false; @@ -213,7 +202,7 @@ bind:isOpen={isEntryDeleteModalOpen} onDeleteClick={async () => { if (await requestEntryDeletion(context.selectedEntry!)) { - void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getDirectoryInfo(data.id); // TODO: FIXME return true; } return false; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index d0e7056..384a703 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -37,7 +37,7 @@ > { return getContext>("context"); }; -export const requestHmacSecretDownload = async (masterKey: CryptoKey) => { - // TODO: MEK rotation - - let hmacSecretsWrapped; - try { - hmacSecretsWrapped = await trpc.hsk.list.query(); - } catch { - // TODO: Error Handling - return false; - } - - const hmacSecrets = await Promise.all( - hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => { - const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey); - return { version, state, secret: hmacSecret }; - }), - ); - - await storeHmacSecrets(hmacSecrets); - hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]))); - - return true; -}; - -export const requestDirectoryCreation = async ( - name: string, - parentId: "root" | number, - masterKey: MasterKey, -) => { +export const requestDirectoryCreation = async (name: string, parentId: "root" | number) => { + const masterKey = keyring.activeMasterKey; const { dataKey, dataKeyVersion } = await generateDataKey(); const nameEncrypted = await encryptString(name, dataKey); @@ -87,11 +54,9 @@ export const requestDirectoryCreation = async ( export const requestFileUpload = async ( file: File, parentId: "root" | number, - masterKey: MasterKey, - hmacSecret: HmacSecret, onDuplicate: () => Promise, ) => { - const res = await uploadFile(file, parentId, masterKey, hmacSecret, onDuplicate); + const res = await uploadFile(file, parentId, onDuplicate); if (!res) return false; if (res.fileBuffer) { diff --git a/src/routes/(main)/favorites/+page.svelte b/src/routes/(main)/favorites/+page.svelte index 42d7b88..bf304c4 100644 --- a/src/routes/(main)/favorites/+page.svelte +++ b/src/routes/(main)/favorites/+page.svelte @@ -3,7 +3,6 @@ import { goto } from "$app/navigation"; import { RowVirtualizer } from "#lib/components/atoms/index.js"; import { TopBar } from "#lib/components/molecules/index.js"; - import { masterKeyStore } from "#lib/stores/index.js"; import Directory from "./Directory.svelte"; import File from "./File.svelte"; import { requestFavoriteEntries, requestRemoveFavorite, type FavoriteEntry } from "./service"; @@ -16,10 +15,7 @@ let isLoading = $state(true); onMount(async () => { - const masterKey = $masterKeyStore?.get(1)?.key; - if (masterKey) { - entries = await requestFavoriteEntries(data.favorites, masterKey); - } + entries = await requestFavoriteEntries(data.favorites); isLoading = false; }); diff --git a/src/routes/(main)/favorites/File.svelte b/src/routes/(main)/favorites/File.svelte index d0c56e1..943728f 100644 --- a/src/routes/(main)/favorites/File.svelte +++ b/src/routes/(main)/favorites/File.svelte @@ -26,7 +26,7 @@ > { +export const requestFavoriteEntries = async (favorites: RouterOutputs["favorites"]["get"]) => { const [directories, files] = await HybridPromise.all([ HybridPromise.all( favorites.directories.map((directory) => - getDirectoryInfo(directory.id, masterKey, { + getDirectoryInfo(directory.id, { serverResponse: { ...directory, isFavorite: true }, }), ), ), HybridPromise.all( favorites.files.map((file) => - getFileInfo(file.id, masterKey, { serverResponse: { ...file, isFavorite: true } }), + getFileInfo(file.id, { serverResponse: { ...file, isFavorite: true } }), ), ), ]); diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 06bd5c2..dd93bc2 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,6 +1,5 @@ import { redirect } from "@sveltejs/kit"; -import { get } from "svelte/store"; -import { clientKeyStore, masterKeyStore } from "#lib/stores/index.js"; +import { keyring } from "#lib/client/keys/index.js"; import type { LayoutLoad } from "./$types"; export const ssr = false; @@ -11,9 +10,9 @@ export const load: LayoutLoad = ({ url }) => { if (KEYLESS_PATHS.some((path) => url.pathname.startsWith(path))) return; const redirectQuery = `?redirect=${encodeURIComponent(url.pathname + url.search)}`; - if (!get(clientKeyStore)) { + if (!keyring.clientKeys) { redirect(307, "/key/generate" + redirectQuery); - } else if (!get(masterKeyStore)) { + } else if (!keyring.hasMasterKeys) { redirect(307, "/client/pending" + redirectQuery); } }; diff --git a/tests/unit/keyring.test.ts b/tests/unit/keyring.test.ts new file mode 100644 index 0000000..ea42642 --- /dev/null +++ b/tests/unit/keyring.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { keyring } from "#lib/client/keys/keyring.svelte.js"; +import type { MasterKey } from "#lib/client/keys/types.js"; + +vi.mock("#lib/client/storage/indexedDB/index.js", () => ({ + getClientKey: vi.fn(async () => null), + getMasterKeys: vi.fn(async () => []), + getHmacSecrets: vi.fn(async () => []), + storeClientKey: vi.fn(async () => {}), + storeMasterKeys: vi.fn(async () => {}), + storeHmacSecrets: vi.fn(async () => {}), +})); + +const key = {} as CryptoKey; +const masterKey = (version: number, state: MasterKey["state"]): MasterKey => ({ + version, + state, + key, +}); + +beforeEach(async () => { + await keyring.setMasterKeys([]); + await keyring.setHmacSecrets([]); +}); + +describe("Keyring", () => { + it("uses the latest active master key for new data", async () => { + await keyring.setMasterKeys([ + masterKey(1, "retired"), + masterKey(3, "active"), + masterKey(2, "active"), + ]); + + expect(keyring.hasMasterKeys).toBe(true); + expect(keyring.activeMasterKey.version).toBe(3); + }); + + it("finds master keys by version", async () => { + await keyring.setMasterKeys([masterKey(1, "retired"), masterKey(2, "active")]); + + expect(keyring.getMasterKey(1).state).toBe("retired"); + expect(() => keyring.getMasterKey(3)).toThrow("not found"); + }); + + it("throws if there is no active key", async () => { + await keyring.setMasterKeys([masterKey(1, "retired")]); + + expect(() => keyring.activeMasterKey).toThrow("Active master key not found"); + expect(() => keyring.activeHmacSecret).toThrow("Active HMAC secret not found"); + }); + + it("requires client keys", () => { + expect(() => keyring.requireClientKeys()).toThrow("Client keys not found"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1049122..a627a7c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,9 @@ +import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vitest/config"; export default defineConfig({ + // Compiles runes in `.svelte.ts` files + plugins: [svelte()], test: { projects: [ {