12 Commits

52 changed files with 1635 additions and 1512 deletions

View File

@@ -2,11 +2,7 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN apk add --no-cache bash curl && \ RUN npm install -g pnpm@10
curl -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
chmod +x /usr/local/bin/wait-for-it
RUN npm install -g pnpm@9
COPY pnpm-lock.yaml . COPY pnpm-lock.yaml .
# Build Stage # Build Stage
@@ -29,4 +25,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000 EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"] CMD ["node", "./build/index.js"]

View File

@@ -3,7 +3,8 @@ services:
build: . build: .
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- database database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes: volumes:
- ./data/library:/app/data/library - ./data/library:/app/data/library
@@ -35,3 +36,8 @@ services:
environment: environment:
- POSTGRES_USER=arkvault - POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?} - POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.5.0", "version": "0.5.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -16,53 +16,55 @@
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.1", "@eslint/compat": "^1.4.1",
"@iconify-json/material-symbols": "^1.2.29", "@iconify-json/material-symbols": "^1.2.44",
"@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.22.5", "@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@trpc/client": "^11.7.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",
"@types/pg": "^8.15.4", "@types/pg": "^8.15.6",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^1.10.0", "axios": "^1.13.1",
"dexie": "^4.0.11", "dexie": "^4.2.1",
"eslint": "^9.30.1", "eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.10.1", "eslint-plugin-svelte": "^3.13.0",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.31.1", "exifreader": "^4.32.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^16.3.0", "globals": "^16.5.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.13.1", "kysely-ctl": "^0.19.0",
"lru-cache": "^11.1.0", "lru-cache": "^11.2.2",
"mime": "^4.0.7", "mime": "^4.1.0",
"p-limit": "^6.2.0", "p-limit": "^7.2.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.35.6", "svelte": "^5.43.2",
"svelte-check": "^4.2.2", "svelte-check": "^4.3.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.18",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.36.0", "typescript-eslint": "^8.46.2",
"unplugin-icons": "^22.1.0", "unplugin-icons": "^22.5.0",
"vite": "^5.4.19" "vite": "^7.1.12"
}, },
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.1.1", "@fastify/busboy": "^3.2.0",
"argon2": "^0.43.0", "@trpc/server": "^11.7.1",
"kysely": "^0.28.2", "argon2": "^0.44.0",
"kysely": "^0.28.8",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"uuid": "^11.1.0", "uuid": "^13.0.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",
"pnpm": "^9.0.0" "pnpm": "^10.0.0"
} }
} }

1806
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
}; };
$effect(() => { $effect(() => {
if ($info?.dataKey) { if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey) requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => { .then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined; thumbnail = thumbnailUrl ?? undefined;

View File

@@ -25,6 +25,7 @@ interface CategoryInfo {
parentId: CategoryId; parentId: CategoryId;
name: string; name: string;
files: { id: number; isRecursive: boolean }[]; files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
} }
const filesystem = new Dexie("filesystem") as Dexie & { const filesystem = new Dexie("filesystem") as Dexie & {
@@ -33,11 +34,21 @@ const filesystem = new Dexie("filesystem") as Dexie & {
category: EntityTable<CategoryInfo, "id">; category: EntityTable<CategoryInfo, "id">;
}; };
filesystem.version(2).stores({ filesystem
.version(3)
.stores({
directory: "id, parentId", directory: "id, parentId",
file: "id, parentId", file: "id, parentId",
category: "id, parentId", category: "id, parentId",
}); })
.upgrade(async (trx) => {
await trx
.table("category")
.toCollection()
.modify((category) => {
category.isFileRecursive = false;
});
});
export const getDirectoryInfos = async (parentId: DirectoryId) => { export const getDirectoryInfos = async (parentId: DirectoryId) => {
return await filesystem.directory.where({ parentId }).toArray(); return await filesystem.directory.where({ parentId }).toArray();
@@ -87,6 +98,10 @@ export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo); await filesystem.category.put(categoryInfo);
}; };
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
await filesystem.category.update(id, changes);
};
export const deleteCategoryInfo = async (id: number) => { export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id); await filesystem.category.delete(id);
}; };

View File

@@ -12,6 +12,7 @@ import {
getCategoryInfos as getCategoryInfosFromIndexedDB, getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB, getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo, storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo, deleteCategoryInfo,
type DirectoryId, type DirectoryId,
type CategoryId, type CategoryId,
@@ -62,6 +63,7 @@ export type CategoryInfo =
name?: undefined; name?: undefined;
subCategoryIds: number[]; subCategoryIds: number[];
files?: undefined; files?: undefined;
isFileRecursive?: undefined;
} }
| { | {
id: number; id: number;
@@ -70,6 +72,7 @@ export type CategoryInfo =
name: string; name: string;
subCategoryIds: number[]; subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[]; files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
}; };
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>(); const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
@@ -255,7 +258,13 @@ const fetchCategoryInfoFromIndexedDB = async (
info.set({ id, subCategoryIds }); info.set({ id, subCategoryIds });
} else { } else {
if (!category) return; if (!category) return;
info.set({ id, name: category.name, subCategoryIds, files: category.files }); info.set({
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
} }
}; };
@@ -288,20 +297,28 @@ const fetchCategoryInfoFromServer = async (
const { files }: CategoryFileListResponse = await res.json(); const { files }: CategoryFileListResponse = await res.json();
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive })); const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.set({ info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id, id,
dataKey, dataKey,
dataKeyVersion: new Date(metadata!.dekVersion), dataKeyVersion: new Date(metadata!.dekVersion),
name, name,
subCategoryIds: subCategories, subCategoryIds: subCategories,
files: filesMapped, files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
}); });
await storeCategoryInfo({ await storeCategoryInfo({
id, id,
parentId: metadata!.parent, parentId: metadata!.parent,
name, name,
files: filesMapped, files: filesMapped,
isFileRecursive: isFileRecursive!,
}); });
} }
}; };
@@ -327,3 +344,17 @@ export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) =>
fetchCategoryInfo(categoryId, info, masterKey); // Intended fetchCategoryInfo(categoryId, info, masterKey); // Intended
return info; return info;
}; };
export const updateCategoryInfo = async (
categoryId: number,
changes: { isFileRecursive?: boolean },
) => {
await updateCategoryInfoInIndexedDB(categoryId, changes);
categoryInfoStore.get(categoryId)?.update((value) => {
if (!value) return value;
if (changes.isFileRecursive !== undefined) {
value.isFileRecursive = changes.isFileRecursive;
}
return value;
});
};

