mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
키 관리 로직을 Keyring으로 통합하여 하드코딩된 MEK 및 HSK 버전을 제거하고 Svelte Store를 Rune으로 대체
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aee87fd9b6
commit
2ce73aa331
50 files changed
+633
-641
No files matched your search
+3
-40
@@ -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
|
||||
};
|
||||
Vendored
+1
-1
@@ -1,2 +1,2 @@
|
||||
export * from "./fileCache";
|
||||
export * from "./thumbnailCache";
|
||||
export * from "./thumbnailCache.svelte";
|
||||
+29
-20
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
+45
-36
@@ -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(" ");
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./generation";
|
||||
export * from "./keyring.svelte";
|
||||
export * from "./serialization";
|
||||
export * from "./sync";
|
||||
export * from "./types";
|
||||
@@ -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"),
|
||||
]);
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export * from "./key";
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
>
|
||||
<DirectoryEntryLabel
|
||||
type="file"
|
||||
thumbnail={$thumbnail}
|
||||
thumbnail={thumbnail?.url}
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
isFavorite
|
||||
|
||||
@@ -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 } }),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user