5 Commits

Author SHA1 Message Date
static
0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +09:00
static
361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +09:00
static
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
a198e5f6dc Merge pull request #2 from kmc7468/dev
v0.1.0
2025-01-09 06:24:31 +09:00
52 changed files with 1513 additions and 1636 deletions

View File

@@ -2,7 +2,11 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@10 RUN apk add --no-cache bash curl && \
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
@@ -25,4 +29,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000 EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "./build/index.js"] CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"]

View File

@@ -3,8 +3,7 @@ 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
@@ -36,8 +35,3 @@ 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.1", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -16,55 +16,53 @@
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.1", "@eslint/compat": "^1.3.1",
"@iconify-json/material-symbols": "^1.2.44", "@iconify-json/material-symbols": "^1.2.29",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.48.4", "@sveltejs/kit": "^2.22.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@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.6", "@types/pg": "^8.15.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^1.13.1", "axios": "^1.10.0",
"dexie": "^4.2.1", "dexie": "^4.0.11",
"eslint": "^9.39.0", "eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.13.0", "eslint-plugin-svelte": "^3.10.1",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.0",
"exifreader": "^4.32.0", "exifreader": "^4.31.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^16.5.0", "globals": "^16.3.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.19.0", "kysely-ctl": "^0.13.1",
"lru-cache": "^11.2.2", "lru-cache": "^11.1.0",
"mime": "^4.1.0", "mime": "^4.0.7",
"p-limit": "^7.2.0", "p-limit": "^6.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.7.1", "prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.43.2", "svelte": "^5.35.6",
"svelte-check": "^4.3.3", "svelte-check": "^4.2.2",
"tailwindcss": "^3.4.18", "tailwindcss": "^3.4.17",
"typescript": "^5.9.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.36.0",
"unplugin-icons": "^22.5.0", "unplugin-icons": "^22.1.0",
"vite": "^7.1.12" "vite": "^5.4.19"
}, },
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.2.0", "@fastify/busboy": "^3.1.1",
"@trpc/server": "^11.7.1", "argon2": "^0.43.0",
"argon2": "^0.44.0", "kysely": "^0.28.2",
"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": "^13.0.0", "uuid": "^11.1.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",
"pnpm": "^10.0.0" "pnpm": "^9.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) { if ($info?.dataKey) {
requestFileThumbnailDownload($info.id, $info.dataKey) requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => { .then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined; thumbnail = thumbnailUrl ?? undefined;

View File

@@ -25,7 +25,6 @@ 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 & {
@@ -34,21 +33,11 @@ const filesystem = new Dexie("filesystem") as Dexie & {
category: EntityTable<CategoryInfo, "id">; category: EntityTable<CategoryInfo, "id">;
}; };
filesystem filesystem.version(2).stores({
.version(3) directory: "id, parentId",
.stores({ file: "id, parentId",
directory: "id, parentId", category: "id, parentId",
file: "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();
@@ -98,10 +87,6 @@ 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,7 +12,6 @@ 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,
@@ -63,7 +62,6 @@ export type CategoryInfo =
name?: undefined; name?: undefined;
subCategoryIds: number[]; subCategoryIds: number[];
files?: undefined; files?: undefined;
isFileRecursive?: undefined;
} }
| { | {
id: number; id: number;
@@ -72,7 +70,6 @@ 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>>();
@@ -258,13 +255,7 @@ const fetchCategoryInfoFromIndexedDB = async (
info.set({ id, subCategoryIds }); info.set({ id, subCategoryIds });
} else { } else {
if (!category) return; if (!category) return;
info.set({ info.set({ id, name: category.name, subCategoryIds, files: category.files });
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
} }
}; };
@@ -297,28 +288,20 @@ 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.update((value) => { info.set({
const newValue = { id,
isFileRecursive: false, dataKey,
...value, dataKeyVersion: new Date(metadata!.dekVersion),
id, name,
dataKey, subCategoryIds: subCategories,
dataKeyVersion: new Date(metadata!.dekVersion), files: filesMapped,
name,
subCategoryIds: subCategories,
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!,
}); });
} }
}; };
@@ -344,17 +327,3 @@ 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 && blob.type === "image/webp") { if (blob) {
resolve(blob); resolve(blob);
} else { } else {
reject(new Error("Failed to generate thumbnail")); reject(new Error("Failed to generate thumbnail"));
@@ -67,15 +67,10 @@ 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 = () => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return reject();
}
const callbackId = video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
video.cancelVideoFrameCallback(callbackId);
});
video.currentTime = Math.min(time, video.duration); video.currentTime = Math.min(time, video.duration);
video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
});
}; };
video.onerror = reject; video.onerror = reject;
@@ -88,26 +83,18 @@ 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/")) { if (fileType === "image/heic") {
const fileBlob = new Blob([fileBuffer], { type: fileType }); const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL(fileBlob); url = URL.createObjectURL(
(await heic2any({
try { blob: new Blob([fileBuffer], { type: fileType }),
return await generateImageThumbnail(url); toType: "image/png",
} catch { })) as Blob,
URL.revokeObjectURL(url); );
url = undefined; return await generateImageThumbnail(url);
} else if (fileType.startsWith("image/")) {
if (fileType === "image/heic") { url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
const { default: heic2any } = await import("heic2any"); return await generateImageThumbnail(url);
url = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
);
return await generateImageThumbnail(url);
} else {
return null;
}
}
} 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,6 +98,22 @@ 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