View File

@@ -32,7 +32,7 @@ const capture = (
drawer(context, scaledWidth, scaledHeight); drawer(context, scaledWidth, scaledHeight);
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) { if (blob && blob.type === "image/webp") {
resolve(blob); resolve(blob);
} else { } else {
reject(new Error("Failed to generate thumbnail")); reject(new Error("Failed to generate thumbnail"));
@@ -67,10 +67,15 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => { return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video"); const video = document.createElement("video");
video.onloadedmetadata = () => { video.onloadedmetadata = () => {
video.currentTime = Math.min(time, video.duration); if (video.videoWidth === 0 || video.videoHeight === 0) {
video.requestVideoFrameCallback(() => { return reject();
}
const callbackId = video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject); captureVideoThumbnail(video).then(resolve).catch(reject);
video.cancelVideoFrameCallback(callbackId);
}); });
video.currentTime = Math.min(time, video.duration);
}; };
video.onerror = reject; video.onerror = reject;
@@ -83,18 +88,26 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => { export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
let url; let url;
try { try {
if (fileType.startsWith("image/")) {
const fileBlob = new Blob([fileBuffer], { type: fileType });
url = URL.createObjectURL(fileBlob);
try {
return await generateImageThumbnail(url);
} catch {
URL.revokeObjectURL(url);
url = undefined;
if (fileType === "image/heic") { if (fileType === "image/heic") {
const { default: heic2any } = await import("heic2any"); const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL( url = URL.createObjectURL(
(await heic2any({ (await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
blob: new Blob([fileBuffer], { type: fileType }),
toType: "image/png",
})) as Blob,
); );
return await generateImageThumbnail(url); return await generateImageThumbnail(url);
} else if (fileType.startsWith("image/")) { } else {
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); return null;
return await generateImageThumbnail(url); }
}
} else if (fileType.startsWith("video/")) { } else if (fileType.startsWith("video/")) {
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
return await generateVideoThumbnail(url); return await generateVideoThumbnail(url);

View File

@@ -98,22 +98,6 @@ export const createUserClient = async (userId: number, clientId: number) => {
} }
}; };
export const getAllUserClients = async (userId: number) => {
const userClients = await db
.selectFrom("user_client")
.selectAll()
.where("user_id", "=", userId)
.execute();
return userClients.map(
({ user_id, client_id, state }) =>
({
userId: user_id,
clientId: client_id,
state,
}) satisfies UserClient,
);
};
export const getUserClient = async (userId: number, clientId: number) => { export const getUserClient = async (userId: number, clientId: number) => {
const userClient = await db const userClient = await db
.selectFrom("user_client") .selectFrom("user_client")

View File

@@ -0,0 +1,10 @@
export * as CategoryRepo from "./category";
export * as ClientRepo from "./client";
export * as FileRepo from "./file";
export * as HskRepo from "./hsk";
export * as MediaRepo from "./media";
export * as MekRepo from "./mek";
export * as SessionRepo from "./session";
export * as UserRepo from "./user";
export * from "./error";

View File

@@ -60,19 +60,6 @@ export const registerInitialMek = async (
}); });
}; };
export const getInitialMek = async (userId: number) => {
const mek = await db
.selectFrom("master_encryption_key")
.selectAll()
.where("user_id", "=", userId)
.where("version", "=", 1)
.limit(1)
.executeTakeFirst();
return mek
? ({ userId: mek.user_id, version: mek.version, state: mek.state } satisfies Mek)
: null;
};
export const getAllValidClientMeks = async (userId: number, clientId: number) => { export const getAllValidClientMeks = async (userId: number, clientId: number) => {
const clientMeks = await db const clientMeks = await db
.selectFrom("client_master_encryption_key") .selectFrom("client_master_encryption_key")

View File

@@ -4,7 +4,7 @@ import { authenticate, AuthenticationError } from "$lib/server/modules/auth";
export const authenticateMiddleware: Handle = async ({ event, resolve }) => { export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { pathname, search } = event.url; const { pathname, search } = event.url;
if (pathname === "/api/auth/login") { if (pathname === "/api/auth/login" || pathname.startsWith("/api/trpc")) {
return await resolve(event); return await resolve(event);
} }

View File

@@ -11,10 +11,17 @@ interface Session {
clientId?: number; clientId?: number;
} }
interface ClientSession extends Session { export interface ClientSession extends Session {
clientId: number; clientId: number;
} }
export type SessionPermission =
| "any"
| "notClient"
| "anyClient"
| "pendingClient"
| "activeClient";
export class AuthenticationError extends Error { export class AuthenticationError extends Error {
constructor( constructor(
public status: 400 | 401, public status: 400 | 401,
@@ -25,6 +32,16 @@ export class AuthenticationError extends Error {
} }
} }
export class AuthorizationError extends Error {
constructor(
public status: 403 | 500,
message: string,
) {
super(message);
this.name = "AuthorizationError";
}
}
export const startSession = async (userId: number, ip: string, userAgent: string) => { export const startSession = async (userId: number, ip: string, userAgent: string) => {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await createSession(userId, sessionId, ip, userAgent); await createSession(userId, sessionId, ip, userAgent);
@@ -52,34 +69,12 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
} }
}; };
export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>; export const authorizeInternal = async (
export async function authorize(
locals: App.Locals, locals: App.Locals,
requiredPermission: "notClient", requiredPermission: SessionPermission,
): Promise<Session>; ): Promise<Session> => {
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "pendingClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
): Promise<Session> {
if (!locals.session) { if (!locals.session) {
error(500, "Unauthenticated"); throw new AuthorizationError(500, "Unauthenticated");
} }
const { id: sessionId, userId, clientId } = locals.session; const { id: sessionId, userId, clientId } = locals.session;
@@ -89,39 +84,63 @@ export async function authorize(
break; break;
case "notClient": case "notClient":
if (clientId) { if (clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
case "anyClient": case "anyClient":
if (!clientId) { if (!clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
case "pendingClient": { case "pendingClient": {
if (!clientId) { if (!clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
error(500, "Invalid session id"); throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "pending") { } else if (userClient.state !== "pending") {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
} }
case "activeClient": { case "activeClient": {
if (!clientId) { if (!clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
error(500, "Invalid session id"); throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "active") { } else if (userClient.state !== "active") {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
} }
} }
return { sessionId, userId, clientId }; return { sessionId, userId, clientId };
};
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: SessionPermission,
): Promise<Session> {
try {
return await authorizeInternal(locals, requiredPermission);
} catch (e) {
if (e instanceof AuthorizationError) {
error(e.status, e.message);
}
throw e;
}
} }

View File

@@ -1,25 +0,0 @@
import { error } from "@sveltejs/kit";
import { getUserClientWithDetails } from "$lib/server/db/client";
import { getInitialMek } from "$lib/server/db/mek";
import { verifySignature } from "$lib/server/modules/crypto";
export const isInitialMekNeeded = async (userId: number) => {
const initialMek = await getInitialMek(userId);
return !initialMek;
};
export const verifyClientEncMekSig = async (
userId: number,
clientId: number,
version: number,
encMek: string,
encMekSig: string,
) => {
const userClient = await getUserClientWithDetails(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
const data = JSON.stringify({ version, key: encMek });
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
};

View File

@@ -1,36 +0,0 @@
import { z } from "zod";
export const clientListResponse = z.object({
clients: z.array(
z.object({
id: z.number().int().positive(),
state: z.enum(["pending", "active"]),
}),
),
});
export type ClientListResponse = z.output<typeof clientListResponse>;
export const clientRegisterRequest = z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
});
export type ClientRegisterRequest = z.input<typeof clientRegisterRequest>;
export const clientRegisterResponse = z.object({
id: z.number().int().positive(),
challenge: z.string().base64().nonempty(),
});
export type ClientRegisterResponse = z.output<typeof clientRegisterResponse>;
export const clientRegisterVerifyRequest = z.object({
id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(),
});
export type ClientRegisterVerifyRequest = z.input<typeof clientRegisterVerifyRequest>;
export const clientStatusResponse = z.object({
id: z.number().int().positive(),
state: z.enum(["pending", "active"]),
isInitialMekNeeded: z.boolean(),
});
export type ClientStatusResponse = z.output<typeof clientStatusResponse>;

View File

@@ -1,19 +0,0 @@
import { z } from "zod";
export const hmacSecretListResponse = z.object({
hsks: z.array(
z.object({
version: z.number().int().positive(),
state: z.enum(["active"]),
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
}),
),
});
export type HmacSecretListResponse = z.output<typeof hmacSecretListResponse>;
export const initialHmacSecretRegisterRequest = z.object({
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
});
export type InitialHmacSecretRegisterRequest = z.input<typeof initialHmacSecretRegisterRequest>;

View File

@@ -1,8 +1,4 @@
export * from "./auth"; export * from "./auth";
export * from "./category"; export * from "./category";
export * from "./client";
export * from "./directory"; export * from "./directory";
export * from "./file"; export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./user";

View File

@@ -1,19 +0,0 @@
import { z } from "zod";
export const masterKeyListResponse = z.object({
meks: z.array(
z.object({
version: z.number().int().positive(),
state: z.enum(["active", "retired"]),
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
}),
),
});
export type MasterKeyListResponse = z.output<typeof masterKeyListResponse>;
export const initialMasterKeyRegisterRequest = z.object({
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
});
export type InitialMasterKeyRegisterRequest = z.input<typeof initialMasterKeyRegisterRequest>;

View File

@@ -1,12 +0,0 @@
import { z } from "zod";
export const userInfoResponse = z.object({
email: z.string().email(),
nickname: z.string().nonempty(),
});
export type UserInfoResponse = z.output<typeof userInfoResponse>;
export const nicknameChangeRequest = z.object({
newNickname: z.string().trim().min(2).max(8),
});
export type NicknameChangeRequest = z.input<typeof nicknameChangeRequest>;

View File

@@ -1,116 +0,0 @@
import { error } from "@sveltejs/kit";
import {
createClient,
getClient,
getClientByPubKeys,
createUserClient,
getAllUserClients,
getUserClient,
setUserClientStateToPending,
registerUserClientChallenge,
consumeUserClientChallenge,
} from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import { isInitialMekNeeded } from "$lib/server/modules/mek";
import env from "$lib/server/loadenv";
export const getUserClientList = async (userId: number) => {
const userClients = await getAllUserClients(userId);
return {
userClients: userClients.map(({ clientId, state }) => ({
id: clientId,
state: state as "pending" | "active",
})),
};
};
const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp);
const createUserClientChallenge = async (
ip: string,
userId: number,
clientId: number,
encPubKey: string,
) => {
const { answer, challenge } = await generateChallenge(32, encPubKey);
const { id } = await registerUserClientChallenge(
userId,
clientId,
answer.toString("base64"),
ip,
expiresAt(),
);
return { id, challenge: challenge.toString("base64") };
};
export const registerUserClient = async (
userId: number,
ip: string,
encPubKey: string,
sigPubKey: string,
) => {
const client = await getClientByPubKeys(encPubKey, sigPubKey);
if (client) {
try {
await createUserClient(userId, client.id);
return await createUserClientChallenge(ip, userId, client.id, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "User client already exists") {
error(409, "Client already registered");
}
throw e;
}
} else {
if (encPubKey === sigPubKey) {
error(400, "Same public keys");
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
error(400, "Invalid public key(s)");
}
try {
const { id: clientId } = await createClient(encPubKey, sigPubKey, userId);
return await createUserClientChallenge(ip, userId, clientId, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
error(409, "Public key(s) already used");
}
throw e;
}
}
};
export const verifyUserClient = async (
userId: number,
ip: string,
challengeId: number,
answerSig: string,
) => {
const challenge = await consumeUserClientChallenge(challengeId, userId, ip);
if (!challenge) {
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey)
) {
error(403, "Invalid challenge answer signature");
}
await setUserClientStateToPending(userId, client.id);
};
export const getUserClientStatus = async (userId: number, clientId: number) => {
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
return {
state: userClient.state as "pending" | "active",
isInitialMekNeeded: await isInitialMekNeeded(userId),
};
};

View File

@@ -1,31 +0,0 @@
import { error } from "@sveltejs/kit";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialHsk, getAllValidHsks } from "$lib/server/db/hsk";
export const getHskList = async (userId: number) => {
const hsks = await getAllValidHsks(userId);
return {
encHsks: hsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
encHsk,
})),
};
};
export const registerInitialActiveHsk = async (
userId: number,
createdBy: number,
mekVersion: number,
encHsk: string,
) => {
try {
await registerInitialHsk(userId, createdBy, mekVersion, encHsk);
} catch (e) {
if (e instanceof IntegrityError && e.message === "HSK already registered") {
error(409, "Initial HSK already registered");
}
throw e;
}
};

