키 관리 로직을 Keyring으로 통합하여 하드코딩된 MEK 및 HSK 버전을 제거하고 Svelte Store를 Rune으로 대체

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 00:10:37 +09:00
1 parent aee87fd9b6
commit 2ce73aa331
50 files changed
+633 -641

No files matched your search

+3 -40
View File
@@ -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
};
+1 -1
View File
@@ -1,2 +1,2 @@
export * from "./fileCache";
export * from "./thumbnailCache";
export * from "./thumbnailCache.svelte";
@@ -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<number, Writable<string>>({ max: 100 });
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
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<number, Thumbnail>({ max: 100 });
const loadingThumbnails = new Map<number, Thumbnail>();
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<string>);
.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);
}
};
@@ -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(" ");
};
+5
View File
@@ -0,0 +1,5 @@
export * from "./generation";
export * from "./keyring.svelte";
export * from "./serialization";
export * from "./sync";
export * from "./types";
+113
View File
@@ -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 = <T extends { version: number; state: string }>(
entries: ReadonlyMap<number, T>,
) => {
return entries
.values()
.filter(({ state }) => state === "active")
.reduce<T | undefined>(
(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<ClientKeys | null>(null);
masterKeys = $state.raw<ReadonlyMap<number, MasterKey>>(new Map());
hmacSecrets = $state.raw<ReadonlyMap<number, HmacSecret>>(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();
@@ -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"),
]);
};
+182
View File
@@ -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");
}
};
+18
View File
@@ -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;
}
-6
View File
@@ -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)}`;
};
+10 -3
View File
@@ -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<boolean>,
) => {
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) {
@@ -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}
<img src={$thumbnail} alt={info.name} class="size-full object-cover" />
{#if thumbnail?.url}
<img src={thumbnail?.url} alt={info.name} class="size-full object-cover" />
{:else}
<div class="size-full bg-gray-100"></div>
{/if}
+6 -6
View File
@@ -48,7 +48,7 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
}
},
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<CategoryId, MaybeCategoryInfo>({
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<CategoryId, MaybeCategoryInfo>({
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);
};
+7 -7
View File
@@ -31,7 +31,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
}
},
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<DirectoryId, MaybeDirectoryInfo>({
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<DirectoryId, MaybeDirectoryInfo>({
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: [],
+11 -11
View File
@@ -32,7 +32,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
}
},
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<number, MaybeFileInfo>({
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<number, MaybeFileInfo>({
);
},
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<number, MaybeFileInfo>({
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));
};
+34 -33
View File
@@ -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<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise<V>;
fetchFromServer: (key: K, cachedValue: V | undefined) => Promise<V>;
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
bulkFetchFromServer?: (
keys: Map<K, { cachedValue: V | undefined }>,
masterKey: CryptoKey,
) => Promise<Map<K, V>>;
bulkFetchFromServer?: (keys: Map<K, { cachedValue: V | undefined }>) => Promise<Map<K, V>>;
}
export class FilesystemCache<K, V extends object> {
@@ -16,11 +14,7 @@ export class FilesystemCache<K, V extends object> {
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
get(
key: K,
masterKey: CryptoKey,
options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> },
) {
get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> }) {
return untrack(() => {
let state = this.map.get(key);
if (state?.promise) return state.value ?? state.promise;
@@ -45,8 +39,7 @@ export class FilesystemCache<K, V extends object> {
)
.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<K, V extends object> {
});
}
bulkGet(keys: Set<K>, masterKey: CryptoKey) {
bulkGet(keys: Set<K>) {
return untrack(() => {
const newPromises = new Map(
keys
@@ -105,7 +98,6 @@ export class FilesystemCache<K, V extends object> {
new Map(
newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]),
),
masterKey,
),
)
.then(resolve)
@@ -130,11 +122,22 @@ export class FilesystemCache<K, V extends object> {
}
}
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
+1 -40
View File
@@ -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 {
+3 -6
View File
@@ -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);
-131
View File
@@ -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;
}
};
-1
View File
@@ -1 +0,0 @@
export * from "./key";
-26
View File
@@ -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<ClientKeys | null>(null);
export const masterKeyStore = writable<Map<number, MasterKey> | null>(null);
export const hmacSecretStore = writable<Map<number, HmacSecret> | null>(null);
@@ -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) {
@@ -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 });
@@ -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 });
}
});
@@ -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(" ");
};
@@ -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;
}
@@ -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));
}
});
</script>
@@ -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 @@
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
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;
@@ -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,
+1 -2
View File
@@ -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());
});
</script>
+11 -34
View File
@@ -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");
@@ -1,5 +0,0 @@
export { requestSessionUpgrade } from "#lib/services/auth.js";
export {
requestClientRegistration,
requestInitialMasterKeyAndHmacSecretRegistration,
} from "#lib/services/key.js";
@@ -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 });
}
});
+9 -15
View File
@@ -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;
});
}
+1 -1
View File
@@ -18,7 +18,7 @@
<ActionEntryButton class="h-14" {onclick}>
<DirectoryEntryLabel
type="file"
thumbnail={$thumbnail}
thumbnail={thumbnail?.url}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite}
@@ -6,7 +6,6 @@
type LocalCategoryInfo,
type MaybeCategoryInfo,
} from "#lib/modules/filesystem/index.js";
import { masterKeyStore } from "#lib/stores/index.js";
import { HybridPromise } from "#lib/utils/index.js";
interface Props {
@@ -21,9 +20,7 @@
$effect(() => {
if (isOpen) {
HybridPromise.resolve(getCategoryInfo("root", $masterKeyStore?.get(1)?.key!)).then(
(result) => (categoryInfo = result),
);
HybridPromise.resolve(getCategoryInfo("root")).then((result) => (categoryInfo = result));
}
});
</script>
@@ -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"}
<BottomDiv>
+3 -5
View File
@@ -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;
};
+1 -5
View File
@@ -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());
@@ -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);
@@ -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;
};
+10 -13
View File
@@ -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;
}
});
});
</script>
@@ -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 @@
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
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;
@@ -27,7 +27,7 @@
>
<DirectoryEntryLabel
type="file"
thumbnail={$thumbnail}
thumbnail={thumbnail?.url}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite}
+12 -23
View File
@@ -1,11 +1,9 @@
<script lang="ts">
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;
}
});
});
</script>
@@ -162,8 +151,8 @@
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
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;
@@ -37,7 +37,7 @@
>
<DirectoryEntryLabel
type="file"
thumbnail={$thumbnail}
thumbnail={thumbnail?.url}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite}
@@ -5,16 +5,10 @@ import {
storeFileThumbnailCache,
deleteFileThumbnailCache,
} from "#lib/client/cache/index.js";
import { storeHmacSecrets } from "#lib/client/storage/indexedDB/index.js";
import { keyring } from "#lib/client/keys/index.js";
import { uploadFile } from "#lib/client/transfer/index.js";
import type { DataKey } from "#lib/modules/filesystem/index.js";
import {
generateDataKey,
wrapDataKey,
unwrapHmacSecret,
encryptString,
} from "#lib/shared/crypto/index.js";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "#lib/stores/index.js";
import { generateDataKey, wrapDataKey, encryptString } from "#lib/shared/crypto/index.js";
import { trpc } from "#trpc/client.js";
export interface SelectedEntry {
@@ -36,35 +30,8 @@ export const useContext = () => {
return getContext<ReturnType<typeof createContext>>("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<boolean>,
) => {
const res = await uploadFile(file, parentId, masterKey, hmacSecret, onDuplicate);
const res = await uploadFile(file, parentId, onDuplicate);
if (!res) return false;
if (res.fileBuffer) {
+1 -5
View File
@@ -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;
});
+1 -1
View File
@@ -26,7 +26,7 @@
>
<DirectoryEntryLabel
type="file"
thumbnail={$thumbnail}
thumbnail={thumbnail?.url}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite
+3 -6
View File
@@ -13,21 +13,18 @@ export type FavoriteEntry =
| { type: "directory"; name: string; details: SubDirectoryInfo }
| { type: "file"; name: string; details: SummarizedFileInfo };
export const requestFavoriteEntries = async (
favorites: RouterOutputs["favorites"]["get"],
masterKey: CryptoKey,
) => {
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 } }),
),
),
]);
+3 -4
View File
@@ -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);
}
};
+55
View File
@@ -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");
});
});
+3
View File
@@ -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: [
{