@@ -1,10 +0,0 @@
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,6 +60,19 @@ 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" || pathname.startsWith("/api/trpc")) { if (pathname === "/api/auth/login") {
return await resolve(event); return await resolve(event);
} }

View File

@@ -11,17 +11,10 @@ interface Session {
clientId?: number; clientId?: number;
} }
export interface ClientSession extends Session { 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,
@@ -32,16 +25,6 @@ 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);
@@ -69,12 +52,34 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
} }
}; };
export const authorizeInternal = async ( export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>;
export async function authorize(
locals: App.Locals, locals: App.Locals,
requiredPermission: SessionPermission, requiredPermission: "notClient",
): 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) {
throw new AuthorizationError(500, "Unauthenticated"); error(500, "Unauthenticated");
} }
const { id: sessionId, userId, clientId } = locals.session; const { id: sessionId, userId, clientId } = locals.session;
@@ -84,63 +89,39 @@ export const authorizeInternal = async (
break; break;
case "notClient": case "notClient":
if (clientId) { if (clientId) {
throw new AuthorizationError(403, "Forbidden"); error(403, "Forbidden");
} }
break; break;
case "anyClient": case "anyClient":
if (!clientId) { if (!clientId) {
throw new AuthorizationError(403, "Forbidden"); error(403, "Forbidden");
} }
break; break;
case "pendingClient": { case "pendingClient": {
if (!clientId) { if (!clientId) {
throw new AuthorizationError(403, "Forbidden"); error(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
throw new AuthorizationError(500, "Invalid session id"); error(500, "Invalid session id");
} else if (userClient.state !== "pending") { } else if (userClient.state !== "pending") {
throw new AuthorizationError(403, "Forbidden"); error(403, "Forbidden");
} }
break; break;
} }
case "activeClient": { case "activeClient": {
if (!clientId) { if (!clientId) {
throw new AuthorizationError(403, "Forbidden"); error(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
throw new AuthorizationError(500, "Invalid session id"); error(500, "Invalid session id");
} else if (userClient.state !== "active") { } else if (userClient.state !== "active") {
throw new AuthorizationError(403, "Forbidden"); error(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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,19 @@
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,4 +1,8 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,116 @@
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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,15 @@
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 || !dataKey) return cache; if (cache) 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 { TRPCClientError } from "@trpc/client"; import { callGetApi, callPostApi } from "$lib/hooks";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
@@ -9,9 +9,16 @@ 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,
@@ -19,24 +26,21 @@ export const requestClientRegistration = async (
verifyKeyBase64: string, verifyKeyBase64: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
const trpc = useTRPC(); let res = await callPostApi<ClientRegisterRequest>("/api/client/register", {
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
});
if (!res.ok) return false;
try { const { id, challenge }: ClientRegisterResponse = await res.json();
const { id, challenge } = await trpc.client.register.mutate({ const answer = await decryptChallenge(challenge, decryptKey);
encPubKey: encryptKeyBase64, const answerSig = await signMessageRSA(answer, signKey);
sigPubKey: verifyKeyBase64,
}); res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
const answer = await decryptChallenge(challenge, decryptKey); id,
const answerSig = await signMessageRSA(answer, signKey); answerSig: encodeToBase64(answerSig),
await trpc.client.verify.mutate({ });
id, return res.ok;
answerSig: encodeToBase64(answerSig),
});
return true;
} catch {
// TODO: Error Handling
return false;
}
}; };
export const requestClientRegistrationAndSessionUpgrade = async ( export const requestClientRegistrationAndSessionUpgrade = async (
@@ -69,16 +73,10 @@ export const requestClientRegistrationAndSessionUpgrade = async (
}; };
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => { export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
const trpc = useTRPC(); const res = await callGetApi("/api/mek/list");
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 }) => {
@@ -110,32 +108,17 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
hmacSecretWrapped: string, hmacSecretWrapped: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
const trpc = useTRPC(); let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
mek: masterKeyWrapped,
try { mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
await trpc.mek.registerInitial.mutate({ });
mek: masterKeyWrapped, if (!res.ok) {
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), return res.status === 403 || res.status === 409;
});
} catch (e) {
if (
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true;
}
// TODO: Error Handling
return false;
} }
try { res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", {
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,14 +6,8 @@
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");
} }
@@ -36,7 +30,6 @@
<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,31 +43,22 @@
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 (viewerType) { if (contentType === "image/heic") {
const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
);
} else if (viewerType) {
fileBlobUrl = URL.createObjectURL(fileBlob); fileBlobUrl = URL.createObjectURL(fileBlob);
heicBlob = contentType === "image/heic" ? fileBlob : undefined;
} }
return fileBlob; return fileBlob;
}; };
const convertHeicToJpeg = async () => {
if (!heicBlob) return;
URL.revokeObjectURL(fileBlobUrl!);
fileBlobUrl = undefined;
const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: heicBlob, toType: "image/jpeg" })) as Blob,
);
heicBlob = undefined;
};
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => { const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
const thumbnail = await captureVideoThumbnail(videoElement!); const thumbnail = await captureVideoThumbnail(videoElement!);
await requestThumbnailUpload(data.id, thumbnail, dataKey, dataKeyVersion); await requestThumbnailUpload(data.id, thumbnail, dataKey, dataKeyVersion);
@@ -145,7 +136,7 @@
{#if viewerType === "image"} {#if viewerType === "image"}
{#if fileBlobUrl} {#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} /> <img src={fileBlobUrl} alt={$info.name} />
{: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, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { getCategoryInfo, 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: boolean | undefined = $state(); let isFileRecursive = $state(false);
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false);
@@ -30,19 +30,6 @@
$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>
@@ -54,7 +41,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 && isFileRecursive !== undefined} {#if $info}
<Category <Category
bind:isFileRecursive bind:isFileRecursive
info={$info} info={$info}

View File

@@ -34,7 +34,7 @@
}; };
$effect(() => { $effect(() => {
if ($info) { if ($info?.dataKey) {
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 { callPostApi } from "$lib/hooks"; import { callGetApi, 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,16 +40,10 @@ export const useContext = () => {
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => { export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
// TODO: MEK rotation // TODO: MEK rotation
const trpc = useTRPC(); const res = await callGetApi("/api/hsk/list");
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 { useTRPC } from "$trpc/client"; import { callGetApi } from "$lib/hooks";
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 trpc = useTRPC(fetch); const res = await callGetApi("/api/user", 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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,16 @@
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

@@ -1,15 +0,0 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,16 @@
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" } });
};

View File

@@ -1,23 +0,0 @@
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;
};

View File

@@ -1,25 +0,0 @@
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

@@ -1,36 +0,0 @@
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 } });
});

View File

@@ -1,17 +0,0 @@
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

@@ -1,96 +0,0 @@
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;

View File

@@ -1,41 +0,0 @@
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

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

View File

@@ -1,63 +0,0 @@
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;

View File

@@ -1,27 +0,0 @@
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,12 +3,15 @@ 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",
},
}, },
}; };