View File

@@ -1,38 +0,0 @@
import { error } from "@sveltejs/kit";
import { setUserClientStateToActive } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek";
import { verifyClientEncMekSig } from "$lib/server/modules/mek";
export const getClientMekList = async (userId: number, clientId: number) => {
const clientMeks = await getAllValidClientMeks(userId, clientId);
return {
encMeks: clientMeks.map(({ version, state, encMek, encMekSig }) => ({
version,
state,
encMek,
encMekSig,
})),
};
};
export const registerInitialActiveMek = async (
userId: number,
createdBy: number,
encMek: string,
encMekSig: string,
) => {
if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
error(400, "Invalid signature");
}
try {
await registerInitialMek(userId, createdBy, encMek, encMekSig);
await setUserClientStateToActive(userId, createdBy);
} catch (e) {
if (e instanceof IntegrityError && e.message === "MEK already registered") {
error(409, "Initial MEK already registered");
}
throw e;
}
};

View File

@@ -1,15 +0,0 @@
import { error } from "@sveltejs/kit";
import { getUser, setUserNickname } from "$lib/server/db/user";
export const getUserInformation = async (userId: number) => {
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
}
return { email: user.email, nickname: user.nickname };
};
export const changeNickname = async (userId: number, nickname: string) => {
await setUserNickname(userId, nickname);
};

