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
JWT_SECRET=
SESSION_SECRET=
# Optional environment variables
DATABASE_URL=
JWT_ACCESS_TOKEN_EXPIRES=
JWT_REFRESH_TOKEN_EXPIRES=
SESSION_EXPIRES=
USER_CLIENT_CHALLENGE_EXPIRES=
TOKEN_UPGRADE_CHALLENGE_EXPIRES=
SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH=

View File

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

View File

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

View File

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

View File

@@ -17,17 +17,16 @@ CREATE TABLE `user_client_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`challenge` text NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text 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 (`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
CREATE TABLE `directory` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`created_at` integer NOT NULL,
`parent_id` integer,
`user_id` 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
);
--> 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` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`path` text NOT NULL,
`parent_id` integer,
`created_at` integer NOT NULL,
`user_id` integer NOT NULL,
`path` text NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL,
`data_encryption_key_version` integer NOT NULL,
`hmac_secret_key_version` integer,
`content_hmac` text,
`content_type` text NOT NULL,
`encrypted_content_iv` 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 (`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
);
--> 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` (
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
@@ -71,49 +113,63 @@ CREATE TABLE `client_master_encryption_key` (
CREATE TABLE `master_encryption_key` (
`user_id` integer NOT NULL,
`version` integer NOT NULL,
`created_by` integer NOT NULL,
`created_at` integer NOT NULL,
`state` text NOT NULL,
`retired_at` integer,
PRIMARY KEY(`user_id`, `version`),
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
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> 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,
`user_id` integer NOT NULL,
`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 (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `token_upgrade_challenge` (
CREATE TABLE `session_upgrade_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`refresh_token_id` text NOT NULL,
`session_id` text NOT NULL,
`client_id` integer NOT NULL,
`challenge` text NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
`is_used` integer DEFAULT false NOT NULL,
FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`session_id`) REFERENCES `session`(`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
CREATE TABLE `user` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text NOT NULL,
`password` text NOT NULL
`password` text NOT NULL,
`nickname` text NOT NULL
);
--> 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_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 `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 `refresh_token_user_id_client_id_unique` ON `refresh_token` (`user_id`,`client_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> statement-breakpoint
CREATE UNIQUE INDEX `hmac_secret_key_encrypted_key_unique` ON `hmac_secret_key` (`encrypted_key`);--> 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`);

View File

@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "929c6bca-d0c0-4899-afc6-a0a498226f28",
"id": "928e5669-81cf-486c-9122-8ee64fc9f457",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"client": {
@@ -147,8 +147,8 @@
"notNull": true,
"autoincrement": false
},
"challenge": {
"name": "challenge",
"answer": {
"name": "answer",
"type": "text",
"primaryKey": false,
"notNull": true,
@@ -167,21 +167,13 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"user_client_challenge_challenge_unique": {
"name": "user_client_challenge_challenge_unique",
"user_client_challenge_answer_unique": {
"name": "user_client_challenge_answer_unique",
"columns": [
"challenge"
"answer"
],
"isUnique": true
}
@@ -212,6 +204,21 @@
],
"onDelete": "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": {},
@@ -227,13 +234,6 @@
"notNull": true,
"autoincrement": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
@@ -332,6 +332,64 @@
"compositePrimaryKeys": {},
"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": {
"name": "file",
"columns": {
@@ -342,13 +400,6 @@
"notNull": true,
"autoincrement": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
@@ -356,16 +407,16 @@
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -391,6 +442,20 @@
"notNull": true,
"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": {
"name": "content_type",
"type": "text",
@@ -470,6 +535,261 @@
],
"onDelete": "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": {},
@@ -587,20 +907,6 @@
"notNull": true,
"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": {
"name": "state",
"type": "text",
@@ -630,19 +936,6 @@
],
"onDelete": "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": {
@@ -656,8 +949,101 @@
},
"uniqueConstraints": {}
},
"refresh_token": {
"name": "refresh_token",
"master_encryption_key_log": {
"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": {
"id": {
"name": "id",
@@ -680,17 +1066,38 @@
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"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": {
"refresh_token_user_id_client_id_unique": {
"name": "refresh_token_user_id_client_id_unique",
"session_user_id_client_id_unique": {
"name": "session_user_id_client_id_unique",
"columns": [
"user_id",
"client_id"
@@ -699,9 +1106,9 @@
}
},
"foreignKeys": {
"refresh_token_user_id_user_id_fk": {
"name": "refresh_token_user_id_user_id_fk",
"tableFrom": "refresh_token",
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
@@ -712,9 +1119,9 @@
"onDelete": "no action",
"onUpdate": "no action"
},
"refresh_token_client_id_client_id_fk": {
"name": "refresh_token_client_id_client_id_fk",
"tableFrom": "refresh_token",
"session_client_id_client_id_fk": {
"name": "session_client_id_client_id_fk",
"tableFrom": "session",
"tableTo": "client",
"columnsFrom": [
"client_id"
@@ -729,8 +1136,8 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"token_upgrade_challenge": {
"name": "token_upgrade_challenge",
"session_upgrade_challenge": {
"name": "session_upgrade_challenge",
"columns": {
"id": {
"name": "id",
@@ -739,8 +1146,8 @@
"notNull": true,
"autoincrement": false
},
"refresh_token_id": {
"name": "refresh_token_id",
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
@@ -753,8 +1160,8 @@
"notNull": true,
"autoincrement": false
},
"challenge": {
"name": "challenge",
"answer": {
"name": "answer",
"type": "text",
"primaryKey": false,
"notNull": true,
@@ -773,32 +1180,31 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"token_upgrade_challenge_challenge_unique": {
"name": "token_upgrade_challenge_challenge_unique",
"session_upgrade_challenge_session_id_unique": {
"name": "session_upgrade_challenge_session_id_unique",
"columns": [
"challenge"
"session_id"
],
"isUnique": true
},
"session_upgrade_challenge_answer_unique": {
"name": "session_upgrade_challenge_answer_unique",
"columns": [
"answer"
],
"isUnique": true
}
},
"foreignKeys": {
"token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": {
"name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk",
"tableFrom": "token_upgrade_challenge",
"tableTo": "refresh_token",
"session_upgrade_challenge_session_id_session_id_fk": {
"name": "session_upgrade_challenge_session_id_session_id_fk",
"tableFrom": "session_upgrade_challenge",
"tableTo": "session",
"columnsFrom": [
"refresh_token_id"
"session_id"
],
"columnsTo": [
"id"
@@ -806,9 +1212,9 @@
"onDelete": "no action",
"onUpdate": "no action"
},
"token_upgrade_challenge_client_id_client_id_fk": {
"name": "token_upgrade_challenge_client_id_client_id_fk",
"tableFrom": "token_upgrade_challenge",
"session_upgrade_challenge_client_id_client_id_fk": {
"name": "session_upgrade_challenge_client_id_client_id_fk",
"tableFrom": "session_upgrade_challenge",
"tableTo": "client",
"columnsFrom": [
"client_id"
@@ -846,6 +1252,13 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "arkvault",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -17,46 +17,48 @@
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@eslint/compat": "^1.2.3",
"@eslint/compat": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.12",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/better-sqlite3": "^7.6.11",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/better-sqlite3": "^7.6.12",
"@types/file-saver": "^2.0.7",
"@types/jsonwebtoken": "^9.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7",
"autoprefixer": "^10.4.20",
"dexie": "^4.0.10",
"drizzle-kit": "^0.22.0",
"eslint": "^9.7.0",
"drizzle-kit": "^0.22.8",
"eslint": "^9.17.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",
"file-saver": "^2.0.5",
"globals": "^15.0.0",
"globals": "^15.14.0",
"heic2any": "^0.0.4",
"mime": "^4.0.6",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.17.1",
"svelte-check": "^4.1.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript-eslint": "^8.19.1",
"unplugin-icons": "^0.22.0",
"vite": "^5.4.11"
},
"dependencies": {
"argon2": "^0.41.1",
"better-sqlite3": "^11.1.2",
"better-sqlite3": "^11.7.2",
"drizzle-orm": "^0.33.0",
"jsonwebtoken": "^9.0.2",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"uuid": "^11.0.3",
"uuid": "^11.0.4",
"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 {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
interface Locals {
ip: string;
userAgent: string;
session?: {
id: string;
userId: number;
clientId?: number;
};
}
}
}

View File

@@ -1,6 +1,6 @@
import type { ClientInit } from "@sveltejs/kit";
import { getClientKey, getMasterKeys } from "$lib/indexedDB";
import { clientKeyStore, masterKeyStore } from "$lib/stores";
import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
const prepareClientKeyStore = async () => {
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
@@ -21,6 +21,13 @@ const prepareMasterKeyStore = async () => {
}
};
export const init: ClientInit = async () => {
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore()]);
const prepareHmacSecretStore = async () => {
const hmacSecrets = await getHmacSecrets();
if (hmacSecrets.length > 0) {
hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])));
}
};
export const init: ClientInit = async () => {
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 { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/drizzle";
import {
cleanupExpiredRefreshTokens,
cleanupExpiredTokenUpgradeChallenges,
} from "$lib/server/db/token";
cleanupExpiredSessions,
cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
export const init: ServerInit = () => {
migrateDB();
schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges();
cleanupExpiredRefreshTokens();
cleanupExpiredTokenUpgradeChallenges();
cleanupExpiredSessions();
cleanupExpiredSessionUpgradeChallenges();
});
};
export const handle: Handle = async ({ event, resolve }) => {
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),
);
}
};
export const handle = sequence(setAgentInfo, authenticate);

View File

@@ -29,7 +29,7 @@
onclick?.();
}, 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">
{@render children?.()}

View File

@@ -3,15 +3,16 @@
import type { SvelteHTMLElements } from "svelte/elements";
interface Props {
icon?: Component<SvelteHTMLElements["svg"]>;
children: Snippet;
icon?: Component<SvelteHTMLElements["svg"]>;
topPadding?: boolean;
}
let { icon: Icon, children }: Props = $props();
let { topPadding = true, children, icon: Icon }: Props = $props();
</script>
<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}
<Icon class="text-5xl text-gray-600" />
{/if}

View File

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

View File

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

View File

@@ -7,22 +7,28 @@ interface ClientKey {
key: CryptoKey;
}
type MasterKeyState = "active" | "retired";
interface MasterKey {
version: number;
state: MasterKeyState;
state: "active" | "retired";
key: CryptoKey;
}
interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
const keyStore = new Dexie("keyStore") as Dexie & {
clientKey: EntityTable<ClientKey, "usage">;
masterKey: EntityTable<MasterKey, "version">;
hmacSecret: EntityTable<HmacSecret, "version">;
};
keyStore.version(1).stores({
clientKey: "usage",
masterKey: "version",
hmacSecret: "version",
});
export const getClientKey = async (usage: ClientKeyUsage) => {
@@ -62,3 +68,14 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
}
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) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
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(
{
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,
signature: BufferSource,
verifyKey: CryptoKey,
@@ -131,7 +131,7 @@ export const signMasterKeyWrapped = async (
version: masterKeyVersion,
key: masterKeyWrapped,
});
return encodeToBase64(await signMessage(encodeString(serialized), signKey));
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
};
export const verifyMasterKeyWrapped = async (
@@ -144,7 +144,7 @@ export const verifyMasterKeyWrapped = async (
version: masterKeyVersion,
key: masterKeyWrapped,
});
return await verifySignature(
return await verifySignatureRSA(
encodeString(serialized),
decodeFromBase64(masterKeyWrappedSig),
verifyKey,

View File

@@ -1,3 +1,20 @@
export const digestMessage = async (message: BufferSource) => {
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 { IntegrityError } from "./error";
import { client, userClient, userClientChallenge } from "./schema";
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
return await db.transaction(async (tx) => {
const clients = await tx
.select()
.from(client)
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)));
if (clients.length > 0) {
throw new Error("Already used public key(s)");
}
return await db.transaction(
async (tx) => {
const clients = await tx
.select({ id: client.id })
.from(client)
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)))
.limit(1);
if (clients.length !== 0) {
throw new IntegrityError("Public key(s) already registered");
}
const insertRes = await tx
.insert(client)
.values({ encPubKey, sigPubKey })
.returning({ id: client.id });
const { id: clientId } = insertRes[0]!;
await tx.insert(userClient).values({ userId, clientId });
const newClients = await tx
.insert(client)
.values({ encPubKey, sigPubKey })
.returning({ id: client.id });
const { id: clientId } = newClients[0]!;
await tx.insert(userClient).values({ userId, clientId });
return clientId;
});
return clientId;
},
{ behavior: "exclusive" },
);
};
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;
};
@@ -33,24 +39,23 @@ export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) =
.select()
.from(client)
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
.execute();
.limit(1);
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) => {
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) => {
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) => {
@@ -58,7 +63,7 @@ export const getUserClient = async (userId: number, clientId: number) => {
.select()
.from(userClient)
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.execute();
.limit(1);
return userClients[0] ?? null;
};
@@ -68,7 +73,7 @@ export const getUserClientWithDetails = async (userId: number, clientId: number)
.from(userClient)
.innerJoin(client, eq(userClient.clientId, client.id))
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.execute();
.limit(1);
return userClients[0] ?? null;
};
@@ -82,8 +87,7 @@ export const setUserClientStateToPending = async (userId: number, clientId: numb
eq(userClient.clientId, clientId),
eq(userClient.state, "challenging"),
),
)
.execute();
);
};
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.state, "pending"),
),
)
.execute();
);
};
export const registerUserClientChallenge = async (
@@ -107,45 +110,30 @@ export const registerUserClientChallenge = async (
allowedIp: string,
expiresAt: Date,
) => {
await db
.insert(userClientChallenge)
.values({
userId,
clientId,
answer,
allowedIp,
expiresAt,
})
.execute();
await db.insert(userClientChallenge).values({
userId,
clientId,
answer,
allowedIp,
expiresAt,
});
};
export const getUserClientChallenge = async (answer: string, ip: string) => {
export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => {
const challenges = await db
.select()
.from(userClientChallenge)
.delete(userClientChallenge)
.where(
and(
eq(userClientChallenge.userId, userId),
eq(userClientChallenge.answer, answer),
eq(userClientChallenge.allowedIp, ip),
gt(userClientChallenge.expiresAt, new Date()),
eq(userClientChallenge.isUsed, false),
),
)
.execute();
.returning({ clientId: userClientChallenge.clientId });
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 () => {
await db
.delete(userClientChallenge)
.where(lte(userClientChallenge.expiresAt, new Date()))
.execute();
await db.delete(userClientChallenge).where(lte(userClientChallenge.expiresAt, new Date()));
};

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 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;
export interface NewDirectoryParams {
userId: number;
parentId: DirectoryId;
userId: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
@@ -15,52 +16,65 @@ export interface NewDirectoryParams {
}
export interface NewFileParams {
path: string;
parentId: DirectoryId;
userId: number;
path: string;
mekVersion: number;
encDek: string;
dekVersion: Date;
hskVersion: number | null;
contentHmac: string | null;
contentType: string;
encContentIv: string;
encName: string;
encNameIv: string;
}
export const registerNewDirectory = async (params: NewDirectoryParams) => {
return await db.transaction(async (tx) => {
const meks = await tx
.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");
}
export const registerDirectory = async (params: NewDirectoryParams) => {
await db.transaction(
async (tx) => {
const meks = await tx
.select({ version: mek.version })
.from(mek)
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
.limit(1);
if (meks[0]?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
const now = new Date();
await tx.insert(directory).values({
createdAt: now,
parentId: params.parentId === "root" ? null : params.parentId,
userId: params.userId,
mekVersion: params.mekVersion,
encDek: params.encDek,
dekVersion: params.dekVersion,
encName: { ciphertext: params.encName, iv: params.encNameIv },
});
});
const newDirectories = await tx
.insert(directory)
.values({
parentId: params.parentId === "root" ? null : params.parentId,
userId: params.userId,
mekVersion: params.mekVersion,
encDek: params.encDek,
dekVersion: params.dekVersion,
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
.select()
.from(directory)
.where(
and(
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) => {
@@ -68,7 +82,7 @@ export const getDirectory = async (userId: number, directoryId: number) => {
.select()
.from(directory)
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
.execute();
.limit(1);
return res[0] ?? null;
};
@@ -79,72 +93,119 @@ export const setDirectoryEncName = async (
encName: string,
encNameIv: string,
) => {
const res = await db
.update(directory)
.set({ encName: { ciphertext: encName, iv: encNameIv } })
.where(
and(
eq(directory.userId, userId),
eq(directory.id, directoryId),
eq(directory.dekVersion, dekVersion),
),
)
.execute();
return res.changes > 0;
await db.transaction(
async (tx) => {
const directories = await tx
.select({ version: directory.dekVersion })
.from(directory)
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
.limit(1);
if (!directories[0]) {
throw new IntegrityError("Directory not found");
} else if (directories[0].version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
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) => {
return await db.transaction(async (tx) => {
const getFilePaths = async (parentId: number) => {
const files = await tx
.select({ path: file.path })
.from(file)
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)));
return files.map(({ path }) => path);
};
const unregisterSubDirectoriesRecursively = async (directoryId: number): Promise<string[]> => {
const subDirectories = await tx
.select({ id: directory.id })
.from(directory)
.where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId)));
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterSubDirectoriesRecursively(id)),
);
const filePaths = await getFilePaths(directoryId);
return await db.transaction(
async (tx) => {
const unregisterFiles = async (parentId: number) => {
const files = await tx
.delete(file)
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
.returning({ path: file.path });
return files.map(({ path }) => path);
};
const unregisterDirectoryRecursively = async (directoryId: number): Promise<string[]> => {
const filePaths = await unregisterFiles(directoryId);
const subDirectories = await tx
.select({ id: directory.id })
.from(directory)
.where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId)));
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
await tx.delete(file).where(eq(file.parentId, directoryId));
await tx.delete(directory).where(eq(directory.id, directoryId));
return filePaths.concat(...subDirectoryFilePaths);
};
return await unregisterSubDirectoriesRecursively(directoryId);
});
const deleteRes = await tx.delete(directory).where(eq(directory.id, directoryId));
if (deleteRes.changes === 0) {
throw new IntegrityError("Directory not found");
}
return filePaths.concat(...subDirectoryFilePaths);
};
return await unregisterDirectoryRecursively(directoryId);
},
{ behavior: "exclusive" },
);
};
export const registerNewFile = async (params: NewFileParams) => {
await db.transaction(async (tx) => {
const meks = await tx
.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");
}
export const registerFile = async (params: NewFileParams) => {
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
throw new Error("Invalid arguments");
}
const now = new Date();
await tx.insert(file).values({
path: params.path,
parentId: params.parentId === "root" ? null : params.parentId,
createdAt: now,
userId: params.userId,
mekVersion: params.mekVersion,
contentType: params.contentType,
encDek: params.encDek,
dekVersion: params.dekVersion,
encContentIv: params.encContentIv,
encName: { ciphertext: params.encName, iv: params.encNameIv },
});
});
await db.transaction(
async (tx) => {
const meks = await tx
.select({ version: mek.version })
.from(mek)
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
.limit(1);
if (meks[0]?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
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) => {
@@ -156,8 +217,24 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
eq(file.userId, userId),
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) => {
@@ -165,7 +242,7 @@ export const getFile = async (userId: number, fileId: number) => {
.select()
.from(file)
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
.execute();
.limit(1);
return res[0] ?? null;
};
@@ -176,19 +253,41 @@ export const setFileEncName = async (
encName: string,
encNameIv: string,
) => {
const res = await db
.update(file)
.set({ encName: { ciphertext: encName, iv: encNameIv } })
.where(and(eq(file.userId, userId), eq(file.id, fileId), eq(file.dekVersion, dekVersion)))
.execute();
return res.changes > 0;
await db.transaction(
async (tx) => {
const files = await tx
.select({ version: file.dekVersion })
.from(file)
.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) => {
const res = await db
const files = await db
.delete(file)
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
.returning({ path: file.path })
.execute();
return res[0]?.path ?? null;
.returning({ path: file.path });
if (!files[0]) {
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 db from "./drizzle";
import { mek, clientMek } from "./schema";
import { IntegrityError } from "./error";
import { mek, mekLog, clientMek } from "./schema";
export const registerInitialMek = async (
userId: number,
@@ -8,22 +10,37 @@ export const registerInitialMek = async (
encMek: string,
encMekSig: string,
) => {
await db.transaction(async (tx) => {
await tx.insert(mek).values({
userId,
version: 1,
createdBy,
createdAt: new Date(),
state: "active",
});
await tx.insert(clientMek).values({
userId,
clientId: createdBy,
mekVersion: 1,
encMek,
encMekSig,
});
});
await db.transaction(
async (tx) => {
try {
await tx.insert(mek).values({
userId,
version: 1,
state: "active",
});
await tx.insert(clientMek).values({
userId,
clientId: createdBy,
mekVersion: 1,
encMek,
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) => {
@@ -31,19 +48,10 @@ export const getInitialMek = async (userId: number) => {
.select()
.from(mek)
.where(and(eq(mek.userId, userId), eq(mek.version, 1)))
.execute();
.limit(1);
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) => {
return await db
.select()
@@ -55,6 +63,5 @@ export const getAllValidClientMeks = async (userId: number, clientId: number) =>
eq(clientMek.clientId, clientId),
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";
export const client = sqliteTable(
@@ -31,16 +38,24 @@ export const userClient = sqliteTable(
}),
);
export const userClientChallenge = sqliteTable("user_client_challenge", {
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => user.id),
clientId: integer("client_id")
.notNull()
.references(() => client.id),
answer: text("challenge").notNull().unique(), // Base64
allowedIp: text("allowed_ip").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
});
export const userClientChallenge = sqliteTable(
"user_client_challenge",
{
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => user.id),
clientId: integer("client_id")
.notNull()
.references(() => client.id),
answer: text("answer").notNull().unique(), // Base64
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 { hsk } from "./hsk";
import { mek } from "./mek";
import { user } from "./user";
@@ -12,7 +13,6 @@ export const directory = sqliteTable(
"directory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
parentId: integer("parent_id"),
userId: integer("user_id")
.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(
"file",
{
id: integer("id").primaryKey({ autoIncrement: true }),
path: text("path").notNull().unique(),
parentId: integer("parent_id").references(() => directory.id),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
userId: integer("user_id")
.notNull()
.references(() => user.id),
path: text("path").notNull().unique(),
mekVersion: integer("master_encryption_key_version").notNull(),
encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64
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(),
encContentIv: text("encrypted_content_iv").notNull(), // Base64
encName: ciphertext("encrypted_name").notNull(),
},
(t) => ({
ref: foreignKey({
ref1: foreignKey({
columns: [t.userId, t.mekVersion],
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 "./file";
export * from "./hsk";
export * from "./mek";
export * from "./token";
export * from "./session";
export * from "./user";

View File

@@ -9,10 +9,6 @@ export const mek = sqliteTable(
.notNull()
.references(() => user.id),
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(),
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(
"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 { user } from "./user";
export const refreshToken = sqliteTable(
"refresh_token",
export const session = sqliteTable(
"session",
{
id: text("id").primaryKey(),
id: text("id").notNull().primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => user.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) => ({
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(),
refreshTokenId: text("refresh_token_id")
sessionId: text("session_id")
.notNull()
.references(() => refreshToken.id),
.references(() => session.id)
.unique(),
clientId: integer("client_id")
.notNull()
.references(() => client.id),
answer: text("challenge").notNull().unique(), // Base64
answer: text("answer").notNull().unique(), // Base64
allowedIp: text("allowed_ip").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 }),
email: text("email").notNull().unique(),
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 { user } from "./schema";
export const getUserByEmail = async (email: string) => {
const users = await db.select().from(user).where(eq(user.email, email)).execute();
export const getUser = async (userId: number) => {
const users = await db.select().from(user).where(eq(user.id, userId)).limit(1);
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";
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 {
databaseUrl: env.DATABASE_URL || "local.db",
jwt: {
secret: env.JWT_SECRET,
accessExp: ms(env.JWT_ACCESS_TOKEN_EXPIRES || "5m"),
refreshExp: ms(env.JWT_REFRESH_TOKEN_EXPIRES || "14d"),
session: {
secret: env.SESSION_SECRET!,
exp: ms(env.SESSION_EXPIRES || "14d"),
},
challenge: {
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",
};

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 jwt from "jsonwebtoken";
import { error } from "@sveltejs/kit";
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 { issueSessionId, verifySessionId } from "$lib/server/modules/crypto";
type TokenPayload =
| {
type: "access";
userId: number;
clientId?: number;
}
| {
type: "refresh";
jti: string;
};
export enum TokenError {
EXPIRED,
INVALID,
interface Session {
sessionId: string;
userId: number;
clientId?: number;
}
type Permission = "pendingClient" | "activeClient";
interface ClientSession extends Session {
clientId: number;
}
export const issueToken = (payload: TokenPayload) => {
return jwt.sign(payload, env.jwt.secret, {
expiresIn: (payload.type === "access" ? env.jwt.accessExp : env.jwt.refreshExp) / 1000,
});
export class AuthenticationError extends Error {
constructor(
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 {
return jwt.verify(token, env.jwt.secret) as TokenPayload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return TokenError.EXPIRED;
const { userId, clientId } = await refreshSession(sessionId, ip, userAgent);
return {
id: sessionId,
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) => {
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(locals: App.Locals, requiredPermission: "any"): Promise<Session>;
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",
): Promise<{ userId: number; clientId: number }>;
): Promise<ClientSession>;
export async function authorize(
cookies: Cookies,
locals: App.Locals,
requiredPermission: "activeClient",
): Promise<{ userId: number; clientId: number }>;
): Promise<ClientSession>;
export async function authorize(
cookies: Cookies,
requiredPermission: Permission,
): Promise<{ userId: number; clientId?: number }> {
const tokenPayload = authenticate(cookies);
const { userId, clientId } = tokenPayload;
const userClient = clientId ? await getUserClient(userId, clientId) : undefined;
locals: App.Locals,
requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
): Promise<Session> {
if (!locals.session) {
error(500, "Unauthenticated");
}
const { id: sessionId, userId, clientId } = locals.session;
switch (requiredPermission) {
case "pendingClient":
if (!userClient || userClient.state !== "pending") {
case "any":
break;
case "notClient":
if (clientId) {
error(403, "Forbidden");
}
return tokenPayload;
case "activeClient":
if (!userClient || userClient.state !== "active") {
break;
case "anyClient":
if (!clientId) {
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";
const makePubKeyToPem = (pubKey: string) =>
@@ -34,3 +42,26 @@ export const generateChallenge = async (length: number, encPubKey: string) => {
const challenge = encryptAsymmetric(answer, encPubKey);
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);
if (!userClient) {
error(500, "Invalid access token");
error(500, "Invalid session id");
}
const data = JSON.stringify({ version, key: encMek });

View File

@@ -1,24 +1,30 @@
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({
email: z.string().email().nonempty(),
password: z.string().trim().nonempty(),
});
export type LoginRequest = z.infer<typeof loginRequest>;
export const tokenUpgradeRequest = z.object({
export const sessionUpgradeRequest = z.object({
encPubKey: 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(),
});
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(),
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({
metadata: z
.object({
createdAt: z.string().datetime(),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),

View File

@@ -2,7 +2,6 @@ import mime from "mime";
import { z } from "zod";
export const fileInfoResponse = z.object({
createdAt: z.string().datetime(),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -23,11 +22,24 @@ export const fileRenameRequest = z.object({
});
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({
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
contentType: z
.string()
.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 "./directory";
export * from "./file";
export * from "./hsk";
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 argon2 from "argon2";
import { v4 as uuidv4 } from "uuid";
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
import { getUserByEmail } from "$lib/server/db/user";
import env from "$lib/server/loadenv";
import { IntegrityError } from "$lib/server/db/error";
import {
getRefreshToken,
registerRefreshToken,
rotateRefreshToken,
upgradeRefreshToken,
revokeRefreshToken,
registerTokenUpgradeChallenge,
getTokenUpgradeChallenge,
markTokenUpgradeChallengeAsUsed,
} from "$lib/server/db/token";
import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth";
upgradeSession,
deleteSession,
deleteAllOtherSessions,
registerSessionUpgradeChallenge,
consumeSessionUpgradeChallenge,
} from "$lib/server/db/session";
import { getUser, getUserByEmail, setUserPassword } from "$lib/server/db/user";
import env from "$lib/server/loadenv";
import { startSession } from "$lib/server/modules/auth";
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) => {
return await argon2.verify(hash, password);
};
const issueAccessToken = (userId: number, clientId?: number) => {
return issueToken({ type: "access", userId, clientId });
};
const issueRefreshToken = async (userId: number, clientId?: number) => {
const jti = uuidv4();
const token = issueToken({ type: "refresh", jti });
if (!(await registerRefreshToken(userId, clientId ?? null, jti))) {
error(403, "Already logged in");
export const changePassword = async (
userId: number,
sessionId: string,
oldPassword: string,
newPassword: string,
) => {
if (oldPassword === newPassword) {
error(400, "Same passwords");
} else if (newPassword.length < 8) {
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);
if (!user || !(await verifyPassword(user.password, password))) {
error(401, "Invalid email or password");
}
return {
accessToken: issueAccessToken(user.id),
refreshToken: await issueRefreshToken(user.id),
};
};
const verifyRefreshToken = async (refreshToken: string) => {
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");
try {
return { sessionIdSigned: await startSession(user.id, ip, userAgent) };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Session already exists") {
error(403, "Already logged in");
}
throw e;
}
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) => {
const { jti } = await verifyRefreshToken(refreshToken);
await revokeRefreshToken(jti);
export const logout = async (sessionId: string) => {
await deleteSession(sessionId);
};
export const refreshToken = async (refreshToken: string) => {
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
const newJti = uuidv4();
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,
export const createSessionUpgradeChallenge = async (
sessionId: string,
userId: number,
ip: string,
encPubKey: string,
sigPubKey: string,
) => {
const { jti, userId, clientId } = await verifyRefreshToken(refreshToken);
if (clientId) {
error(403, "Forbidden");
}
const client = await getClientByPubKeys(encPubKey, sigPubKey);
const userClient = client ? await getUserClient(userId, client.id) : undefined;
if (!client) {
error(401, "Invalid public key(s)");
} 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 (
refreshToken: string,
export const verifySessionUpgradeChallenge = async (
sessionId: string,
ip: string,
answer: string,
answerSig: string,
) => {
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
if (clientId) {
error(403, "Forbidden");
}
const challenge = await getTokenUpgradeChallenge(answer, ip);
const challenge = await consumeSessionUpgradeChallenge(sessionId, answer, ip);
if (!challenge) {
error(401, "Invalid challenge answer");
} else if (challenge.refreshTokenId !== oldJti) {
error(403, "Forbidden");
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} 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);
const newJti = uuidv4();
if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) {
error(500, "Refresh token not found");
try {
await upgradeSession(sessionId, client.id);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Session 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,
getClient,
getClientByPubKeys,
countClientByPubKey,
createUserClient,
getAllUserClients,
getUserClient,
setUserClientStateToPending,
registerUserClientChallenge,
getUserClientChallenge,
markUserClientChallengeAsUsed,
consumeUserClientChallenge,
} from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import { isInitialMekNeeded } from "$lib/server/modules/mek";
import env from "$lib/server/loadenv";
@@ -29,8 +28,8 @@ export const getUserClientList = async (userId: number) => {
const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp);
const createUserClientChallenge = async (
userId: number,
ip: string,
userId: number,
clientId: number,
encPubKey: string,
) => {
@@ -45,45 +44,34 @@ export const registerUserClient = async (
encPubKey: string,
sigPubKey: string,
) => {
let clientId;
const client = await getClientByPubKeys(encPubKey, sigPubKey);
if (client) {
const userClient = await getUserClient(userId, client.id);
if (userClient) {
error(409, "Client already registered");
try {
await createUserClient(userId, client.id);
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 {
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)");
} 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 (
@@ -92,20 +80,29 @@ export const verifyUserClient = async (
answer: string,
answerSig: string,
) => {
const challenge = await getUserClientChallenge(answer, ip);
const challenge = await consumeUserClientChallenge(userId, answer, ip);
if (!challenge) {
error(401, "Invalid challenge answer");
} else if (challenge.userId !== userId) {
error(403, "Forbidden");
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} 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, challenge.clientId);
await setUserClientStateToPending(userId, client.id);
};
export const getUserClientStatus = async (userId: number, clientId: number) => {
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
return {
state: userClient.state as "pending" | "active",
isInitialMekNeeded: await isInitialMekNeeded(userId),
};
};

View File

@@ -1,44 +1,15 @@
import { error } from "@sveltejs/kit";
import { unlink } from "fs/promises";
import { IntegrityError } from "$lib/server/db/error";
import {
registerDirectory,
getAllDirectoriesByParent,
registerNewDirectory,
getDirectory,
setDirectoryEncName,
unregisterDirectory,
getAllFilesByParent,
type NewDirectoryParams,
} 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) => {
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
@@ -51,7 +22,6 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
return {
metadata: directory && {
createdAt: directory.createdAt,
mekVersion: directory.mekVersion,
encDek: directory.encDek,
dekVersion: directory.dekVersion,
@@ -62,19 +32,52 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
};
};
export const createDirectory = async (params: NewDirectoryParams) => {
const activeMekVersion = await getActiveMekVersion(params.userId);
if (activeMekVersion === null) {
error(500, "Invalid MEK version");
} else if (activeMekVersion !== params.mekVersion) {
error(400, "Invalid MEK version");
export const deleteDirectory = async (userId: number, directoryId: number) => {
try {
const filePaths = await unregisterDirectory(userId, directoryId);
filePaths.map((path) => unlink(path)); // Intended
} catch (e) {
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 oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
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 { createReadStream, createWriteStream, ReadStream, WriteStream } from "fs";
import { createReadStream, createWriteStream } from "fs";
import { mkdir, stat, unlink } from "fs/promises";
import { dirname } from "path";
import { Readable, Writable } from "stream";
import { v4 as uuidv4 } from "uuid";
import { IntegrityError } from "$lib/server/db/error";
import {
registerNewFile,
registerFile,
getAllFileIdsByContentHmac,
getFile,
setFileEncName,
unregisterFile,
type NewFileParams,
} from "$lib/server/db/file";
import { getActiveMekVersion } from "$lib/server/db/mek";
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);
if (!file) {
error(404, "Invalid file id");
}
const path = await unregisterFile(userId, fileId);
if (!path) {
error(500, "Invalid file id");
}
unlink(path); // Intended
return {
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
encContentIv: file.encContentIv,
encName: file.encName,
};
};
const convertToReadableStream = (readStream: ReadStream) => {
return new ReadableStream<Uint8Array>({
start: (controller) => {
readStream.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer)));
readStream.on("end", () => controller.close());
readStream.on("error", (e) => controller.error(e));
},
cancel: () => {
readStream.destroy();
},
});
export const deleteFile = async (userId: number, fileId: number) => {
try {
const filePath = await unregisterFile(userId, fileId);
unlink(filePath); // Intended
} catch (e) {
if (e instanceof IntegrityError && e.message === "File not found") {
error(404, "Invalid file id");
}
throw e;
}
};
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);
return {
encContentStream: convertToReadableStream(createReadStream(file.path)),
encContentStream: Readable.toWeb(createReadStream(file.path)),
encContentSize: size,
};
};
@@ -60,49 +63,27 @@ export const renameFile = async (
newEncName: string,
newEncNameIv: string,
) => {
const file = await getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
} else if (file.dekVersion.getTime() !== dekVersion.getTime()) {
error(400, "Invalid DEK version");
}
if (!(await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv))) {
error(500, "Invalid file id or DEK version");
try {
await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
error(404, "Invalid file id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const getFileInformation = async (userId: number, fileId: number) => {
const file = await getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
return {
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)),
});
export const scanDuplicateFiles = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
return { files: fileIds.map(({ id }) => id) };
};
const safeUnlink = async (path: string) => {
@@ -113,13 +94,6 @@ export const uploadFile = async (
params: Omit<NewFileParams, "path">,
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 oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
@@ -131,14 +105,20 @@ export const uploadFile = async (
try {
await encContentStream.pipeTo(
convertToWritableStream(createWriteStream(path, { flags: "wx", mode: 0o600 })),
Writable.toWeb(createWriteStream(path, { flags: "wx", mode: 0o600 })),
);
await registerNewFile({
await registerFile({
...params,
path,
});
} catch (e) {
await safeUnlink(path);
if (e instanceof IntegrityError) {
if (e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
}
}
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 { setUserClientStateToActive } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
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) => {
const clientMeks = await getAllValidClientMeks(userId, clientId);
@@ -21,12 +22,17 @@ export const registerInitialActiveMek = async (
encMek: string,
encMekSig: string,
) => {
if (!(await isInitialMekNeeded(userId))) {
error(409, "Initial MEK already registered");
} else if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
error(400, "Invalid signature");
}
await registerInitialMek(userId, createdBy, encMek, encMekSig);
await setUserClientStateToActive(userId, createdBy);
try {
await registerInitialMek(userId, createdBy, encMek, encMekSig);
await setUserClientStateToActive(userId, createdBy);
} catch (e) {
if (e instanceof IntegrityError && e.message === "MEK already registered") {
error(409, "Initial MEK already registered");
}
throw e;
}
};

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { storeMasterKeys } from "$lib/indexedDB";
import {
encodeToBase64,
decryptChallenge,
signMessage,
signMessageRSA,
unwrapMasterKey,
verifyMasterKeyWrapped,
} from "$lib/modules/crypto";
@@ -29,7 +29,7 @@ export const requestClientRegistration = async (
const { challenge }: ClientRegisterResponse = await res.json();
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", {
answer: encodeToBase64(answer),

View File

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

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 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 accessToken = cookies.get("accessToken");
if (accessToken) {
if (locals.session) {
redirect(302, redirectPath);
}

View File

@@ -1,12 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { Button, TextButton } from "$lib/components/buttons";
import { TitleDiv, BottomDiv } from "$lib/components/divs";
import { TextInput } from "$lib/components/inputs";
import { refreshToken } from "$lib/hooks";
import { clientKeyStore, masterKeyStore } from "$lib/stores";
import { requestLogin, requestTokenUpgrade, requestMasterKeyDownload } from "./service";
import { requestLogin, requestSessionUpgrade, requestMasterKeyDownload } from "./service";
let { data } = $props();
@@ -25,7 +23,8 @@
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
@@ -42,13 +41,6 @@
throw e;
}
};
onMount(async () => {
const res = await refreshToken();
if (res.ok) {
await goto(data.redirectPath, { replaceState: true });
}
});
</script>
<svelte:head>

View File

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

View File

@@ -10,8 +10,8 @@
serializeClientKeys,
requestClientRegistration,
storeClientKeys,
requestTokenUpgrade,
requestInitialMasterKeyRegistration,
requestSessionUpgrade,
requestInitialMasterKeyAndHmacSecretRegistration,
} from "./service";
import IconKey from "~icons/material-symbols/key";
@@ -59,19 +59,23 @@
await storeClientKeys($clientKeyStore);
if (
!(await requestTokenUpgrade(
!(await requestSessionUpgrade(
data.encryptKeyBase64,
$clientKeyStore.decryptKey,
data.verifyKeyBase64,
$clientKeyStore.signKey,
))
)
throw new Error("Failed to upgrade token");
throw new Error("Failed to upgrade session");
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));
} catch (e) {

View File

@@ -1,10 +1,13 @@
import { callPostApi } from "$lib/hooks";
import { storeClientKey } from "$lib/indexedDB";
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";
export { requestTokenUpgrade } from "$lib/services/auth";
export { requestSessionUpgrade } from "$lib/services/auth";
export { requestClientRegistration } from "$lib/services/key";
type SerializedKeyPairs = {
@@ -44,13 +47,22 @@ export const storeClientKeys = async (clientKeys: ClientKeys) => {
]);
};
export const requestInitialMasterKeyRegistration = async (
export const requestInitialMasterKeyAndHmacSecretRegistration = async (
masterKeyWrapped: string,
hmacSecretWrapped: string,
signKey: CryptoKey,
) => {
const res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
mek: masterKeyWrapped,
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 { clientKeyStore } from "$lib/stores";
import Order from "./Order.svelte";
import { generateClientKeys, generateInitialMasterKey } from "./service";
import {
generateClientKeys,
generateInitialMasterKey,
generateInitialHmacSecret,
} from "./service";
import IconKey from "~icons/material-symbols/key";
@@ -36,12 +40,14 @@
// TODO: Loading indicator
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", {
...clientKeys,
redirectPath: data.redirectPath,
masterKeyWrapped,
hmacSecretWrapped,
});
};

View File

@@ -3,8 +3,11 @@ import {
generateSigningKeyPair,
exportRSAKeyToBase64,
makeRSAKeyNonextractable,
generateMasterKey,
wrapMasterKey,
generateMasterKey,
makeAESKeyNonextractable,
wrapHmacSecret,
generateHmacSecret,
} from "$lib/modules/crypto";
import { clientKeyStore } from "$lib/stores";
@@ -31,6 +34,14 @@ export const generateClientKeys = async () => {
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
const { masterKey } = await generateMasterKey();
return {
masterKey: await makeAESKeyNonextractable(masterKey),
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">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { FloatingButton } from "$lib/components/buttons";
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 CreateDirectoryModal from "./CreateDirectoryModal.svelte";
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
import DuplicateFileModal from "./DuplicateFileModal.svelte";
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
import {
requestHmacSecretDownload,
requestDirectoryCreation,
requestDuplicateFileScan,
requestFileUpload,
requestDirectoryEntryRename,
requestDirectoryEntryDeletion,
@@ -21,14 +25,22 @@
import IconAdd from "~icons/material-symbols/add";
interface LoadedFile {
file: File;
fileBuffer: ArrayBuffer;
fileSigned: string;
}
let { data } = $props();
let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let loadedFile: LoadedFile | undefined = $state();
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
let isCreateBottomSheetOpen = $state(false);
let isCreateDirectoryModalOpen = $state(false);
let isDuplicateFileModalOpen = $state(false);
let isDirectoryEntryMenuBottomSheetOpen = $state(false);
let isRenameDirectoryEntryModalOpen = $state(false);
@@ -40,15 +52,42 @@
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
const uploadFile = () => {
const file = fileInput?.files?.[0];
if (!file) return;
requestFileUpload(file, data.id, $masterKeyStore?.get(1)!).then(() => {
const uploadFile = (loadedFile: LoadedFile) => {
requestFileUpload(
loadedFile.file,
loadedFile.fileBuffer,
loadedFile.fileSigned,
data.id,
$masterKeyStore?.get(1)!,
$hmacSecretStore?.get(1)!,
).then(() => {
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(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
@@ -58,7 +97,7 @@
<title>파일</title>
</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">
{#if data.id !== "root"}
@@ -99,6 +138,18 @@
}}
/>
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
onclose={() => {
isDuplicateFileModalOpen = false;
loadedFile = undefined;
}}
onDuplicateClick={() => {
uploadFile(loadedFile!);
isDuplicateFileModalOpen = false;
loadedFile = undefined;
}}
/>
<DirectoryEntryMenuBottomSheet
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 { generateDataKey, wrapDataKey, encryptData, encryptString } from "$lib/modules/crypto";
import { callGetApi, callPostApi } from "$lib/hooks";
import { storeHmacSecrets } from "$lib/indexedDB";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
unwrapHmacSecret,
encryptData,
encryptString,
signMessageHmac,
} from "$lib/modules/crypto";
import type {
DirectoryRenameRequest,
DirectoryCreateRequest,
FileRenameRequest,
FileUploadRequest,
HmacSecretListResponse,
DuplicateFileScanRequest,
DuplicateFileScanResponse,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
export interface SelectedDirectoryEntry {
type: "directory" | "file";
@@ -16,6 +28,26 @@ export interface SelectedDirectoryEntry {
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 (
name: string,
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 (
file: File,
fileBuffer: ArrayBuffer,
fileSigned: string,
parentId: "root" | number,
masterKey: MasterKey,
hmacSecret: HmacSecret,
) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const fileEncrypted = await encryptData(await file.arrayBuffer(), dataKey);
const nameEncrypted = await encryptString(file.name, dataKey);
const fileEncrypted = await encryptData(fileBuffer, dataKey);
const form = new FormData();
form.set(
@@ -50,6 +102,8 @@ export const requestFileUpload = async (
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: file.type,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,

View File

@@ -1,3 +1,29 @@
<div class="flex h-full items-center justify-center p-4">
<p class="text-gray-500">아직 개발 중이에요.</p>
<script lang="ts">
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>

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 () => {
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))) {
await svelteGoto(
`${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 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());
if (!zodRes.success) error(400, "Invalid request body");
const { email, password } = zodRes.data;
const { accessToken, refreshToken } = await login(email, password);
cookies.set("accessToken", accessToken, {
const { sessionIdSigned } = await login(email, password, locals.ip, locals.userAgent);
cookies.set("sessionId", sessionIdSigned, {
path: "/",
maxAge: env.jwt.accessExp / 1000,
sameSite: "strict",
});
cookies.set("refreshToken", refreshToken, {
path: "/api/auth",
maxAge: env.jwt.refreshExp / 1000,
maxAge: env.session.exp / 1000,
secure: true,
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 type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ cookies }) => {
const token = cookies.get("refreshToken");
if (!token) error(401, "Refresh token not found");
export const POST: RequestHandler = async ({ locals, cookies }) => {
const { sessionId } = await authorize(locals, "any");
await logout(token);
cookies.delete("accessToken", { path: "/" });
cookies.delete("refreshToken", { path: "/api/auth" });
await logout(sessionId);
cookies.delete("sessionId", { path: "/" });
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 { authenticate } from "$lib/server/modules/auth";
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientListResponse, type ClientListResponse } from "$lib/server/schemas";
import { getUserClientList } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => {
const { userId, clientId } = authenticate(cookies);
if (!clientId) {
error(403, "Forbidden");
}
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "anyClient");
const { userClients } = await getUserClientList(userId);
return json(clientListResponse.parse({ clients: userClients } satisfies ClientListResponse));
};

View File

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

View File

@@ -1,19 +1,16 @@
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 { verifyUserClient } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const { userId, clientId } = authenticate(cookies);
if (clientId) {
error(403, "Forbidden");
}
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "notClient");
const zodRes = clientRegisterVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { 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" } });
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ import { fileRenameRequest } from "$lib/server/schemas";
import { renameFile } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies, params }) => {
const { userId } = await authorize(cookies, "activeClient");
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.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 type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies }) => {
const { userId } = await authorize(cookies, "activeClient");
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "activeClient");
const form = await request.formData();
const metadata = form.get("metadata");
@@ -16,8 +16,18 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata));
if (!zodRes.success) error(400, "Invalid request body");
const { parentId, mekVersion, dek, dekVersion, contentType, contentIv, name, nameIv } =
zodRes.data;
const {
parentId,
mekVersion,
dek,
dekVersion,
hskVersion,
contentHmac,
contentType,
contentIv,
name,
nameIv,
} = zodRes.data;
await uploadFile(
{
@@ -26,6 +36,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
mekVersion,
encDek: dek,
dekVersion: new Date(dekVersion),
hskVersion,
contentHmac,
contentType,
encContentIv: contentIv,
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 type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => {
const { userId, clientId } = await authorize(cookies, "activeClient");
export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = await authorize(locals, "activeClient");
const { encMeks } = await getClientMekList(userId, clientId);
return json(
masterKeyListResponse.parse({

View File

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

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