Merge pull request #5 from kmc7468/dev

v0.2.0
This commit is contained in:
static
2025-01-13 03:53:14 +09:00
committed by GitHub
101 changed files with 4831 additions and 3309 deletions

View File

@@ -1,10 +1,9 @@
# Required environment variables # Required environment variables
JWT_SECRET= SESSION_SECRET=
# Optional environment variables # Optional environment variables
DATABASE_URL= DATABASE_URL=
JWT_ACCESS_TOKEN_EXPIRES= SESSION_EXPIRES=
JWT_REFRESH_TOKEN_EXPIRES=
USER_CLIENT_CHALLENGE_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES=
TOKEN_UPGRADE_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH= LIBRARY_PATH=

View File

@@ -1,8 +1,8 @@
# Base Image # Base Image
FROM node:18-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@8 RUN npm install -g pnpm@9
COPY pnpm-lock.yaml . COPY pnpm-lock.yaml .
# Build Stage # Build Stage

View File

@@ -30,12 +30,11 @@ docker compose up --build -d
필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요. 필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요.
|이름|필수|기본값|설명| |이름|필수|기본값|설명|
|-:|:-:|:-:|:-| |:-|:-:|:-:|:-|
|`JWT_SECRET`|Y||JWT의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.| |`SESSION_SECRET`|Y||Session ID의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|`JWT_ACCESS_TOKEN_EXPIRES`||`5m`|Access Token의 유효 시간이에요.| |`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|`JWT_REFRESH_TOKEN_EXPIRES`||`14d`|Refresh Token의 유효 시간이에요.|
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.| |`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|`TOKEN_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.| |`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.| |`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.|
|`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.| |`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.|
|`PORT`||`80`|ArkVault 서버의 포트예요.| |`PORT`||`80`|ArkVault 서버의 포트예요.|

View File

@@ -8,11 +8,10 @@ services:
environment: environment:
# ArkVault # ArkVault
- DATABASE_URL=/app/data/database.sqlite - DATABASE_URL=/app/data/database.sqlite
- JWT_SECRET=${JWT_SECRET:?} # Required - SESSION_SECRET=${SESSION_SECRET:?} # Required
- JWT_ACCESS_TOKEN_EXPIRES - SESSION_EXPIRES
- JWT_REFRESH_TOKEN_EXPIRES
- USER_CLIENT_CHALLENGE_EXPIRES - USER_CLIENT_CHALLENGE_EXPIRES
- TOKEN_UPGRADE_CHALLENGE_EXPIRES - SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library - LIBRARY_PATH=/app/data/library
# SvelteKit # SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}

View File

@@ -17,17 +17,16 @@ CREATE TABLE `user_client_challenge` (
`id` integer PRIMARY KEY NOT NULL, `id` integer PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL, `user_id` integer NOT NULL,
`client_id` integer NOT NULL, `client_id` integer NOT NULL,
`challenge` text NOT NULL, `answer` text NOT NULL,
`allowed_ip` text NOT NULL, `allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL, `expires_at` integer NOT NULL,
`is_used` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`client_id`) REFERENCES `user_client`(`user_id`,`client_id`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `directory` ( CREATE TABLE `directory` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`created_at` integer NOT NULL,
`parent_id` integer, `parent_id` integer,
`user_id` integer NOT NULL, `user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL, `master_encryption_key_version` integer NOT NULL,
@@ -39,23 +38,66 @@ CREATE TABLE `directory` (
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `directory_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`directory_id` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`new_name` text,
FOREIGN KEY (`directory_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `file` ( CREATE TABLE `file` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`path` text NOT NULL,
`parent_id` integer, `parent_id` integer,
`created_at` integer NOT NULL,
`user_id` integer NOT NULL, `user_id` integer NOT NULL,
`path` text NOT NULL,
`master_encryption_key_version` integer NOT NULL, `master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL, `encrypted_data_encryption_key` text NOT NULL,
`data_encryption_key_version` integer NOT NULL, `data_encryption_key_version` integer NOT NULL,
`hmac_secret_key_version` integer,
`content_hmac` text,
`content_type` text NOT NULL, `content_type` text NOT NULL,
`encrypted_content_iv` text NOT NULL, `encrypted_content_iv` text NOT NULL,
`encrypted_name` text NOT NULL, `encrypted_name` text NOT NULL,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `file_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`file_id` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`new_name` text,
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `hmac_secret_key` (
`user_id` integer NOT NULL,
`version` integer NOT NULL,
`state` text NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_key` text NOT NULL,
PRIMARY KEY(`user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `hmac_secret_key_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`hmac_secret_key_version` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`action_by` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`action_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `client_master_encryption_key` ( CREATE TABLE `client_master_encryption_key` (
`user_id` integer NOT NULL, `user_id` integer NOT NULL,
`client_id` integer NOT NULL, `client_id` integer NOT NULL,
@@ -71,49 +113,63 @@ CREATE TABLE `client_master_encryption_key` (
CREATE TABLE `master_encryption_key` ( CREATE TABLE `master_encryption_key` (
`user_id` integer NOT NULL, `user_id` integer NOT NULL,
`version` integer NOT NULL, `version` integer NOT NULL,
`created_by` integer NOT NULL,
`created_at` integer NOT NULL,
`state` text NOT NULL, `state` text NOT NULL,
`retired_at` integer, `retired_at` integer,
PRIMARY KEY(`user_id`, `version`), PRIMARY KEY(`user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
FOREIGN KEY (`created_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `refresh_token` ( CREATE TABLE `master_encryption_key_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`action_by` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`action_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL, `user_id` integer NOT NULL,
`client_id` integer, `client_id` integer,
`expires_at` integer NOT NULL, `created_at` integer NOT NULL,
`last_used_at` integer NOT NULL,
`last_used_by_ip` text,
`last_used_by_user_agent` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `token_upgrade_challenge` ( CREATE TABLE `session_upgrade_challenge` (
`id` integer PRIMARY KEY NOT NULL, `id` integer PRIMARY KEY NOT NULL,
`refresh_token_id` text NOT NULL, `session_id` text NOT NULL,
`client_id` integer NOT NULL, `client_id` integer NOT NULL,
`challenge` text NOT NULL, `answer` text NOT NULL,
`allowed_ip` text NOT NULL, `allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL, `expires_at` integer NOT NULL,
`is_used` integer DEFAULT false NOT NULL, FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `user` ( CREATE TABLE `user` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text NOT NULL, `email` text NOT NULL,
`password` text NOT NULL `password` text NOT NULL,
`nickname` text NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`);--> statement-breakpoint CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_client_challenge_challenge_unique` ON `user_client_challenge` (`challenge`);--> statement-breakpoint CREATE UNIQUE INDEX `user_client_challenge_answer_unique` ON `user_client_challenge` (`answer`);--> statement-breakpoint
CREATE UNIQUE INDEX `directory_encrypted_data_encryption_key_unique` ON `directory` (`encrypted_data_encryption_key`);--> statement-breakpoint CREATE UNIQUE INDEX `directory_encrypted_data_encryption_key_unique` ON `directory` (`encrypted_data_encryption_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `file_path_unique` ON `file` (`path`);--> statement-breakpoint CREATE UNIQUE INDEX `file_path_unique` ON `file` (`path`);--> statement-breakpoint
CREATE UNIQUE INDEX `file_encrypted_data_encryption_key_unique` ON `file` (`encrypted_data_encryption_key`);--> statement-breakpoint CREATE UNIQUE INDEX `file_encrypted_data_encryption_key_unique` ON `file` (`encrypted_data_encryption_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `refresh_token_user_id_client_id_unique` ON `refresh_token` (`user_id`,`client_id`);--> statement-breakpoint CREATE UNIQUE INDEX `hmac_secret_key_encrypted_key_unique` ON `hmac_secret_key` (`encrypted_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> statement-breakpoint CREATE UNIQUE INDEX `session_user_id_client_id_unique` ON `session` (`user_id`,`client_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_upgrade_challenge_session_id_unique` ON `session_upgrade_challenge` (`session_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_upgrade_challenge_answer_unique` ON `session_upgrade_challenge` (`answer`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);

View File

@@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "929c6bca-d0c0-4899-afc6-a0a498226f28", "id": "928e5669-81cf-486c-9122-8ee64fc9f457",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"client": { "client": {
@@ -147,8 +147,8 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"challenge": { "answer": {
"name": "challenge", "name": "answer",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@@ -167,21 +167,13 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
} }
}, },
"indexes": { "indexes": {
"user_client_challenge_challenge_unique": { "user_client_challenge_answer_unique": {
"name": "user_client_challenge_challenge_unique", "name": "user_client_challenge_answer_unique",
"columns": [ "columns": [
"challenge" "answer"
], ],
"isUnique": true "isUnique": true
} }
@@ -212,6 +204,21 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
},
"user_client_challenge_user_id_client_id_user_client_user_id_client_id_fk": {
"name": "user_client_challenge_user_id_client_id_user_client_user_id_client_id_fk",
"tableFrom": "user_client_challenge",
"tableTo": "user_client",
"columnsFrom": [
"user_id",
"client_id"
],
"columnsTo": [
"user_id",
"client_id"
],
"onDelete": "no action",
"onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
@@ -227,13 +234,6 @@
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": { "parent_id": {
"name": "parent_id", "name": "parent_id",
"type": "integer", "type": "integer",
@@ -332,6 +332,64 @@
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {} "uniqueConstraints": {}
}, },
"directory_log": {
"name": "directory_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"directory_id": {
"name": "directory_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"new_name": {
"name": "new_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"directory_log_directory_id_directory_id_fk": {
"name": "directory_log_directory_id_directory_id_fk",
"tableFrom": "directory_log",
"tableTo": "directory",
"columnsFrom": [
"directory_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"file": { "file": {
"name": "file", "name": "file",
"columns": { "columns": {
@@ -342,13 +400,6 @@
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": { "parent_id": {
"name": "parent_id", "name": "parent_id",
"type": "integer", "type": "integer",
@@ -356,16 +407,16 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"created_at": { "user_id": {
"name": "created_at", "name": "user_id",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"user_id": { "path": {
"name": "user_id", "name": "path",
"type": "integer", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
@@ -391,6 +442,20 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"hmac_secret_key_version": {
"name": "hmac_secret_key_version",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_hmac": {
"name": "content_hmac",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": { "content_type": {
"name": "content_type", "name": "content_type",
"type": "text", "type": "text",
@@ -470,6 +535,261 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
},
"file_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk": {
"name": "file_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk",
"tableFrom": "file",
"tableTo": "hmac_secret_key",
"columnsFrom": [
"user_id",
"hmac_secret_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"file_log": {
"name": "file_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"file_id": {
"name": "file_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"new_name": {
"name": "new_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"file_log_file_id_file_id_fk": {
"name": "file_log_file_id_file_id_fk",
"tableFrom": "file_log",
"tableTo": "file",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"hmac_secret_key": {
"name": "hmac_secret_key",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"master_encryption_key_version": {
"name": "master_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"hmac_secret_key_encrypted_key_unique": {
"name": "hmac_secret_key_encrypted_key_unique",
"columns": [
"encrypted_key"
],
"isUnique": true
}
},
"foreignKeys": {
"hmac_secret_key_user_id_user_id_fk": {
"name": "hmac_secret_key_user_id_user_id_fk",
"tableFrom": "hmac_secret_key",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"hmac_secret_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
"name": "hmac_secret_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
"tableFrom": "hmac_secret_key",
"tableTo": "master_encryption_key",
"columnsFrom": [
"user_id",
"master_encryption_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"hmac_secret_key_user_id_version_pk": {
"columns": [
"user_id",
"version"
],
"name": "hmac_secret_key_user_id_version_pk"
}
},
"uniqueConstraints": {}
},
"hmac_secret_key_log": {
"name": "hmac_secret_key_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hmac_secret_key_version": {
"name": "hmac_secret_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action_by": {
"name": "action_by",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"hmac_secret_key_log_user_id_user_id_fk": {
"name": "hmac_secret_key_log_user_id_user_id_fk",
"tableFrom": "hmac_secret_key_log",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"hmac_secret_key_log_action_by_user_id_fk": {
"name": "hmac_secret_key_log_action_by_user_id_fk",
"tableFrom": "hmac_secret_key_log",
"tableTo": "user",
"columnsFrom": [
"action_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"hmac_secret_key_log_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk": {
"name": "hmac_secret_key_log_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk",
"tableFrom": "hmac_secret_key_log",
"tableTo": "hmac_secret_key",
"columnsFrom": [
"user_id",
"hmac_secret_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
@@ -587,20 +907,6 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"created_by": {
"name": "created_by",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": { "state": {
"name": "state", "name": "state",
"type": "text", "type": "text",
@@ -630,19 +936,6 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
},
"master_encryption_key_created_by_client_id_fk": {
"name": "master_encryption_key_created_by_client_id_fk",
"tableFrom": "master_encryption_key",
"tableTo": "client",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": { "compositePrimaryKeys": {
@@ -656,8 +949,101 @@
}, },
"uniqueConstraints": {} "uniqueConstraints": {}
}, },
"refresh_token": { "master_encryption_key_log": {
"name": "refresh_token", "name": "master_encryption_key_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"master_encryption_key_version": {
"name": "master_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action_by": {
"name": "action_by",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"master_encryption_key_log_user_id_user_id_fk": {
"name": "master_encryption_key_log_user_id_user_id_fk",
"tableFrom": "master_encryption_key_log",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"master_encryption_key_log_action_by_client_id_fk": {
"name": "master_encryption_key_log_action_by_client_id_fk",
"tableFrom": "master_encryption_key_log",
"tableTo": "client",
"columnsFrom": [
"action_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"master_encryption_key_log_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
"name": "master_encryption_key_log_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
"tableFrom": "master_encryption_key_log",
"tableTo": "master_encryption_key",
"columnsFrom": [
"user_id",
"master_encryption_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
@@ -680,17 +1066,38 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"expires_at": { "created_at": {
"name": "expires_at", "name": "created_at",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_used_by_ip": {
"name": "last_used_by_ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_used_by_user_agent": {
"name": "last_used_by_user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
} }
}, },
"indexes": { "indexes": {
"refresh_token_user_id_client_id_unique": { "session_user_id_client_id_unique": {
"name": "refresh_token_user_id_client_id_unique", "name": "session_user_id_client_id_unique",
"columns": [ "columns": [
"user_id", "user_id",
"client_id" "client_id"
@@ -699,9 +1106,9 @@
} }
}, },
"foreignKeys": { "foreignKeys": {
"refresh_token_user_id_user_id_fk": { "session_user_id_user_id_fk": {
"name": "refresh_token_user_id_user_id_fk", "name": "session_user_id_user_id_fk",
"tableFrom": "refresh_token", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": [
"user_id" "user_id"
@@ -712,9 +1119,9 @@
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"refresh_token_client_id_client_id_fk": { "session_client_id_client_id_fk": {
"name": "refresh_token_client_id_client_id_fk", "name": "session_client_id_client_id_fk",
"tableFrom": "refresh_token", "tableFrom": "session",
"tableTo": "client", "tableTo": "client",
"columnsFrom": [ "columnsFrom": [
"client_id" "client_id"
@@ -729,8 +1136,8 @@
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {} "uniqueConstraints": {}
}, },
"token_upgrade_challenge": { "session_upgrade_challenge": {
"name": "token_upgrade_challenge", "name": "session_upgrade_challenge",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
@@ -739,8 +1146,8 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"refresh_token_id": { "session_id": {
"name": "refresh_token_id", "name": "session_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@@ -753,8 +1160,8 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"challenge": { "answer": {
"name": "challenge", "name": "answer",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@@ -773,32 +1180,31 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
} }
}, },
"indexes": { "indexes": {
"token_upgrade_challenge_challenge_unique": { "session_upgrade_challenge_session_id_unique": {
"name": "token_upgrade_challenge_challenge_unique", "name": "session_upgrade_challenge_session_id_unique",
"columns": [ "columns": [
"challenge" "session_id"
],
"isUnique": true
},
"session_upgrade_challenge_answer_unique": {
"name": "session_upgrade_challenge_answer_unique",
"columns": [
"answer"
], ],
"isUnique": true "isUnique": true
} }
}, },
"foreignKeys": { "foreignKeys": {
"token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": { "session_upgrade_challenge_session_id_session_id_fk": {
"name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk", "name": "session_upgrade_challenge_session_id_session_id_fk",
"tableFrom": "token_upgrade_challenge", "tableFrom": "session_upgrade_challenge",
"tableTo": "refresh_token", "tableTo": "session",
"columnsFrom": [ "columnsFrom": [
"refresh_token_id" "session_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -806,9 +1212,9 @@
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"token_upgrade_challenge_client_id_client_id_fk": { "session_upgrade_challenge_client_id_client_id_fk": {
"name": "token_upgrade_challenge_client_id_client_id_fk", "name": "session_upgrade_challenge_client_id_client_id_fk",
"tableFrom": "token_upgrade_challenge", "tableFrom": "session_upgrade_challenge",
"tableTo": "client", "tableTo": "client",
"columnsFrom": [ "columnsFrom": [
"client_id" "client_id"
@@ -846,6 +1252,13 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
} }
}, },
"indexes": { "indexes": {

View File

@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1736170919561, "when": 1736704436996,
"tag": "0000_handy_captain_marvel", "tag": "0000_unknown_stark_industries",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -17,46 +17,48 @@
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.3", "@eslint/compat": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.12", "@iconify-json/material-symbols": "^1.2.12",
"@sveltejs/adapter-node": "^5.2.9", "@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.12",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/jsonwebtoken": "^9.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7", "@types/node-schedule": "^2.1.7",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"dexie": "^4.0.10", "dexie": "^4.0.10",
"drizzle-kit": "^0.22.0", "drizzle-kit": "^0.22.8",
"eslint": "^9.7.0", "eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-tailwindcss": "^3.17.5",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^15.0.0", "globals": "^15.14.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"mime": "^4.0.6", "mime": "^4.0.6",
"prettier": "^3.3.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.0.0", "svelte": "^5.17.1",
"svelte-check": "^4.0.0", "svelte-check": "^4.1.3",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.17",
"typescript": "^5.0.0", "typescript": "^5.7.3",
"typescript-eslint": "^8.0.0", "typescript-eslint": "^8.19.1",
"unplugin-icons": "^0.22.0", "unplugin-icons": "^0.22.0",
"vite": "^5.4.11" "vite": "^5.4.11"
}, },
"dependencies": { "dependencies": {
"argon2": "^0.41.1", "argon2": "^0.41.1",
"better-sqlite3": "^11.1.2", "better-sqlite3": "^11.7.2",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"jsonwebtoken": "^9.0.2",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"uuid": "^11.0.3", "uuid": "^11.0.4",
"zod": "^3.24.1" "zod": "^3.24.1"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^9.0.0"
} }
} }

4569
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

14
src/app.d.ts vendored
View File

@@ -5,11 +5,15 @@ import "unplugin-icons/types/svelte";
declare global { declare global {
namespace App { namespace App {
// interface Error {} interface Locals {
// interface Locals {} ip: string;
// interface PageData {} userAgent: string;
// interface PageState {} session?: {
// interface Platform {} id: string;
userId: number;
clientId?: number;
};
}
} }
} }

View File

@@ -1,6 +1,6 @@
import type { ClientInit } from "@sveltejs/kit"; import type { ClientInit } from "@sveltejs/kit";
import { getClientKey, getMasterKeys } from "$lib/indexedDB"; import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { clientKeyStore, masterKeyStore } from "$lib/stores"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
const prepareClientKeyStore = async () => { const prepareClientKeyStore = async () => {
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([ const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
@@ -21,6 +21,13 @@ const prepareMasterKeyStore = async () => {
} }
}; };
export const init: ClientInit = async () => { const prepareHmacSecretStore = async () => {
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore()]); const hmacSecrets = await getHmacSecrets();
if (hmacSecrets.length > 0) {
hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])));
}
};
export const init: ClientInit = async () => {
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
}; };

View File

@@ -1,34 +1,22 @@
import { redirect, type ServerInit, type Handle } from "@sveltejs/kit"; import type { ServerInit } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import schedule from "node-schedule"; import schedule from "node-schedule";
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/drizzle"; import { migrateDB } from "$lib/server/db/drizzle";
import { import {
cleanupExpiredRefreshTokens, cleanupExpiredSessions,
cleanupExpiredTokenUpgradeChallenges, cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/token"; } from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
export const init: ServerInit = () => { export const init: ServerInit = () => {
migrateDB(); migrateDB();
schedule.scheduleJob("0 * * * *", () => { schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges(); cleanupExpiredUserClientChallenges();
cleanupExpiredRefreshTokens(); cleanupExpiredSessions();
cleanupExpiredTokenUpgradeChallenges(); cleanupExpiredSessionUpgradeChallenges();
}); });
}; };
export const handle: Handle = async ({ event, resolve }) => { export const handle = sequence(setAgentInfo, authenticate);
if (["/api", "/auth"].some((path) => event.url.pathname.startsWith(path))) {
return await resolve(event);
}
const accessToken = event.cookies.get("accessToken");
if (accessToken) {
return await resolve(event);
} else {
redirect(
302,
"/auth/login?redirect=" + encodeURIComponent(event.url.pathname + event.url.search),
);
}
};

View File

@@ -29,7 +29,7 @@
onclick?.(); onclick?.();
}, 100); }, 100);
}} }}
class="{bgColorStyle} {fontColorStyle} h-12 w-full rounded-xl font-medium" class="{bgColorStyle} {fontColorStyle} h-12 w-full min-w-fit rounded-xl font-medium"
> >
<div class="h-full w-full p-3 transition active:scale-95"> <div class="h-full w-full p-3 transition active:scale-95">
{@render children?.()} {@render children?.()}

View File

@@ -3,15 +3,16 @@
import type { SvelteHTMLElements } from "svelte/elements"; import type { SvelteHTMLElements } from "svelte/elements";
interface Props { interface Props {
icon?: Component<SvelteHTMLElements["svg"]>;
children: Snippet; children: Snippet;
icon?: Component<SvelteHTMLElements["svg"]>;
topPadding?: boolean;
} }
let { icon: Icon, children }: Props = $props(); let { topPadding = true, children, icon: Icon }: Props = $props();
</script> </script>
<div> <div>
<div class="box-content flex min-h-[10vh] items-center pt-4"> <div class="box-content flex min-h-[10vh] items-center {topPadding ? 'pt-4' : ''}">
{#if Icon} {#if Icon}
<Icon class="text-5xl text-gray-600" /> <Icon class="text-5xl text-gray-600" />
{/if} {/if}

View File

@@ -1,35 +1,11 @@
export const refreshToken = async (fetchInternal = fetch) => { export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal("/api/auth/refreshToken", { method: "POST" }); return await fetchInternal(input);
}; };
const callApi = async (input: RequestInfo, init?: RequestInit, fetchInternal = fetch) => { export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
let res = await fetchInternal(input, init); return await fetchInternal(input, {
if (res.status === 401) { method: "POST",
res = await refreshToken(); headers: { "Content-Type": "application/json" },
if (!res.ok) { body: payload ? JSON.stringify(payload) : undefined,
return res; });
}
res = await fetchInternal(input, init);
}
return res;
};
export const callGetApi = async (input: RequestInfo, fetchInternal?: typeof fetch) => {
return await callApi(input, undefined, fetchInternal);
};
export const callPostApi = async <T>(
input: RequestInfo,
payload?: T,
fetchInternal?: typeof fetch,
) => {
return await callApi(
input,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
},
fetchInternal,
);
}; };

View File

@@ -11,6 +11,7 @@ interface KeyExportState {
verifyKeyBase64: string; verifyKeyBase64: string;
masterKeyWrapped: string; masterKeyWrapped: string;
hmacSecretWrapped: string;
} }
const useAutoNull = <T>(value: T | null) => { const useAutoNull = <T>(value: T | null) => {

View File

@@ -7,22 +7,28 @@ interface ClientKey {
key: CryptoKey; key: CryptoKey;
} }
type MasterKeyState = "active" | "retired";
interface MasterKey { interface MasterKey {
version: number; version: number;
state: MasterKeyState; state: "active" | "retired";
key: CryptoKey; key: CryptoKey;
} }
interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
const keyStore = new Dexie("keyStore") as Dexie & { const keyStore = new Dexie("keyStore") as Dexie & {
clientKey: EntityTable<ClientKey, "usage">; clientKey: EntityTable<ClientKey, "usage">;
masterKey: EntityTable<MasterKey, "version">; masterKey: EntityTable<MasterKey, "version">;
hmacSecret: EntityTable<HmacSecret, "version">;
}; };
keyStore.version(1).stores({ keyStore.version(1).stores({
clientKey: "usage", clientKey: "usage",
masterKey: "version", masterKey: "version",
hmacSecret: "version",
}); });
export const getClientKey = async (usage: ClientKeyUsage) => { export const getClientKey = async (usage: ClientKeyUsage) => {
@@ -62,3 +68,14 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
} }
await keyStore.masterKey.bulkPut(keys); await keyStore.masterKey.bulkPut(keys);
}; };
export const getHmacSecrets = async () => {
return await keyStore.hmacSecret.toArray();
};
export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
if (secrets.some(({ secret }) => secret.extractable)) {
throw new Error("Hmac secrets must be nonextractable");
}
await keyStore.hmacSecret.bulkPut(secrets);
};

View File

@@ -55,6 +55,27 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
}; };
}; };
export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await window.crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
};
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
return {
hmacSecret: await window.crypto.subtle.unwrapKey(
"raw",
decodeFromBase64(hmacSecretWrapped),
masterKey,
"AES-KW",
{
name: "HMAC",
hash: "SHA-256",
} satisfies HmacImportParams,
false, // Nonextractable
["sign", "verify"],
),
};
};
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12)); const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt( const ciphertext = await window.crypto.subtle.encrypt(

View File

@@ -95,7 +95,7 @@ export const unwrapMasterKey = async (
}; };
}; };
export const signMessage = async (message: BufferSource, signKey: CryptoKey) => { export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
return await window.crypto.subtle.sign( return await window.crypto.subtle.sign(
{ {
name: "RSA-PSS", name: "RSA-PSS",
@@ -106,7 +106,7 @@ export const signMessage = async (message: BufferSource, signKey: CryptoKey) =>
); );
}; };
export const verifySignature = async ( export const verifySignatureRSA = async (
message: BufferSource, message: BufferSource,
signature: BufferSource, signature: BufferSource,
verifyKey: CryptoKey, verifyKey: CryptoKey,
@@ -131,7 +131,7 @@ export const signMasterKeyWrapped = async (
version: masterKeyVersion, version: masterKeyVersion,
key: masterKeyWrapped, key: masterKeyWrapped,
}); });
return encodeToBase64(await signMessage(encodeString(serialized), signKey)); return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
}; };
export const verifyMasterKeyWrapped = async ( export const verifyMasterKeyWrapped = async (
@@ -144,7 +144,7 @@ export const verifyMasterKeyWrapped = async (
version: masterKeyVersion, version: masterKeyVersion,
key: masterKeyWrapped, key: masterKeyWrapped,
}); });
return await verifySignature( return await verifySignatureRSA(
encodeString(serialized), encodeString(serialized),
decodeFromBase64(masterKeyWrappedSig), decodeFromBase64(masterKeyWrappedSig),
verifyKey, verifyKey,

View File

@@ -1,3 +1,20 @@
export const digestMessage = async (message: BufferSource) => { export const digestMessage = async (message: BufferSource) => {
return await window.crypto.subtle.digest("SHA-256", message); return await window.crypto.subtle.digest("SHA-256", message);
}; };
export const generateHmacSecret = async () => {
return {
hmacSecret: await window.crypto.subtle.generateKey(
{
name: "HMAC",
hash: "SHA-256",
} satisfies HmacKeyGenParams,
true,
["sign", "verify"],
),
};
};
export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => {
return await window.crypto.subtle.sign("HMAC", hmacSecret, message);
};

View File

@@ -1,30 +1,36 @@
import { and, or, eq, gt, lte, count } from "drizzle-orm"; import { SqliteError } from "better-sqlite3";
import { and, or, eq, gt, lte } from "drizzle-orm";
import db from "./drizzle"; import db from "./drizzle";
import { IntegrityError } from "./error";
import { client, userClient, userClientChallenge } from "./schema"; import { client, userClient, userClientChallenge } from "./schema";
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => { export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
return await db.transaction(async (tx) => { return await db.transaction(
const clients = await tx async (tx) => {
.select() const clients = await tx
.from(client) .select({ id: client.id })
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey))); .from(client)
if (clients.length > 0) { .where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)))
throw new Error("Already used public key(s)"); .limit(1);
} if (clients.length !== 0) {
throw new IntegrityError("Public key(s) already registered");
}
const insertRes = await tx const newClients = await tx
.insert(client) .insert(client)
.values({ encPubKey, sigPubKey }) .values({ encPubKey, sigPubKey })
.returning({ id: client.id }); .returning({ id: client.id });
const { id: clientId } = insertRes[0]!; const { id: clientId } = newClients[0]!;
await tx.insert(userClient).values({ userId, clientId }); await tx.insert(userClient).values({ userId, clientId });
return clientId; return clientId;
}); },
{ behavior: "exclusive" },
);
}; };
export const getClient = async (clientId: number) => { export const getClient = async (clientId: number) => {
const clients = await db.select().from(client).where(eq(client.id, clientId)).execute(); const clients = await db.select().from(client).where(eq(client.id, clientId)).limit(1);
return clients[0] ?? null; return clients[0] ?? null;
}; };
@@ -33,24 +39,23 @@ export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) =
.select() .select()
.from(client) .from(client)
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey))) .where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
.execute(); .limit(1);
return clients[0] ?? null; return clients[0] ?? null;
}; };
export const countClientByPubKey = async (pubKey: string) => {
const clients = await db
.select({ count: count() })
.from(client)
.where(or(eq(client.encPubKey, pubKey), eq(client.encPubKey, pubKey)));
return clients[0]?.count ?? 0;
};
export const createUserClient = async (userId: number, clientId: number) => { export const createUserClient = async (userId: number, clientId: number) => {
await db.insert(userClient).values({ userId, clientId }).execute(); try {
await db.insert(userClient).values({ userId, clientId });
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("User client already exists");
}
throw e;
}
}; };
export const getAllUserClients = async (userId: number) => { export const getAllUserClients = async (userId: number) => {
return await db.select().from(userClient).where(eq(userClient.userId, userId)).execute(); return await db.select().from(userClient).where(eq(userClient.userId, userId));
}; };
export const getUserClient = async (userId: number, clientId: number) => { export const getUserClient = async (userId: number, clientId: number) => {
@@ -58,7 +63,7 @@ export const getUserClient = async (userId: number, clientId: number) => {
.select() .select()
.from(userClient) .from(userClient)
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.execute(); .limit(1);
return userClients[0] ?? null; return userClients[0] ?? null;
}; };
@@ -68,7 +73,7 @@ export const getUserClientWithDetails = async (userId: number, clientId: number)
.from(userClient) .from(userClient)
.innerJoin(client, eq(userClient.clientId, client.id)) .innerJoin(client, eq(userClient.clientId, client.id))
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.execute(); .limit(1);
return userClients[0] ?? null; return userClients[0] ?? null;
}; };
@@ -82,8 +87,7 @@ export const setUserClientStateToPending = async (userId: number, clientId: numb
eq(userClient.clientId, clientId), eq(userClient.clientId, clientId),
eq(userClient.state, "challenging"), eq(userClient.state, "challenging"),
), ),
) );
.execute();
}; };
export const setUserClientStateToActive = async (userId: number, clientId: number) => { export const setUserClientStateToActive = async (userId: number, clientId: number) => {
@@ -96,8 +100,7 @@ export const setUserClientStateToActive = async (userId: number, clientId: numbe
eq(userClient.clientId, clientId), eq(userClient.clientId, clientId),
eq(userClient.state, "pending"), eq(userClient.state, "pending"),
), ),
) );
.execute();
}; };
export const registerUserClientChallenge = async ( export const registerUserClientChallenge = async (
@@ -107,45 +110,30 @@ export const registerUserClientChallenge = async (
allowedIp: string, allowedIp: string,
expiresAt: Date, expiresAt: Date,
) => { ) => {
await db await db.insert(userClientChallenge).values({
.insert(userClientChallenge) userId,
.values({ clientId,
userId, answer,
clientId, allowedIp,
answer, expiresAt,
allowedIp, });
expiresAt,
})
.execute();
}; };
export const getUserClientChallenge = async (answer: string, ip: string) => { export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => {
const challenges = await db const challenges = await db
.select() .delete(userClientChallenge)
.from(userClientChallenge)
.where( .where(
and( and(
eq(userClientChallenge.userId, userId),
eq(userClientChallenge.answer, answer), eq(userClientChallenge.answer, answer),
eq(userClientChallenge.allowedIp, ip), eq(userClientChallenge.allowedIp, ip),
gt(userClientChallenge.expiresAt, new Date()), gt(userClientChallenge.expiresAt, new Date()),
eq(userClientChallenge.isUsed, false),
), ),
) )
.execute(); .returning({ clientId: userClientChallenge.clientId });
return challenges[0] ?? null; return challenges[0] ?? null;
}; };
export const markUserClientChallengeAsUsed = async (id: number) => {
await db
.update(userClientChallenge)
.set({ isUsed: true })
.where(eq(userClientChallenge.id, id))
.execute();
};
export const cleanupExpiredUserClientChallenges = async () => { export const cleanupExpiredUserClientChallenges = async () => {
await db await db.delete(userClientChallenge).where(lte(userClientChallenge.expiresAt, new Date()));
.delete(userClientChallenge)
.where(lte(userClientChallenge.expiresAt, new Date()))
.execute();
}; };

View File

@@ -0,0 +1,26 @@
type IntegrityErrorMessages =
// Challenge
| "Challenge already registered"
// Client
| "Public key(s) already registered"
| "User client already exists"
// File
| "Directory not found"
| "File not found"
| "Invalid DEK version"
// HSK
| "HSK already registered"
| "Inactive HSK version"
// MEK
| "MEK already registered"
| "Inactive MEK version"
// Session
| "Session not found"
| "Session already exists";
export class IntegrityError extends Error {
constructor(public message: IntegrityErrorMessages) {
super(message);
this.name = "IntegrityError";
}
}

View File

@@ -1,12 +1,13 @@
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import db from "./drizzle"; import db from "./drizzle";
import { directory, file, mek } from "./schema"; import { IntegrityError } from "./error";
import { directory, directoryLog, file, fileLog, hsk, mek } from "./schema";
type DirectoryId = "root" | number; type DirectoryId = "root" | number;
export interface NewDirectoryParams { export interface NewDirectoryParams {
userId: number;
parentId: DirectoryId; parentId: DirectoryId;
userId: number;
mekVersion: number; mekVersion: number;
encDek: string; encDek: string;
dekVersion: Date; dekVersion: Date;
@@ -15,52 +16,65 @@ export interface NewDirectoryParams {
} }
export interface NewFileParams { export interface NewFileParams {
path: string;
parentId: DirectoryId; parentId: DirectoryId;
userId: number; userId: number;
path: string;
mekVersion: number; mekVersion: number;
encDek: string; encDek: string;
dekVersion: Date; dekVersion: Date;
hskVersion: number | null;
contentHmac: string | null;
contentType: string; contentType: string;
encContentIv: string; encContentIv: string;
encName: string; encName: string;
encNameIv: string; encNameIv: string;
} }
export const registerNewDirectory = async (params: NewDirectoryParams) => { export const registerDirectory = async (params: NewDirectoryParams) => {
return await db.transaction(async (tx) => { await db.transaction(
const meks = await tx async (tx) => {
.select() const meks = await tx
.from(mek) .select({ version: mek.version })
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))); .from(mek)
if (meks[0]?.version !== params.mekVersion) { .where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
throw new Error("Invalid MEK version"); .limit(1);
} if (meks[0]?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
const now = new Date(); const newDirectories = await tx
await tx.insert(directory).values({ .insert(directory)
createdAt: now, .values({
parentId: params.parentId === "root" ? null : params.parentId, parentId: params.parentId === "root" ? null : params.parentId,
userId: params.userId, userId: params.userId,
mekVersion: params.mekVersion, mekVersion: params.mekVersion,
encDek: params.encDek, encDek: params.encDek,
dekVersion: params.dekVersion, dekVersion: params.dekVersion,
encName: { ciphertext: params.encName, iv: params.encNameIv }, encName: { ciphertext: params.encName, iv: params.encNameIv },
}); })
}); .returning({ id: directory.id });
const { id: directoryId } = newDirectories[0]!;
await tx.insert(directoryLog).values({
directoryId,
timestamp: new Date(),
action: "create",
newName: { ciphertext: params.encName, iv: params.encNameIv },
});
},
{ behavior: "exclusive" },
);
}; };
export const getAllDirectoriesByParent = async (userId: number, directoryId: DirectoryId) => { export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
return await db return await db
.select() .select()
.from(directory) .from(directory)
.where( .where(
and( and(
eq(directory.userId, userId), eq(directory.userId, userId),
directoryId === "root" ? isNull(directory.parentId) : eq(directory.parentId, directoryId), parentId === "root" ? isNull(directory.parentId) : eq(directory.parentId, parentId),
), ),
) );
.execute();
}; };
export const getDirectory = async (userId: number, directoryId: number) => { export const getDirectory = async (userId: number, directoryId: number) => {
@@ -68,7 +82,7 @@ export const getDirectory = async (userId: number, directoryId: number) => {
.select() .select()
.from(directory) .from(directory)
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId))) .where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
.execute(); .limit(1);
return res[0] ?? null; return res[0] ?? null;
}; };
@@ -79,72 +93,119 @@ export const setDirectoryEncName = async (
encName: string, encName: string,
encNameIv: string, encNameIv: string,
) => { ) => {
const res = await db await db.transaction(
.update(directory) async (tx) => {
.set({ encName: { ciphertext: encName, iv: encNameIv } }) const directories = await tx
.where( .select({ version: directory.dekVersion })
and( .from(directory)
eq(directory.userId, userId), .where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
eq(directory.id, directoryId), .limit(1);
eq(directory.dekVersion, dekVersion), if (!directories[0]) {
), throw new IntegrityError("Directory not found");
) } else if (directories[0].version.getTime() !== dekVersion.getTime()) {
.execute(); throw new IntegrityError("Invalid DEK version");
return res.changes > 0; }
await tx
.update(directory)
.set({ encName: { ciphertext: encName, iv: encNameIv } })
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)));
await tx.insert(directoryLog).values({
directoryId,
timestamp: new Date(),
action: "rename",
newName: { ciphertext: encName, iv: encNameIv },
});
},
{ behavior: "exclusive" },
);
}; };
export const unregisterDirectory = async (userId: number, directoryId: number) => { export const unregisterDirectory = async (userId: number, directoryId: number) => {
return await db.transaction(async (tx) => { return await db.transaction(
const getFilePaths = async (parentId: number) => { async (tx) => {
const files = await tx const unregisterFiles = async (parentId: number) => {
.select({ path: file.path }) const files = await tx
.from(file) .delete(file)
.where(and(eq(file.userId, userId), eq(file.parentId, parentId))); .where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
return files.map(({ path }) => path); .returning({ path: file.path });
}; return files.map(({ path }) => path);
const unregisterSubDirectoriesRecursively = async (directoryId: number): Promise<string[]> => { };
const subDirectories = await tx const unregisterDirectoryRecursively = async (directoryId: number): Promise<string[]> => {
.select({ id: directory.id }) const filePaths = await unregisterFiles(directoryId);
.from(directory) const subDirectories = await tx
.where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId))); .select({ id: directory.id })
const subDirectoryFilePaths = await Promise.all( .from(directory)
subDirectories.map(async ({ id }) => await unregisterSubDirectoriesRecursively(id)), .where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId)));
); const subDirectoryFilePaths = await Promise.all(
const filePaths = await getFilePaths(directoryId); subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
await tx.delete(file).where(eq(file.parentId, directoryId)); const deleteRes = await tx.delete(directory).where(eq(directory.id, directoryId));
await tx.delete(directory).where(eq(directory.id, directoryId)); if (deleteRes.changes === 0) {
throw new IntegrityError("Directory not found");
return filePaths.concat(...subDirectoryFilePaths); }
}; return filePaths.concat(...subDirectoryFilePaths);
return await unregisterSubDirectoriesRecursively(directoryId); };
}); return await unregisterDirectoryRecursively(directoryId);
},
{ behavior: "exclusive" },
);
}; };
export const registerNewFile = async (params: NewFileParams) => { export const registerFile = async (params: NewFileParams) => {
await db.transaction(async (tx) => { if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
const meks = await tx throw new Error("Invalid arguments");
.select() }
.from(mek)
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")));
if (meks[0]?.version !== params.mekVersion) {
throw new Error("Invalid MEK version");
}
const now = new Date(); await db.transaction(
await tx.insert(file).values({ async (tx) => {
path: params.path, const meks = await tx
parentId: params.parentId === "root" ? null : params.parentId, .select({ version: mek.version })
createdAt: now, .from(mek)
userId: params.userId, .where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
mekVersion: params.mekVersion, .limit(1);
contentType: params.contentType, if (meks[0]?.version !== params.mekVersion) {
encDek: params.encDek, throw new IntegrityError("Inactive MEK version");
dekVersion: params.dekVersion, }
encContentIv: params.encContentIv,
encName: { ciphertext: params.encName, iv: params.encNameIv }, if (params.hskVersion) {
}); const hsks = await tx
}); .select({ version: hsk.version })
.from(hsk)
.where(and(eq(hsk.userId, params.userId), eq(hsk.state, "active")))
.limit(1);
if (hsks[0]?.version !== params.hskVersion) {
throw new IntegrityError("Inactive HSK version");
}
}
const newFiles = await tx
.insert(file)
.values({
path: params.path,
parentId: params.parentId === "root" ? null : params.parentId,
userId: params.userId,
mekVersion: params.mekVersion,
hskVersion: params.hskVersion,
contentHmac: params.contentHmac,
contentType: params.contentType,
encDek: params.encDek,
dekVersion: params.dekVersion,
encContentIv: params.encContentIv,
encName: { ciphertext: params.encName, iv: params.encNameIv },
})
.returning({ id: file.id });
const { id: fileId } = newFiles[0]!;
await tx.insert(fileLog).values({
fileId,
timestamp: new Date(),
action: "create",
newName: { ciphertext: params.encName, iv: params.encNameIv },
});
},
{ behavior: "exclusive" },
);
}; };
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => { export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
@@ -156,8 +217,24 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
eq(file.userId, userId), eq(file.userId, userId),
parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId), parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId),
), ),
) );
.execute(); };
export const getAllFileIdsByContentHmac = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
return await db
.select({ id: file.id })
.from(file)
.where(
and(
eq(file.userId, userId),
eq(file.hskVersion, hskVersion),
eq(file.contentHmac, contentHmac),
),
);
}; };
export const getFile = async (userId: number, fileId: number) => { export const getFile = async (userId: number, fileId: number) => {
@@ -165,7 +242,7 @@ export const getFile = async (userId: number, fileId: number) => {
.select() .select()
.from(file) .from(file)
.where(and(eq(file.userId, userId), eq(file.id, fileId))) .where(and(eq(file.userId, userId), eq(file.id, fileId)))
.execute(); .limit(1);
return res[0] ?? null; return res[0] ?? null;
}; };
@@ -176,19 +253,41 @@ export const setFileEncName = async (
encName: string, encName: string,
encNameIv: string, encNameIv: string,
) => { ) => {
const res = await db await db.transaction(
.update(file) async (tx) => {
.set({ encName: { ciphertext: encName, iv: encNameIv } }) const files = await tx
.where(and(eq(file.userId, userId), eq(file.id, fileId), eq(file.dekVersion, dekVersion))) .select({ version: file.dekVersion })
.execute(); .from(file)
return res.changes > 0; .where(and(eq(file.userId, userId), eq(file.id, fileId)))
.limit(1);
if (!files[0]) {
throw new IntegrityError("File not found");
} else if (files[0].version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await tx
.update(file)
.set({ encName: { ciphertext: encName, iv: encNameIv } })
.where(and(eq(file.userId, userId), eq(file.id, fileId)));
await tx.insert(fileLog).values({
fileId,
timestamp: new Date(),
action: "rename",
newName: { ciphertext: encName, iv: encNameIv },
});
},
{ behavior: "exclusive" },
);
}; };
export const unregisterFile = async (userId: number, fileId: number) => { export const unregisterFile = async (userId: number, fileId: number) => {
const res = await db const files = await db
.delete(file) .delete(file)
.where(and(eq(file.userId, userId), eq(file.id, fileId))) .where(and(eq(file.userId, userId), eq(file.id, fileId)))
.returning({ path: file.path }) .returning({ path: file.path });
.execute(); if (!files[0]) {
return res[0]?.path ?? null; throw new IntegrityError("File not found");
}
return files[0].path;
}; };

46
src/lib/server/db/hsk.ts Normal file
View File

@@ -0,0 +1,46 @@
import { SqliteError } from "better-sqlite3";
import { and, eq } from "drizzle-orm";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { hsk, hskLog } from "./schema";
export const registerInitialHsk = async (
userId: number,
createdBy: number,
mekVersion: number,
encHsk: string,
) => {
await db.transaction(
async (tx) => {
try {
await tx.insert(hsk).values({
userId,
version: 1,
state: "active",
mekVersion,
encHsk,
});
await tx.insert(hskLog).values({
userId,
hskVersion: 1,
timestamp: new Date(),
action: "create",
actionBy: createdBy,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("HSK already registered");
}
throw e;
}
},
{ behavior: "exclusive" },
);
};
export const getAllValidHsks = async (userId: number) => {
return await db
.select()
.from(hsk)
.where(and(eq(hsk.userId, userId), eq(hsk.state, "active")));
};

View File

@@ -1,6 +1,8 @@
import { SqliteError } from "better-sqlite3";
import { and, or, eq } from "drizzle-orm"; import { and, or, eq } from "drizzle-orm";
import db from "./drizzle"; import db from "./drizzle";
import { mek, clientMek } from "./schema"; import { IntegrityError } from "./error";
import { mek, mekLog, clientMek } from "./schema";
export const registerInitialMek = async ( export const registerInitialMek = async (
userId: number, userId: number,
@@ -8,22 +10,37 @@ export const registerInitialMek = async (
encMek: string, encMek: string,
encMekSig: string, encMekSig: string,
) => { ) => {
await db.transaction(async (tx) => { await db.transaction(
await tx.insert(mek).values({ async (tx) => {
userId, try {
version: 1, await tx.insert(mek).values({
createdBy, userId,
createdAt: new Date(), version: 1,
state: "active", state: "active",
}); });
await tx.insert(clientMek).values({ await tx.insert(clientMek).values({
userId, userId,
clientId: createdBy, clientId: createdBy,
mekVersion: 1, mekVersion: 1,
encMek, encMek,
encMekSig, encMekSig,
}); });
}); await tx.insert(mekLog).values({
userId,
mekVersion: 1,
timestamp: new Date(),
action: "create",
actionBy: createdBy,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("MEK already registered");
}
throw e;
}
},
{ behavior: "exclusive" },
);
}; };
export const getInitialMek = async (userId: number) => { export const getInitialMek = async (userId: number) => {
@@ -31,19 +48,10 @@ export const getInitialMek = async (userId: number) => {
.select() .select()
.from(mek) .from(mek)
.where(and(eq(mek.userId, userId), eq(mek.version, 1))) .where(and(eq(mek.userId, userId), eq(mek.version, 1)))
.execute(); .limit(1);
return meks[0] ?? null; return meks[0] ?? null;
}; };
export const getActiveMekVersion = async (userId: number) => {
const meks = await db
.select({ version: mek.version })
.from(mek)
.where(and(eq(mek.userId, userId), eq(mek.state, "active")))
.execute();
return meks[0]?.version ?? null;
};
export const getAllValidClientMeks = async (userId: number, clientId: number) => { export const getAllValidClientMeks = async (userId: number, clientId: number) => {
return await db return await db
.select() .select()
@@ -55,6 +63,5 @@ export const getAllValidClientMeks = async (userId: number, clientId: number) =>
eq(clientMek.clientId, clientId), eq(clientMek.clientId, clientId),
or(eq(mek.state, "active"), eq(mek.state, "retired")), or(eq(mek.state, "active"), eq(mek.state, "retired")),
), ),
) );
.execute();
}; };

View File

@@ -1,4 +1,11 @@
import { sqliteTable, text, integer, primaryKey, unique } from "drizzle-orm/sqlite-core"; import {
sqliteTable,
text,
integer,
primaryKey,
foreignKey,
unique,
} from "drizzle-orm/sqlite-core";
import { user } from "./user"; import { user } from "./user";
export const client = sqliteTable( export const client = sqliteTable(
@@ -31,16 +38,24 @@ export const userClient = sqliteTable(
}), }),
); );
export const userClientChallenge = sqliteTable("user_client_challenge", { export const userClientChallenge = sqliteTable(
id: integer("id").primaryKey(), "user_client_challenge",
userId: integer("user_id") {
.notNull() id: integer("id").primaryKey(),
.references(() => user.id), userId: integer("user_id")
clientId: integer("client_id") .notNull()
.notNull() .references(() => user.id),
.references(() => client.id), clientId: integer("client_id")
answer: text("challenge").notNull().unique(), // Base64 .notNull()
allowedIp: text("allowed_ip").notNull(), .references(() => client.id),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), answer: text("answer").notNull().unique(), // Base64
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false), allowedIp: text("allowed_ip").notNull(),
}); expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
},
(t) => ({
ref: foreignKey({
columns: [t.userId, t.clientId],
foreignColumns: [userClient.userId, userClient.clientId],
}),
}),
);

View File

@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core"; import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core";
import { hsk } from "./hsk";
import { mek } from "./mek"; import { mek } from "./mek";
import { user } from "./user"; import { user } from "./user";
@@ -12,7 +13,6 @@ export const directory = sqliteTable(
"directory", "directory",
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
parentId: integer("parent_id"), parentId: integer("parent_id"),
userId: integer("user_id") userId: integer("user_id")
.notNull() .notNull()
@@ -34,27 +34,52 @@ export const directory = sqliteTable(
}), }),
); );
export const directoryLog = sqliteTable("directory_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
directoryId: integer("directory_id")
.notNull()
.references(() => directory.id, { onDelete: "cascade" }),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create", "rename"] }).notNull(),
newName: ciphertext("new_name"),
});
export const file = sqliteTable( export const file = sqliteTable(
"file", "file",
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
path: text("path").notNull().unique(),
parentId: integer("parent_id").references(() => directory.id), parentId: integer("parent_id").references(() => directory.id),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
userId: integer("user_id") userId: integer("user_id")
.notNull() .notNull()
.references(() => user.id), .references(() => user.id),
path: text("path").notNull().unique(),
mekVersion: integer("master_encryption_key_version").notNull(), mekVersion: integer("master_encryption_key_version").notNull(),
encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64 encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64
dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(), dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(),
hskVersion: integer("hmac_secret_key_version"),
contentHmac: text("content_hmac"), // Base64
contentType: text("content_type").notNull(), contentType: text("content_type").notNull(),
encContentIv: text("encrypted_content_iv").notNull(), // Base64 encContentIv: text("encrypted_content_iv").notNull(), // Base64
encName: ciphertext("encrypted_name").notNull(), encName: ciphertext("encrypted_name").notNull(),
}, },
(t) => ({ (t) => ({
ref: foreignKey({ ref1: foreignKey({
columns: [t.userId, t.mekVersion], columns: [t.userId, t.mekVersion],
foreignColumns: [mek.userId, mek.version], foreignColumns: [mek.userId, mek.version],
}), }),
ref2: foreignKey({
columns: [t.userId, t.hskVersion],
foreignColumns: [hsk.userId, hsk.version],
}),
}), }),
); );
export const fileLog = sqliteTable("file_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
fileId: integer("file_id")
.notNull()
.references(() => file.id, { onDelete: "cascade" }),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create", "rename"] }).notNull(),
newName: ciphertext("new_name"),
});

View File

@@ -0,0 +1,43 @@
import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core";
import { mek } from "./mek";
import { user } from "./user";
export const hsk = sqliteTable(
"hmac_secret_key",
{
userId: integer("user_id")
.notNull()
.references(() => user.id),
version: integer("version").notNull(),
state: text("state", { enum: ["active"] }).notNull(),
mekVersion: integer("master_encryption_key_version").notNull(),
encHsk: text("encrypted_key").notNull().unique(), // Base64
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.version] }),
ref: foreignKey({
columns: [t.userId, t.mekVersion],
foreignColumns: [mek.userId, mek.version],
}),
}),
);
export const hskLog = sqliteTable(
"hmac_secret_key_log",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => user.id),
hskVersion: integer("hmac_secret_key_version").notNull(),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create"] }).notNull(),
actionBy: integer("action_by").references(() => user.id),
},
(t) => ({
ref: foreignKey({
columns: [t.userId, t.hskVersion],
foreignColumns: [hsk.userId, hsk.version],
}),
}),
);

View File

@@ -1,5 +1,6 @@
export * from "./client"; export * from "./client";
export * from "./file"; export * from "./file";
export * from "./hsk";
export * from "./mek"; export * from "./mek";
export * from "./token"; export * from "./session";
export * from "./user"; export * from "./user";

View File

@@ -9,10 +9,6 @@ export const mek = sqliteTable(
.notNull() .notNull()
.references(() => user.id), .references(() => user.id),
version: integer("version").notNull(), version: integer("version").notNull(),
createdBy: integer("created_by")
.notNull()
.references(() => client.id),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
state: text("state", { enum: ["active", "retired", "dead"] }).notNull(), state: text("state", { enum: ["active", "retired", "dead"] }).notNull(),
retiredAt: integer("retired_at", { mode: "timestamp_ms" }), retiredAt: integer("retired_at", { mode: "timestamp_ms" }),
}, },
@@ -21,6 +17,26 @@ export const mek = sqliteTable(
}), }),
); );
export const mekLog = sqliteTable(
"master_encryption_key_log",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => user.id),
mekVersion: integer("master_encryption_key_version").notNull(),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create"] }).notNull(),
actionBy: integer("action_by").references(() => client.id),
},
(t) => ({
ref: foreignKey({
columns: [t.userId, t.mekVersion],
foreignColumns: [mek.userId, mek.version],
}),
}),
);
export const clientMek = sqliteTable( export const clientMek = sqliteTable(
"client_master_encryption_key", "client_master_encryption_key",
{ {

View File

@@ -2,31 +2,34 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core";
import { client } from "./client"; import { client } from "./client";
import { user } from "./user"; import { user } from "./user";
export const refreshToken = sqliteTable( export const session = sqliteTable(
"refresh_token", "session",
{ {
id: text("id").primaryKey(), id: text("id").notNull().primaryKey(),
userId: integer("user_id") userId: integer("user_id")
.notNull() .notNull()
.references(() => user.id), .references(() => user.id),
clientId: integer("client_id").references(() => client.id), clientId: integer("client_id").references(() => client.id),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), // Only used for cleanup createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }).notNull(),
lastUsedByIp: text("last_used_by_ip"),
lastUsedByUserAgent: text("last_used_by_user_agent"),
}, },
(t) => ({ (t) => ({
unq: unique().on(t.userId, t.clientId), unq: unique().on(t.userId, t.clientId),
}), }),
); );
export const tokenUpgradeChallenge = sqliteTable("token_upgrade_challenge", { export const sessionUpgradeChallenge = sqliteTable("session_upgrade_challenge", {
id: integer("id").primaryKey(), id: integer("id").primaryKey(),
refreshTokenId: text("refresh_token_id") sessionId: text("session_id")
.notNull() .notNull()
.references(() => refreshToken.id), .references(() => session.id)
.unique(),
clientId: integer("client_id") clientId: integer("client_id")
.notNull() .notNull()
.references(() => client.id), .references(() => client.id),
answer: text("challenge").notNull().unique(), // Base64 answer: text("answer").notNull().unique(), // Base64
allowedIp: text("allowed_ip").notNull(), allowedIp: text("allowed_ip").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
}); });

View File

@@ -4,4 +4,5 @@ export const user = sqliteTable("user", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
password: text("password").notNull(), password: text("password").notNull(),
nickname: text("nickname").notNull(),
}); });

View File

@@ -0,0 +1,128 @@
import { SqliteError } from "better-sqlite3";
import { and, eq, ne, gt, lte, isNull } from "drizzle-orm";
import env from "$lib/server/loadenv";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { session, sessionUpgradeChallenge } from "./schema";
export const createSession = async (
userId: number,
clientId: number | null,
sessionId: string,
ip: string | null,
userAgent: string | null,
) => {
try {
const now = new Date();
await db.insert(session).values({
id: sessionId,
userId,
clientId,
createdAt: now,
lastUsedAt: now,
lastUsedByIp: ip || null,
lastUsedByUserAgent: userAgent || null,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new IntegrityError("Session already exists");
}
throw e;
}
};
export const refreshSession = async (
sessionId: string,
ip: string | null,
userAgent: string | null,
) => {
const now = new Date();
const sessions = await db
.update(session)
.set({
lastUsedAt: now,
lastUsedByIp: ip || undefined,
lastUsedByUserAgent: userAgent || undefined,
})
.where(
and(
eq(session.id, sessionId),
gt(session.lastUsedAt, new Date(now.getTime() - env.session.exp)),
),
)
.returning({ userId: session.userId, clientId: session.clientId });
if (!sessions[0]) {
throw new IntegrityError("Session not found");
}
return sessions[0];
};
export const upgradeSession = async (sessionId: string, clientId: number) => {
const res = await db
.update(session)
.set({ clientId })
.where(and(eq(session.id, sessionId), isNull(session.clientId)));
if (res.changes === 0) {
throw new IntegrityError("Session not found");
}
};
export const deleteSession = async (sessionId: string) => {
await db.delete(session).where(eq(session.id, sessionId));
};
export const deleteAllOtherSessions = async (userId: number, sessionId: string) => {
await db.delete(session).where(and(eq(session.userId, userId), ne(session.id, sessionId)));
};
export const cleanupExpiredSessions = async () => {
await db.delete(session).where(lte(session.lastUsedAt, new Date(Date.now() - env.session.exp)));
};
export const registerSessionUpgradeChallenge = async (
sessionId: string,
clientId: number,
answer: string,
allowedIp: string,
expiresAt: Date,
) => {
try {
await db.insert(sessionUpgradeChallenge).values({
sessionId,
clientId,
answer,
allowedIp,
expiresAt,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new IntegrityError("Challenge already registered");
}
throw e;
}
};
export const consumeSessionUpgradeChallenge = async (
sessionId: string,
answer: string,
ip: string,
) => {
const challenges = await db
.delete(sessionUpgradeChallenge)
.where(
and(
eq(sessionUpgradeChallenge.sessionId, sessionId),
eq(sessionUpgradeChallenge.answer, answer),
eq(sessionUpgradeChallenge.allowedIp, ip),
gt(sessionUpgradeChallenge.expiresAt, new Date()),
),
)
.returning({ clientId: sessionUpgradeChallenge.clientId });
return challenges[0] ?? null;
};
export const cleanupExpiredSessionUpgradeChallenges = async () => {
await db
.delete(sessionUpgradeChallenge)
.where(lte(sessionUpgradeChallenge.expiresAt, new Date()));
};

View File

@@ -1,133 +0,0 @@
import { SqliteError } from "better-sqlite3";
import { and, eq, gt, lte } from "drizzle-orm";
import env from "$lib/server/loadenv";
import db from "./drizzle";
import { refreshToken, tokenUpgradeChallenge } from "./schema";
const expiresAt = () => new Date(Date.now() + env.jwt.refreshExp);
export const registerRefreshToken = async (
userId: number,
clientId: number | null,
tokenId: string,
) => {
try {
await db
.insert(refreshToken)
.values({
id: tokenId,
userId,
clientId,
expiresAt: expiresAt(),
})
.execute();
return true;
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
return false;
}
throw e;
}
};
export const getRefreshToken = async (tokenId: string) => {
const tokens = await db.select().from(refreshToken).where(eq(refreshToken.id, tokenId)).execute();
return tokens[0] ?? null;
};
export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => {
return await db.transaction(async (tx) => {
await tx
.delete(tokenUpgradeChallenge)
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
const res = await db
.update(refreshToken)
.set({
id: newTokenId,
expiresAt: expiresAt(),
})
.where(eq(refreshToken.id, oldTokenId))
.execute();
return res.changes > 0;
});
};
export const upgradeRefreshToken = async (
oldTokenId: string,
newTokenId: string,
clientId: number,
) => {
return await db.transaction(async (tx) => {
await tx
.delete(tokenUpgradeChallenge)
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
const res = await tx
.update(refreshToken)
.set({
id: newTokenId,
clientId,
expiresAt: expiresAt(),
})
.where(eq(refreshToken.id, oldTokenId))
.execute();
return res.changes > 0;
});
};
export const revokeRefreshToken = async (tokenId: string) => {
await db.delete(refreshToken).where(eq(refreshToken.id, tokenId)).execute();
};
export const cleanupExpiredRefreshTokens = async () => {
await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date())).execute();
};
export const registerTokenUpgradeChallenge = async (
tokenId: string,
clientId: number,
answer: string,
allowedIp: string,
expiresAt: Date,
) => {
await db
.insert(tokenUpgradeChallenge)
.values({
refreshTokenId: tokenId,
clientId,
answer,
allowedIp,
expiresAt,
})
.execute();
};
export const getTokenUpgradeChallenge = async (answer: string, ip: string) => {
const challenges = await db
.select()
.from(tokenUpgradeChallenge)
.where(
and(
eq(tokenUpgradeChallenge.answer, answer),
eq(tokenUpgradeChallenge.allowedIp, ip),
gt(tokenUpgradeChallenge.expiresAt, new Date()),
eq(tokenUpgradeChallenge.isUsed, false),
),
)
.execute();
return challenges[0] ?? null;
};
export const markTokenUpgradeChallengeAsUsed = async (id: number) => {
await db
.update(tokenUpgradeChallenge)
.set({ isUsed: true })
.where(eq(tokenUpgradeChallenge.id, id))
.execute();
};
export const cleanupExpiredTokenUpgradeChallenges = async () => {
await db
.delete(tokenUpgradeChallenge)
.where(lte(tokenUpgradeChallenge.expiresAt, new Date()))
.execute();
};

View File

@@ -2,7 +2,20 @@ import { eq } from "drizzle-orm";
import db from "./drizzle"; import db from "./drizzle";
import { user } from "./schema"; import { user } from "./schema";
export const getUserByEmail = async (email: string) => { export const getUser = async (userId: number) => {
const users = await db.select().from(user).where(eq(user.email, email)).execute(); const users = await db.select().from(user).where(eq(user.id, userId)).limit(1);
return users[0] ?? null; return users[0] ?? null;
}; };
export const getUserByEmail = async (email: string) => {
const users = await db.select().from(user).where(eq(user.email, email)).limit(1);
return users[0] ?? null;
};
export const setUserPassword = async (userId: number, password: string) => {
await db.update(user).set({ password }).where(eq(user.id, userId));
};
export const setUserNickname = async (userId: number, nickname: string) => {
await db.update(user).set({ nickname }).where(eq(user.id, userId));
};

View File

@@ -3,19 +3,18 @@ import { building } from "$app/environment";
import { env } from "$env/dynamic/private"; import { env } from "$env/dynamic/private";
if (!building) { if (!building) {
if (!env.JWT_SECRET) throw new Error("JWT_SECRET is not set"); if (!env.SESSION_SECRET) throw new Error("SESSION_SECRET not set");
} }
export default { export default {
databaseUrl: env.DATABASE_URL || "local.db", databaseUrl: env.DATABASE_URL || "local.db",
jwt: { session: {
secret: env.JWT_SECRET, secret: env.SESSION_SECRET!,
accessExp: ms(env.JWT_ACCESS_TOKEN_EXPIRES || "5m"), exp: ms(env.SESSION_EXPIRES || "14d"),
refreshExp: ms(env.JWT_REFRESH_TOKEN_EXPIRES || "14d"),
}, },
challenge: { challenge: {
userClientExp: ms(env.USER_CLIENT_CHALLENGE_EXPIRES || "5m"), userClientExp: ms(env.USER_CLIENT_CHALLENGE_EXPIRES || "5m"),
tokenUpgradeExp: ms(env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m"), sessionUpgradeExp: ms(env.SESSION_UPGRADE_CHALLENGE_EXPIRES || "5m"),
}, },
libraryPath: env.LIBRARY_PATH || "library", libraryPath: env.LIBRARY_PATH || "library",
}; };

View File

@@ -0,0 +1,34 @@
import { error, redirect, type Handle } from "@sveltejs/kit";
import { authenticate, AuthenticationError } from "$lib/server/modules/auth";
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { pathname, search } = event.url;
if (pathname === "/api/auth/login") {
return await resolve(event);
}
try {
const sessionIdSigned = event.cookies.get("sessionId");
if (!sessionIdSigned) {
throw new AuthenticationError(401, "Session id not found");
}
const { ip, userAgent } = event.locals;
event.locals.session = await authenticate(sessionIdSigned, ip, userAgent);
} catch (e) {
if (e instanceof AuthenticationError) {
if (pathname === "/auth/login") {
return await resolve(event);
} else if (pathname.startsWith("/api")) {
error(e.status, e.message);
} else {
redirect(302, "/auth/login?redirect=" + encodeURIComponent(pathname + search));
}
}
throw e;
}
return await resolve(event);
};
export default authenticateMiddleware;

View File

@@ -0,0 +1,2 @@
export { default as authenticate } from "./authenticate";
export { default as setAgentInfo } from "./setAgentInfo";

View File

@@ -0,0 +1,18 @@
import { error, type Handle } from "@sveltejs/kit";
export const setAgentInfoMiddleware: Handle = async ({ event, resolve }) => {
const ip = event.getClientAddress();
const userAgent = event.request.headers.get("User-Agent");
if (!ip) {
error(500, "IP address not found");
} else if (!userAgent && !event.isSubRequest) {
error(400, "User agent not found");
}
event.locals.ip = ip;
event.locals.userAgent = userAgent ?? "";
return await resolve(event);
};
export default setAgentInfoMiddleware;

View File

@@ -1,90 +1,127 @@
import { error, type Cookies } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import jwt from "jsonwebtoken";
import { getUserClient } from "$lib/server/db/client"; import { getUserClient } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { createSession, refreshSession } from "$lib/server/db/session";
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
import { issueSessionId, verifySessionId } from "$lib/server/modules/crypto";
type TokenPayload = interface Session {
| { sessionId: string;
type: "access"; userId: number;
userId: number; clientId?: number;
clientId?: number;
}
| {
type: "refresh";
jti: string;
};
export enum TokenError {
EXPIRED,
INVALID,
} }
type Permission = "pendingClient" | "activeClient"; interface ClientSession extends Session {
clientId: number;
}
export const issueToken = (payload: TokenPayload) => { export class AuthenticationError extends Error {
return jwt.sign(payload, env.jwt.secret, { constructor(
expiresIn: (payload.type === "access" ? env.jwt.accessExp : env.jwt.refreshExp) / 1000, public status: 400 | 401,
}); message: string,
) {
super(message);
this.name = "AuthenticationError";
}
}
export const startSession = async (userId: number, ip: string, userAgent: string) => {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await createSession(userId, null, sessionId, ip, userAgent);
return sessionIdSigned;
}; };
export const verifyToken = (token: string) => { export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => {
const sessionId = verifySessionId(sessionIdSigned, env.session.secret);
if (!sessionId) {
throw new AuthenticationError(400, "Invalid session id");
}
try { try {
return jwt.verify(token, env.jwt.secret) as TokenPayload; const { userId, clientId } = await refreshSession(sessionId, ip, userAgent);
} catch (error) { return {
if (error instanceof jwt.TokenExpiredError) { id: sessionId,
return TokenError.EXPIRED; userId,
clientId: clientId ?? undefined,
};
} catch (e) {
if (e instanceof IntegrityError && e.message === "Session not found") {
throw new AuthenticationError(401, "Invalid session id");
} }
return TokenError.INVALID; throw e;
} }
}; };
export const authenticate = (cookies: Cookies) => { export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>;
const accessToken = cookies.get("accessToken");
if (!accessToken) {
error(401, "Access token not found");
}
const tokenPayload = verifyToken(accessToken);
if (tokenPayload === TokenError.EXPIRED) {
error(401, "Access token expired");
} else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "access") {
error(401, "Invalid access token");
}
return {
userId: tokenPayload.userId,
clientId: tokenPayload.clientId,
};
};
export async function authorize( export async function authorize(
cookies: Cookies, locals: App.Locals,
requiredPermission: "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "pendingClient", requiredPermission: "pendingClient",
): Promise<{ userId: number; clientId: number }>; ): Promise<ClientSession>;
export async function authorize( export async function authorize(
cookies: Cookies, locals: App.Locals,
requiredPermission: "activeClient", requiredPermission: "activeClient",
): Promise<{ userId: number; clientId: number }>; ): Promise<ClientSession>;
export async function authorize( export async function authorize(
cookies: Cookies, locals: App.Locals,
requiredPermission: Permission, requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
): Promise<{ userId: number; clientId?: number }> { ): Promise<Session> {
const tokenPayload = authenticate(cookies); if (!locals.session) {
const { userId, clientId } = tokenPayload; error(500, "Unauthenticated");
const userClient = clientId ? await getUserClient(userId, clientId) : undefined; }
const { id: sessionId, userId, clientId } = locals.session;
switch (requiredPermission) { switch (requiredPermission) {
case "pendingClient": case "any":
if (!userClient || userClient.state !== "pending") { break;
case "notClient":
if (clientId) {
error(403, "Forbidden"); error(403, "Forbidden");
} }
return tokenPayload; break;
case "activeClient": case "anyClient":
if (!userClient || userClient.state !== "active") { if (!clientId) {
error(403, "Forbidden"); error(403, "Forbidden");
} }
return tokenPayload; break;
case "pendingClient": {
if (!clientId) {
error(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
} else if (userClient.state !== "pending") {
error(403, "Forbidden");
}
break;
}
case "activeClient": {
if (!clientId) {
error(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
} else if (userClient.state !== "active") {
error(403, "Forbidden");
}
break;
}
} }
return { sessionId, userId, clientId };
} }

View File

@@ -1,4 +1,12 @@
import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto"; import {
constants,
randomBytes,
createPublicKey,
publicEncrypt,
verify,
createHmac,
timingSafeEqual,
} from "crypto";
import { promisify } from "util"; import { promisify } from "util";
const makePubKeyToPem = (pubKey: string) => const makePubKeyToPem = (pubKey: string) =>
@@ -34,3 +42,26 @@ export const generateChallenge = async (length: number, encPubKey: string) => {
const challenge = encryptAsymmetric(answer, encPubKey); const challenge = encryptAsymmetric(answer, encPubKey);
return { answer, challenge }; return { answer, challenge };
}; };
export const issueSessionId = async (length: number, secret: string) => {
const sessionId = await promisify(randomBytes)(length);
const sessionIdHex = sessionId.toString("hex");
const sessionIdHmac = createHmac("sha256", secret).update(sessionId).digest("hex");
return {
sessionId: sessionIdHex,
sessionIdSigned: `${sessionIdHex}.${sessionIdHmac}`,
};
};
export const verifySessionId = (sessionIdSigned: string, secret: string) => {
const [sessionIdHex, sessionIdHmac] = sessionIdSigned.split(".");
if (!sessionIdHex || !sessionIdHmac) return;
if (
timingSafeEqual(
Buffer.from(sessionIdHmac, "hex"),
createHmac("sha256", secret).update(Buffer.from(sessionIdHex, "hex")).digest(),
)
) {
return sessionIdHex;
}
};

View File

@@ -17,7 +17,7 @@ export const verifyClientEncMekSig = async (
) => { ) => {
const userClient = await getUserClientWithDetails(userId, clientId); const userClient = await getUserClientWithDetails(userId, clientId);
if (!userClient) { if (!userClient) {
error(500, "Invalid access token"); error(500, "Invalid session id");
} }
const data = JSON.stringify({ version, key: encMek }); const data = JSON.stringify({ version, key: encMek });

View File

@@ -1,24 +1,30 @@
import { z } from "zod"; import { z } from "zod";
export const passwordChangeRequest = z.object({
oldPassword: z.string().trim().nonempty(),
newPassword: z.string().trim().nonempty(),
});
export type PasswordChangeRequest = z.infer<typeof passwordChangeRequest>;
export const loginRequest = z.object({ export const loginRequest = z.object({
email: z.string().email().nonempty(), email: z.string().email().nonempty(),
password: z.string().trim().nonempty(), password: z.string().trim().nonempty(),
}); });
export type LoginRequest = z.infer<typeof loginRequest>; export type LoginRequest = z.infer<typeof loginRequest>;
export const tokenUpgradeRequest = z.object({ export const sessionUpgradeRequest = z.object({
encPubKey: z.string().base64().nonempty(), encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(), sigPubKey: z.string().base64().nonempty(),
}); });
export type TokenUpgradeRequest = z.infer<typeof tokenUpgradeRequest>; export type SessionUpgradeRequest = z.infer<typeof sessionUpgradeRequest>;
export const tokenUpgradeResponse = z.object({ export const sessionUpgradeResponse = z.object({
challenge: z.string().base64().nonempty(), challenge: z.string().base64().nonempty(),
}); });
export type TokenUpgradeResponse = z.infer<typeof tokenUpgradeResponse>; export type SessionUpgradeResponse = z.infer<typeof sessionUpgradeResponse>;
export const tokenUpgradeVerifyRequest = z.object({ export const sessionUpgradeVerifyRequest = z.object({
answer: z.string().base64().nonempty(), answer: z.string().base64().nonempty(),
answerSig: z.string().base64().nonempty(), answerSig: z.string().base64().nonempty(),
}); });
export type TokenUpgradeVerifyRequest = z.infer<typeof tokenUpgradeVerifyRequest>; export type SessionUpgradeVerifyRequest = z.infer<typeof sessionUpgradeVerifyRequest>;

View File

@@ -3,7 +3,6 @@ import { z } from "zod";
export const directoryInfoResponse = z.object({ export const directoryInfoResponse = z.object({
metadata: z metadata: z
.object({ .object({
createdAt: z.string().datetime(),
mekVersion: z.number().int().positive(), mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(), dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(), dekVersion: z.string().datetime(),

View File

@@ -2,7 +2,6 @@ import mime from "mime";
import { z } from "zod"; import { z } from "zod";
export const fileInfoResponse = z.object({ export const fileInfoResponse = z.object({
createdAt: z.string().datetime(),
mekVersion: z.number().int().positive(), mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(), dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(), dekVersion: z.string().datetime(),
@@ -23,11 +22,24 @@ export const fileRenameRequest = z.object({
}); });
export type FileRenameRequest = z.infer<typeof fileRenameRequest>; export type FileRenameRequest = z.infer<typeof fileRenameRequest>;
export const duplicateFileScanRequest = z.object({
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
});
export type DuplicateFileScanRequest = z.infer<typeof duplicateFileScanRequest>;
export const duplicateFileScanResponse = z.object({
files: z.number().int().positive().array(),
});
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
export const fileUploadRequest = z.object({ export const fileUploadRequest = z.object({
parentId: z.union([z.enum(["root"]), z.number().int().positive()]), parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(), mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(), dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(), dekVersion: z.string().datetime(),
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
contentType: z contentType: z
.string() .string()
.nonempty() .nonempty()

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.infer<typeof hmacSecretListResponse>;
export const initialHmacSecretRegisterRequest = z.object({
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
});
export type InitialHmacSecretRegisterRequest = z.infer<typeof initialHmacSecretRegisterRequest>;

View File

@@ -2,4 +2,6 @@ export * from "./auth";
export * from "./client"; export * from "./client";
export * from "./directory"; export * from "./directory";
export * from "./file"; export * from "./file";
export * from "./hsk";
export * from "./mek"; export * from "./mek";
export * from "./user";

View File

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

View File

@@ -1,164 +1,121 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import argon2 from "argon2"; import argon2 from "argon2";
import { v4 as uuidv4 } from "uuid";
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client"; import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
import { getUserByEmail } from "$lib/server/db/user"; import { IntegrityError } from "$lib/server/db/error";
import env from "$lib/server/loadenv";
import { import {
getRefreshToken, upgradeSession,
registerRefreshToken, deleteSession,
rotateRefreshToken, deleteAllOtherSessions,
upgradeRefreshToken, registerSessionUpgradeChallenge,
revokeRefreshToken, consumeSessionUpgradeChallenge,
registerTokenUpgradeChallenge, } from "$lib/server/db/session";
getTokenUpgradeChallenge, import { getUser, getUserByEmail, setUserPassword } from "$lib/server/db/user";
markTokenUpgradeChallengeAsUsed, import env from "$lib/server/loadenv";
} from "$lib/server/db/token"; import { startSession } from "$lib/server/modules/auth";
import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth";
import { verifySignature, generateChallenge } from "$lib/server/modules/crypto"; import { verifySignature, generateChallenge } from "$lib/server/modules/crypto";
const hashPassword = async (password: string) => {
return await argon2.hash(password);
};
const verifyPassword = async (hash: string, password: string) => { const verifyPassword = async (hash: string, password: string) => {
return await argon2.verify(hash, password); return await argon2.verify(hash, password);
}; };
const issueAccessToken = (userId: number, clientId?: number) => { export const changePassword = async (
return issueToken({ type: "access", userId, clientId }); userId: number,
}; sessionId: string,
oldPassword: string,
const issueRefreshToken = async (userId: number, clientId?: number) => { newPassword: string,
const jti = uuidv4(); ) => {
const token = issueToken({ type: "refresh", jti }); if (oldPassword === newPassword) {
error(400, "Same passwords");
if (!(await registerRefreshToken(userId, clientId ?? null, jti))) { } else if (newPassword.length < 8) {
error(403, "Already logged in"); error(400, "Too short password");
} }
return token;
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
} else if (!(await verifyPassword(user.password, oldPassword))) {
error(403, "Invalid password");
}
await setUserPassword(userId, await hashPassword(newPassword));
await deleteAllOtherSessions(userId, sessionId);
}; };
export const login = async (email: string, password: string) => { export const login = async (email: string, password: string, ip: string, userAgent: string) => {
const user = await getUserByEmail(email); const user = await getUserByEmail(email);
if (!user || !(await verifyPassword(user.password, password))) { if (!user || !(await verifyPassword(user.password, password))) {
error(401, "Invalid email or password"); error(401, "Invalid email or password");
} }
return { try {
accessToken: issueAccessToken(user.id), return { sessionIdSigned: await startSession(user.id, ip, userAgent) };
refreshToken: await issueRefreshToken(user.id), } catch (e) {
}; if (e instanceof IntegrityError && e.message === "Session already exists") {
}; error(403, "Already logged in");
}
const verifyRefreshToken = async (refreshToken: string) => { throw e;
const tokenPayload = verifyToken(refreshToken);
if (tokenPayload === TokenError.EXPIRED) {
error(401, "Refresh token expired");
} else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "refresh") {
error(401, "Invalid refresh token");
} }
const tokenData = await getRefreshToken(tokenPayload.jti);
if (!tokenData) {
error(500, "Refresh token not found");
}
return {
jti: tokenPayload.jti,
userId: tokenData.userId,
clientId: tokenData.clientId ?? undefined,
};
}; };
export const logout = async (refreshToken: string) => { export const logout = async (sessionId: string) => {
const { jti } = await verifyRefreshToken(refreshToken); await deleteSession(sessionId);
await revokeRefreshToken(jti);
}; };
export const refreshToken = async (refreshToken: string) => { export const createSessionUpgradeChallenge = async (
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); sessionId: string,
const newJti = uuidv4(); userId: number,
if (!(await rotateRefreshToken(oldJti, newJti))) {
error(500, "Refresh token not found");
}
return {
accessToken: issueAccessToken(userId, clientId),
refreshToken: issueToken({ type: "refresh", jti: newJti }),
};
};
const expiresAt = () => new Date(Date.now() + env.challenge.tokenUpgradeExp);
const createChallenge = async (
ip: string,
tokenId: string,
clientId: number,
encPubKey: string,
) => {
const { answer, challenge } = await generateChallenge(32, encPubKey);
await registerTokenUpgradeChallenge(
tokenId,
clientId,
answer.toString("base64"),
ip,
expiresAt(),
);
return challenge.toString("base64");
};
export const createTokenUpgradeChallenge = async (
refreshToken: string,
ip: string, ip: string,
encPubKey: string, encPubKey: string,
sigPubKey: string, sigPubKey: string,
) => { ) => {
const { jti, userId, clientId } = await verifyRefreshToken(refreshToken);
if (clientId) {
error(403, "Forbidden");
}
const client = await getClientByPubKeys(encPubKey, sigPubKey); const client = await getClientByPubKeys(encPubKey, sigPubKey);
const userClient = client ? await getUserClient(userId, client.id) : undefined; const userClient = client ? await getUserClient(userId, client.id) : undefined;
if (!client) { if (!client) {
error(401, "Invalid public key(s)"); error(401, "Invalid public key(s)");
} else if (!userClient || userClient.state === "challenging") { } else if (!userClient || userClient.state === "challenging") {
error(401, "Unregistered client"); error(403, "Unregistered client");
} }
return { challenge: await createChallenge(ip, jti, client.id, encPubKey) }; const { answer, challenge } = await generateChallenge(32, encPubKey);
await registerSessionUpgradeChallenge(
sessionId,
client.id,
answer.toString("base64"),
ip,
new Date(Date.now() + env.challenge.sessionUpgradeExp),
);
return { challenge: challenge.toString("base64") };
}; };
export const upgradeToken = async ( export const verifySessionUpgradeChallenge = async (
refreshToken: string, sessionId: string,
ip: string, ip: string,
answer: string, answer: string,
answerSig: string, answerSig: string,
) => { ) => {
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); const challenge = await consumeSessionUpgradeChallenge(sessionId, answer, ip);
if (clientId) {
error(403, "Forbidden");
}
const challenge = await getTokenUpgradeChallenge(answer, ip);
if (!challenge) { if (!challenge) {
error(401, "Invalid challenge answer"); error(403, "Invalid challenge answer");
} else if (challenge.refreshTokenId !== oldJti) {
error(403, "Forbidden");
} }
const client = await getClient(challenge.clientId); const client = await getClient(challenge.clientId);
if (!client) { if (!client) {
error(500, "Invalid challenge answer"); error(500, "Invalid challenge answer");
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) { } else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) {
error(401, "Invalid challenge answer signature"); error(403, "Invalid challenge answer signature");
} }
await markTokenUpgradeChallengeAsUsed(challenge.id); try {
await upgradeSession(sessionId, client.id);
const newJti = uuidv4(); } catch (e) {
if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) { if (e instanceof IntegrityError && e.message === "Session not found") {
error(500, "Refresh token not found"); error(500, "Invalid challenge answer");
}
throw e;
} }
return {
accessToken: issueAccessToken(userId, client.id),
refreshToken: issueToken({ type: "refresh", jti: newJti }),
};
}; };

View File

@@ -3,15 +3,14 @@ import {
createClient, createClient,
getClient, getClient,
getClientByPubKeys, getClientByPubKeys,
countClientByPubKey,
createUserClient, createUserClient,
getAllUserClients, getAllUserClients,
getUserClient, getUserClient,
setUserClientStateToPending, setUserClientStateToPending,
registerUserClientChallenge, registerUserClientChallenge,
getUserClientChallenge, consumeUserClientChallenge,
markUserClientChallengeAsUsed,
} from "$lib/server/db/client"; } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto"; import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import { isInitialMekNeeded } from "$lib/server/modules/mek"; import { isInitialMekNeeded } from "$lib/server/modules/mek";
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
@@ -29,8 +28,8 @@ export const getUserClientList = async (userId: number) => {
const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp); const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp);
const createUserClientChallenge = async ( const createUserClientChallenge = async (
userId: number,
ip: string, ip: string,
userId: number,
clientId: number, clientId: number,
encPubKey: string, encPubKey: string,
) => { ) => {
@@ -45,45 +44,34 @@ export const registerUserClient = async (
encPubKey: string, encPubKey: string,
sigPubKey: string, sigPubKey: string,
) => { ) => {
let clientId;
const client = await getClientByPubKeys(encPubKey, sigPubKey); const client = await getClientByPubKeys(encPubKey, sigPubKey);
if (client) { if (client) {
const userClient = await getUserClient(userId, client.id); try {
if (userClient) { await createUserClient(userId, client.id);
error(409, "Client already registered"); return { challenge: 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;
} }
await createUserClient(userId, client.id);
clientId = client.id;
} else { } else {
if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) { if (encPubKey === sigPubKey) {
error(400, "Same public keys");
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
error(400, "Invalid public key(s)"); error(400, "Invalid public key(s)");
} else if (encPubKey === sigPubKey) {
error(400, "Public keys must be different");
} else if (
(await countClientByPubKey(encPubKey)) > 0 ||
(await countClientByPubKey(sigPubKey)) > 0
) {
error(409, "Public key(s) already registered");
} }
clientId = await createClient(encPubKey, sigPubKey, userId); try {
const clientId = await createClient(encPubKey, sigPubKey, userId);
return { challenge: 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;
}
} }
return { challenge: await createUserClientChallenge(userId, ip, clientId, encPubKey) };
};
export const getUserClientStatus = async (userId: number, clientId: number) => {
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid access token");
}
return {
state: userClient.state as "pending" | "active",
isInitialMekNeeded: await isInitialMekNeeded(userId),
};
}; };
export const verifyUserClient = async ( export const verifyUserClient = async (
@@ -92,20 +80,29 @@ export const verifyUserClient = async (
answer: string, answer: string,
answerSig: string, answerSig: string,
) => { ) => {
const challenge = await getUserClientChallenge(answer, ip); const challenge = await consumeUserClientChallenge(userId, answer, ip);
if (!challenge) { if (!challenge) {
error(401, "Invalid challenge answer"); error(403, "Invalid challenge answer");
} else if (challenge.userId !== userId) {
error(403, "Forbidden");
} }
const client = await getClient(challenge.clientId); const client = await getClient(challenge.clientId);
if (!client) { if (!client) {
error(500, "Invalid challenge answer"); error(500, "Invalid challenge answer");
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) { } else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) {
error(401, "Invalid challenge answer signature"); error(403, "Invalid challenge answer signature");
} }
await markUserClientChallengeAsUsed(challenge.id); await setUserClientStateToPending(userId, client.id);
await setUserClientStateToPending(userId, challenge.clientId); };
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,44 +1,15 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { unlink } from "fs/promises"; import { unlink } from "fs/promises";
import { IntegrityError } from "$lib/server/db/error";
import { import {
registerDirectory,
getAllDirectoriesByParent, getAllDirectoriesByParent,
registerNewDirectory,
getDirectory, getDirectory,
setDirectoryEncName, setDirectoryEncName,
unregisterDirectory, unregisterDirectory,
getAllFilesByParent, getAllFilesByParent,
type NewDirectoryParams, type NewDirectoryParams,
} from "$lib/server/db/file"; } from "$lib/server/db/file";
import { getActiveMekVersion } from "$lib/server/db/mek";
export const deleteDirectory = async (userId: number, directoryId: number) => {
const directory = await getDirectory(userId, directoryId);
if (!directory) {
error(404, "Invalid directory id");
}
const filePaths = await unregisterDirectory(userId, directoryId);
filePaths.map((path) => unlink(path)); // Intended
};
export const renameDirectory = async (
userId: number,
directoryId: number,
dekVersion: Date,
newEncName: string,
newEncNameIv: string,
) => {
const directory = await getDirectory(userId, directoryId);
if (!directory) {
error(404, "Invalid directory id");
} else if (directory.dekVersion.getTime() !== dekVersion.getTime()) {
error(400, "Invalid DEK version");
}
if (!(await setDirectoryEncName(userId, directoryId, dekVersion, newEncName, newEncNameIv))) {
error(500, "Invalid directory id or DEK version");
}
};
export const getDirectoryInformation = async (userId: number, directoryId: "root" | number) => { export const getDirectoryInformation = async (userId: number, directoryId: "root" | number) => {
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined; const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
@@ -51,7 +22,6 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
return { return {
metadata: directory && { metadata: directory && {
createdAt: directory.createdAt,
mekVersion: directory.mekVersion, mekVersion: directory.mekVersion,
encDek: directory.encDek, encDek: directory.encDek,
dekVersion: directory.dekVersion, dekVersion: directory.dekVersion,
@@ -62,19 +32,52 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
}; };
}; };
export const createDirectory = async (params: NewDirectoryParams) => { export const deleteDirectory = async (userId: number, directoryId: number) => {
const activeMekVersion = await getActiveMekVersion(params.userId); try {
if (activeMekVersion === null) { const filePaths = await unregisterDirectory(userId, directoryId);
error(500, "Invalid MEK version"); filePaths.map((path) => unlink(path)); // Intended
} else if (activeMekVersion !== params.mekVersion) { } catch (e) {
error(400, "Invalid MEK version"); if (e instanceof IntegrityError && e.message === "Directory not found") {
error(404, "Invalid directory id");
}
throw e;
} }
};
export const renameDirectory = async (
userId: number,
directoryId: number,
dekVersion: Date,
newEncName: string,
newEncNameIv: string,
) => {
try {
await setDirectoryEncName(userId, directoryId, dekVersion, newEncName, newEncNameIv);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
error(404, "Invalid directory id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const createDirectory = async (params: NewDirectoryParams) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000); const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000); const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) { if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version"); error(400, "Invalid DEK version");
} }
await registerNewDirectory(params); try {
await registerDirectory(params);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
}
throw e;
}
}; };

View File

@@ -1,43 +1,46 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { createReadStream, createWriteStream, ReadStream, WriteStream } from "fs"; import { createReadStream, createWriteStream } from "fs";
import { mkdir, stat, unlink } from "fs/promises"; import { mkdir, stat, unlink } from "fs/promises";
import { dirname } from "path"; import { dirname } from "path";
import { Readable, Writable } from "stream";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { IntegrityError } from "$lib/server/db/error";
import { import {
registerNewFile, registerFile,
getAllFileIdsByContentHmac,
getFile, getFile,
setFileEncName, setFileEncName,
unregisterFile, unregisterFile,
type NewFileParams, type NewFileParams,
} from "$lib/server/db/file"; } from "$lib/server/db/file";
import { getActiveMekVersion } from "$lib/server/db/mek";
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
export const deleteFile = async (userId: number, fileId: number) => { export const getFileInformation = async (userId: number, fileId: number) => {
const file = await getFile(userId, fileId); const file = await getFile(userId, fileId);
if (!file) { if (!file) {
error(404, "Invalid file id"); error(404, "Invalid file id");
} }
const path = await unregisterFile(userId, fileId); return {
if (!path) { mekVersion: file.mekVersion,
error(500, "Invalid file id"); encDek: file.encDek,
} dekVersion: file.dekVersion,
contentType: file.contentType,
unlink(path); // Intended encContentIv: file.encContentIv,
encName: file.encName,
};
}; };
const convertToReadableStream = (readStream: ReadStream) => { export const deleteFile = async (userId: number, fileId: number) => {
return new ReadableStream<Uint8Array>({ try {
start: (controller) => { const filePath = await unregisterFile(userId, fileId);
readStream.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer))); unlink(filePath); // Intended
readStream.on("end", () => controller.close()); } catch (e) {
readStream.on("error", (e) => controller.error(e)); if (e instanceof IntegrityError && e.message === "File not found") {
}, error(404, "Invalid file id");
cancel: () => { }
readStream.destroy(); throw e;
}, }
});
}; };
export const getFileStream = async (userId: number, fileId: number) => { export const getFileStream = async (userId: number, fileId: number) => {
@@ -48,7 +51,7 @@ export const getFileStream = async (userId: number, fileId: number) => {
const { size } = await stat(file.path); const { size } = await stat(file.path);
return { return {
encContentStream: convertToReadableStream(createReadStream(file.path)), encContentStream: Readable.toWeb(createReadStream(file.path)),
encContentSize: size, encContentSize: size,
}; };
}; };
@@ -60,49 +63,27 @@ export const renameFile = async (
newEncName: string, newEncName: string,
newEncNameIv: string, newEncNameIv: string,
) => { ) => {
const file = await getFile(userId, fileId); try {
if (!file) { await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv);
error(404, "Invalid file id"); } catch (e) {
} else if (file.dekVersion.getTime() !== dekVersion.getTime()) { if (e instanceof IntegrityError) {
error(400, "Invalid DEK version"); if (e.message === "File not found") {
} error(404, "Invalid file id");
} else if (e.message === "Invalid DEK version") {
if (!(await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv))) { error(400, "Invalid DEK version");
error(500, "Invalid file id or DEK version"); }
}
throw e;
} }
}; };
export const getFileInformation = async (userId: number, fileId: number) => { export const scanDuplicateFiles = async (
const file = await getFile(userId, fileId); userId: number,
if (!file) { hskVersion: number,
error(404, "Invalid file id"); contentHmac: string,
} ) => {
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
return { return { files: fileIds.map(({ id }) => id) };
createdAt: file.createdAt,
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
encContentIv: file.encContentIv,
encName: file.encName,
};
};
const convertToWritableStream = (writeStream: WriteStream) => {
return new WritableStream<Uint8Array>({
write: (chunk) =>
new Promise((resolve, reject) => {
writeStream.write(chunk, (e) => {
if (e) {
reject(e);
} else {
resolve();
}
});
}),
close: () => new Promise((resolve) => writeStream.end(resolve)),
});
}; };
const safeUnlink = async (path: string) => { const safeUnlink = async (path: string) => {
@@ -113,13 +94,6 @@ export const uploadFile = async (
params: Omit<NewFileParams, "path">, params: Omit<NewFileParams, "path">,
encContentStream: ReadableStream<Uint8Array>, encContentStream: ReadableStream<Uint8Array>,
) => { ) => {
const activeMekVersion = await getActiveMekVersion(params.userId);
if (activeMekVersion === null) {
error(500, "Invalid MEK version");
} else if (activeMekVersion !== params.mekVersion) {
error(400, "Invalid MEK version");
}
const oneMinuteAgo = new Date(Date.now() - 60 * 1000); const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000); const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) { if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
@@ -131,14 +105,20 @@ export const uploadFile = async (
try { try {
await encContentStream.pipeTo( await encContentStream.pipeTo(
convertToWritableStream(createWriteStream(path, { flags: "wx", mode: 0o600 })), Writable.toWeb(createWriteStream(path, { flags: "wx", mode: 0o600 })),
); );
await registerNewFile({ await registerFile({
...params, ...params,
path, path,
}); });
} catch (e) { } catch (e) {
await safeUnlink(path); await safeUnlink(path);
if (e instanceof IntegrityError) {
if (e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
}
}
throw e; throw e;
} }
}; };

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

@@ -1,7 +1,8 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { setUserClientStateToActive } from "$lib/server/db/client"; import { setUserClientStateToActive } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek"; import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek";
import { isInitialMekNeeded, verifyClientEncMekSig } from "$lib/server/modules/mek"; import { verifyClientEncMekSig } from "$lib/server/modules/mek";
export const getClientMekList = async (userId: number, clientId: number) => { export const getClientMekList = async (userId: number, clientId: number) => {
const clientMeks = await getAllValidClientMeks(userId, clientId); const clientMeks = await getAllValidClientMeks(userId, clientId);
@@ -21,12 +22,17 @@ export const registerInitialActiveMek = async (
encMek: string, encMek: string,
encMekSig: string, encMekSig: string,
) => { ) => {
if (!(await isInitialMekNeeded(userId))) { if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
error(409, "Initial MEK already registered");
} else if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
error(400, "Invalid signature"); error(400, "Invalid signature");
} }
await registerInitialMek(userId, createdBy, encMek, encMekSig); try {
await setUserClientStateToActive(userId, createdBy); 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

@@ -1,37 +1,30 @@
import { encodeToBase64, decryptChallenge, signMessage } from "$lib/modules/crypto"; import { callPostApi } from "$lib/hooks";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import type { import type {
TokenUpgradeRequest, SessionUpgradeRequest,
TokenUpgradeResponse, SessionUpgradeResponse,
TokenUpgradeVerifyRequest, SessionUpgradeVerifyRequest,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
export const requestTokenUpgrade = async ( export const requestSessionUpgrade = async (
encryptKeyBase64: string, encryptKeyBase64: string,
decryptKey: CryptoKey, decryptKey: CryptoKey,
verifyKeyBase64: string, verifyKeyBase64: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
let res = await fetch("/api/auth/upgradeToken", { let res = await callPostApi<SessionUpgradeRequest>("/api/auth/upgradeSession", {
method: "POST", encPubKey: encryptKeyBase64,
headers: { "Content-Type": "application/json" }, sigPubKey: verifyKeyBase64,
body: JSON.stringify({
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
} satisfies TokenUpgradeRequest),
}); });
if (!res.ok) return false; if (!res.ok) return false;
const { challenge }: TokenUpgradeResponse = await res.json(); const { challenge }: SessionUpgradeResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey); const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessage(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
res = await fetch("/api/auth/upgradeToken/verify", { res = await callPostApi<SessionUpgradeVerifyRequest>("/api/auth/upgradeSession/verify", {
method: "POST", answer: encodeToBase64(answer),
headers: { "Content-Type": "application/json" }, answerSig: encodeToBase64(answerSig),
body: JSON.stringify({
answer: encodeToBase64(answer),
answerSig: encodeToBase64(answerSig),
} satisfies TokenUpgradeVerifyRequest),
}); });
return res.ok; return res.ok;
}; };

View File

@@ -3,7 +3,7 @@ import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
decryptChallenge, decryptChallenge,
signMessage, signMessageRSA,
unwrapMasterKey, unwrapMasterKey,
verifyMasterKeyWrapped, verifyMasterKeyWrapped,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
@@ -29,7 +29,7 @@ export const requestClientRegistration = async (
const { challenge }: ClientRegisterResponse = await res.json(); const { challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey); const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessage(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", { res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
answer: encodeToBase64(answer), answer: encodeToBase64(answer),

View File

@@ -13,6 +13,14 @@ export interface MasterKey {
key: CryptoKey; key: CryptoKey;
} }
export interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
export const clientKeyStore = writable<ClientKeys | null>(null); export const clientKeyStore = writable<ClientKeys | null>(null);
export const masterKeyStore = writable<Map<number, MasterKey> | null>(null); export const masterKeyStore = writable<Map<number, MasterKey> | null>(null);
export const hmacSecretStore = writable<Map<number, HmacSecret> | null>(null);

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { Button } from "$lib/components/buttons";
import { TitleDiv, BottomDiv } from "$lib/components/divs";
import { TextInput } from "$lib/components/inputs";
import { requestPasswordChange } from "./service";
let oldPassword = $state("");
let newPassword = $state("");
const changePassword = async () => {
if (await requestPasswordChange(oldPassword, newPassword)) {
await goto("/menu");
}
};
</script>
<svelte:head>
<title>비밀번호 바꾸기</title>
</svelte:head>
<div>
<TopBar />
<TitleDiv topPadding={false}>
<div class="space-y-2 break-keep">
<p class="text-2xl font-bold">기존 비밀번호와 새 비밀번호를 입력해 주세요.</p>
<p>새 비밀번호는 8자 이상이어야 해요. 다른 사람들이 알 수 없도록 안전하게 설정해 주세요.</p>
</div>
<div class="my-4 flex flex-col gap-y-2">
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
</div>
</TitleDiv>
</div>
<BottomDiv>
<Button onclick={changePassword}>비밀번호 바꾸기</Button>
</BottomDiv>

View File

@@ -0,0 +1,10 @@
import { callPostApi } from "$lib/hooks";
import type { PasswordChangeRequest } from "$lib/server/schemas";
export const requestPasswordChange = async (oldPassword: string, newPassword: string) => {
const res = await callPostApi<PasswordChangeRequest>("/api/auth/changePassword", {
oldPassword,
newPassword,
});
return res.ok;
};

View File

@@ -1,11 +1,10 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ url, cookies }) => { export const load: PageServerLoad = async ({ locals, url }) => {
const redirectPath = url.searchParams.get("redirect") || "/home"; const redirectPath = url.searchParams.get("redirect") || "/home";
const accessToken = cookies.get("accessToken"); if (locals.session) {
if (accessToken) {
redirect(302, redirectPath); redirect(302, redirectPath);
} }

View File

@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { Button, TextButton } from "$lib/components/buttons"; import { Button, TextButton } from "$lib/components/buttons";
import { TitleDiv, BottomDiv } from "$lib/components/divs"; import { TitleDiv, BottomDiv } from "$lib/components/divs";
import { TextInput } from "$lib/components/inputs"; import { TextInput } from "$lib/components/inputs";
import { refreshToken } from "$lib/hooks";
import { clientKeyStore, masterKeyStore } from "$lib/stores"; import { clientKeyStore, masterKeyStore } from "$lib/stores";
import { requestLogin, requestTokenUpgrade, requestMasterKeyDownload } from "./service"; import { requestLogin, requestSessionUpgrade, requestMasterKeyDownload } from "./service";
let { data } = $props(); let { data } = $props();
@@ -25,7 +23,8 @@
if (!$clientKeyStore) return await redirect("/key/generate"); if (!$clientKeyStore) return await redirect("/key/generate");
if (!(await requestTokenUpgrade($clientKeyStore))) throw new Error("Failed to upgrade token"); if (!(await requestSessionUpgrade($clientKeyStore)))
throw new Error("Failed to upgrade session");
// TODO: Multi-user support // TODO: Multi-user support
@@ -42,13 +41,6 @@
throw e; throw e;
} }
}; };
onMount(async () => {
const res = await refreshToken();
if (res.ok) {
await goto(data.redirectPath, { replaceState: true });
}
});
</script> </script>
<svelte:head> <svelte:head>

View File

@@ -1,21 +1,18 @@
import { callPostApi } from "$lib/hooks";
import { exportRSAKeyToBase64 } from "$lib/modules/crypto"; import { exportRSAKeyToBase64 } from "$lib/modules/crypto";
import type { LoginRequest } from "$lib/server/schemas"; import type { LoginRequest } from "$lib/server/schemas";
import { requestTokenUpgrade as requestTokenUpgradeInternal } from "$lib/services/auth"; import { requestSessionUpgrade as requestSessionUpgradeInternal } from "$lib/services/auth";
import { requestClientRegistration } from "$lib/services/key"; import { requestClientRegistration } from "$lib/services/key";
import type { ClientKeys } from "$lib/stores"; import type { ClientKeys } from "$lib/stores";
export { requestMasterKeyDownload } from "$lib/services/key"; export { requestMasterKeyDownload } from "$lib/services/key";
export const requestLogin = async (email: string, password: string) => { export const requestLogin = async (email: string, password: string) => {
const res = await fetch("/api/auth/login", { const res = await callPostApi<LoginRequest>("/api/auth/login", { email, password });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password } satisfies LoginRequest),
});
return res.ok; return res.ok;
}; };
export const requestTokenUpgrade = async ({ export const requestSessionUpgrade = async ({
encryptKey, encryptKey,
decryptKey, decryptKey,
signKey, signKey,
@@ -23,12 +20,12 @@ export const requestTokenUpgrade = async ({
}: ClientKeys) => { }: ClientKeys) => {
const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey); const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey);
const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey); const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey);
if (await requestTokenUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { if (await requestSessionUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
return true; return true;
} }
if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
return await requestTokenUpgradeInternal( return await requestSessionUpgradeInternal(
encryptKeyBase64, encryptKeyBase64,
decryptKey, decryptKey,
verifyKeyBase64, verifyKeyBase64,

View File

@@ -10,8 +10,8 @@
serializeClientKeys, serializeClientKeys,
requestClientRegistration, requestClientRegistration,
storeClientKeys, storeClientKeys,
requestTokenUpgrade, requestSessionUpgrade,
requestInitialMasterKeyRegistration, requestInitialMasterKeyAndHmacSecretRegistration,
} from "./service"; } from "./service";
import IconKey from "~icons/material-symbols/key"; import IconKey from "~icons/material-symbols/key";
@@ -59,19 +59,23 @@
await storeClientKeys($clientKeyStore); await storeClientKeys($clientKeyStore);
if ( if (
!(await requestTokenUpgrade( !(await requestSessionUpgrade(
data.encryptKeyBase64, data.encryptKeyBase64,
$clientKeyStore.decryptKey, $clientKeyStore.decryptKey,
data.verifyKeyBase64, data.verifyKeyBase64,
$clientKeyStore.signKey, $clientKeyStore.signKey,
)) ))
) )
throw new Error("Failed to upgrade token"); throw new Error("Failed to upgrade session");
if ( if (
!(await requestInitialMasterKeyRegistration(data.masterKeyWrapped, $clientKeyStore.signKey)) !(await requestInitialMasterKeyAndHmacSecretRegistration(
data.masterKeyWrapped,
data.hmacSecretWrapped,
$clientKeyStore.signKey,
))
) )
throw new Error("Failed to register initial MEK"); throw new Error("Failed to register initial MEK and HSK");
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath)); await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
} catch (e) { } catch (e) {

View File

@@ -1,10 +1,13 @@
import { callPostApi } from "$lib/hooks"; import { callPostApi } from "$lib/hooks";
import { storeClientKey } from "$lib/indexedDB"; import { storeClientKey } from "$lib/indexedDB";
import { signMasterKeyWrapped } from "$lib/modules/crypto"; import { signMasterKeyWrapped } from "$lib/modules/crypto";
import type { InitialMasterKeyRegisterRequest } from "$lib/server/schemas"; import type {
InitialMasterKeyRegisterRequest,
InitialHmacSecretRegisterRequest,
} from "$lib/server/schemas";
import type { ClientKeys } from "$lib/stores"; import type { ClientKeys } from "$lib/stores";
export { requestTokenUpgrade } from "$lib/services/auth"; export { requestSessionUpgrade } from "$lib/services/auth";
export { requestClientRegistration } from "$lib/services/key"; export { requestClientRegistration } from "$lib/services/key";
type SerializedKeyPairs = { type SerializedKeyPairs = {
@@ -44,13 +47,22 @@ export const storeClientKeys = async (clientKeys: ClientKeys) => {
]); ]);
}; };
export const requestInitialMasterKeyRegistration = async ( export const requestInitialMasterKeyAndHmacSecretRegistration = async (
masterKeyWrapped: string, masterKeyWrapped: string,
hmacSecretWrapped: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
const res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", { let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
mek: masterKeyWrapped, mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
}); });
return res.ok || res.status === 409; if (!res.ok) {
return res.status === 409;
}
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", {
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return res.ok;
}; };

View File

@@ -6,7 +6,11 @@
import { gotoStateful } from "$lib/hooks"; import { gotoStateful } from "$lib/hooks";
import { clientKeyStore } from "$lib/stores"; import { clientKeyStore } from "$lib/stores";
import Order from "./Order.svelte"; import Order from "./Order.svelte";
import { generateClientKeys, generateInitialMasterKey } from "./service"; import {
generateClientKeys,
generateInitialMasterKey,
generateInitialHmacSecret,
} from "./service";
import IconKey from "~icons/material-symbols/key"; import IconKey from "~icons/material-symbols/key";
@@ -36,12 +40,14 @@
// TODO: Loading indicator // TODO: Loading indicator
const { encryptKey, ...clientKeys } = await generateClientKeys(); const { encryptKey, ...clientKeys } = await generateClientKeys();
const { masterKeyWrapped } = await generateInitialMasterKey(encryptKey); const { masterKey, masterKeyWrapped } = await generateInitialMasterKey(encryptKey);
const { hmacSecretWrapped } = await generateInitialHmacSecret(masterKey);
await gotoStateful("/key/export", { await gotoStateful("/key/export", {
...clientKeys, ...clientKeys,
redirectPath: data.redirectPath, redirectPath: data.redirectPath,
masterKeyWrapped, masterKeyWrapped,
hmacSecretWrapped,
}); });
}; };

View File

@@ -3,8 +3,11 @@ import {
generateSigningKeyPair, generateSigningKeyPair,
exportRSAKeyToBase64, exportRSAKeyToBase64,
makeRSAKeyNonextractable, makeRSAKeyNonextractable,
generateMasterKey,
wrapMasterKey, wrapMasterKey,
generateMasterKey,
makeAESKeyNonextractable,
wrapHmacSecret,
generateHmacSecret,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { clientKeyStore } from "$lib/stores"; import { clientKeyStore } from "$lib/stores";
@@ -31,6 +34,14 @@ export const generateClientKeys = async () => {
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => { export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
const { masterKey } = await generateMasterKey(); const { masterKey } = await generateMasterKey();
return { return {
masterKey: await makeAESKeyNonextractable(masterKey),
masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey), masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey),
}; };
}; };
export const generateInitialHmacSecret = async (masterKey: CryptoKey) => {
const { hmacSecret } = await generateHmacSecret();
return {
hmacSecretWrapped: await wrapHmacSecret(hmacSecret, masterKey),
};
};

View File

@@ -1,18 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { TopBar } from "$lib/components"; import { TopBar } from "$lib/components";
import { FloatingButton } from "$lib/components/buttons"; import { FloatingButton } from "$lib/components/buttons";
import { getDirectoryInfo } from "$lib/modules/file"; import { getDirectoryInfo } from "$lib/modules/file";
import { masterKeyStore, type DirectoryInfo } from "$lib/stores"; import { masterKeyStore, hmacSecretStore, type DirectoryInfo } from "$lib/stores";
import CreateBottomSheet from "./CreateBottomSheet.svelte"; import CreateBottomSheet from "./CreateBottomSheet.svelte";
import CreateDirectoryModal from "./CreateDirectoryModal.svelte"; import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte"; import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte"; import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
import DuplicateFileModal from "./DuplicateFileModal.svelte";
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte"; import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
import { import {
requestHmacSecretDownload,
requestDirectoryCreation, requestDirectoryCreation,
requestDuplicateFileScan,
requestFileUpload, requestFileUpload,
requestDirectoryEntryRename, requestDirectoryEntryRename,
requestDirectoryEntryDeletion, requestDirectoryEntryDeletion,
@@ -21,14 +25,22 @@
import IconAdd from "~icons/material-symbols/add"; import IconAdd from "~icons/material-symbols/add";
interface LoadedFile {
file: File;
fileBuffer: ArrayBuffer;
fileSigned: string;
}
let { data } = $props(); let { data } = $props();
let info: Writable<DirectoryInfo | null> | undefined = $state(); let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state();
let loadedFile: LoadedFile | undefined = $state();
let selectedEntry: SelectedDirectoryEntry | undefined = $state(); let selectedEntry: SelectedDirectoryEntry | undefined = $state();
let isCreateBottomSheetOpen = $state(false); let isCreateBottomSheetOpen = $state(false);
let isCreateDirectoryModalOpen = $state(false); let isCreateDirectoryModalOpen = $state(false);
let isDuplicateFileModalOpen = $state(false);
let isDirectoryEntryMenuBottomSheetOpen = $state(false); let isDirectoryEntryMenuBottomSheetOpen = $state(false);
let isRenameDirectoryEntryModalOpen = $state(false); let isRenameDirectoryEntryModalOpen = $state(false);
@@ -40,15 +52,42 @@
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
const uploadFile = () => { const uploadFile = (loadedFile: LoadedFile) => {
const file = fileInput?.files?.[0]; requestFileUpload(
if (!file) return; loadedFile.file,
loadedFile.fileBuffer,
requestFileUpload(file, data.id, $masterKeyStore?.get(1)!).then(() => { loadedFile.fileSigned,
data.id,
$masterKeyStore?.get(1)!,
$hmacSecretStore?.get(1)!,
).then(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}); });
}; };
const loadAndUploadFile = async () => {
const file = fileInput?.files?.[0];
if (!file) return;
fileInput!.value = "";
const scanRes = await requestDuplicateFileScan(file, $hmacSecretStore?.get(1)!);
if (scanRes === null) {
throw new Error("Failed to scan duplicate files");
} else if (scanRes.isDuplicate) {
loadedFile = { ...scanRes, file };
isDuplicateFileModalOpen = true;
} else {
uploadFile({ ...scanRes, file });
}
};
onMount(async () => {
if (!$hmacSecretStore && !(await requestHmacSecretDownload($masterKeyStore?.get(1)?.key!))) {
throw new Error("Failed to download hmac secrets");
}
});
$effect(() => { $effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}); });
@@ -58,7 +97,7 @@
<title>파일</title> <title>파일</title>
</svelte:head> </svelte:head>
<input bind:this={fileInput} onchange={uploadFile} type="file" class="hidden" /> <input bind:this={fileInput} onchange={loadAndUploadFile} type="file" class="hidden" />
<div class="flex min-h-full flex-col px-4"> <div class="flex min-h-full flex-col px-4">
{#if data.id !== "root"} {#if data.id !== "root"}
@@ -99,6 +138,18 @@
}} }}
/> />
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} /> <CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
onclose={() => {
isDuplicateFileModalOpen = false;
loadedFile = undefined;
}}
onDuplicateClick={() => {
uploadFile(loadedFile!);
isDuplicateFileModalOpen = false;
loadedFile = undefined;
}}
/>
<DirectoryEntryMenuBottomSheet <DirectoryEntryMenuBottomSheet
bind:isOpen={isDirectoryEntryMenuBottomSheetOpen} bind:isOpen={isDirectoryEntryMenuBottomSheetOpen}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Modal } from "$lib/components";
import { Button } from "$lib/components/buttons";
interface Props {
onclose: () => void;
onDuplicateClick: () => void;
isOpen: boolean;
}
let { onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
</script>
<Modal bind:isOpen {onclose}>
<div class="space-y-4">
<div class="space-y-2 break-keep">
<p class="text-xl font-bold">이미 업로드된 파일이에요.</p>
<p>그래도 업로드할까요?</p>
</div>
<div class="flex gap-2">
<Button
color="gray"
onclick={() => {
isOpen = false;
}}
>
아니요
</Button>
<Button onclick={onDuplicateClick}>업로드할게요</Button>
</div>
</div>
</Modal>

View File

@@ -1,12 +1,24 @@
import { callPostApi } from "$lib/hooks"; import { callGetApi, callPostApi } from "$lib/hooks";
import { generateDataKey, wrapDataKey, encryptData, encryptString } from "$lib/modules/crypto"; import { storeHmacSecrets } from "$lib/indexedDB";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
unwrapHmacSecret,
encryptData,
encryptString,
signMessageHmac,
} from "$lib/modules/crypto";
import type { import type {
DirectoryRenameRequest, DirectoryRenameRequest,
DirectoryCreateRequest, DirectoryCreateRequest,
FileRenameRequest, FileRenameRequest,
FileUploadRequest, FileUploadRequest,
HmacSecretListResponse,
DuplicateFileScanRequest,
DuplicateFileScanResponse,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
export interface SelectedDirectoryEntry { export interface SelectedDirectoryEntry {
type: "directory" | "file"; type: "directory" | "file";
@@ -16,6 +28,26 @@ export interface SelectedDirectoryEntry {
name: string; name: string;
} }
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
// TODO: MEK rotation
const res = await callGetApi("/api/hsk/list");
if (!res.ok) return false;
const { hsks: hmacSecretsWrapped }: HmacSecretListResponse = await res.json();
const hmacSecrets = await Promise.all(
hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => {
const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey);
return { version, state, secret: hmacSecret };
}),
);
await storeHmacSecrets(hmacSecrets);
hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])));
return true;
};
export const requestDirectoryCreation = async ( export const requestDirectoryCreation = async (
name: string, name: string,
parentId: "root" | number, parentId: "root" | number,
@@ -33,14 +65,34 @@ export const requestDirectoryCreation = async (
}); });
}; };
export const requestDuplicateFileScan = async (file: File, hmacSecret: HmacSecret) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const res = await callPostApi<DuplicateFileScanRequest>("/api/file/scanDuplicates", {
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (!res.ok) return null;
const { files }: DuplicateFileScanResponse = await res.json();
return {
fileBuffer,
fileSigned,
isDuplicate: files.length > 0,
};
};
export const requestFileUpload = async ( export const requestFileUpload = async (
file: File, file: File,
fileBuffer: ArrayBuffer,
fileSigned: string,
parentId: "root" | number, parentId: "root" | number,
masterKey: MasterKey, masterKey: MasterKey,
hmacSecret: HmacSecret,
) => { ) => {
const { dataKey, dataKeyVersion } = await generateDataKey(); const { dataKey, dataKeyVersion } = await generateDataKey();
const fileEncrypted = await encryptData(await file.arrayBuffer(), dataKey);
const nameEncrypted = await encryptString(file.name, dataKey); const nameEncrypted = await encryptString(file.name, dataKey);
const fileEncrypted = await encryptData(fileBuffer, dataKey);
const form = new FormData(); const form = new FormData();
form.set( form.set(
@@ -50,6 +102,8 @@ export const requestFileUpload = async (
mekVersion: masterKey.version, mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key), dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(), dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: file.type, contentType: file.type,
contentIv: fileEncrypted.iv, contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext, name: nameEncrypted.ciphertext,

View File

@@ -1,3 +1,29 @@
<div class="flex h-full items-center justify-center p-4"> <script lang="ts">
<p class="text-gray-500">아직 개발 중이에요.</p> import { goto } from "$app/navigation";
import { EntryButton } from "$lib/components/buttons";
import IconPassword from "~icons/material-symbols/password";
let { data } = $props();
</script>
<svelte:head>
<title>메뉴</title>
</svelte:head>
<div class="sticky top-0 bg-white px-6 py-4">
<p class="font-semibold">{data.nickname}</p>
</div>
<div class="space-y-4 px-4 pb-4">
<div class="space-y-2">
<p class="font-semibold">보안</p>
<EntryButton onclick={() => goto("/auth/changePassword")}>
<div class="flex items-center gap-x-4">
<div class="rounded-lg bg-gray-200 p-1 text-blue-500">
<IconPassword />
</div>
<p class="font-medium">비밀번호 바꾸기</p>
</div>
</EntryButton>
</div>
</div> </div>

View File

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

View File

@@ -8,7 +8,7 @@
onMount(async () => { onMount(async () => {
const goto = async (url: string) => { const goto = async (url: string) => {
const whitelist = ["/auth", "/key", "/client/pending"]; const whitelist = ["/auth/login", "/key", "/client/pending"];
if (!whitelist.some((path) => location.pathname.startsWith(path))) { if (!whitelist.some((path) => location.pathname.startsWith(path))) {
await svelteGoto( await svelteGoto(
`${url}?redirect=${encodeURIComponent(location.pathname + location.search)}`, `${url}?redirect=${encodeURIComponent(location.pathname + location.search)}`,

View File

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

View File

@@ -4,20 +4,16 @@ import { loginRequest } from "$lib/server/schemas";
import { login } from "$lib/server/services/auth"; import { login } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies }) => { export const POST: RequestHandler = async ({ locals, request, cookies }) => {
const zodRes = loginRequest.safeParse(await request.json()); const zodRes = loginRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { email, password } = zodRes.data; const { email, password } = zodRes.data;
const { accessToken, refreshToken } = await login(email, password); const { sessionIdSigned } = await login(email, password, locals.ip, locals.userAgent);
cookies.set("accessToken", accessToken, { cookies.set("sessionId", sessionIdSigned, {
path: "/", path: "/",
maxAge: env.jwt.accessExp / 1000, maxAge: env.session.exp / 1000,
sameSite: "strict", secure: true,
});
cookies.set("refreshToken", refreshToken, {
path: "/api/auth",
maxAge: env.jwt.refreshExp / 1000,
sameSite: "strict", sameSite: "strict",
}); });

View File

@@ -1,14 +1,13 @@
import { error, text } from "@sveltejs/kit"; import { text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { logout } from "$lib/server/services/auth"; import { logout } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ cookies }) => { export const POST: RequestHandler = async ({ locals, cookies }) => {
const token = cookies.get("refreshToken"); const { sessionId } = await authorize(locals, "any");
if (!token) error(401, "Refresh token not found");
await logout(token); await logout(sessionId);
cookies.delete("accessToken", { path: "/" }); cookies.delete("sessionId", { path: "/" });
cookies.delete("refreshToken", { path: "/api/auth" });
return text("Logged out", { headers: { "Content-Type": "text/plain" } }); return text("Logged out", { headers: { "Content-Type": "text/plain" } });
}; };

View File

@@ -1,23 +0,0 @@
import { error, text } from "@sveltejs/kit";
import env from "$lib/server/loadenv";
import { refreshToken as doRefreshToken } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ cookies }) => {
const token = cookies.get("refreshToken");
if (!token) error(401, "Refresh token not found");
const { accessToken, refreshToken } = await doRefreshToken(token);
cookies.set("accessToken", accessToken, {
path: "/",
maxAge: env.jwt.accessExp / 1000,
sameSite: "strict",
});
cookies.set("refreshToken", refreshToken, {
path: "/api/auth",
maxAge: env.jwt.refreshExp / 1000,
sameSite: "strict",
});
return text("Token refreshed", { headers: { "Content-Type": "text/plain" } });
};

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
import { error, json } from "@sveltejs/kit";
import {
tokenUpgradeRequest,
tokenUpgradeResponse,
type TokenUpgradeResponse,
} from "$lib/server/schemas";
import { createTokenUpgradeChallenge } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const token = cookies.get("refreshToken");
if (!token) error(401, "Refresh token not found");
const zodRes = tokenUpgradeRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data;
const { challenge } = await createTokenUpgradeChallenge(
token,
getClientAddress(),
encPubKey,
sigPubKey,
);
return json(tokenUpgradeResponse.parse({ challenge } satisfies TokenUpgradeResponse));
};

View File

@@ -1,33 +0,0 @@
import { error, text } from "@sveltejs/kit";
import env from "$lib/server/loadenv";
import { tokenUpgradeVerifyRequest } from "$lib/server/schemas";
import { upgradeToken } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const token = cookies.get("refreshToken");
if (!token) error(401, "Refresh token not found");
const zodRes = tokenUpgradeVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { answer, answerSig } = zodRes.data;
const { accessToken, refreshToken } = await upgradeToken(
token,
getClientAddress(),
answer,
answerSig,
);
cookies.set("accessToken", accessToken, {
path: "/",
maxAge: env.jwt.accessExp / 1000,
sameSite: "strict",
});
cookies.set("refreshToken", refreshToken, {
path: "/api/auth",
maxAge: env.jwt.refreshExp / 1000,
sameSite: "strict",
});
return text("Token upgraded", { headers: { "Content-Type": "text/plain" } });
};

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,11 @@
import { error, json } from "@sveltejs/kit"; import { json } from "@sveltejs/kit";
import { authenticate } from "$lib/server/modules/auth"; import { authorize } from "$lib/server/modules/auth";
import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas"; import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas";
import { getUserClientStatus } from "$lib/server/services/client"; import { getUserClientStatus } from "$lib/server/services/client";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = authenticate(cookies); const { userId, clientId } = await authorize(locals, "anyClient");
if (!clientId) {
error(403, "Forbidden");
}
const { state, isInitialMekNeeded } = await getUserClientStatus(userId, clientId); const { state, isInitialMekNeeded } = await getUserClientStatus(userId, clientId);
return json( return json(
clientStatusResponse.parse({ clientStatusResponse.parse({

View File

@@ -5,8 +5,8 @@ import { directoryInfoResponse, type DirectoryInfoResponse } from "$lib/server/s
import { getDirectoryInformation } from "$lib/server/services/directory"; import { getDirectoryInformation } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies, params }) => { export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
.object({ .object({
@@ -20,7 +20,6 @@ export const GET: RequestHandler = async ({ cookies, params }) => {
return json( return json(
directoryInfoResponse.parse({ directoryInfoResponse.parse({
metadata: metadata && { metadata: metadata && {
createdAt: metadata.createdAt.toISOString(),
mekVersion: metadata.mekVersion, mekVersion: metadata.mekVersion,
dek: metadata.encDek, dek: metadata.encDek,
dekVersion: metadata.dekVersion.toISOString(), dekVersion: metadata.dekVersion.toISOString(),

View File

@@ -4,8 +4,8 @@ import { authorize } from "$lib/server/modules/auth";
import { deleteDirectory } from "$lib/server/services/directory"; import { deleteDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ cookies, params }) => { export const POST: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
.object({ .object({

View File

@@ -5,8 +5,8 @@ import { directoryRenameRequest } from "$lib/server/schemas";
import { renameDirectory } from "$lib/server/services/directory"; import { renameDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies, params }) => { export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z const paramsZodRes = z
.object({ .object({

View File

@@ -4,8 +4,8 @@ import { directoryCreateRequest } from "$lib/server/schemas";
import { createDirectory } from "$lib/server/services/directory"; import { createDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies }) => { export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = directoryCreateRequest.safeParse(await request.json()); const zodRes = directoryCreateRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");

View File

@@ -5,8 +5,8 @@ import { fileInfoResponse, type FileInfoResponse } from "$lib/server/schemas";
import { getFileInformation } from "$lib/server/services/file"; import { getFileInformation } from "$lib/server/services/file";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies, params }) => { export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
.object({ .object({
@@ -16,11 +16,10 @@ export const GET: RequestHandler = async ({ cookies, params }) => {
if (!zodRes.success) error(400, "Invalid path parameters"); if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data; const { id } = zodRes.data;
const { createdAt, mekVersion, encDek, dekVersion, contentType, encContentIv, encName } = const { mekVersion, encDek, dekVersion, contentType, encContentIv, encName } =
await getFileInformation(userId, id); await getFileInformation(userId, id);
return json( return json(
fileInfoResponse.parse({ fileInfoResponse.parse({
createdAt: createdAt.toISOString(),
mekVersion, mekVersion,
dek: encDek, dek: encDek,
dekVersion: dekVersion.toISOString(), dekVersion: dekVersion.toISOString(),

View File

@@ -4,8 +4,8 @@ import { authorize } from "$lib/server/modules/auth";
import { deleteFile } from "$lib/server/services/file"; import { deleteFile } from "$lib/server/services/file";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ cookies, params }) => { export const POST: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
.object({ .object({

View File

@@ -4,8 +4,8 @@ import { authorize } from "$lib/server/modules/auth";
import { getFileStream } from "$lib/server/services/file"; import { getFileStream } from "$lib/server/services/file";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies, params }) => { export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
.object({ .object({
@@ -16,7 +16,7 @@ export const GET: RequestHandler = async ({ cookies, params }) => {
const { id } = zodRes.data; const { id } = zodRes.data;
const { encContentStream, encContentSize } = await getFileStream(userId, id); const { encContentStream, encContentSize } = await getFileStream(userId, id);
return new Response(encContentStream, { return new Response(encContentStream as ReadableStream, {
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Length": encContentSize.toString(), "Content-Length": encContentSize.toString(),

View File

@@ -5,8 +5,8 @@ import { fileRenameRequest } from "$lib/server/schemas";
import { renameFile } from "$lib/server/services/file"; import { renameFile } from "$lib/server/services/file";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies, params }) => { export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z const paramsZodRes = z
.object({ .object({

View File

@@ -0,0 +1,20 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
duplicateFileScanRequest,
duplicateFileScanResponse,
type DuplicateFileScanResponse,
} from "$lib/server/schemas";
import { scanDuplicateFiles } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = duplicateFileScanRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { hskVersion, contentHmac } = zodRes.data;
const { files } = await scanDuplicateFiles(userId, hskVersion, contentHmac);
return json(duplicateFileScanResponse.parse({ files } satisfies DuplicateFileScanResponse));
};

View File

@@ -4,8 +4,8 @@ import { fileUploadRequest } from "$lib/server/schemas";
import { uploadFile } from "$lib/server/services/file"; import { uploadFile } from "$lib/server/services/file";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies }) => { export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(cookies, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const form = await request.formData(); const form = await request.formData();
const metadata = form.get("metadata"); const metadata = form.get("metadata");
@@ -16,8 +16,18 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata)); const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata));
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { parentId, mekVersion, dek, dekVersion, contentType, contentIv, name, nameIv } = const {
zodRes.data; parentId,
mekVersion,
dek,
dekVersion,
hskVersion,
contentHmac,
contentType,
contentIv,
name,
nameIv,
} = zodRes.data;
await uploadFile( await uploadFile(
{ {
@@ -26,6 +36,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
mekVersion, mekVersion,
encDek: dek, encDek: dek,
dekVersion: new Date(dekVersion), dekVersion: new Date(dekVersion),
hskVersion,
contentHmac,
contentType, contentType,
encContentIv: contentIv, encContentIv: contentIv,
encName: name, encName: name,

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

@@ -4,8 +4,8 @@ import { masterKeyListResponse, type MasterKeyListResponse } from "$lib/server/s
import { getClientMekList } from "$lib/server/services/mek"; import { getClientMekList } from "$lib/server/services/mek";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = await authorize(cookies, "activeClient"); const { userId, clientId } = await authorize(locals, "activeClient");
const { encMeks } = await getClientMekList(userId, clientId); const { encMeks } = await getClientMekList(userId, clientId);
return json( return json(
masterKeyListResponse.parse({ masterKeyListResponse.parse({

View File

@@ -1,14 +1,11 @@
import { error, text } from "@sveltejs/kit"; import { error, text } from "@sveltejs/kit";
import { authenticate } from "$lib/server/modules/auth"; import { authorize } from "$lib/server/modules/auth";
import { initialMasterKeyRegisterRequest } from "$lib/server/schemas"; import { initialMasterKeyRegisterRequest } from "$lib/server/schemas";
import { registerInitialActiveMek } from "$lib/server/services/mek"; import { registerInitialActiveMek } from "$lib/server/services/mek";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies }) => { export const POST: RequestHandler = async ({ locals, request }) => {
const { userId, clientId } = authenticate(cookies); const { userId, clientId } = await authorize(locals, "pendingClient");
if (!clientId) {
error(403, "Forbidden");
}
const zodRes = initialMasterKeyRegisterRequest.safeParse(await request.json()); const zodRes = initialMasterKeyRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");

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));
};

Some files were not shown because too many files have changed in this diff Show More