View File

@@ -48,9 +48,9 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
}; };
export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => { export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId); const cache = await getFileThumbnailCache(fileId);
if (cache) return cache; if (cache || !dataKey) return cache;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`); let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null; if (!res.ok) return null;

View File

@@ -1,4 +1,4 @@
import { callGetApi, callPostApi } from "$lib/hooks"; import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
@@ -9,16 +9,9 @@ import {
signMasterKeyWrapped, signMasterKeyWrapped,
verifyMasterKeyWrapped, verifyMasterKeyWrapped,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import type {
ClientRegisterRequest,
ClientRegisterResponse,
ClientRegisterVerifyRequest,
InitialHmacSecretRegisterRequest,
MasterKeyListResponse,
InitialMasterKeyRegisterRequest,
} from "$lib/server/schemas";
import { requestSessionUpgrade } from "$lib/services/auth"; import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores"; import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { useTRPC } from "$trpc/client";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -26,21 +19,24 @@ export const requestClientRegistration = async (
verifyKeyBase64: string, verifyKeyBase64: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
let res = await callPostApi<ClientRegisterRequest>("/api/client/register", { const trpc = useTRPC();
try {
const { id, challenge } = await trpc.client.register.mutate({
encPubKey: encryptKeyBase64, encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64, sigPubKey: verifyKeyBase64,
}); });
if (!res.ok) return false;
const { id, challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey); const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
await trpc.client.verify.mutate({
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
id, id,
answerSig: encodeToBase64(answerSig), answerSig: encodeToBase64(answerSig),
}); });
return res.ok; return true;
} catch {
// TODO: Error Handling
return false;
}
}; };
export const requestClientRegistrationAndSessionUpgrade = async ( export const requestClientRegistrationAndSessionUpgrade = async (
@@ -73,10 +69,16 @@ export const requestClientRegistrationAndSessionUpgrade = async (
}; };
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => { export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
const res = await callGetApi("/api/mek/list"); const trpc = useTRPC();
if (!res.ok) return false;
let masterKeysWrapped;
try {
masterKeysWrapped = await trpc.mek.list.query();
} catch {
// TODO: Error Handling
return false;
}
const { meks: masterKeysWrapped }: MasterKeyListResponse = await res.json();
const masterKeys = await Promise.all( const masterKeys = await Promise.all(
masterKeysWrapped.map( masterKeysWrapped.map(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => { async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
@@ -108,17 +110,32 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
hmacSecretWrapped: string, hmacSecretWrapped: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", { const trpc = useTRPC();
try {
await trpc.mek.registerInitial.mutate({
mek: masterKeyWrapped, mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
}); });
if (!res.ok) { } catch (e) {
return res.status === 403 || res.status === 409; if (
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true;
}
// TODO: Error Handling
return false;
} }
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", { try {
await trpc.hsk.registerInitial.mutate({
mekVersion: 1, mekVersion: 1,
hsk: hmacSecretWrapped, hsk: hmacSecretWrapped,
}); });
return res.ok; return true;
} catch {
// TODO: Error Handling
return false;
}
}; };

View File

@@ -6,8 +6,14 @@
let oldPassword = $state(""); let oldPassword = $state("");
let newPassword = $state(""); let newPassword = $state("");
let confirmPassword = $state("");
const changePassword = async () => { const changePassword = async () => {
if (newPassword !== confirmPassword) {
// TODO: Alert
return;
}
if (await requestPasswordChange(oldPassword, newPassword)) { if (await requestPasswordChange(oldPassword, newPassword)) {
await goto("/menu"); await goto("/menu");
} }
@@ -30,6 +36,7 @@
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" /> <TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" /> <TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
<TextInput bind:value={confirmPassword} placeholder="새 비밀번호 확인" type="password" />
</TitledDiv> </TitledDiv>
<BottomDiv> <BottomDiv>
<Button onclick={changePassword} class="w-full">비밀번호 바꾸기</Button> <Button onclick={changePassword} class="w-full">비밀번호 바꾸기</Button>

View File

@@ -43,20 +43,29 @@
let isDownloadRequested = $state(false); let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state(); let viewerType: "image" | "video" | undefined = $state();
let fileBlobUrl: string | undefined = $state(); let fileBlobUrl: string | undefined = $state();
let heicBlob: Blob | undefined = $state();
let videoElement: HTMLVideoElement | undefined = $state(); let videoElement: HTMLVideoElement | undefined = $state();
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => { const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
const fileBlob = new Blob([buffer], { type: contentType }); const fileBlob = new Blob([buffer], { type: contentType });
if (contentType === "image/heic") { if (viewerType) {
fileBlobUrl = URL.createObjectURL(fileBlob);
heicBlob = contentType === "image/heic" ? fileBlob : undefined;
}
return fileBlob;
};
const convertHeicToJpeg = async () => {
if (!heicBlob) return;
URL.revokeObjectURL(fileBlobUrl!);
fileBlobUrl = undefined;
const { default: heic2any } = await import("heic2any"); const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL( fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob, (await heic2any({ blob: heicBlob, toType: "image/jpeg" })) as Blob,
); );
} else if (viewerType) { heicBlob = undefined;
fileBlobUrl = URL.createObjectURL(fileBlob);
}
return fileBlob;
}; };
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => { const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
@@ -136,7 +145,7 @@
{#if viewerType === "image"} {#if viewerType === "image"}
{#if fileBlobUrl} {#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} /> <img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} />
{:else} {:else}
{@render viewerLoading("이미지를 불러오고 있어요.")} {@render viewerLoading("이미지를 불러오고 있어요.")}
{/if} {/if}

View File

@@ -3,7 +3,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms"; import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -21,7 +21,7 @@
let info: Writable<CategoryInfo | null> | undefined = $state(); let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive = $state(false); let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false);
@@ -30,6 +30,19 @@
$effect(() => { $effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
}); });
</script> </script>
@@ -41,7 +54,7 @@
<TopBar title={$info?.name} /> <TopBar title={$info?.name} />
{/if} {/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]"> <div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info} {#if $info && isFileRecursive !== undefined}
<Category <Category
bind:isFileRecursive bind:isFileRecursive
info={$info} info={$info}

View File

@@ -34,7 +34,7 @@
}; };
$effect(() => { $effect(() => {
if ($info?.dataKey) { if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey) requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => { .then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined; thumbnail = thumbnailUrl ?? undefined;

View File

@@ -1,5 +1,5 @@
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { callGetApi, callPostApi } from "$lib/hooks"; import { callPostApi } from "$lib/hooks";
import { storeHmacSecrets } from "$lib/indexedDB"; import { storeHmacSecrets } from "$lib/indexedDB";
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto"; import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
import { import {
@@ -13,10 +13,10 @@ import type {
DirectoryRenameRequest, DirectoryRenameRequest,
DirectoryCreateRequest, DirectoryCreateRequest,
FileRenameRequest, FileRenameRequest,
HmacSecretListResponse,
DirectoryDeleteResponse, DirectoryDeleteResponse,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
import { useTRPC } from "$trpc/client";
export interface SelectedEntry { export interface SelectedEntry {
type: "directory" | "file"; type: "directory" | "file";
@@ -40,10 +40,16 @@ export const useContext = () => {
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => { export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
// TODO: MEK rotation // TODO: MEK rotation
const res = await callGetApi("/api/hsk/list"); const trpc = useTRPC();
if (!res.ok) return false;
let hmacSecretsWrapped;
try {
hmacSecretsWrapped = await trpc.hsk.list.query();
} catch {
// TODO: Error Handling
return false;
}
const { hsks: hmacSecretsWrapped }: HmacSecretListResponse = await res.json();
const hmacSecrets = await Promise.all( const hmacSecrets = await Promise.all(
hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => { hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => {
const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey); const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey);

View File

@@ -1,14 +1,14 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { callGetApi } from "$lib/hooks"; import { useTRPC } from "$trpc/client";
import type { UserInfoResponse } from "$lib/server/schemas";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => { export const load: PageLoad = async ({ fetch }) => {
const res = await callGetApi("/api/user", fetch); const trpc = useTRPC(fetch);
if (!res.ok) {
try {
const { nickname } = await trpc.user.info.query();
return { nickname };
} catch {
error(500, "Internal server error"); error(500, "Internal server error");
} }
const { nickname }: UserInfoResponse = await res.json();
return { nickname };
}; };

View File

@@ -1,11 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientListResponse, type ClientListResponse } from "$lib/server/schemas";
import { getUserClientList } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "anyClient");
const { userClients } = await getUserClientList(userId);
return json(clientListResponse.parse({ clients: userClients } satisfies ClientListResponse));
};

View File

@@ -1,20 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
clientRegisterRequest,
clientRegisterResponse,
type ClientRegisterResponse,
} from "$lib/server/schemas";
import { registerUserClient } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "notClient");
const zodRes = clientRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data;
const { id, challenge } = await registerUserClient(userId, locals.ip, encPubKey, sigPubKey);
return json(clientRegisterResponse.parse({ id, challenge } satisfies ClientRegisterResponse));
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientRegisterVerifyRequest } from "$lib/server/schemas";
import { verifyUserClient } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "notClient");
const zodRes = clientRegisterVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { id, answerSig } = zodRes.data;
await verifyUserClient(userId, locals.ip, id, answerSig);
return text("Client verified", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,17 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas";
import { getUserClientStatus } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = await authorize(locals, "anyClient");
const { state, isInitialMekNeeded } = await getUserClientStatus(userId, clientId);
return json(
clientStatusResponse.parse({
id: clientId,
state,
isInitialMekNeeded,
} satisfies ClientStatusResponse),
);
};

View File

@@ -1,20 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { hmacSecretListResponse, type HmacSecretListResponse } from "$lib/server/schemas";
import { getHskList } from "$lib/server/services/hsk";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "activeClient");
const { encHsks } = await getHskList(userId);
return json(
hmacSecretListResponse.parse({
hsks: encHsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
hsk: encHsk,
})),
} satisfies HmacSecretListResponse),
);
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { initialHmacSecretRegisterRequest } from "$lib/server/schemas";
import { registerInitialActiveHsk } from "$lib/server/services/hsk";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId, clientId } = await authorize(locals, "activeClient");
const zodRes = initialHmacSecretRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { mekVersion, hsk } = zodRes.data;
await registerInitialActiveHsk(userId, clientId, mekVersion, hsk);
return text("HSK registered", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,20 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { masterKeyListResponse, type MasterKeyListResponse } from "$lib/server/schemas";
import { getClientMekList } from "$lib/server/services/mek";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = await authorize(locals, "activeClient");
const { encMeks } = await getClientMekList(userId, clientId);
return json(
masterKeyListResponse.parse({
meks: encMeks.map(({ version, state, encMek, encMekSig }) => ({
version,
state,
mek: encMek,
mekSig: encMekSig,
})),
} satisfies MasterKeyListResponse),
);
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { initialMasterKeyRegisterRequest } from "$lib/server/schemas";
import { registerInitialActiveMek } from "$lib/server/services/mek";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId, clientId } = await authorize(locals, "pendingClient");
const zodRes = initialMasterKeyRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { mek, mekSig } = zodRes.data;
await registerInitialActiveMek(userId, clientId, mek, mekSig);
return text("MEK registered", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -0,0 +1,15 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createContext } from "$trpc/init.server";
import { appRouter } from "$trpc/router.server";
import type { RequestHandler } from "./$types";
const trpcHandler: RequestHandler = (event) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req: event.request,
router: appRouter,
createContext: () => createContext(event),
});
export const GET = trpcHandler;
export const POST = trpcHandler;

View File

@@ -1,11 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { userInfoResponse, type UserInfoResponse } from "$lib/server/schemas";
import { getUserInformation } from "$lib/server/services/user";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "any");
const { email, nickname } = await getUserInformation(userId);
return json(userInfoResponse.parse({ email, nickname } satisfies UserInfoResponse));
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { nicknameChangeRequest } from "$lib/server/schemas";
import { changeNickname } from "$lib/server/services/user";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "any");
const zodRes = nicknameChangeRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { newNickname } = zodRes.data;
await changeNickname(userId, newNickname);
return text("Nickname changed", { headers: { "Content-Type": "text/plain" } });
};

23
src/trpc/client.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { browser } from "$app/environment";
import type { AppRouter } from "./router.server";
const createClient = (fetch: typeof globalThis.fetch) =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
fetch,
}),
],
});
let browserClient: ReturnType<typeof createClient>;
export const useTRPC = (fetch = globalThis.fetch) => {
const client = browserClient ?? createClient(fetch);
if (browser) {
browserClient ??= client;
}
return client;
};

25
src/trpc/init.server.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { RequestEvent } from "@sveltejs/kit";
import { initTRPC, TRPCError } from "@trpc/server";
import { authorizeMiddleware, authorizeClientMiddleware } from "./middlewares/authorize";
export const createContext = (event: RequestEvent) => event;
export const t = initTRPC.context<Awaited<ReturnType<typeof createContext>>>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
if (!ctx.locals.session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
});
export const roleProcedure = {
any: authedProcedure.use(authorizeMiddleware("any")),
notClient: authedProcedure.use(authorizeMiddleware("notClient")),
anyClient: authedProcedure.use(authorizeClientMiddleware("anyClient")),
pendingClient: authedProcedure.use(authorizeClientMiddleware("pendingClient")),
activeClient: authedProcedure.use(authorizeClientMiddleware("activeClient")),
};

View File

@@ -0,0 +1,36 @@
import { TRPCError } from "@trpc/server";
import {
AuthorizationError,
authorizeInternal,
type ClientSession,
type SessionPermission,
} from "$lib/server/modules/auth";
import { t } from "../init.server";
const authorize = async (locals: App.Locals, requiredPermission: SessionPermission) => {
try {
return await authorizeInternal(locals, requiredPermission);
} catch (e) {
if (e instanceof AuthorizationError) {
throw new TRPCError({
code: e.status === 403 ? "FORBIDDEN" : "INTERNAL_SERVER_ERROR",
message: e.message,
});
}
throw e;
}
};
export const authorizeMiddleware = (requiredPermission: "any" | "notClient") =>
t.middleware(async ({ ctx, next }) => {
const session = await authorize(ctx.locals, requiredPermission);
return next({ ctx: { session } });
});
export const authorizeClientMiddleware = (
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
) =>
t.middleware(async ({ ctx, next }) => {
const session = (await authorize(ctx.locals, requiredPermission)) as ClientSession;
return next({ ctx: { session } });
});

17
src/trpc/router.server.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { RequestEvent } from "@sveltejs/kit";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { createContext, router } from "./init.server";
import { clientRouter, hskRouter, mekRouter, userRouter } from "./routers";
export const appRouter = router({
client: clientRouter,
hsk: hskRouter,
mek: mekRouter,
user: userRouter,
});
export const createCaller = (event: RequestEvent) => appRouter.createCaller(createContext(event));
export type AppRouter = typeof appRouter;
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;

View File

@@ -0,0 +1,96 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { ClientRepo, IntegrityError } from "$lib/server/db";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import env from "$lib/server/loadenv";
import { router, roleProcedure } from "../init.server";
const createUserClientChallenge = async (
ip: string,
userId: number,
clientId: number,
encPubKey: string,
) => {
const { answer, challenge } = await generateChallenge(32, encPubKey);
const { id } = await ClientRepo.registerUserClientChallenge(
userId,
clientId,
answer.toString("base64"),
ip,
new Date(Date.now() + env.challenge.userClientExp),
);
return { id, challenge: challenge.toString("base64") };
};
const clientRouter = router({
register: roleProcedure["notClient"]
.input(
z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const { userId } = ctx.session;
const { encPubKey, sigPubKey } = input;
const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey);
if (client) {
try {
await ClientRepo.createUserClient(userId, client.id);
return await createUserClientChallenge(ctx.locals.ip, userId, client.id, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "User client already exists") {
throw new TRPCError({ code: "CONFLICT", message: "Client already registered" });
}
throw e;
}
} else {
if (encPubKey === sigPubKey) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Same public keys" });
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid public key(s)" });
}
try {
const { id: clientId } = await ClientRepo.createClient(encPubKey, sigPubKey, userId);
return await createUserClientChallenge(ctx.locals.ip, userId, clientId, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
throw new TRPCError({ code: "CONFLICT", message: "Public key(s) already used" });
}
throw e;
}
}
}),
verify: roleProcedure["notClient"]
.input(
z.object({
id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const challenge = await ClientRepo.consumeUserClientChallenge(
input.id,
ctx.session.userId,
ctx.locals.ip,
);
if (!challenge) {
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer" });
}
const client = await ClientRepo.getClient(challenge.clientId);
if (!client) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid challenge answer" });
} else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), input.answerSig, client.sigPubKey)
) {
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer signature" });
}
await ClientRepo.setUserClientStateToPending(ctx.session.userId, client.id);
}),
});
export default clientRouter;

41
src/trpc/routers/hsk.ts Normal file
View File

@@ -0,0 +1,41 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { HskRepo, IntegrityError } from "$lib/server/db";
import { router, roleProcedure } from "../init.server";
const hskRouter = router({
list: roleProcedure["activeClient"].query(async ({ ctx }) => {
const hsks = await HskRepo.getAllValidHsks(ctx.session.userId);
return hsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
hsk: encHsk,
}));
}),
registerInitial: roleProcedure["activeClient"]
.input(
z.object({
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await HskRepo.registerInitialHsk(
ctx.session.userId,
ctx.session.clientId,
input.mekVersion,
input.hsk,
);
} catch (e) {
if (e instanceof IntegrityError && e.message === "HSK already registered") {
throw new TRPCError({ code: "CONFLICT", message: "Initial HSK already registered" });
}
throw e;
}
}),
});
export default hskRouter;

View File

@@ -0,0 +1,4 @@
export { default as clientRouter } from "./client";
export { default as hskRouter } from "./hsk";
export { default as mekRouter } from "./mek";
export { default as userRouter } from "./user";

63
src/trpc/routers/mek.ts Normal file
View File

@@ -0,0 +1,63 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { ClientRepo, MekRepo, IntegrityError } from "$lib/server/db";
import { verifySignature } from "$lib/server/modules/crypto";
import { router, roleProcedure } from "../init.server";
const verifyClientEncMekSig = async (
userId: number,
clientId: number,
version: number,
encMek: string,
encMekSig: string,
) => {
const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId);
if (!userClient) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
}
const data = JSON.stringify({ version, key: encMek });
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
};
const mekRouter = router({
list: roleProcedure["activeClient"].query(async ({ ctx }) => {
const clientMeks = await MekRepo.getAllValidClientMeks(
ctx.session.userId,
ctx.session.clientId,
);
return clientMeks.map(({ version, state, encMek, encMekSig }) => ({
version,
state,
mek: encMek,
mekSig: encMekSig,
}));
}),
registerInitial: roleProcedure["pendingClient"]
.input(
z.object({
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const { userId, clientId } = ctx.session;
const { mek, mekSig } = input;
if (!(await verifyClientEncMekSig(userId, clientId, 1, mek, mekSig))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid signature" });
}
try {
await MekRepo.registerInitialMek(userId, clientId, mek, mekSig);
await ClientRepo.setUserClientStateToActive(userId, clientId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "MEK already registered") {
throw new TRPCError({ code: "CONFLICT", message: "Initial MEK already registered" });
}
throw e;
}
}),
});
export default mekRouter;

27
src/trpc/routers/user.ts Normal file
View File

@@ -0,0 +1,27 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { UserRepo } from "$lib/server/db";
import { router, roleProcedure } from "../init.server";
const userRouter = router({
info: roleProcedure.any.query(async ({ ctx }) => {
const user = await UserRepo.getUser(ctx.session.userId);
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
}
return { email: user.email, nickname: user.nickname };
}),
changeNickname: roleProcedure.any
.input(
z.object({
newNickname: z.string().trim().min(2).max(8),
}),
)
.mutation(async ({ ctx, input }) => {
await UserRepo.setUserNickname(ctx.session.userId, input.newNickname);
}),
});
export default userRouter;

View File

@@ -3,15 +3,12 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(), adapter: adapter(),
alias: {
$trpc: "./src/trpc",
},
}, },
}; };