diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b302e36 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +test-results +node_modules + +# Output +.output +.vercel +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# jetbrains setting folder +.idea/ + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# SQLite +*.db + +# Docker +Dockerfile +Dockerfile.migrate +docker-compose.yaml + +# Deployment +deploy.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d59bf33 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL=local.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b20898 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +test-results +node_modules + +# Output +.output +.vercel +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# jetbrains setting folder +.idea/ + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# SQLite +*.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61d43f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Adjust BUN_VERSION as desired +ARG BUN_VERSION=1.1.36 +FROM oven/bun:${BUN_VERSION}-slim AS base + +# Base image +WORKDIR /app +ENV NODE_ENV="production" + + +FROM base AS build + +# Install packages needed to build node modules +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends build-essential pkg-config python-is-python3 + +# Install node modules +COPY --link package.json bun.lockb ./ +RUN bun install + +# Copy application code +COPY --link . . + +# Build application +RUN bunx svelte-kit sync +RUN DATABASE_URL=blabla bun run build + +## Remove development dependencies +RUN rm -rf node_modules && \ + bun install --production + + +# Final stage for app image +FROM base AS app + +# Copy built application +COPY --from=build /app/node_modules /app/node_modules +COPY --from=build /app/build /app/build + +ENV PORT=4321 +ENV HOST=0.0.0.0 + +# Start the server by default, this can be overwritten at runtime +EXPOSE 4321 +CMD ["bun", "./build"] diff --git a/Dockerfile.migrate b/Dockerfile.migrate new file mode 100644 index 0000000..bcc48d9 --- /dev/null +++ b/Dockerfile.migrate @@ -0,0 +1,28 @@ +# Adjust BUN_VERSION as desired +ARG BUN_VERSION=1.1.36 +FROM node:20-slim AS base + +# Base image +WORKDIR /app +ENV NODE_ENV="production" + +# Install packages needed to build node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential pkg-config python-is-python3 + +# Install bun +RUN npm install -g bun@${BUN_VERSION} + + +FROM base AS build + +# Install node modules +COPY --link package.json bun.lockb ./ +RUN bun install + +# Copy application code +COPY --link drizzle/ ./drizzle/ +COPY --link drizzle.config.ts ./ + +# Migrate the database +CMD ["bun", "run", "db", "migrate"] diff --git a/README.md b/README.md index b446e79..32f198f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,50 @@ -# Cybersecurity-projects-2024 -Projects of the Cybersecurity course 2024/25, University of Bologna +# sv + +## Use cases + +- [x] Register + - Username + - Password + -> Returns session token and user database chunk +- [x] Login + - Username + - Password + -> Returns session token and user database chunk +- [x] Get current session +- [x] Change password +- [ ] Upload file + 1. Select file + 2. Chunk file using fast-cdc + 3. Encrypt file + 4. Upload chunks + 5. Update local database + 6. Chunk local database using fast-cdc + 7. Encrypt local database + 8. Upload chunks +- [ ] Download file + 1. Download chunks + 2. Join chunks and restore blob + 3. Decrypt blob +- [ ] Delete file + 1. Delete chunks + 2. Update local database + 3. Encrypt local database + 4. Chunk local database using fast-cdc + 5. Upload chunks + +## Thoughts + +- Use `zlib` to compress the local database + +## Current issues + +- Registration leaks the information whether a username exists or not +- Does the second request of the login procedure properly prevent timing attacks? +- Is my own implementation of signing the session id secure? +- Is sending the signed session id as a cookie secure? +- What if an upload takes longer than 24 hours when the `upload` record is deleted? +- We have to take all data of the user into consideration when calculating their storage usage because evil people could + just hide their data in the database + - Take the 40 bytes overhead into consideration + - Encrypted file attributes + - Any keys the user uploads diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..5dd6df2 Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..38a00f6 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://next.shadcn-svelte.com/schema.json", + "style": "default", + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app.css", + "baseColor": "stone" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils/components", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks" + }, + "typescript": true, + "registry": "https://next.shadcn-svelte.com/registry" +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..b2f141a --- /dev/null +++ b/deploy.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# This will build and restart to the production container. +set -e + +server=stefan +app_root=/home/amadeus/http/csp +app_storage=/home/amadeus/storage/csp +app_url=https://csp.deer13.dev + +echo -e '\033[1mDeploying...\033[0m' +echo $app_url +echo + +rsync -vhra \ + . \ + $server:$app_root \ + --include='**.gitignore' \ + --exclude='/.git' \ + --filter=':- .gitignore' \ + --delete-after + +ssh $server << EOF + set -e + + cd $app_root + + # Build images + docker build -f Dockerfile -t llamadeus/csp . + docker build -f Dockerfile.migrate -t llamadeus/csp-migrate . + + # Stop container + docker compose stop + + # Migrate + docker run --rm -e DATABASE_URL="/app/db/prod.db" -v "/home/amadeus/storage/csp/db":"/app/db" llamadeus/csp-migrate + + # Restart container + docker compose up -d + + # Clean up cached resources (anything older than 7 days) + yes | docker image prune -a --filter "until=168h" + yes | docker builder prune -a --filter "until=168h" +EOF diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..9dc5c26 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,18 @@ +services: + web: + image: llamadeus/csp + restart: unless-stopped + networks: + - caddy + labels: + caddy: csp.deer13.dev + caddy.reverse_proxy: "{{upstreams 4321}}" + volumes: + - /home/amadeus/storage/csp/db:/app/db + environment: + - PUBLIC_APP_URL=https://csp.deer13.dev + - DATABASE_URL=/app/db/prod.db + +networks: + caddy: + external: true diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..e8e0755 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "drizzle-kit"; + + +if (! process.env.DATABASE_URL) { + throw new Error("DATABASE_URL is not set"); +} + +export default defineConfig({ + schema: "./src/lib/server/db/schema.ts", + + dbCredentials: { + url: process.env.DATABASE_URL, + }, + + verbose: true, + strict: true, + dialect: "sqlite", +}); diff --git a/drizzle/0000_the_friendly_executioner.sql b/drizzle/0000_the_friendly_executioner.sql new file mode 100644 index 0000000..0e61f84 --- /dev/null +++ b/drizzle/0000_the_friendly_executioner.sql @@ -0,0 +1,35 @@ +CREATE TABLE `chunks` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `blob` blob NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `files` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `key` text NOT NULL, + `cmac` text NOT NULL, + `attributes` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `session_id` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sessions_session_id_unique` ON `sessions` (`session_id`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `crv` text NOT NULL, + `hak` text NOT NULL, + `key` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); \ No newline at end of file diff --git a/drizzle/0001_watery_eternals.sql b/drizzle/0001_watery_eternals.sql new file mode 100644 index 0000000..c99cb28 --- /dev/null +++ b/drizzle/0001_watery_eternals.sql @@ -0,0 +1,34 @@ +CREATE TABLE `slot_chunks` ( + `slot_id` text NOT NULL, + `chunk_id` text NOT NULL, + PRIMARY KEY(`slot_id`, `chunk_id`), + FOREIGN KEY (`slot_id`) REFERENCES `slots`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`chunk_id`) REFERENCES `chunks`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `slots` ( + `id` text PRIMARY KEY NOT NULL, + `vault_id` text NOT NULL, + `max_size` integer NOT NULL, + `attributes` text NOT NULL, + `file_x25519_public_key` text, + `file_id` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`vault_id`) REFERENCES `vaults`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `vaults` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `token` text NOT NULL, + `key` text NOT NULL, + `attributes` text NOT NULL, + `auth_ed25519_private_key` text NOT NULL, + `auth_ed25519_public_key` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +ALTER TABLE `users` ADD `x25519_private_key` text;--> statement-breakpoint +ALTER TABLE `users` ADD `x25519_public_key` text; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..921fe21 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,228 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6a564f0e-d7c2-4c7a-80db-63f3de227fc0", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "chunks": { + "name": "chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob": { + "name": "blob", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chunks_user_id_users_id_fk": { + "name": "chunks_user_id_users_id_fk", + "tableFrom": "chunks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cmac": { + "name": "cmac", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attributes": { + "name": "attributes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_session_id_unique": { + "name": "sessions_session_id_unique", + "columns": [ + "session_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hak": { + "name": "hak", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..b207ad6 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,467 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "603f72a4-fd10-4d55-96fd-bb80b641dbc5", + "prevId": "6a564f0e-d7c2-4c7a-80db-63f3de227fc0", + "tables": { + "chunks": { + "name": "chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob": { + "name": "blob", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chunks_user_id_users_id_fk": { + "name": "chunks_user_id_users_id_fk", + "tableFrom": "chunks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cmac": { + "name": "cmac", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attributes": { + "name": "attributes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_session_id_unique": { + "name": "sessions_session_id_unique", + "columns": [ + "session_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slot_chunks": { + "name": "slot_chunks", + "columns": { + "slot_id": { + "name": "slot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_id": { + "name": "chunk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "slot_chunks_slot_id_slots_id_fk": { + "name": "slot_chunks_slot_id_slots_id_fk", + "tableFrom": "slot_chunks", + "tableTo": "slots", + "columnsFrom": [ + "slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "slot_chunks_chunk_id_chunks_id_fk": { + "name": "slot_chunks_chunk_id_chunks_id_fk", + "tableFrom": "slot_chunks", + "tableTo": "chunks", + "columnsFrom": [ + "chunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "slot_chunks_slot_id_chunk_id_pk": { + "columns": [ + "slot_id", + "chunk_id" + ], + "name": "slot_chunks_slot_id_chunk_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slots": { + "name": "slots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "vault_id": { + "name": "vault_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_size": { + "name": "max_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attributes": { + "name": "attributes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_x25519_public_key": { + "name": "file_x25519_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "slots_vault_id_vaults_id_fk": { + "name": "slots_vault_id_vaults_id_fk", + "tableFrom": "slots", + "tableTo": "vaults", + "columnsFrom": [ + "vault_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "slots_file_id_files_id_fk": { + "name": "slots_file_id_files_id_fk", + "tableFrom": "slots", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hak": { + "name": "hak", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x25519_private_key": { + "name": "x25519_private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x25519_public_key": { + "name": "x25519_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vaults": { + "name": "vaults", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attributes": { + "name": "attributes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_ed25519_private_key": { + "name": "auth_ed25519_private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_ed25519_public_key": { + "name": "auth_ed25519_public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vaults_user_id_users_id_fk": { + "name": "vaults_user_id_users_id_fk", + "tableFrom": "vaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..3c5bf51 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1732462040141, + "tag": "0000_the_friendly_executioner", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1734459805195, + "tag": "0001_watery_eternals", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/e2e/demo.test.ts b/e2e/demo.test.ts new file mode 100644 index 0000000..9985ce1 --- /dev/null +++ b/e2e/demo.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('home page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a704f4 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "cs-project", + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "bunx --bun vite dev", + "build": "bunx --bun vite build", + "preview": "bunx --bun vite preview", + "start": "bun ./build", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "db": "tsx node_modules/drizzle-kit/bin.cjs", + "test:e2e": "playwright test", + "test": "npm run test:e2e && npm run test:unit -- --run", + "test:unit": "vitest", + "shadcn": "bunx shadcn-svelte@next" + }, + "dependencies": { + "@noble/ciphers": "^1.1.0", + "@noble/curves": "^1.7.0", + "@noble/hashes": "^1.6.0", + "@trpc/client": "^10.45.2", + "@trpc/server": "^10.45.2", + "better-sqlite3": "^11.5.0", + "bits-ui": "^1.0.0-next.72", + "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "drizzle-orm": "^0.36.4", + "lucide-svelte": "^0.468.0", + "nanoid": "^5.0.8", + "tailwind-variants": "^0.3.0", + "trpc-sveltekit": "^3.6.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@sveltejs/adapter-node": "^5.2.10", + "@sveltejs/kit": "^2.12.0", + "@sveltejs/vite-plugin-svelte": "^4.0.3", + "@types/better-sqlite3": "^7.6.12", + "@types/bun": "^1.1.13", + "autoprefixer": "^10.4.20", + "drizzle-kit": "^0.28.1", + "svelte": "^5.14.0", + "svelte-adapter-bun": "^0.5.2", + "svelte-check": "^4.1.1", + "svelte-sonner": "^0.3.28", + "tailwind-merge": "^2.5.4", + "tailwindcss": "^3.4.15", + "tailwindcss-animate": "^1.0.7", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.5" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0cf2600 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@playwright/test'; + + +export default defineConfig({ + webServer: { + command: 'npm run build && npm run preview', + port: 4173, + }, + + testDir: 'e2e', +}); diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/presentation/presentation.pdf b/presentation/presentation.pdf new file mode 100644 index 0000000..54747dd Binary files /dev/null and b/presentation/presentation.pdf differ diff --git a/report/report.pdf b/report/report.pdf new file mode 100644 index 0000000..0596a9f Binary files /dev/null and b/report/report.pdf differ diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..1ec863c --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,15 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces + + +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..9f10bf1 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/demo.spec.ts b/src/demo.spec.ts new file mode 100644 index 0000000..489f398 --- /dev/null +++ b/src/demo.spec.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; + + +describe("sum test", () => { + it("adds 1 + 2 to equal 3", () => { + expect(1 + 2).toBe(3); + }); +}); diff --git a/src/features/login/index.ts b/src/features/login/index.ts new file mode 100644 index 0000000..5932299 --- /dev/null +++ b/src/features/login/index.ts @@ -0,0 +1,62 @@ +import { trpc } from "$lib/trpc/client"; +import { decrypt, encrypt } from "$lib/tulip"; +import { generateDerivedKeys } from "$lib/tulip/auth"; +import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import { x25519 } from "@noble/curves/ed25519"; + + +interface LoginInput { + username: string; + password: string; +} + +interface LoginResult { + username: string; + masterKey: Uint8Array; + x25519PrivateKey: Uint8Array; + x25519PublicKey: Uint8Array; +} + +export async function login(values: LoginInput): Promise { + const crvResponse = await trpc().crv.query({ + u: values.username, + }); + + const [derivedEncryptionKey, derivedAuthenticationKey] = generateDerivedKeys( + utf8ToBytes(values.password), + decodeBase64Url(crvResponse.salt), + ); + + if (derivedEncryptionKey == null || derivedAuthenticationKey == null) { + throw new Error("Could not generate derived keys"); + } + + const loginResponse = await trpc().login.mutate({ + u: values.username, + ak: encodeBase64Url(derivedAuthenticationKey), + }); + + const [decryptedMasterKey] = decrypt(derivedEncryptionKey, decodeBase64Url(loginResponse.key)); + let x25519PrivateKey = loginResponse.x25519PrivateKey !== null ? decodeBase64Url(loginResponse.x25519PrivateKey) : null; + let x25519PublicKey = loginResponse.x25519PublicKey !== null ? decodeBase64Url(loginResponse.x25519PublicKey) : null; + + if (x25519PrivateKey === null || x25519PublicKey === null) { + x25519PrivateKey = x25519.utils.randomPrivateKey(); + x25519PublicKey = x25519.getPublicKey(x25519PrivateKey); + + const encryptedX25519PrivateKey = encrypt(decryptedMasterKey, x25519PrivateKey); + + await trpc().updateX25519.mutate({ + x25519PrivateKey: encodeBase64Url(encryptedX25519PrivateKey), + x25519PublicKey: encodeBase64Url(x25519PublicKey), + }); + } + + return { + username: loginResponse.u, + masterKey: decryptedMasterKey, + x25519PrivateKey, + x25519PublicKey, + }; +} diff --git a/src/features/register/index.ts b/src/features/register/index.ts new file mode 100644 index 0000000..41f6e53 --- /dev/null +++ b/src/features/register/index.ts @@ -0,0 +1,52 @@ +import { trpc } from "$lib/trpc/client"; +import { encrypt, randomKey } from "$lib/tulip"; +import { generateDerivedKeys, generateHashedAuthenticationKey, generateSalt, randomClientValue } from "$lib/tulip/auth"; +import { encodeBase64Url } from "$lib/utils/base64url"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import { x25519 } from "@noble/curves/ed25519"; + + +interface CreateAccountInput { + username: string; + password: string; +} + +interface CreateAccountResult { + username: string; + masterKey: Uint8Array; + salt: Uint8Array; + clientRandomValue: Uint8Array; + encryptedMasterKey: Uint8Array; + hashedAuthenticationKey: Uint8Array; +} + +export async function createAccount(values: CreateAccountInput): Promise { + const masterKey = randomKey(); + const clientRandomValue = randomClientValue(); + const salt = generateSalt(clientRandomValue); + const [derivedEncryptionKey, derivedAuthenticationKey] = generateDerivedKeys(utf8ToBytes(values.password), salt); + const encryptedMasterKey = encrypt(derivedEncryptionKey, masterKey); + const hashedAuthenticationKey = generateHashedAuthenticationKey(derivedAuthenticationKey); + + const x25519PrivateKey = x25519.utils.randomPrivateKey(); + const x25519PublicKey = x25519.getPublicKey(x25519PrivateKey); + const encryptedX25519PrivateKey = encrypt(masterKey, x25519PrivateKey); + + await trpc().register.mutate({ + u: values.username, + crv: encodeBase64Url(clientRandomValue), + key: encodeBase64Url(encryptedMasterKey), + hak: encodeBase64Url(hashedAuthenticationKey), + x25519PrivateKey: encodeBase64Url(encryptedX25519PrivateKey), + x25519PublicKey: encodeBase64Url(x25519PublicKey), + }); + + return { + username: values.username, + masterKey, + salt, + clientRandomValue, + encryptedMasterKey, + hashedAuthenticationKey, + }; +} diff --git a/src/features/storage/download.ts b/src/features/storage/download.ts new file mode 100644 index 0000000..b81cb5b --- /dev/null +++ b/src/features/storage/download.ts @@ -0,0 +1,37 @@ +import type { RouterOutput } from "$lib/trpc/client"; +import { decrypt } from "$lib/tulip"; +import { type FileAttributes, getDecryptedFileAttributes, getDecryptedFileKey } from "~/features/storage/file"; + + +type RemoteFile = RouterOutput["files"]["files"][number]; + +/** + * Downloads and decrypts the chunks of a file. + * + * @param masterKey + * @param file + * @param attributes + */ +export async function* download( + masterKey: Uint8Array, + file: RemoteFile, + attributes?: FileAttributes, +): AsyncGenerator { + const fileKey = getDecryptedFileKey(masterKey, file); + + if (typeof attributes == "undefined") { + attributes = getDecryptedFileAttributes(masterKey, file); + } + + for (const chunkId of attributes.chunks) { + const response = await fetch(`/api/dl/${chunkId}`); + if (! response.ok) { + throw new Error("Failed to download chunk"); + } + + const arrayBuffer = await response.arrayBuffer(); + const [decrypted] = decrypt(fileKey, new Uint8Array(arrayBuffer)); + + yield decrypted; + } +} diff --git a/src/features/storage/file.ts b/src/features/storage/file.ts new file mode 100644 index 0000000..971bf0e --- /dev/null +++ b/src/features/storage/file.ts @@ -0,0 +1,49 @@ +import type { RouterOutput } from "$lib/trpc/client"; +import { decrypt } from "$lib/tulip"; +import { unobfuscateFileKey } from "$lib/tulip/files"; +import { decodeBase64Url } from "$lib/utils/base64url"; +import { bytesToUtf8 } from "@noble/ciphers/utils"; +import { z } from "zod"; + + +type RemoteFile = RouterOutput["files"]["files"][number]; + +const fileAttributesSchema = z.object({ + name: z.string(), + type: z.string(), + size: z.number(), + chunks: z.array(z.string()), +}); + +export type FileAttributes = z.infer; +export type ChunkEntry = FileAttributes["chunks"][number]; + +/** + * Returns the decrypted file key for a file. + * + * @param masterKey + * @param file + */ +export function getDecryptedFileKey(masterKey: Uint8Array, file: Pick): Uint8Array { + const encryptedFileKey = decodeBase64Url(file.key); + const [obfuscatedFileKey, nonce] = decrypt(masterKey, encryptedFileKey); + + return unobfuscateFileKey(obfuscatedFileKey, nonce, decodeBase64Url(file.cmac)); +} + +/** + * Returns the decrypted file attributes for a file. + * + * @param masterKey + * @param file + */ +export function getDecryptedFileAttributes( + masterKey: Uint8Array, + file: Pick, +): FileAttributes { + const fileKey = getDecryptedFileKey(masterKey, file); + const rawAttributes = decodeBase64Url(file.attributes); + const [decryptedAttributes] = decrypt(fileKey, rawAttributes); + + return JSON.parse(bytesToUtf8(decryptedAttributes)); +} diff --git a/src/features/storage/index.ts b/src/features/storage/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/storage/upload.ts b/src/features/storage/upload.ts new file mode 100644 index 0000000..8d1e5f5 --- /dev/null +++ b/src/features/storage/upload.ts @@ -0,0 +1,95 @@ +import { fastCDC2 } from "$lib/fastcdc"; +import { trpc } from "$lib/trpc/client"; +import { encrypt, getAuthTag, randomKey, randomNonce } from "$lib/tulip"; +import { obfuscateFileKey, updateCondensedMac } from "$lib/tulip/files"; +import { encodeBase64Url } from "$lib/utils/base64url"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import type { FileAttributes } from "~/features/storage/file"; + + +interface VaultInfo { + ticket: Uint8Array; + x25519PublicKey: Uint8Array +} + +/** + * Chunks the given file using FastCDC, encrypts them, and stores the chunks on the server. + * + * @param masterKey + * @param file + * @param vaultInfo + */ +export async function upload(masterKey: Uint8Array, file: File, vaultInfo?: VaultInfo) { + const chunks = fastCDC2(file.stream().getReader(), { + minSize: 1024, + maxSize: 8 * 1024 * 1024, + averageSize: 4 * 1024 * 1024, + }); + + const fileKey = randomKey(); + const baseNonce = randomNonce(); + const chunkIds: string[] = []; + + let condensedMac = new Uint8Array(16); + + for await (const chunk of chunks) { + const encryptedChunk = encrypt(fileKey, chunk.data); + const formData = new FormData(); + formData.append("blob", new Blob([encryptedChunk])); + + if (typeof vaultInfo != "undefined") { + formData.append("ticket", new Blob([vaultInfo.ticket])); + } + + // Upload the chunk to the server + const response = await fetch("/api/ul", { + method: "POST", + headers: { + "x-application": "sveltekit", + }, + body: formData, + }); + if (! response.ok) { + const { message } = await response.json(); + + throw new Error(message); + } + + const { id: chunkId } = await response.json(); + + condensedMac = updateCondensedMac(condensedMac, getAuthTag(encryptedChunk)); + chunkIds.push(chunkId); + } + + // Obfuscate the file key using the base nonce and the condensed MAC + const obfuscatedFileKey = obfuscateFileKey(fileKey, baseNonce, condensedMac); + // Encrypt the file key using the master key + const encryptedFileKey = encrypt(masterKey, obfuscatedFileKey, baseNonce); + + // Finalize the upload and store the file key and the base nonce + const fileType = file.type.length > 0 ? file.type : "application/octet-stream"; + const attributes = JSON.stringify({ + name: file.name, + type: fileType, + size: file.size, + chunks: chunkIds, + } satisfies FileAttributes); + const encryptedAttributes = encrypt(fileKey, utf8ToBytes(attributes)); + + if (typeof vaultInfo == "undefined") { + await trpc().upload.mutate({ + key: encodeBase64Url(encryptedFileKey), + cmac: encodeBase64Url(condensedMac), + attributes: encodeBase64Url(encryptedAttributes), + }); + } + else { + await trpc().uploadWithTicket.mutate({ + ticket: encodeBase64Url(vaultInfo.ticket), + x25519PublicKey: encodeBase64Url(vaultInfo.x25519PublicKey), + key: encodeBase64Url(encryptedFileKey), + cmac: encodeBase64Url(condensedMac), + attributes: encodeBase64Url(encryptedAttributes), + }); + } +} diff --git a/src/features/vaults/components/CreateVaultButton.svelte b/src/features/vaults/components/CreateVaultButton.svelte new file mode 100644 index 0000000..9f47449 --- /dev/null +++ b/src/features/vaults/components/CreateVaultButton.svelte @@ -0,0 +1,157 @@ + + + + open = value}> + +
+ + Create a new public vault + +
+ Public vaults can be shared with other people. This will allow them to upload files into your vaults. + Vaults cannot be modified after creation. +
+ + + + Read-only! + + You are the only person who will be able to view files uploaded into this vault. + + +
+
+ + + + {#snippet children({ props })} + Vault name + + + {/snippet} + + + {#each form.state.slots as slot, i} +
+

Slot {i + 1}

+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ {/each} + +
+ +
+ + + + +
+ + + + {#snippet child({ props })} + + {/snippet} + + + Create + + +
+
+
diff --git a/src/features/vaults/create.ts b/src/features/vaults/create.ts new file mode 100644 index 0000000..61cd4a9 --- /dev/null +++ b/src/features/vaults/create.ts @@ -0,0 +1,62 @@ +import { trpc } from "$lib/trpc/client"; +import { encrypt, randomKey } from "$lib/tulip"; +import { encodeBase64Url } from "$lib/utils/base64url"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import { ed25519 } from "@noble/curves/ed25519"; + + +interface CreateVaultInput { + name: string; + slots: Slot[]; +} + +interface Slot { + name: string; + maxSize: number; +} + +interface CreateVaultResult { + id: string; + token: string; + vaultKey: Uint8Array; +} + +export async function createVault(masterKey: Uint8Array, values: CreateVaultInput): Promise { + const attributes = JSON.stringify({ + name: values.name, + }); + const vaultKey = randomKey(); + const encryptedVaultKey = encrypt(masterKey, vaultKey); + const encryptedAttributes = encrypt(vaultKey, utf8ToBytes(attributes)); + const slots = values.slots.map((slot) => { + const slotAttributes = JSON.stringify({ + name: slot.name, + }); + + return ({ + attributes: encrypt(vaultKey, utf8ToBytes(slotAttributes)), + maxSize: slot.maxSize, + }); + }); + + const authEd25519PrivateKey = ed25519.utils.randomPrivateKey(); + const authEd25519PublicKey = ed25519.getPublicKey(authEd25519PrivateKey); + const encryptedAuthEd25519PrivateKey = encrypt(vaultKey, authEd25519PrivateKey); + + const vault = await trpc().createVault.mutate({ + key: encodeBase64Url(encryptedVaultKey), + attributes: encodeBase64Url(encryptedAttributes), + authEd25519PrivateKey: encodeBase64Url(encryptedAuthEd25519PrivateKey), + authEd25519PublicKey: encodeBase64Url(authEd25519PublicKey), + slots: slots.map((slot) => ({ + attributes: encodeBase64Url(slot.attributes), + maxSize: slot.maxSize, + })), + }); + + return { + id: vault.id, + token: vault.token, + vaultKey, + }; +} diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 0000000..3ee953f --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,11 @@ +import { globals } from "$lib/globals"; +import { openDB } from "$lib/keyval"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + + +dayjs.extend(relativeTime); + +openDB().then((store) => { + globals.setKeyValStore(store); +}); diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..9b9b887 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,7 @@ +import { createContext } from "$lib/trpc/context"; +import { router } from "$lib/trpc/router"; +import type { Handle } from "@sveltejs/kit"; +import { createTRPCHandle } from "trpc-sveltekit"; + + +export const handle: Handle = createTRPCHandle({ router, createContext }); diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 0000000..39021ce --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,8 @@ +import { AuthState } from "$lib/auth/state.svelte"; + + +export const auth = new AuthState(); + +export function onReady(callback: () => void) { + auth.ready.then(callback); +} diff --git a/src/lib/auth/state.svelte.ts b/src/lib/auth/state.svelte.ts new file mode 100644 index 0000000..ab92dde --- /dev/null +++ b/src/lib/auth/state.svelte.ts @@ -0,0 +1,40 @@ +function deferred(): [promise: Promise, resolve: (value: T) => void] { + let resolve: ((value: T) => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + + if (typeof resolve == "undefined") { + throw new Error("Deferred not initialized"); + } + + return [promise, resolve]; +} + +export interface Auth { + username: string; + masterKey: Uint8Array; + x25519PrivateKey: Uint8Array; + x25519PublicKey: Uint8Array; +} + +export class AuthState { + #state = $state(null); + #ready = deferred(); + + get state(): Auth | null { + return this.#state; + } + + get ready(): Promise { + return this.#ready[0]; + } + + setLogin(auth: Auth | null) { + this.#state = auth; + } + + setReady() { + this.#ready[1](); + } +} diff --git a/src/lib/components/Dropzone.svelte b/src/lib/components/Dropzone.svelte new file mode 100644 index 0000000..c48fc93 --- /dev/null +++ b/src/lib/components/Dropzone.svelte @@ -0,0 +1,76 @@ + + +
inputElement?.click()} + ondrop={handleDrop} + ondragover={handleDragOver} + ondragleave={handleDragLeave} + onkeydown={(event) => event.key === " " && inputElement?.click()} +> +
+ + +
+ + Upload a file + +

or drag and drop

+
+

Any file, any size

+ + +
+
diff --git a/src/lib/components/LogoutButton.svelte b/src/lib/components/LogoutButton.svelte new file mode 100644 index 0000000..2071063 --- /dev/null +++ b/src/lib/components/LogoutButton.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/lib/components/PurgeButton.svelte b/src/lib/components/PurgeButton.svelte new file mode 100644 index 0000000..0632dc1 --- /dev/null +++ b/src/lib/components/PurgeButton.svelte @@ -0,0 +1,33 @@ + + + diff --git a/src/lib/components/Tag.svelte b/src/lib/components/Tag.svelte new file mode 100644 index 0000000..49fd4ad --- /dev/null +++ b/src/lib/components/Tag.svelte @@ -0,0 +1,11 @@ + + +
+ {adjective} +
diff --git a/src/lib/components/ToastContent.svelte b/src/lib/components/ToastContent.svelte new file mode 100644 index 0000000..9bc72d6 --- /dev/null +++ b/src/lib/components/ToastContent.svelte @@ -0,0 +1,12 @@ + + +{$message} diff --git a/src/lib/components/devtools/DevTools.svelte b/src/lib/components/devtools/DevTools.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/components/ui/alert/alert-description.svelte b/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..8bff2d5 --- /dev/null +++ b/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert/alert-title.svelte b/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..a13acf5 --- /dev/null +++ b/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,26 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert/alert.svelte b/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..ce8d2bf --- /dev/null +++ b/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,41 @@ + + + + + diff --git a/src/lib/components/ui/alert/index.ts b/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..f9a276c --- /dev/null +++ b/src/lib/components/ui/alert/index.ts @@ -0,0 +1,16 @@ +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +import Root from "./alert.svelte"; + + +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/src/lib/components/ui/badge/badge.svelte b/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..d6aa12f --- /dev/null +++ b/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,49 @@ + + + + + + {@render children?.()} + diff --git a/src/lib/components/ui/badge/index.ts b/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..64e0aa9 --- /dev/null +++ b/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..1520aa6 --- /dev/null +++ b/src/lib/components/ui/button/button.svelte @@ -0,0 +1,74 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..33cd8be --- /dev/null +++ b/src/lib/components/ui/button/index.ts @@ -0,0 +1,10 @@ +import Root, { type ButtonProps, type ButtonSize, type ButtonVariant, buttonVariants } from "./button.svelte"; + + +export { + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..ed9ef9f --- /dev/null +++ b/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..0f13d6d --- /dev/null +++ b/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,17 @@ + + +

+ {@render children?.()} +

diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..ff396fb --- /dev/null +++ b/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..3c49057 --- /dev/null +++ b/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..60e2a8b --- /dev/null +++ b/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,26 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..0993569 --- /dev/null +++ b/src/lib/components/ui/card/card.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..e6baa72 --- /dev/null +++ b/src/lib/components/ui/card/index.ts @@ -0,0 +1,16 @@ +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Root from "./card.svelte"; + + +export { + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, +}; diff --git a/src/lib/components/ui/dialog/dialog-body.svelte b/src/lib/components/ui/dialog/dialog-body.svelte new file mode 100644 index 0000000..de4f9cc --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-body.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..eb8b37f --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,39 @@ + + + + + + {@render children?.()} + + + Close + + + diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..7da41b9 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-footer.svelte b/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..732e0e5 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/dialog/dialog-header.svelte b/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..de4f9cc --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..7ead66c --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..2b51aa7 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..4b2ffca --- /dev/null +++ b/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,40 @@ +import { Dialog as DialogPrimitive } from "bits-ui"; +import Body from "./dialog-body.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Title from "./dialog-title.svelte"; + + +const Root = DialogPrimitive.Root; +const Trigger = DialogPrimitive.Trigger; +const Close = DialogPrimitive.Close; +const Portal = DialogPrimitive.Portal; + +export { + Root, + Title, + Portal, + Footer, + Body, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Body as DialogBody, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..6adb61b --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,40 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..08f8fbd --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..3a4c63f --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..db7bb3b --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..d485e53 --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..e81728f --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..ad44db5 --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..01c26c2 --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..1c0828f --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..a71c59a --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,28 @@ + + + + {@render children?.()} + + diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..40c4502 --- /dev/null +++ b/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,50 @@ +import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; + +const Sub = DropdownMenuPrimitive.Sub; +const Root = DropdownMenuPrimitive.Root; +const Trigger = DropdownMenuPrimitive.Trigger; +const Group = DropdownMenuPrimitive.Group; +const RadioGroup = DropdownMenuPrimitive.RadioGroup; + +export { + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + GroupHeading as DropdownMenuGroupHeading, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..3b8dffd --- /dev/null +++ b/src/lib/components/ui/input/index.ts @@ -0,0 +1,6 @@ +import Root from "./input.svelte"; + + +export { + Root as Input, +}; diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..fc782a5 --- /dev/null +++ b/src/lib/components/ui/input/input.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..ceeb662 --- /dev/null +++ b/src/lib/components/ui/label/index.ts @@ -0,0 +1,8 @@ +import Root from "./label.svelte"; + + +export { + Root, + // + Root as Label, +}; diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..552fa9c --- /dev/null +++ b/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/sonner/index.ts b/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/src/lib/components/ui/sonner/sonner.svelte b/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..750ba0a --- /dev/null +++ b/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/tabs/index.ts b/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..1abc044 --- /dev/null +++ b/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,19 @@ +import { Tabs as TabsPrimitive } from "bits-ui"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + + +const Root = TabsPrimitive.Root; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/src/lib/components/ui/tabs/tabs-content.svelte b/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..1a6f956 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs-list.svelte b/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..411ad56 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs-trigger.svelte b/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..754ace3 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/fastcdc/index.ts b/src/lib/fastcdc/index.ts new file mode 100644 index 0000000..1377c70 --- /dev/null +++ b/src/lib/fastcdc/index.ts @@ -0,0 +1,217 @@ +import { createTable } from "$lib/fastcdc/table"; +import type { Chunk, Options } from "$lib/fastcdc/types"; +import { concat } from "$lib/tulip/utils/buffer"; + + +const MIN_SIZE = 64; +const MAX_SIZE = 1 << 30; // 1GiB + +/** + * Chunks the given data using FastCDC and a rolling Gear hash. + * See https://en.wikipedia.org/wiki/Rolling_hash#Gear_fingerprint_and_content-based_chunking_algorithm_FastCDC + * Paper https://www.usenix.org/system/files/conference/atc16/atc16-paper-xia.pdf + * Based on https://github.com/jotfs/fastcdc-go/tree/v0.2.0 + * + * @param data + * @param options + */ +export function* fastCDC(data: Uint8Array, options: Options): Generator { + const { averageSize, minSize, maxSize, normalization, seed } = setDefaults(options); + const [maskBelow, maskAbove] = computeBitmasks(averageSize, normalization); + const table = createTable(seed); + + let fingerprint = 0n; + let start = 0; + + for (let i = 0; i < data.length; i++) { + const value = data[i]; + if (typeof value == "undefined") { + throw new Error("Should never happen"); + } + + const len = i - start + 1; // one for the bias, jk + const isBelowMinSize = len < minSize; + const isLastByte = i === data.length - 1; + + const lookup = table[value]; + if (typeof lookup == "undefined") { + throw new Error("Should never happen"); + } + + // Compute Gear hash + fingerprint = BigInt.asUintN(64, (fingerprint << 1n) + lookup); + + // Skip the first minSize bytes, and kickstart the hash + if (isBelowMinSize && ! isLastByte) { + continue; + } + + const mask = len < averageSize ? maskBelow : maskAbove; + const isBoundary = isChunkBoundary(fingerprint, mask); + const isMaxSizeExceeded = len >= maxSize; + + // Yield if there is either a natural boundary, the max size is exceeded or we are at the end of the data + if (isBoundary || isMaxSizeExceeded || isLastByte) { + yield { + data: data.subarray(start, i + 1), + fingerprint: fingerprint, + }; + + start = i + 1; + fingerprint = 0n; + } + } +} + +/** + * Chunks the data from the given reader using FastCDC and a rolling Gear hash. + * + * @param reader + * @param options + */ +export async function* fastCDC2( + reader: ReadableStreamDefaultReader, + options: Options, +): AsyncGenerator { + const { averageSize, minSize, maxSize, normalization, seed } = setDefaults(options); + const [maskBelow, maskAbove] = computeBitmasks(averageSize, normalization); + const table = createTable(seed); + + let fingerprint = 0n; + let previous: Uint8Array[] = []; + let byteCounter = 0; + + while (true) { + const { value, done } = await reader.read(); + + if (done) { + if (previous.length > 0) { + yield { + data: concat(...previous), + fingerprint: fingerprint, + }; + } + + break; + } + + for (let i = 0, start = 0; i < value.length; i++) { + byteCounter++; + + const byte = value[i]; + if (typeof byte == "undefined") { + throw new Error("Should never happen"); + } + + const lookup = table[byte]; + if (typeof lookup == "undefined") { + throw new Error("Should never happen"); + } + + const isBelowMinSize = byteCounter < minSize; + const isLastByte = i === value.length - 1; + + // Compute Gear hash + fingerprint = BigInt.asUintN(64, (fingerprint << 1n) + lookup); + + // Skip the first minSize bytes, and kickstart the hash + if (isBelowMinSize && ! isLastByte) { + continue; + } + + const mask = byteCounter < averageSize ? maskBelow : maskAbove; + const isBoundary = isChunkBoundary(fingerprint, mask); + const isMaxSizeExceeded = byteCounter >= maxSize; + + // Yield if there is either a natural boundary or the max size is exceeded + if (isBoundary || isMaxSizeExceeded) { + const part = value.subarray(start, i + 1); + const data = concat(...previous, part); + + yield { + data, + fingerprint, + }; + + fingerprint = 0n; + start = i + 1; + byteCounter = 0; + previous = []; + } + else if (isLastByte) { + previous.push(value.subarray(start, i + 1)); + } + } + } +} + +/** + * Set the defaults for the given options. + * Also checks, whether the given values are valid. + * + * @param options + */ +function setDefaults(options: Options): Required { + const { averageSize } = options; + const { + minSize = Math.floor(averageSize / 4), + maxSize = Math.floor(averageSize * 4), + normalization = 2, + seed = 0n, + } = options; + + if (averageSize <= 0) { + throw new Error("averageSize must be greater than 0"); + } + if (minSize < MIN_SIZE || minSize > MAX_SIZE) { + throw new Error("minSize must be in range 64B to 1GiB"); + } + if (maxSize < MIN_SIZE || maxSize > MAX_SIZE) { + throw new Error("maxSize must be in range 64B to 1GiB"); + } + if (maxSize <= minSize) { + throw new Error("minSize must be less than option maxSize"); + } + if (averageSize > maxSize || averageSize < minSize) { + throw new Error("averageSize must be between minSize and maxSize"); + } + + return { + averageSize, + minSize, + maxSize, + normalization, + seed, + }; +} + +/** + * Computes two bitmasks for chunk boundary determination. + * The first bitmask should be used for when the current chunk length is below the given average size, while the second + * bitmask is for when it's above. + * + * @param averageSize + * @param normalization + */ +function computeBitmasks(averageSize: number, normalization: 0 | 1 | 2 | 3): [bigint, bigint] { + const bits = Math.round(Math.log2(averageSize)); + const smallBits = bits + normalization; + const largeBits = bits - normalization; + const maskBelow = BigInt((1 << smallBits) - 1); + const maskAbove = BigInt((1 << largeBits) - 1); + + return [ + maskBelow, + maskAbove, + ]; +} + +/** + * Checks if a fingerprint defines a chunk boundary. + * + * @param fingerprint + * @param mask + */ +function isChunkBoundary(fingerprint: bigint, mask: bigint): boolean { + return (fingerprint & mask) == 0n; +} diff --git a/src/lib/fastcdc/table.ts b/src/lib/fastcdc/table.ts new file mode 100644 index 0000000..1da1ce5 --- /dev/null +++ b/src/lib/fastcdc/table.ts @@ -0,0 +1,91 @@ +import type { Table } from "$lib/fastcdc/types"; + + +/** + * Create a new table with the given seed. + * + * @param seed + */ +export function createTable(seed: bigint): Table { + const table = [...TABLE]; + + for (let i = 0; i < table.length; i++) { + const value = table[i]; + if (typeof value == "undefined") { + throw new Error("Should never happen"); + } + + table[i] = value ^ seed; + } + + return table; +} + +// 256 random uint64s for the rolling hash function +// Borrowed from https://github.com/jotfs/fastcdc-go/blob/v0.2.0/fastcdc.go +const TABLE: Table = [ + 0xe80e8d55032474b3n, 0x11b25b61f5924e15n, 0x03aa5bd82a9eb669n, 0xc45a153ef107a38cn, + 0xeac874b86f0f57b9n, 0xa5ccedec95ec79c7n, 0xe15a3320ad42ac0an, 0x5ed3583fa63cec15n, + 0xcd497bf624a4451dn, 0xf9ade5b059683605n, 0x773940c03fb11ca1n, 0xa36b16e4a6ae15b2n, + 0x67afd1adb5a89eacn, 0xc44c75ee32f0038en, 0x2101790f365c0967n, 0x76415c64a222fc4an, + 0x579929249a1e577an, 0xe4762fc41fdbf750n, 0xea52198e57dfcdccn, 0xe2535aafe30b4281n, + 0xcb1a1bd6c77c9056n, 0x5a1aa9bfc4612a62n, 0x15a728aef8943eb5n, 0x2f8f09738a8ec8d9n, + 0x200f3dec9fac8074n, 0x0fa9a7b1e0d318dfn, 0x06c0804ffd0d8e3an, 0x630cbc412669dd25n, + 0x10e34f85f4b10285n, 0x2a6fe8164b9b6410n, 0xcacb57d857d55810n, 0x77f8a3a36ff11b46n, + 0x66af517e0dc3003en, 0x76c073c789b4009an, 0x853230dbb529f22an, 0x1e9e9c09a1f77e56n, + 0x1e871223802ee65dn, 0x37fe4588718ff813n, 0x10088539f30db464n, 0x366f7470b80b72d1n, + 0x33f2634d9a6b31dbn, 0xd43917751d69ea18n, 0xa0f492bc1aa7b8den, 0x3f94e5a8054edd20n, + 0xedfd6e25eb8b1dbfn, 0x759517a54f196a56n, 0xe81d5006ec7b6b17n, 0x8dd8385fa894a6b7n, + 0x45f4d5467b0d6f91n, 0xa1f894699de22bc8n, 0x33829d09ef93e0fen, 0x3e29e250caed603cn, + 0xf7382cba7f63a45en, 0x970f95412bb569d1n, 0xc7fcea456d356b4bn, 0x723042513f3e7a57n, + 0x17ae7688de3596f1n, 0x27ac1fcd7cd23c1an, 0xf429beeb78b3f71fn, 0xd0780692fb93a3f9n, + 0x9f507e28a7c9842fn, 0x56001ad536e433aen, 0x7e1dd1ecf58be306n, 0x15fee353aa233fc6n, + 0xb033a0730b7638e8n, 0xeb593ad6bd2406d1n, 0x7c86502574d0f133n, 0xce3b008d4ccb4be7n, + 0xf8566e3d383594c8n, 0xb2c261e9b7af4429n, 0xf685e7e253799dbbn, 0x05d33ed60a494cbcn, + 0xeaf88d55a4cb0d1an, 0x3ee9368a902415a1n, 0x8980fe6a8493a9a4n, 0x358ed008cb448631n, + 0xd0cb7e37b46824b8n, 0xe9bc375c0bc94f84n, 0xea0bf1d8e6b55bb3n, 0xb66a60d0f9f6f297n, + 0x66db2cc4807b3758n, 0x7e4e014afbca8b4dn, 0xa5686a4938b0c730n, 0xa5f0d7353d623316n, + 0x26e38c349242d5e8n, 0xeeefa80a29858e30n, 0x8915cb912aa67386n, 0x4b957a47bfc420d4n, + 0xbb53d051a895f7e1n, 0x09f5e3235f6911cen, 0x416b98e695cfb7cen, 0x97a08183344c5c86n, + 0xbf68e0791839a861n, 0xea05dde59ed3ed56n, 0x0ca732280beda160n, 0xac748ed62fe7f4e2n, + 0xc686da075cf6e151n, 0xe1ba5658f4af05c8n, 0xe9ff09fbeb67cc35n, 0xafaea9470323b28dn, + 0x0291e8db5bb0ac2an, 0x342072a9bbee77aen, 0x03147eed6b3d0a9cn, 0x21379d4de31dbadbn, + 0x2388d965226fb986n, 0x52c96988bfebabfan, 0xa6fc29896595bc2dn, 0x38fa4af70aa46b8bn, + 0xa688dd13939421een, 0x99d5275d9b1415dan, 0x453d31bb4fe73631n, 0xde51debc1fbe3356n, + 0x75a3c847a06c622fn, 0xe80e32755d272579n, 0x5444052250d8ec0dn, 0x8f17dfda19580a3bn, + 0xf6b3e9363a185e42n, 0x7a42adec6868732fn, 0x32cb6a07629203a2n, 0x1eca8957defe56d9n, + 0x9fa85e4bc78ff9edn, 0x20ff07224a499ca7n, 0x3fa6295ff9682c70n, 0xe3d5b1e3ce993effn, + 0xa341209362e0b79an, 0x64bd9eae5712ffe8n, 0xceebb537babbd12an, 0x5586ef404315954fn, + 0x46c3085c938ab51an, 0xa82ccb9199907ceen, 0x8c51b6690a3523c8n, 0xc4dbd4c9ae518332n, + 0x979898dbb23db7b2n, 0x1b5b585e6f672a9dn, 0xce284da7c4903810n, 0x841166e8bb5f1c4fn, + 0xb7d884a3fceca7d0n, 0xa76468f5a4572374n, 0xc10c45f49ee9513dn, 0x68f9a5663c1908c9n, + 0x0095a13476a6339dn, 0xd1d7516ffbe9c679n, 0xfd94ab0c9726f938n, 0x627468bbdb27c959n, + 0xedc3f8988e4a8c9an, 0x58efd33f0dfaa499n, 0x21e37d7e2ef4ac8bn, 0x297f9ab5586259c6n, + 0xda3ba4dc6cb9617dn, 0xae11d8d9de2284d2n, 0xcfeed88cb3729865n, 0xefc2f9e4f03e2633n, + 0x8226393e8f0855a4n, 0xd6e25fd7acf3a767n, 0x435784c3bfd6d14an, 0xf97142e6343fe757n, + 0xd73b9fe826352f85n, 0x6c3ac444b5b2bd76n, 0xd8e88f3e9fd4a3fdn, 0x31e50875c36f3460n, + 0xa824f1bf88cf4d44n, 0x54a4d2c8f5f25899n, 0xbff254637ce3b1e6n, 0xa02cfe92561b3caan, + 0x7bedb4edee9f0af7n, 0x879c0620ac49a102n, 0xa12c4ccd23b332e7n, 0x09a5ff47bf94ed1en, + 0x7b62f43cd3046fa0n, 0xaa3af0476b9c2fb9n, 0x22e55301abebba8en, 0x3a6035c42747bd58n, + 0x1705373106c8ec07n, 0xb1f660de828d0628n, 0x065fe82d89ca563dn, 0xf555c2d8074d516dn, + 0x6bb6c186b423ee99n, 0x54a807be6f3120a8n, 0x8a3c7fe2f88860b8n, 0xbeffc344f5118e81n, + 0xd686e80b7d1bd268n, 0x661aef4ef5e5e88bn, 0x5bf256c654cd1ddan, 0x9adb1ab85d7640f4n, + 0x68449238920833a2n, 0x843279f4cebcb044n, 0xc8710cdefa93f7bbn, 0x236943294538f3e6n, + 0x80d7d136c486d0b4n, 0x61653956b28851d3n, 0x3f843be9a9a956b5n, 0xf73cfbbf137987e5n, + 0xcf0cb6dee8ceac2cn, 0x50c401f52f185caen, 0xbdbe89ce735c4c1cn, 0xeef3ade9c0570bc7n, + 0xbe8b066f8f64cbf6n, 0x5238d6131705dcb9n, 0x20219086c950e9f6n, 0x634468d9ed74de02n, + 0x0aba4b3d705c7fa5n, 0x3374416f725a6672n, 0xe7378bdf7beb3bc6n, 0x0f7b6a1b1cee565bn, + 0x234e4c41b0c33e64n, 0x4efa9a0c3f21fe28n, 0x1167fc551643e514n, 0x9f81a69d3eb01fa4n, + 0xdb75c22b12306ed0n, 0xe25055d738fc9686n, 0x9f9f167a3f8507bbn, 0x195f8336d3fbe4d3n, + 0x8442b6feffdcb6f6n, 0x1e07ed24746ffde9n, 0x140e31462d555266n, 0x8bd0ce515ae1406en, + 0x2c0be0042b5584b3n, 0x35a23d0e15d45a60n, 0xc14f1ba147d9bc83n, 0xbbf168691264b23fn, + 0xad2cc7b57e589aden, 0x9501963154c7815cn, 0x9664afa6b8d67d47n, 0x7f9e5101fea0a81cn, + 0x45ecffb610d25bfdn, 0x3157f7aecf9b6ab3n, 0xc43ca6f88d87501dn, 0x9576ff838dee38dcn, + 0x93f21afe0ce1c7d7n, 0xceac699df343d8f9n, 0x2fec49e29f03398dn, 0x8805ccd5730281edn, + 0xf9fc16fc750a8e59n, 0x35308cc771adf736n, 0x4a57b7c9ee2b7defn, 0x03a4c6cdc937a02an, + 0x6c9a8a269fc8c4fcn, 0x4681decec7a03f43n, 0x342eecded1353ef9n, 0x8be0552d8413a867n, + 0xc7b4ac51beda8be8n, 0xebcc64fb719842c0n, 0xde8e4c7fb6d40c1cn, 0xcc8263b62f9738b1n, + 0xd3cfc0f86511929an, 0x466024ce8bb226ean, 0x459ff690253a3c18n, 0x98b27e9d91284c9cn, + 0x75c3ae8aa3af373dn, 0xfbf8f8e79a866ffcn, 0x32327f59d0662799n, 0x8228b57e729e9830n, + 0x065ceb7a18381b58n, 0xd2177671a31dc5ffn, 0x90cd801f2f8701f9n, 0x9d714428471c65fen, +]; diff --git a/src/lib/fastcdc/types.ts b/src/lib/fastcdc/types.ts new file mode 100644 index 0000000..cd4cf3d --- /dev/null +++ b/src/lib/fastcdc/types.ts @@ -0,0 +1,28 @@ +export type Table = bigint[]; + + +export interface Options { + // Target chunk size. Typically, a power of 2. It must be in the range 64B to 1GiB + averageSize: number; + + // (Optional) The minimum allowed chunk size. By default, it's set to averageSize / 4. + minSize?: number; + + // (Optional) The maximum allowed chunk size. By default, it's set to averageSize * 4. + maxSize?: number; + + // (Optional) Sets the chunk normalization level. It may be set to 0, 1, 2 or 3. By default, it's set to 2. + normalization?: 0 | 1 | 2 | 3; + + // (Optional) Alters the lookup table of the rolling hash algorithm to mitigate chunk-size based fingerprinting + // attacks. It may be set to a random uint64. + seed?: bigint; +} + +export interface Chunk { + // This chunk's data. + data: Uint8Array; + + // Rolling hash for this chunk. + fingerprint: bigint; +} diff --git a/src/lib/globals/index.ts b/src/lib/globals/index.ts new file mode 100644 index 0000000..c5718b4 --- /dev/null +++ b/src/lib/globals/index.ts @@ -0,0 +1,4 @@ +import { GlobalsState } from "$lib/globals/state.svelte"; + + +export const globals = new GlobalsState(); diff --git a/src/lib/globals/state.svelte.ts b/src/lib/globals/state.svelte.ts new file mode 100644 index 0000000..61c8a89 --- /dev/null +++ b/src/lib/globals/state.svelte.ts @@ -0,0 +1,14 @@ +import type { KeyValStore } from "$lib/keyval"; + + +export class GlobalsState { + #keyval = $state(null); + + get keyval(): KeyValStore | null { + return this.#keyval; + } + + setKeyValStore(store: KeyValStore | null) { + this.#keyval = store; + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/keyval/index.ts b/src/lib/keyval/index.ts new file mode 100644 index 0000000..485f91a --- /dev/null +++ b/src/lib/keyval/index.ts @@ -0,0 +1,69 @@ +const DATABASE_NAME = "keyval"; +const OBJECT_STORE_NAME = "keyval"; +const KEY_PATH = "key"; + + +export interface KeyValStore { + get: (key: string) => Promise; + set: (key: string, value: T) => Promise; + delete: (key: string) => Promise; +} + +export function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + + request.onupgradeneeded = () => { + const db = request.result; + + if (! db.objectStoreNames.contains(OBJECT_STORE_NAME)) { + const objectStore = db.createObjectStore(OBJECT_STORE_NAME, { keyPath: KEY_PATH }); + + objectStore.createIndex(KEY_PATH, KEY_PATH, { unique: true }); + } + }; + + request.onerror = reject; + request.onsuccess = () => { + const db = request.result; + + resolve({ + get: (key: string) => new Promise((resolve, reject) => { + const readTransaction = db.transaction(DATABASE_NAME, "readonly"); + const readStore = readTransaction.objectStore(OBJECT_STORE_NAME); + const readRequest = readStore.get(key); + + readRequest.onerror = reject; + readRequest.onsuccess = () => { + resolve(readRequest.result?.value ?? null); + }; + }), + set: async (key: string, value: T | null) => new Promise((resolve, reject) => { + const modifyTransaction = db.transaction(DATABASE_NAME, "readwrite"); + const modifyStore = modifyTransaction.objectStore(OBJECT_STORE_NAME); + + if (value === null) { + const deleteRequest = modifyStore.delete(key); + + deleteRequest.onerror = reject; + deleteRequest.onsuccess = () => resolve(); + } + else { + const writeRequest = modifyStore.put({ key, value }); + + writeRequest.onerror = reject; + writeRequest.onsuccess = () => resolve(); + } + }), + delete: (key) => new Promise((resolve, reject) => { + const modifyTransaction = db.transaction(DATABASE_NAME, "readwrite"); + const modifyStore = modifyTransaction.objectStore(OBJECT_STORE_NAME); + const deleteRequest = modifyStore.delete(key); + + deleteRequest.onerror = reject; + deleteRequest.onsuccess = () => resolve(); + }), + }); + }; + }); +} diff --git a/src/lib/reform/components/form-button.svelte b/src/lib/reform/components/form-button.svelte new file mode 100644 index 0000000..cd80196 --- /dev/null +++ b/src/lib/reform/components/form-button.svelte @@ -0,0 +1,14 @@ + + + diff --git a/src/lib/reform/components/form-control.svelte b/src/lib/reform/components/form-control.svelte new file mode 100644 index 0000000..dfaf22c --- /dev/null +++ b/src/lib/reform/components/form-control.svelte @@ -0,0 +1,51 @@ + + + + +
+ {@render children?.({ props: control.props, error: control.error })} +
diff --git a/src/lib/reform/components/form-field-error.svelte b/src/lib/reform/components/form-field-error.svelte new file mode 100644 index 0000000..e51cd6a --- /dev/null +++ b/src/lib/reform/components/form-field-error.svelte @@ -0,0 +1,20 @@ + + +{#if control.error} +
+ {control.error} +
+{/if} diff --git a/src/lib/reform/components/form-label.svelte b/src/lib/reform/components/form-label.svelte new file mode 100644 index 0000000..9474e05 --- /dev/null +++ b/src/lib/reform/components/form-label.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/reform/components/index.ts b/src/lib/reform/components/index.ts new file mode 100644 index 0000000..1a1d491 --- /dev/null +++ b/src/lib/reform/components/index.ts @@ -0,0 +1,16 @@ +import Button from "./form-button.svelte"; +import Control from "./form-control.svelte"; +import FieldError from "./form-field-error.svelte"; +import Label from "./form-label.svelte"; + + +export { + Control as FormControl, + // Description as FormDescription, + Label as FormLabel, + FieldError as FormFieldError, + // Fieldset as FormFieldset, + // Legend as FormLegend, + // ElementField as FormElementField, + Button as FormButton, +}; diff --git a/src/lib/reform/context.ts b/src/lib/reform/context.ts new file mode 100644 index 0000000..0fdc0a0 --- /dev/null +++ b/src/lib/reform/context.ts @@ -0,0 +1,18 @@ +import { type FormControlInit, FormControlState } from "$lib/reform/state/form-control.svelte"; +import { getContext, hasContext, setContext } from "svelte"; +import type { AnyZodObject } from "zod"; + + +const FORM_CONTROL_CONTEXT_KEY = Symbol(); + +export function useFormControl(init: FormControlInit) { + return setContext(FORM_CONTROL_CONTEXT_KEY, new FormControlState(init)); +} + +export function getFormControl(): FormControlState { + if (! hasContext(FORM_CONTROL_CONTEXT_KEY)) { + throw new Error("getFormControl must be used within a FormControl"); + } + + return getContext>(FORM_CONTROL_CONTEXT_KEY); +} diff --git a/src/lib/reform/index.ts b/src/lib/reform/index.ts new file mode 100644 index 0000000..d686268 --- /dev/null +++ b/src/lib/reform/index.ts @@ -0,0 +1,13 @@ +import { FormState } from "$lib/reform/state/form.svelte"; +import { type AnyZodObject, z } from "zod"; + + +export interface ReformOptions { + schema: ZodType; + initialState: z.infer; + onSubmit?: (data: z.infer) => void; +} + +export function reform(options: ReformOptions): FormState { + return new FormState(options); +} diff --git a/src/lib/reform/state/form-control.svelte.ts b/src/lib/reform/state/form-control.svelte.ts new file mode 100644 index 0000000..9b5b3c5 --- /dev/null +++ b/src/lib/reform/state/form-control.svelte.ts @@ -0,0 +1,63 @@ +import type { FormState } from "$lib/reform/state/form.svelte"; +import type { allKeys } from "$lib/reform/types"; +import { untrack } from "svelte"; +import type { HTMLInputAttributes } from "svelte/elements"; +import { type AnyZodObject, z } from "zod"; + + +export interface FormControlInit { + form: FormState; + field: allKeys>; + id: string; +} + +export type FormControlAttributes = HTMLInputAttributes; + +export class FormControlState { + private readonly form: FormState; + private readonly field: allKeys>; + private readonly id: string; + + /** + * The props to be passed to the control. + */ + readonly props = $derived.by(() => ({ + id: this.id, + name: this.field as string, + class: "group-data-[rf-error=true]/control:border-destructive", + + // name: this.field.name, + // 'data-fs-error': getDataFsError(this.field.errors), + // 'aria-describedby': getAriaDescribedBy({ + // fieldErrorsId: this.field.errorNode?.id, + // descriptionId: this.field.descriptionNode?.id, + // errors: this.field.errors, + // }), + // 'aria-invalid': getAriaInvalid(this.field.errors), + // 'aria-required': getAriaRequired(this.field.constraints), + // 'data-fs-control': '', + }) satisfies FormControlAttributes); + + constructor(init: FormControlInit) { + this.form = init.form; + this.field = init.field; + this.id = init.id; + + $effect(() => { + // Mark the value as a dependency for this effect + this.value; + + if (untrack(() => this.error) !== null) { + untrack(() => this.form.resetError(this.field)); + } + }); + } + + get value() { + return this.form.state[this.field]; + } + + get error() { + return this.form.errors[this.field] ?? null; + } +} diff --git a/src/lib/reform/state/form.svelte.ts b/src/lib/reform/state/form.svelte.ts new file mode 100644 index 0000000..d3ca23e --- /dev/null +++ b/src/lib/reform/state/form.svelte.ts @@ -0,0 +1,101 @@ +import type { allKeys } from "$lib/reform/types"; +import { type AnyZodObject, z } from "zod"; + + +interface FormOptions { + schema: ZodType; + initialState: z.infer; + onSubmit?: (data: z.infer) => Promise | void; +} + +type ErrorMap = Partial>, string>>; + +export class FormState { + readonly #state: z.infer = $state({}); + #errors = $state>({}); + #submitting = $state(false); + readonly props = $derived.by(() => ({ + onsubmit: async (event: Event) => { + event.preventDefault(); + + const values = this.values(); + + this.revalidate(values); + + if (! this.hasErrors()) { + this.#submitting = true; + + try { + await this.options.onSubmit?.(values); + } + catch (error) { + throw error; + } + finally { + this.#submitting = false; + } + } + }, + })); + + constructor(private readonly options: FormOptions) { + this.#state = options.initialState; + } + + get state(): z.infer { + return this.#state; + } + + get errors(): ErrorMap { + return this.#errors; + } + + get submitting(): boolean { + return this.#submitting; + } + + values(): z.infer { + return $state.snapshot(this.state) as z.infer; + } + + revalidate(values = this.values()): void { + const result = this.options.schema.safeParse(values); + if (result.success) { + return; + } + + const { fieldErrors } = result.error.flatten(); + const errors = Object.entries(fieldErrors).reduce((carry, [key, value]) => { + if (typeof value == "undefined") { + return carry; + } + + return { + ...carry, + [key]: value[0], + }; + }, {}); + + this.setErrors(errors); + } + + setErrors(errors: ErrorMap): void { + this.#errors = errors; + } + + setFieldError(field: allKeys>, error: string): void { + this.#errors[field] = error; + } + + hasError(field: allKeys>): boolean { + return this.#errors[field] !== undefined; + } + + hasErrors(): boolean { + return Object.keys(this.#errors).length > 0; + } + + resetError(field: allKeys>): void { + delete this.#errors[field]; + } +} diff --git a/src/lib/reform/types.ts b/src/lib/reform/types.ts new file mode 100644 index 0000000..769740a --- /dev/null +++ b/src/lib/reform/types.ts @@ -0,0 +1 @@ +export declare type allKeys = T extends any ? keyof T : never; diff --git a/src/lib/server/crypto/encrypt.ts b/src/lib/server/crypto/encrypt.ts new file mode 100644 index 0000000..4d3d8a6 --- /dev/null +++ b/src/lib/server/crypto/encrypt.ts @@ -0,0 +1,13 @@ +import { decrypt as tulipDecrypt, encrypt as tulipEncrypt } from "$lib/tulip"; +import { randomBytes } from "@noble/ciphers/webcrypto"; + + +const encryptionKey = randomBytes(32); + +export function encrypt(data: Uint8Array): Uint8Array { + return tulipEncrypt(encryptionKey, data); +} + +export function decrypt(data: Uint8Array): Uint8Array { + return tulipDecrypt(encryptionKey, data)[0]; +} diff --git a/src/lib/server/crypto/sign.ts b/src/lib/server/crypto/sign.ts new file mode 100644 index 0000000..71382d3 --- /dev/null +++ b/src/lib/server/crypto/sign.ts @@ -0,0 +1,25 @@ +import type { Hex } from "@noble/curves/abstract/utils"; +import { ed25519 } from "@noble/curves/ed25519"; + + +const privateKey = ed25519.utils.randomPrivateKey(); +const publicKey = ed25519.getPublicKey(privateKey); + +/** + * Signs a message with the server's private key. + * + * @param message Message to sign + */ +export function sign(message: Hex) { + return ed25519.sign(message, privateKey); +} + +/** + * Verifies a signature with the server's public key. + * + * @param signature Signature to verify + * @param message Message to verify + */ +export function verify(signature: Hex, message: Hex) { + return ed25519.verify(signature, message, publicKey); +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts new file mode 100644 index 0000000..8c628c4 --- /dev/null +++ b/src/lib/server/db/index.ts @@ -0,0 +1,13 @@ +import { env } from "$env/dynamic/private"; +import * as schema from "$lib/server/db/schema"; +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; + + +if (! env.DATABASE_URL) { + throw new Error("DATABASE_URL is not set"); +} + +const client = new Database(env.DATABASE_URL); +export const db = drizzle(client, { schema }); +export { schema }; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts new file mode 100644 index 0000000..ae07708 --- /dev/null +++ b/src/lib/server/db/schema.ts @@ -0,0 +1,132 @@ +import { randomId } from "$lib/utils/id"; +import { relations } from "drizzle-orm"; +import { blob, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; + + +export const users = sqliteTable("users", { + id: text("id").$default(randomId).primaryKey(), + username: text("username").notNull().unique(), + crv: text("crv").notNull(), + hak: text("hak").notNull(), + key: text("key").notNull(), + x25519PrivateKey: text("x25519_private_key"), + x25519PublicKey: text("x25519_public_key"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()), +}); + +export const usersRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), + files: many(files), + chunks: many(chunks), +})); + +export const sessions = sqliteTable("sessions", { + id: text("id").$default(randomId).primaryKey(), + userId: text("user_id").notNull(), + sessionId: text("session_id").notNull().unique(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()), +}); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})); + +export const files = sqliteTable("files", { + id: text("id").$default(randomId).primaryKey(), + userId: text("user_id").notNull(), + key: text("key").notNull(), + cmac: text("cmac").notNull(), + attributes: text("attributes").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()), +}); + +export const filesRelations = relations(files, ({ one, many }) => ({ + user: one(users, { + fields: [files.userId], + references: [users.id], + }), + slot: one(slots, { + fields: [files.id], + references: [slots.fileId], + }), +})); + +export const chunks = sqliteTable("chunks", { + id: text("id").$default(randomId).primaryKey(), + userId: text("user_id").references(() => users.id).notNull(), + blob: blob("blob").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()), +}); + +export const chunksRelations = relations(chunks, ({ one }) => ({ + user: one(users, { + fields: [chunks.userId], + references: [users.id], + }), + slots: one(slotChunks, { + fields: [chunks.id], + references: [slotChunks.chunkId], + }), +})); + +export const vaults = sqliteTable("vaults", { + id: text("id").$default(randomId).primaryKey(), + userId: text("user_id").references(() => users.id).notNull(), + token: text("token").notNull(), + key: text("key").notNull(), + attributes: text("attributes").notNull(), + authEd25519PrivateKey: text("auth_ed25519_private_key").notNull(), + authEd25519PublicKey: text("auth_ed25519_public_key").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()), +}); + +export const vaultsRelations = relations(vaults, ({ one, many }) => ({ + user: one(users, { + fields: [vaults.userId], + references: [users.id], + }), + slots: many(slots), +})); + +export const slots = sqliteTable("slots", { + id: text("id").$default(randomId).primaryKey(), + vaultId: text("vault_id").references(() => vaults.id).notNull(), + maxSize: integer("max_size").notNull(), + attributes: text("attributes").notNull(), + fileX25519PublicKey: text("file_x25519_public_key"), + fileId: text("file_id").references(() => files.id), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()), +}); + +export const slotsRelations = relations(slots, ({ one, many }) => ({ + vault: one(vaults, { + fields: [slots.vaultId], + references: [vaults.id], + }), + file: one(files, { + fields: [slots.fileId], + references: [files.id], + }), + slotChunks: many(slotChunks), +})); + +export const slotChunks = sqliteTable("slot_chunks", { + slotId: text("slot_id").references(() => slots.id).notNull(), + chunkId: text("chunk_id").references(() => chunks.id).notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.slotId, table.chunkId] }), +})); + +export const slotChunksRelations = relations(slotChunks, ({ one }) => ({ + slot: one(slots, { + fields: [slotChunks.slotId], + references: [slots.id], + }), + chunk: one(chunks, { + fields: [slotChunks.chunkId], + references: [chunks.id], + }), +})); diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts new file mode 100644 index 0000000..9ae454b --- /dev/null +++ b/src/lib/server/index.ts @@ -0,0 +1,4 @@ +import { randomClientValue } from "$lib/tulip/auth"; + + +export const serverRandomValue = randomClientValue(); diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts new file mode 100644 index 0000000..9c49683 --- /dev/null +++ b/src/lib/server/session.ts @@ -0,0 +1,57 @@ +import { sign, verify } from "$lib/server/crypto/sign"; +import { db, schema } from "$lib/server/db"; +import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url"; +import { randomString } from "$lib/utils/id"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import type { RequestEvent } from "@sveltejs/kit"; +import { eq, type InferSelectModel } from "drizzle-orm"; + + +const SESSION_ID_LENGTH = 64; + +export async function createSession(user: InferSelectModel) { + const sessionId = randomString(SESSION_ID_LENGTH); + const signature = sign(utf8ToBytes(sessionId)); + + await db.insert(schema.sessions).values({ + userId: user.id, + sessionId, + }); + + return `${sessionId}.${encodeBase64Url(signature)}`; +} + +export async function getSessionFromRequest(event: RequestEvent): Promise | null> { + const sid = event.cookies.get("sid") ?? ""; + if (! sid.includes(".")) { + return null; + } + + const [message, encodedSignature] = sid.split("."); + if (typeof message == "undefined" || typeof encodedSignature == "undefined") { + throw new Error("Invalid session parameter"); + } + + const signature = decodeBase64Url(encodedSignature); + if (! verify(signature, utf8ToBytes(message))) { + return null; + } + + const session = await db.query.sessions.findFirst({ + where: eq(schema.sessions.sessionId, message), + }); + + return session ?? null; +} + +export async function getSessionUser(session: InferSelectModel | null): Promise | null> { + if (session === null) { + return null; + } + + const user = await db.query.users.findFirst({ + where: eq(schema.users.id, session.userId), + }); + + return user ?? null; +} diff --git a/src/lib/server/types.ts b/src/lib/server/types.ts new file mode 100644 index 0000000..e7c4bab --- /dev/null +++ b/src/lib/server/types.ts @@ -0,0 +1,4 @@ +export interface SlotUploadTicket { + slotId: string; + timestamp: number; +} diff --git a/src/lib/state/upload.svelte.ts b/src/lib/state/upload.svelte.ts new file mode 100644 index 0000000..6774461 --- /dev/null +++ b/src/lib/state/upload.svelte.ts @@ -0,0 +1 @@ +// export let diff --git a/src/lib/trpc/client.ts b/src/lib/trpc/client.ts new file mode 100644 index 0000000..4c5aac4 --- /dev/null +++ b/src/lib/trpc/client.ts @@ -0,0 +1,24 @@ +import type { Router } from "$lib/trpc/router"; +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit"; + + +export type TrpcClient = ReturnType>; +export type RouterInput = inferRouterInputs; +export type RouterOutput = inferRouterOutputs; + +let browserClient: TrpcClient; + +export function trpc(init?: TRPCClientInit) { + const isBrowser = typeof window !== "undefined"; + if (isBrowser && browserClient) { + return browserClient; + } + + const client = createTRPCClient({ init }); + if (isBrowser) { + browserClient = client; + } + + return client; +} diff --git a/src/lib/trpc/context.ts b/src/lib/trpc/context.ts new file mode 100644 index 0000000..ce27d6d --- /dev/null +++ b/src/lib/trpc/context.ts @@ -0,0 +1,25 @@ +import { getSessionFromRequest, getSessionUser } from "$lib/server/session"; +import type { RequestEvent } from "@sveltejs/kit"; + + +async function getAuth(event: RequestEvent) { + const session = await getSessionFromRequest(event); + const user = await getSessionUser(session); + if (user === null) { + event.cookies.delete("sid", { path: "/" }); + return null; + } + + return { + user, + }; +} + +export async function createContext(event: RequestEvent) { + return { + event, + auth: await getAuth(event), + }; +} + +export type Context = Awaited>; diff --git a/src/lib/trpc/middleware/auth.ts b/src/lib/trpc/middleware/auth.ts new file mode 100644 index 0000000..43862db --- /dev/null +++ b/src/lib/trpc/middleware/auth.ts @@ -0,0 +1,15 @@ +import { t } from "$lib/trpc/t"; +import { TRPCError } from "@trpc/server"; + + +export const authProcedure = t.procedure.use(async (opts) => { + if (opts.ctx.auth === null) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return opts.next({ + ctx: { + auth: opts.ctx.auth, + }, + }); +}); diff --git a/src/lib/trpc/router.ts b/src/lib/trpc/router.ts new file mode 100644 index 0000000..02bb6b8 --- /dev/null +++ b/src/lib/trpc/router.ts @@ -0,0 +1,58 @@ +import { accessVault } from "$lib/trpc/routes/access-vault"; +import { auth } from "$lib/trpc/routes/auth"; +import { cleanStorage } from "$lib/trpc/routes/clean-storage"; +import { clearSlot } from "$lib/trpc/routes/clear-slot"; +import { createVault } from "$lib/trpc/routes/create-vault"; +import { crv } from "$lib/trpc/routes/crv"; +import { deleteFile } from "$lib/trpc/routes/delete-file"; +import { deleteVault } from "$lib/trpc/routes/delete-vault"; +import { file } from "$lib/trpc/routes/file"; +import { files } from "$lib/trpc/routes/files"; +import { login } from "$lib/trpc/routes/login"; +import { logout } from "$lib/trpc/routes/logout"; +import { register } from "$lib/trpc/routes/register"; +import { requestVault } from "$lib/trpc/routes/request-vault"; +import { slot } from "$lib/trpc/routes/slot"; +import { updateX25519 } from "$lib/trpc/routes/updateX25519"; +import { upload } from "$lib/trpc/routes/upload"; +import { uploadWithTicket } from "$lib/trpc/routes/upload-with-ticket"; +import { vaults } from "$lib/trpc/routes/vaults"; +import { t } from "$lib/trpc/t"; + + +export const router = t.router({ + // Auth + auth, + + // Registration + register, + + // Login + crv, + login, + + // Authenticated routes + updateX25519, + logout, + file, + files, + upload, + uploadWithTicket, + cleanStorage, + deleteFile, + + // Vaults + createVault, + deleteVault, + vaults, + slot, + clearSlot, + + // Public vault access + requestVault, + accessVault, +}); + +export const createCaller = t.createCallerFactory(router); + +export type Router = typeof router; diff --git a/src/lib/trpc/routes/access-vault.ts b/src/lib/trpc/routes/access-vault.ts new file mode 100644 index 0000000..3a6a17d --- /dev/null +++ b/src/lib/trpc/routes/access-vault.ts @@ -0,0 +1,95 @@ +import { encrypt } from "$lib/server/crypto/encrypt"; +import { verify } from "$lib/server/crypto/sign"; +import { db, schema } from "$lib/server/db"; +import type { SlotUploadTicket } from "$lib/server/types"; +import { t } from "$lib/trpc/t"; +import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import { ed25519 } from "@noble/curves/ed25519"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + token: z.string(), + challenge: z.string(), + proof: z.string(), +}); + +const output = z.object({ + x25519PublicKey: z.string(), + token: z.string(), + attributes: z.string(), + createdAt: z.string(), + slots: z.array(z.object({ + maxSize: z.number(), + attributes: z.string(), + available: z.boolean(), + ticket: z.string(), + })), +}); + +export const accessVault = t.procedure + .input(input) + .output(output) + .query(async ({ input }) => { + const vault = await db.query.vaults.findFirst({ + where: eq(schema.vaults.token, input.token), + with: { + user: true, + slots: true, + }, + }); + if (typeof vault == "undefined") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Vault not found", + }); + } + + const rawChallenge = decodeBase64Url(input.challenge); + const challenge = rawChallenge.subarray(0, 32); + const signature = rawChallenge.subarray(32); + if (! verify(signature, challenge)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Foreign challenge", + }); + } + + const proof = decodeBase64Url(input.proof); + if (! ed25519.verify(proof, rawChallenge, decodeBase64Url(vault.authEd25519PublicKey))) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Challenge failed", + }); + } + + if (vault.user.x25519PublicKey === null) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Vault is not public", + }); + } + + return { + x25519PublicKey: vault.user.x25519PublicKey, + token: vault.token, + attributes: vault.attributes, + createdAt: vault.createdAt.toISOString(), + slots: vault.slots.map((slot) => { + const ticket = JSON.stringify({ + slotId: slot.id, + timestamp: Math.floor(Date.now() / 1000), + } satisfies SlotUploadTicket); + + return ({ + maxSize: slot.maxSize, + attributes: slot.attributes, + available: slot.fileId === null, + ticket: encodeBase64Url(encrypt(utf8ToBytes(ticket))), + }); + }), + }; + }); diff --git a/src/lib/trpc/routes/auth.ts b/src/lib/trpc/routes/auth.ts new file mode 100644 index 0000000..4efdb8d --- /dev/null +++ b/src/lib/trpc/routes/auth.ts @@ -0,0 +1,29 @@ +import { getSessionFromRequest, getSessionUser } from "$lib/server/session"; +import { t } from "$lib/trpc/t"; +import { z } from "zod"; + + +const output = z.object({ + u: z.string(), + key: z.string(), + x25519PrivateKey: z.string().nullable(), + x25519PublicKey: z.string().nullable(), +}).nullable(); + +export const auth = t.procedure + .output(output) + .query(async ({ ctx }) => { + const session = await getSessionFromRequest(ctx.event); + const user = await getSessionUser(session); + if (user === null) { + ctx.event.cookies.delete("sid", { path: "/" }); + return null; + } + + return { + u: user.username, + key: user.key, + x25519PrivateKey: user.x25519PrivateKey, + x25519PublicKey: user.x25519PublicKey, + }; + }); diff --git a/src/lib/trpc/routes/clean-storage.ts b/src/lib/trpc/routes/clean-storage.ts new file mode 100644 index 0000000..36e292d --- /dev/null +++ b/src/lib/trpc/routes/clean-storage.ts @@ -0,0 +1,12 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { eq } from "drizzle-orm"; + + +export const cleanStorage = authProcedure + .mutation(async ({ input, ctx }) => { + await db.transaction(async (tx) => { + await tx.delete(schema.chunks).where(eq(schema.chunks.userId, ctx.auth.user.id)); + await tx.delete(schema.files).where(eq(schema.files.userId, ctx.auth.user.id)); + }); + }); diff --git a/src/lib/trpc/routes/clear-slot.ts b/src/lib/trpc/routes/clear-slot.ts new file mode 100644 index 0000000..9b64b3e --- /dev/null +++ b/src/lib/trpc/routes/clear-slot.ts @@ -0,0 +1,49 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + id: z.string().max(20), +}); + +export const clearSlot = authProcedure + .input(input) + .mutation(async ({ input, ctx }) => { + const [slot] = await db.select() + .from(schema.slots) + .innerJoin(schema.vaults, eq(schema.vaults.id, schema.slots.vaultId)) + .where(and( + eq(schema.vaults.userId, ctx.auth.user.id), + eq(schema.slots.id, input.id), + )) + .limit(1); + if (typeof slot == "undefined") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Slot not found", + }); + } + + await db.transaction(async (tx) => { + if (slot.slots.fileId !== null) { + const slotChunks = await tx.query.slotChunks.findMany({ + where: eq(schema.slotChunks.slotId, slot.slots.id), + }); + const chunkIds = slotChunks.map((chunk) => chunk.chunkId); + + await tx.delete(schema.slotChunks).where(eq(schema.slotChunks.slotId, slot.slots.id)); + await tx.delete(schema.chunks).where(inArray(schema.chunks.id, chunkIds)); + await tx.delete(schema.files).where(eq(schema.files.id, slot.slots.fileId)); + } + + await tx.update(schema.slots) + .set({ + fileId: null, + fileX25519PublicKey: null, + }) + .where(eq(schema.slots.id, slot.slots.id)); + }); + }); diff --git a/src/lib/trpc/routes/create-vault.ts b/src/lib/trpc/routes/create-vault.ts new file mode 100644 index 0000000..a8ad706 --- /dev/null +++ b/src/lib/trpc/routes/create-vault.ts @@ -0,0 +1,67 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { randomString } from "$lib/utils/id"; +import { TRPCError } from "@trpc/server"; +import type { InferSelectModel } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + key: z.string(), + attributes: z.string(), + authEd25519PrivateKey: z.string().max(255), + authEd25519PublicKey: z.string().max(255), + slots: z.array(z.object({ + maxSize: z.number().positive(), + attributes: z.string(), + })).min(1), +}); + +const output = z.object({ + id: z.string(), + token: z.string(), +}); + +export const createVault = authProcedure + .input(input) + .output(output) + .mutation(async ({ input, ctx }) => { + let vault: InferSelectModel | undefined; + + await db.transaction(async (tx) => { + [vault] = await tx.insert(schema.vaults).values({ + userId: ctx.auth.user.id, + token: randomString(64), + authEd25519PrivateKey: input.authEd25519PrivateKey, + authEd25519PublicKey: input.authEd25519PublicKey, + key: input.key, + attributes: input.attributes, + }).returning(); + if (typeof vault == "undefined") { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create vault", + }); + } + + for (const slots of input.slots) { + await tx.insert(schema.slots).values({ + vaultId: vault.id, + maxSize: slots.maxSize, + attributes: slots.attributes, + }); + } + }); + + if (typeof vault == "undefined") { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create vault", + }); + } + + return { + id: vault.id, + token: vault.token, + }; + }); diff --git a/src/lib/trpc/routes/crv.ts b/src/lib/trpc/routes/crv.ts new file mode 100644 index 0000000..bbc94f0 --- /dev/null +++ b/src/lib/trpc/routes/crv.ts @@ -0,0 +1,35 @@ +import { serverRandomValue } from "$lib/server"; +import { db, schema } from "$lib/server/db"; +import { t } from "$lib/trpc/t"; +import { generateSalt } from "$lib/tulip/auth"; +import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + u: z.string().max(40), +}); + +const output = z.object({ + salt: z.string(), +}); + +export const crv = t.procedure + .input(input) + .output(output) + .query(async ({ input }) => { + const user = await db.query.users.findFirst({ + where: eq(schema.users.username, input.u), + }); + await new Promise(resolve => setTimeout(resolve, Math.random() * 100)); + + const salt = typeof user != "undefined" + ? generateSalt(decodeBase64Url(user.crv)) + : generateSalt(serverRandomValue, utf8ToBytes(input.u)); + + return { + salt: encodeBase64Url(salt), + }; + }); diff --git a/src/lib/trpc/routes/delete-file.ts b/src/lib/trpc/routes/delete-file.ts new file mode 100644 index 0000000..e20e140 --- /dev/null +++ b/src/lib/trpc/routes/delete-file.ts @@ -0,0 +1,41 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + id: z.string().max(20), + chunkIds: z.array(z.string().max(20)), +}); + +export const deleteFile = authProcedure + .input(input) + .mutation(async ({ input, ctx }) => { + const file = await db.query.files.findFirst({ + where: and( + eq(schema.files.userId, ctx.auth.user.id), + eq(schema.files.id, input.id), + ), + }); + + if (typeof file == "undefined") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "File not found", + }); + } + + await db.transaction(async (tx) => { + await tx.delete(schema.chunks).where(and( + eq(schema.chunks.userId, ctx.auth.user.id), + inArray(schema.chunks.id, input.chunkIds), + )); + await tx.update(schema.slots).set({ + fileX25519PublicKey: null, + fileId: null, + }).where(eq(schema.slots.fileId, file.id)); + await tx.delete(schema.files).where(eq(schema.files.id, file.id)); + }); + }); diff --git a/src/lib/trpc/routes/delete-vault.ts b/src/lib/trpc/routes/delete-vault.ts new file mode 100644 index 0000000..b925cf5 --- /dev/null +++ b/src/lib/trpc/routes/delete-vault.ts @@ -0,0 +1,47 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + id: z.string().max(20), +}); + +export const deleteVault = authProcedure + .input(input) + .mutation(async ({ input, ctx }) => { + const vault = await db.query.vaults.findFirst({ + where: and( + eq(schema.vaults.userId, ctx.auth.user.id), + eq(schema.vaults.id, input.id), + ), + with: { + slots: { + with: { + slotChunks: true, + }, + }, + }, + }); + + if (typeof vault == "undefined") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Vault not found", + }); + } + + await db.transaction(async (tx) => { + const slotIds = vault.slots.map((slot) => slot.id); + const fileIds = vault.slots.filter((slot) => slot.fileId !== null).map((slot) => slot.fileId) as string[]; + const chunkIds = vault.slots.flatMap((slot) => slot.slotChunks.map((chunk) => chunk.chunkId)); + + await tx.delete(schema.slotChunks).where(inArray(schema.slotChunks.slotId, slotIds)); + await tx.delete(schema.chunks).where(inArray(schema.chunks.id, chunkIds)); + await tx.delete(schema.slots).where(eq(schema.slots.vaultId, vault.id)); + await tx.delete(schema.files).where(inArray(schema.files.id, fileIds)); + await tx.delete(schema.vaults).where(eq(schema.vaults.id, vault.id)); + }); + }); diff --git a/src/lib/trpc/routes/file.ts b/src/lib/trpc/routes/file.ts new file mode 100644 index 0000000..3ad791a --- /dev/null +++ b/src/lib/trpc/routes/file.ts @@ -0,0 +1,40 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + id: z.string(), +}); + +const output = z.object({ + id: z.string(), + key: z.string(), + cmac: z.string(), + attributes: z.string(), + createdAt: z.string(), +}).optional(); + +export const file = authProcedure + .input(input) + .output(output) + .query(async ({ input, ctx }) => { + const file = await db.query.files.findFirst({ + where: and( + eq(schema.files.userId, ctx.auth.user.id), + eq(schema.files.id, input.id), + ), + }); + if (typeof file == "undefined") { + return undefined; + } + + return { + id: file.id, + key: file.key, + cmac: file.cmac, + attributes: file.attributes, + createdAt: file.createdAt.toISOString(), + }; + }); diff --git a/src/lib/trpc/routes/files.ts b/src/lib/trpc/routes/files.ts new file mode 100644 index 0000000..e6364d9 --- /dev/null +++ b/src/lib/trpc/routes/files.ts @@ -0,0 +1,36 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { and, eq, notExists } from "drizzle-orm"; +import { z } from "zod"; + + +const output = z.object({ + files: z.array(z.object({ + id: z.string(), + key: z.string(), + cmac: z.string(), + attributes: z.string(), + createdAt: z.string(), + })), +}); + +export const files = authProcedure + .output(output) + .query(async ({ ctx }) => { + const files = await db.query.files.findMany({ + where: and( + eq(schema.files.userId, ctx.auth.user.id), + notExists(db.select().from(schema.slots).where(eq(schema.slots.fileId, schema.files.id))), + ), + }); + + return { + files: files.map(file => ({ + id: file.id, + key: file.key, + cmac: file.cmac, + attributes: file.attributes, + createdAt: file.createdAt.toISOString(), + })), + }; + }); diff --git a/src/lib/trpc/routes/login.ts b/src/lib/trpc/routes/login.ts new file mode 100644 index 0000000..03b4313 --- /dev/null +++ b/src/lib/trpc/routes/login.ts @@ -0,0 +1,58 @@ +import { db, schema } from "$lib/server/db"; +import { createSession } from "$lib/server/session"; +import { t } from "$lib/trpc/t"; +import { generateHashedAuthenticationKey } from "$lib/tulip/auth"; +import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + u: z.string().max(40), + ak: z.string().max(255), +}); + +const output = z.object({ + u: z.string(), + key: z.string(), + x25519PrivateKey: z.string().nullable(), + x25519PublicKey: z.string().nullable(), +}); + +export const login = t.procedure + .input(input) + .output(output) + .mutation(async ({ input, ctx }) => { + const hashedAuthenticationKey = generateHashedAuthenticationKey(decodeBase64Url(input.ak)); + const user = await db.query.users.findFirst({ + where: and( + eq(schema.users.username, input.u), + eq(schema.users.hak, encodeBase64Url(hashedAuthenticationKey)), + ), + }); + + if (typeof user == "undefined") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cannot find user", + }); + } + + const sessionId = await createSession(user); + + ctx.event.cookies.set("sid", sessionId, { + path: "/", + httpOnly: true, + sameSite: "strict", + maxAge: 60 * 60 * 24 * 30, + secure: process.env.NODE_ENV === "production", + }); + + return { + u: user.username, + key: user.key, + x25519PrivateKey: user.x25519PrivateKey, + x25519PublicKey: user.x25519PublicKey, + }; + }); diff --git a/src/lib/trpc/routes/logout.ts b/src/lib/trpc/routes/logout.ts new file mode 100644 index 0000000..b58cac6 --- /dev/null +++ b/src/lib/trpc/routes/logout.ts @@ -0,0 +1,7 @@ +import { authProcedure } from "$lib/trpc/middleware/auth"; + + +export const logout = authProcedure + .mutation(async ({ ctx }) => { + ctx.event.cookies.delete("sid", { path: "/" }); + }); diff --git a/src/lib/trpc/routes/register.ts b/src/lib/trpc/routes/register.ts new file mode 100644 index 0000000..36b6d42 --- /dev/null +++ b/src/lib/trpc/routes/register.ts @@ -0,0 +1,61 @@ +import { db, schema } from "$lib/server/db"; +import { t } from "$lib/trpc/t"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + u: z.string() + .min(3, "Your username must be at least 3 characters") + .max(40, "Your username cannot be longer than 40 characters"), + crv: z.string().min(1).max(255), + hak: z.string().min(1).max(255), + key: z.string().min(1).max(255), + x25519PrivateKey: z.string().min(1).max(255), + x25519PublicKey: z.string().min(1).max(255), +}); + +const ALLOWED_USERNAMES = new Set(["admin", "felix", "jacob420"]); + +export const register = t.procedure + .input(input) + .mutation(async ({ input }) => { + const username = input.u.trim(); + + if (process.env.NODE_ENV === "production" && ! ALLOWED_USERNAMES.has(username)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid username", + }); + } + + // Check if user exists + const existing = await db.query.users.findFirst({ + where: eq(schema.users.username, username), + }); + + if (typeof existing != "undefined") { + throw new TRPCError({ + code: "CONFLICT", + message: "User already exists", + }); + } + + // Create user + const [user] = await db.insert(schema.users).values({ + username, + crv: input.crv, + hak: input.hak, + key: input.key, + x25519PrivateKey: input.x25519PrivateKey, + x25519PublicKey: input.x25519PublicKey, + }).returning(); + + if (typeof user == "undefined") { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cannot create user", + }); + } + }); diff --git a/src/lib/trpc/routes/request-vault.ts b/src/lib/trpc/routes/request-vault.ts new file mode 100644 index 0000000..8a5332f --- /dev/null +++ b/src/lib/trpc/routes/request-vault.ts @@ -0,0 +1,48 @@ +import { sign } from "$lib/server/crypto/sign"; +import { db, schema } from "$lib/server/db"; +import { t } from "$lib/trpc/t"; +import { concat } from "$lib/tulip/utils/buffer"; +import { encodeBase64Url } from "$lib/utils/base64url"; +import { randomBytes } from "@noble/ciphers/webcrypto"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + token: z.string(), +}); + +const output = z.object({ + token: z.string(), + authkey: z.string(), + challenge: z.string(), +}); + +export const requestVault = t.procedure + .input(input) + .output(output) + .query(async ({ input }) => { + const vault = await db.query.vaults.findFirst({ + where: eq(schema.vaults.token, input.token), + }); + if (typeof vault == "undefined") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Vault not found", + }); + } + + // TODO: Just creating a random challenge is not secure enough because this makes us vulnerable to replay attacks + // An attacker could simply send the same challenge and proof to the access endpoint and get access to the vault + // The challenge should probably contain a timestamp and the token of the vault + // Note: but then we can maybe just use XChaCha20-Poly1305 instead of ED25519 for the challenge lol + const challenge = randomBytes(32); + const signature = sign(challenge); + + return { + token: vault.token, + authkey: vault.authEd25519PrivateKey, + challenge: encodeBase64Url(concat(challenge, signature)), + }; + }); diff --git a/src/lib/trpc/routes/slot.ts b/src/lib/trpc/routes/slot.ts new file mode 100644 index 0000000..adb07db --- /dev/null +++ b/src/lib/trpc/routes/slot.ts @@ -0,0 +1,44 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + id: z.string(), +}); + +const output = z.object({ + id: z.string(), + maxSize: z.number(), + attributes: z.string(), + fileX25519PublicKey: z.string().nullable(), + fileId: z.string().nullable(), + createdAt: z.string(), +}).optional(); + +export const slot = authProcedure + .input(input) + .output(output) + .query(async ({ input, ctx }) => { + const [slot] = await db.select() + .from(schema.slots) + .innerJoin(schema.vaults, eq(schema.vaults.id, schema.slots.vaultId)) + .where(and( + eq(schema.vaults.userId, ctx.auth.user.id), + eq(schema.slots.id, input.id), + )) + .limit(1); + if (typeof slot == "undefined") { + return undefined; + } + + return { + id: slot.slots.id, + maxSize: slot.slots.maxSize, + attributes: slot.slots.attributes, + fileX25519PublicKey: slot.slots.fileX25519PublicKey, + fileId: slot.slots.fileId, + createdAt: slot.slots.createdAt.toISOString(), + }; + }); diff --git a/src/lib/trpc/routes/updateX25519.ts b/src/lib/trpc/routes/updateX25519.ts new file mode 100644 index 0000000..196be6d --- /dev/null +++ b/src/lib/trpc/routes/updateX25519.ts @@ -0,0 +1,21 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + x25519PrivateKey: z.string().min(1).max(255), + x25519PublicKey: z.string().min(1).max(255), +}); + +export const updateX25519 = authProcedure + .input(input) + .mutation(async ({ input, ctx }) => { + await db.update(schema.users) + .set({ + x25519PrivateKey: input.x25519PrivateKey, + x25519PublicKey: input.x25519PublicKey, + }) + .where(eq(schema.users.id, ctx.auth.user.id)).returning(); + }); diff --git a/src/lib/trpc/routes/upload-with-ticket.ts b/src/lib/trpc/routes/upload-with-ticket.ts new file mode 100644 index 0000000..a67ca67 --- /dev/null +++ b/src/lib/trpc/routes/upload-with-ticket.ts @@ -0,0 +1,67 @@ +import { decrypt } from "$lib/server/crypto/encrypt"; +import { db, schema } from "$lib/server/db"; +import type { SlotUploadTicket } from "$lib/server/types"; +import { t } from "$lib/trpc/t"; +import { decodeBase64Url } from "$lib/utils/base64url"; +import { bytesToUtf8 } from "@noble/ciphers/utils"; +import { error } from "@sveltejs/kit"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + + +const input = z.object({ + ticket: z.string().max(255), + x25519PublicKey: z.string().max(255), + key: z.string().max(255), + cmac: z.string().max(255), + attributes: z.string(), +}); + +export const uploadWithTicket = t.procedure + .input(input) + .mutation(async ({ input }) => { + const decrypted = decrypt(decodeBase64Url(input.ticket)); + const ticket: SlotUploadTicket = JSON.parse(bytesToUtf8(decrypted)); + + if (ticket.timestamp < Date.now() / 1000 - 60 * 60) { + // Ticket expired + return error(422, "Ticket expired"); + } + + const slot = await db.query.slots.findFirst({ + where: eq(schema.slots.id, ticket.slotId), + with: { + vault: true, + }, + }); + if (typeof slot == "undefined") { + return error(422, "Invalid ticket"); + } + + if (slot.fileId !== null) { + return error(422, "Slot not available"); + } + + await db.transaction(async (tx) => { + const [file] = await tx.insert(schema.files).values({ + userId: slot.vault.userId, + key: input.key, + cmac: input.cmac, + attributes: input.attributes, + }).returning(); + if (typeof file == "undefined") { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create file", + }); + } + + await tx.update(schema.slots) + .set({ + fileX25519PublicKey: input.x25519PublicKey, + fileId: file.id, + }) + .where(eq(schema.slots.id, slot.id)); + }); + }); diff --git a/src/lib/trpc/routes/upload.ts b/src/lib/trpc/routes/upload.ts new file mode 100644 index 0000000..c6a42e1 --- /dev/null +++ b/src/lib/trpc/routes/upload.ts @@ -0,0 +1,21 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { z } from "zod"; + + +const input = z.object({ + key: z.string().max(255), + cmac: z.string().max(255), + attributes: z.string(), +}); + +export const upload = authProcedure + .input(input) + .mutation(async ({ input, ctx }) => { + await db.insert(schema.files).values({ + userId: ctx.auth.user.id, + key: input.key, + cmac: input.cmac, + attributes: input.attributes, + }); + }); diff --git a/src/lib/trpc/routes/vaults.ts b/src/lib/trpc/routes/vaults.ts new file mode 100644 index 0000000..ecd98e0 --- /dev/null +++ b/src/lib/trpc/routes/vaults.ts @@ -0,0 +1,73 @@ +import { db, schema } from "$lib/server/db"; +import { authProcedure } from "$lib/trpc/middleware/auth"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + + +const output = z.object({ + vaults: z.array(z.object({ + id: z.string(), + token: z.string(), + key: z.string(), + attributes: z.string(), + authEd25519PrivateKey: z.string(), + authEd25519PublicKey: z.string(), + createdAt: z.string(), + slots: z.array(z.object({ + id: z.string(), + maxSize: z.number(), + attributes: z.string(), + fileX25519PublicKey: z.string().nullable(), + file: z.object({ + id: z.string(), + key: z.string(), + cmac: z.string(), + attributes: z.string(), + createdAt: z.string(), + }).nullable(), + createdAt: z.string(), + })), + })), +}); + +export const vaults = authProcedure + .output(output) + .query(async ({ ctx }) => { + const vaults = await db.query.vaults.findMany({ + where: eq(schema.vaults.userId, ctx.auth.user.id), + with: { + slots: { + with: { + file: true, + }, + }, + }, + }); + + return { + vaults: vaults.map(vault => ({ + id: vault.id, + token: vault.token, + key: vault.key, + attributes: vault.attributes, + authEd25519PrivateKey: vault.authEd25519PrivateKey, + authEd25519PublicKey: vault.authEd25519PublicKey, + createdAt: vault.createdAt.toISOString(), + slots: vault.slots.map(slot => ({ + id: slot.id, + maxSize: slot.maxSize, + attributes: slot.attributes, + fileX25519PublicKey: slot.fileX25519PublicKey, + fileId: slot.fileId, + file: slot.file !== null ? { + id: slot.file.id, + key: slot.file.key, + cmac: slot.file.cmac, + attributes: slot.file.attributes, + createdAt: slot.file.createdAt.toISOString(), + } : null, + createdAt: slot.createdAt.toISOString(), + })), + })), + }; + }); diff --git a/src/lib/trpc/t.ts b/src/lib/trpc/t.ts new file mode 100644 index 0000000..c3fbb6d --- /dev/null +++ b/src/lib/trpc/t.ts @@ -0,0 +1,14 @@ +import type { Context } from "$lib/trpc/context"; +import { initTRPC } from "@trpc/server"; + + +export const t = initTRPC.context().create({ + errorFormatter: ({ shape, error }) => ({ + code: shape.code, + message: error.message, + data: { + ...shape.data, + stack: process.env.NODE_ENV === "development" ? error.stack : undefined, + }, + }), +}); diff --git a/src/lib/tulip/auth.ts b/src/lib/tulip/auth.ts new file mode 100644 index 0000000..0b6d137 --- /dev/null +++ b/src/lib/tulip/auth.ts @@ -0,0 +1,77 @@ +import { concat } from "$lib/tulip/utils/buffer"; +import { utf8ToBytes } from "@noble/ciphers/utils"; +import { randomBytes } from "@noble/ciphers/webcrypto"; +import { pbkdf2 } from "@noble/hashes/pbkdf2"; +import { sha512 } from "@noble/hashes/sha2"; + + +// Salt values +// Keep them in sync with the backend +const SALT_PREFIX_MAGIC = "tulip-"; +const SALT_PREFIX_LENGTH = 224; +const SALT_PREFIX = SALT_PREFIX_MAGIC.padEnd(SALT_PREFIX_LENGTH, "#"); +const SALT_PREFIX_BYTES = utf8ToBytes(SALT_PREFIX); + +// Key lengths +const CLIENT_RANDOM_VALUE_LENGTH = 32; + +// The derived key length +const DERIVED_KEY_LENGTH = 64; + +// The number of iterations for the derived key +// See https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html +const DERIVED_KEY_ITERATIONS = 210_000; + +// Take only the first 32 bytes of the hashed authentication key +const HASHED_AUTHENTICATION_KEY_LENGTH = 32; + +/** + * Generates a client random value. + * The client random value will be used to generate the salt. + */ +export function randomClientValue(): Uint8Array { + return randomBytes(CLIENT_RANDOM_VALUE_LENGTH); +} + +/** + * Generates a salt given a client random value. + * The salt together with the password will be used for the password-based key derivation function. + * + * @param crv Client random value + * @param prefix Optional prefix + */ +export function generateSalt(crv: Uint8Array, prefix?: Uint8Array): Uint8Array { + return sha512(concat(prefix, SALT_PREFIX_BYTES.subarray(0, SALT_PREFIX_LENGTH - (prefix?.length ?? 0)), crv)); +} + +/** + * Generates the authentication key and the encryption key from the user password and a salt. + * The derived authentication key is being used to authenticate login requests. + * The derived encryption key is being used to encrypt the master key. + * + * @param password The user password + * @param salt The salt generated by `generateSalt` + */ +export function generateDerivedKeys( + password: Uint8Array, + salt: Uint8Array, +): [derivedEncryptionKey: Uint8Array, derivedAuthenticationKey: Uint8Array] { + const derivedKey = pbkdf2(sha512, password, salt, { c: DERIVED_KEY_ITERATIONS, dkLen: DERIVED_KEY_LENGTH }); + const derivedEncryptionKey = derivedKey.subarray(0, derivedKey.length / 2); + const derivedAuthenticationKey = derivedKey.subarray(derivedKey.length / 2); + + return [ + derivedEncryptionKey, + derivedAuthenticationKey, + ]; +} + +/** + * Generates the hashed authentication key by taking a couple of bytes of the sha512'd derived authentication key. + * The hashed authentication key is stored on the server, in order to authenticate login requests. + * + * @param derivedAuthenticationKey Derived authentication key generated by `generateDerivedKeys` + */ +export function generateHashedAuthenticationKey(derivedAuthenticationKey: Uint8Array): Uint8Array { + return sha512(derivedAuthenticationKey).subarray(0, HASHED_AUTHENTICATION_KEY_LENGTH); +} diff --git a/src/lib/tulip/files.ts b/src/lib/tulip/files.ts new file mode 100644 index 0000000..73d4f71 --- /dev/null +++ b/src/lib/tulip/files.ts @@ -0,0 +1,110 @@ +import { sha256 } from "@noble/hashes/sha2"; + + +/** + * Updates a condensed MAC with the given MAC and authentication tag. + * + * @param mac + * @param authenticationTag + */ +export function updateCondensedMac(mac: Uint8Array, authenticationTag: Uint8Array): Uint8Array { + const copy = new Uint8Array(mac); + + // XOR the auth tag into the condensed MAC + for (let i = 0; i < copy.length; i++) { + copy[i]! ^= authenticationTag[i % authenticationTag.length]!; + } + + return sha256(copy).slice(0, 16); +} + +/** + * Obfuscates the given file key using the given nonce and the given condensed MAC. + * + * Based on the following pseudocode: + * Obfuscated File Key = [ + * File Key[0] ⊕ Nonce[0], + * File Key[1] ⊕ Nonce[1], + * File Key[2] ⊕ Nonce[2], + * File Key[3] ⊕ Nonce[3], + * File Key[4] ⊕ Nonce[4], + * File Key[5] ⊕ Nonce[5], + * File Key[6] ⊕ Nonce[6], + * File Key[7] ⊕ Nonce[7], + * File Key[8] ⊕ Nonce[8], + * File Key[9] ⊕ Nonce[9], + * File Key[10] ⊕ Nonce[10], + * File Key[11] ⊕ Nonce[11], + * File Key[12] ⊕ Condensed MAC[0] ⊕ Condensed MAC[1], + * File Key[13] ⊕ Condensed MAC[2] ⊕ Condensed MAC[3], + * File Key[14] ⊕ Condensed MAC[4] ⊕ Condensed MAC[5], + * File Key[15] ⊕ Condensed MAC[6] ⊕ Condensed MAC[7], + * Nonce[0], + * Nonce[1], + * Nonce[2], + * Nonce[3], + * Nonce[4], + * Nonce[5], + * Nonce[6], + * Nonce[7], + * Nonce[8], + * Nonce[9], + * Nonce[10], + * Nonce[11], + * Condensed MAC[0] ⊕ Condensed MAC[1], + * Condensed MAC[2] ⊕ Condensed MAC[3], + * Condensed MAC[4] ⊕ Condensed MAC[5], + * Condensed MAC[6] ⊕ Condensed MAC[7], + * ] + * + * @param fileKey + * @param nonce + * @param condensedMac + */ +export function obfuscateFileKey(fileKey: Uint8Array, nonce: Uint8Array, condensedMac: Uint8Array): Uint8Array { + const obfuscated = new Uint8Array(64); + + // First 24 bytes of the file key are XORed with the nonce + for (let i = 0; i < 24; i++) { + obfuscated[i] = fileKey[i]! ^ nonce[i]!; + } + + // Next 8 bytes of the file key are XORed with the first 8 bytes of the condensed MAC + for (let i = 24; i < 32; i++) { + const cmacIndex1 = (i - 24) * 2; + const cmacIndex2 = cmacIndex1 + 1; + + obfuscated[i] = fileKey[i]! ^ condensedMac[cmacIndex1]! ^ condensedMac[cmacIndex2]!; + } + + + // Next 24 bytes of the obfuscated file key are just the nonce + for (let i = 32; i < 56; i++) { + obfuscated[i] = nonce[i - 32]!; + } + + // Last 8 bytes of the obfuscated file key the XORed using the condensed MAC + for (let i = 56; i < 64; i++) { + const cmacIndex1 = (i - 24) * 2; + const cmacIndex2 = cmacIndex1 + 1; + + obfuscated[i] = condensedMac[cmacIndex1]! ^ condensedMac[cmacIndex2]!; + } + + return obfuscated; +} + +/** + * Reverts the file key obfuscation and returns the original file key. + * + * @param obfuscatedFileKey Obfuscated file key + * @param nonce Nonce used for the encryption + * @param condensedMac Condensed MAC + */ +export function unobfuscateFileKey( + obfuscatedFileKey: Uint8Array, + nonce: Uint8Array, + condensedMac: Uint8Array, +): Uint8Array { + return obfuscateFileKey(obfuscatedFileKey, nonce, condensedMac).slice(0, 32); +} diff --git a/src/lib/tulip/index.ts b/src/lib/tulip/index.ts new file mode 100644 index 0000000..be15509 --- /dev/null +++ b/src/lib/tulip/index.ts @@ -0,0 +1,84 @@ +import { concat } from "$lib/tulip/utils/buffer"; +import { xchacha20poly1305 } from "@noble/ciphers/chacha"; +import { randomBytes } from "@noble/ciphers/webcrypto"; + + +// Key length for XChaCha20-Poly1305 +const KEY_LENGTH = 32; + +// Nonce length for XChaCha20-Poly1305 +const NONCE_LENGTH = 24; + +/** + * Generates a random key for XChaCha20-Poly1305. + */ +export function randomKey(): Uint8Array { + return randomBytes(KEY_LENGTH); +} + +/** + * Generates a nonce. + */ +export function randomNonce(): Uint8Array { + return randomBytes(NONCE_LENGTH); +} + +/** + * Encrypts the given data using XChaCha20-Poly1305. + * + * @param key Encryption key + * @param data Plaintext data to encrypt + * @param nonce Nonce for the encryption. If not provided, a random nonce will be generated. + */ +export function encrypt( + key: Uint8Array, + data: Uint8Array, + nonce: Uint8Array = randomNonce(), +): Uint8Array { + const encrypted = xchacha20poly1305(key, nonce).encrypt(data); + + return concat(nonce, encrypted); +} + +/** + * Decrypts the given XChaCha20-Poly1305 encrypted data. + * + * @param key Encryption key + * @param data Data to decrypt + */ +export function decrypt( + key: Uint8Array, + data: Uint8Array, +): [decrypted: Uint8Array, nonce: Uint8Array] { + const [nonce, encrypted] = splitNonce(data); + + return [ + xchacha20poly1305(key, nonce).decrypt(encrypted), + nonce, + ]; +} + +/** + * Splits a nonce and a buffer into two separate arrays. + * + * @param buffer Buffer to split + */ +export function splitNonce(buffer: Uint8Array): [nonce: Uint8Array, buffer: Uint8Array] { + if (buffer.length < NONCE_LENGTH) { + throw new Error("Invalid buffer length"); + } + + return [ + buffer.subarray(0, NONCE_LENGTH), + buffer.subarray(NONCE_LENGTH), + ]; +} + +/** + * Returns the Poly1305 authentication tag from an encrypted chunk. + * + * @param encryptedChunk + */ +export function getAuthTag(encryptedChunk: Uint8Array): Uint8Array { + return encryptedChunk.subarray(-16); +} diff --git a/src/lib/tulip/utils/buffer.ts b/src/lib/tulip/utils/buffer.ts new file mode 100644 index 0000000..ecd81ab --- /dev/null +++ b/src/lib/tulip/utils/buffer.ts @@ -0,0 +1,28 @@ +type OptionalItem = T | null | undefined; + +/** + * Concatenates multiple Uint8Arrays into a single Uint8Array. + * + * @param arrays The Uint8Arrays to concatenate + */ +export function concat(...arrays: OptionalItem[]): Uint8Array { + if (arrays.length <= 1) { + return arrays[0] ?? new Uint8Array(); + } + + const length = arrays.reduce((carry, array) => carry + (array?.length ?? 0), 0); + const result = new Uint8Array(length); + + let offset = 0; + + for (const array of arrays) { + if (typeof array == "undefined" || array === null) { + continue; + } + + result.set(array, offset); + offset += array.length; + } + + return result; +} diff --git a/src/lib/types/json.ts b/src/lib/types/json.ts new file mode 100644 index 0000000..bc1733f --- /dev/null +++ b/src/lib/types/json.ts @@ -0,0 +1,5 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonArray = Json[]; +export type JsonObject = { [key: string]: Json }; +export type JsonComposite = JsonArray | JsonObject; +export type Json = JsonPrimitive | JsonComposite; diff --git a/src/lib/utils/base64url.ts b/src/lib/utils/base64url.ts new file mode 100644 index 0000000..e504232 --- /dev/null +++ b/src/lib/utils/base64url.ts @@ -0,0 +1,88 @@ +// base64url characters +const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_="; +const BASE64_LOOKUP = BASE64.split(""); +const BASE64_REVERSE_LOOKUP = BASE64_LOOKUP.reduce((acc, char, index) => { + acc[char.charCodeAt(0)] = index; + + return acc; +}, []); + +/** + * Encodes the given data as base64url. + * + * @param data The data to encode + */ +export function encodeBase64Url(data: Uint8Array): string { + const cutoff = data.length % 3; + let result = ""; + let i = 0; + + do { // pack three octets into four hexets + const o1 = data.at(i++) ?? 0; + const o2 = data.at(i++) ?? 0; + const o3 = data.at(i++) ?? 0; + const bits = o1 << 16 | o2 << 8 | o3; + + const h1 = bits >> 18 & 0x3f; + const h2 = bits >> 12 & 0x3f; + const h3 = bits >> 6 & 0x3f; + const h4 = bits & 0x3f; + + // Use hexets to index into b64, and append result to encoded string + result += BASE64_LOOKUP[h1]! + BASE64_LOOKUP[h2] + BASE64_LOOKUP[h3] + BASE64_LOOKUP[h4]; + } while (i < data.length); + + return cutoff > 0 ? result.slice(0, cutoff - 3) : result; +} + +/** + * Decodes the given base64url encoded data. + * + * @param data The data to decode + */ +export function decodeBase64Url(data: string): Uint8Array { + let length = data.indexOf("="); + if (length === -1) { + length = data.length; + } + + if (length === 0) { + return new Uint8Array(); + } + + const padding = "==".substring((2 - data.length * 3) & 3); + const result: number[] = []; + let i = 0; + let j = 0; + + // Append possible padding + data += padding; + + do { + // Unpack four hexets into three octets using index points in b64 + const h1 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0; + const h2 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0; + const h3 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0; + const h4 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0; + const bits = h1 << 18 | h2 << 12 | h3 << 6 | h4; + + const o1 = bits >> 16 & 0xff; + const o2 = bits >> 8 & 0xff; + const o3 = bits & 0xff; + + if (h3 === 64) { + result[j++] = o1; + } + else if (h4 === 64) { + result[j++] = o1; + result[j++] = o2; + } + else { + result[j++] = o1; + result[j++] = o2; + result[j++] = o3; + } + } while (i < data.length); + + return new Uint8Array(result); +} diff --git a/src/lib/utils/buffer.ts b/src/lib/utils/buffer.ts new file mode 100644 index 0000000..570a19c --- /dev/null +++ b/src/lib/utils/buffer.ts @@ -0,0 +1,16 @@ +export function expand(bytes: Uint8Array, length: number): Uint8Array { + if (length < bytes.length) { + throw new Error("The specified length cannot be smaller than the input array's length."); + } + + if (length === bytes.length) { + return bytes; + } + + const result = new Uint8Array(length); + const start = length - bytes.length; + + result.set(bytes, start); + + return result; +} diff --git a/src/lib/utils/components.ts b/src/lib/utils/components.ts new file mode 100644 index 0000000..38bed11 --- /dev/null +++ b/src/lib/utils/components.ts @@ -0,0 +1,7 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/lib/utils/errors.ts b/src/lib/utils/errors.ts new file mode 100644 index 0000000..e543c79 --- /dev/null +++ b/src/lib/utils/errors.ts @@ -0,0 +1,7 @@ +export function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return `${error}`; +} diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts new file mode 100644 index 0000000..8549cd3 --- /dev/null +++ b/src/lib/utils/format.ts @@ -0,0 +1,44 @@ +/** + * Formats a file size in bytes to a human-readable format. + * + * @param sizeInBytes + */ +export function formatFileSize(sizeInBytes: number): string { + const units = ["B", "KB", "MB", "GB"]; + let size = sizeInBytes; + let unitIndex = 0; + + while (size >= 1000 && unitIndex < units.length - 1) { + size /= 1000; + unitIndex++; + } + + return `${parseFloat(size.toFixed(2))} ${units[unitIndex]}`; +} + +/** + * Parses a file size string into a number of bytes. + * + * @param size + */ +export function parseFileSize(size: string): number { + const normalized = size.trim().toLowerCase(); + const match = normalized.match(/^(\d+(?:\.\d+)?)\s*(kb|mb|gb)$/); + if (match === null) { + throw new Error(`Invalid size format: ${size}`); + } + + const [, valueStr, unit] = match; + if (typeof valueStr == "undefined" || typeof unit == "undefined") { + throw new Error(`Invalid size format: ${size}`); + } + + const value = parseFloat(valueStr); + const unitMap: { [key: string]: number } = { + kb: 1000, + mb: 1000 * 1000, + gb: 1000 * 1000 * 1000, + }; + + return value * (unitMap[unit] ?? 1); +} diff --git a/src/lib/utils/id.ts b/src/lib/utils/id.ts new file mode 100644 index 0000000..bd9cbab --- /dev/null +++ b/src/lib/utils/id.ts @@ -0,0 +1,18 @@ +import { customAlphabet } from "nanoid"; + + +const ID_LENGTH = 7; + +const ALPHA_LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; +const ALPHA_UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS = "0123456789"; + +const generator = customAlphabet(ALPHA_LOWERCASE + ALPHA_UPPERCASE + DIGITS, 7); + +export function randomId() { + return generator(ID_LENGTH); +} + +export function randomString(length: number) { + return generator(length); +} diff --git a/src/lib/utils/plural.ts b/src/lib/utils/plural.ts new file mode 100644 index 0000000..b544951 --- /dev/null +++ b/src/lib/utils/plural.ts @@ -0,0 +1,13 @@ +const PLURALS = { + "chunk": "chunks", +} as const; + +/** + * Returns the plural form of the given key. + * + * @param word Thw word to pluralize + * @param count The count of the word + */ +export function pluralize(word: keyof typeof PLURALS, count: number): string { + return count === 1 ? `1 ${word}` : `${count} ${PLURALS[word]}`; +} diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte new file mode 100644 index 0000000..a04f33b --- /dev/null +++ b/src/routes/(auth)/+layout.svelte @@ -0,0 +1,44 @@ + + +
+ + + {@render children()} +
diff --git a/src/routes/(auth)/+page.svelte b/src/routes/(auth)/+page.svelte new file mode 100644 index 0000000..bbb926b --- /dev/null +++ b/src/routes/(auth)/+page.svelte @@ -0,0 +1,233 @@ + + +
+
+ + + Hello {auth.state?.username} + Storage usage: {formatFileSize(cumulativeSize)} + + + + + +
0 && "mt-8")}> + {#each sortedFiles as file} + {@const df = decrypted(file)} +
+
+
+ + {df.name} + + + {dayjs(df.createdAt).fromNow()} + +
+ +
+ + {formatFileSize(fileSize(df))} + + + {df.type} + + + {pluralize("chunk", df.chunks.length)} + +
+
+ +
+ + + {#snippet child({ props })} + + {/snippet} + + + + handleView(df.id)}>View + handleDownload(df.id)}>Download + + handleDelete(df)} + > + Delete + + + + +
+
+ {/each} +
+
+ + + + + +
+
+
diff --git a/src/routes/(auth)/vaults/+page.svelte b/src/routes/(auth)/vaults/+page.svelte new file mode 100644 index 0000000..d7cc0e7 --- /dev/null +++ b/src/routes/(auth)/vaults/+page.svelte @@ -0,0 +1,338 @@ + + +
+
+ +
+ + Your Public Vaults + + Here you can manage your public vaults into which other people can upload files. + + + + + + +
+ + +
+ + {#each sortedVaults as vault} + +
+ + {vault.name} + +
+ {dayjs(vault.createdAt).fromNow()} +
+
+ + + (event.target as HTMLInputElement).select()} + readonly + /> +
+
+
+ + + + + {#snippet child({ props })} + + {/snippet} + + + + handleDeleteVault(vault)} + > + Delete vault + + + + + +
+ + +
+ {#each vault.slots as slot} +
+
+
+ {#if slot.file !== null} + + {slot.name} + + {:else} +
+ {slot.name} +
+ {/if} + + {#if slot.file !== null} + {dayjs(slot.file.createdAt).fromNow()} + {:else} + no upload so far + {/if} + +
+ +
+ + {#if slot.file !== null} + {formatFileSize(slot.file?.size ?? 0)} + / + {:else} + max. + {/if} + {formatFileSize(slot.maxSize)} + + {#if slot.file !== null} + + {slot.file.type} + + + {pluralize("chunk", slot.file.chunks.length)} + + {/if} +
+
+ + {#if slot.file !== null} +
+ + + {#snippet child({ props })} + + {/snippet} + + + + handleView(slot)}>View + handleDownload(slot)}>Download + + handleClear(slot)} + > + Clear + + + + +
+ {/if} +
+ {/each} +
+
+
+ {/each} +
+
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..41b983c --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,32 @@ +import { createContext } from "$lib/trpc/context"; +import { createCaller } from "$lib/trpc/router"; +import { redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from "./$types"; + + +const GUEST_ROUTES = new Set([ + "/register", + "/login", +]); + +export const load: LayoutServerLoad = async (event) => { + const ctx = await createContext(event); + const result = await createCaller(ctx).auth(); + const url = new URL(event.request.url); + const loggedIn = result !== null; + + if (loggedIn) { + if (GUEST_ROUTES.has(url.pathname)) { + return redirect(307, "/"); + } + } + else { + if (! GUEST_ROUTES.has(url.pathname) && ! url.pathname.startsWith("/v/")) { + return redirect(307, "/login"); + } + } + + return { + session: result, + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..fff948e --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,47 @@ + + +{@render children()} + diff --git a/src/routes/api/dl/[id]/+server.ts b/src/routes/api/dl/[id]/+server.ts new file mode 100644 index 0000000..3beceba --- /dev/null +++ b/src/routes/api/dl/[id]/+server.ts @@ -0,0 +1,30 @@ +import { db, schema } from "$lib/server/db"; +import { getSessionFromRequest, getSessionUser } from "$lib/server/session"; +import { error } from "@sveltejs/kit"; +import { and, eq } from "drizzle-orm"; +import type { RequestHandler } from "./$types"; + + +export const GET: RequestHandler = async (event) => { + const session = await getSessionFromRequest(event); + const user = await getSessionUser(session); + if (user === null) { + return new Response("Unauthorized", { status: 401 }); + } + + const chunk = await db.query.chunks.findFirst({ + where: and( + eq(schema.chunks.id, event.params.id), + eq(schema.chunks.userId, user.id), + ), + }); + if (typeof chunk == "undefined") { + return error(404, "Chunk not found"); + } + + return new Response(chunk.blob as Uint8Array, { + headers: { + "Content-Type": "application/octet-stream", + }, + }); +}; diff --git a/src/routes/api/ul/+server.ts b/src/routes/api/ul/+server.ts new file mode 100644 index 0000000..f905dec --- /dev/null +++ b/src/routes/api/ul/+server.ts @@ -0,0 +1,125 @@ +import { decrypt } from "$lib/server/crypto/encrypt"; +import { db, schema } from "$lib/server/db"; +import { getSessionFromRequest, getSessionUser } from "$lib/server/session"; +import type { SlotUploadTicket } from "$lib/server/types"; +import { bytesToUtf8 } from "@noble/ciphers/utils"; +import { error, json } from "@sveltejs/kit"; +import { eq, type InferSelectModel, sql } from "drizzle-orm"; +import type { RequestHandler } from "./$types"; + + +const NONCE_LENGTH = 24; +const AUTH_TAG_LENGTH = 16; +const OVERHEAD = NONCE_LENGTH + AUTH_TAG_LENGTH; + +export const POST: RequestHandler = async (event) => { + if (event.request.headers.get("x-application") !== "sveltekit") { + return new Response("Forbidden", { status: 403 }); + } + + const data = await event.request.formData(); + let userId: string; + let vaultSlot: InferSelectModel | undefined; + + if (data.has("ticket")) { + const raw = data.get("ticket"); + if (raw === null || typeof raw == "string") { + return error(422, "Invalid ticket"); + } + + const buffer = Buffer.from(await raw.arrayBuffer()); + const decrypted = decrypt(buffer); + const ticket: SlotUploadTicket = JSON.parse(bytesToUtf8(decrypted)); + + if (ticket.timestamp < Date.now() / 1000 - 60 * 60) { + // Ticket expired + return error(422, "Ticket expired"); + } + + const slot = await db.query.slots.findFirst({ + where: eq(schema.slots.id, ticket.slotId), + with: { + vault: true, + }, + }); + if (typeof slot == "undefined") { + return error(422, "Invalid ticket"); + } + + if (slot.fileId !== null) { + return error(422, "Slot not available"); + } + + userId = slot.vault.userId; + vaultSlot = slot; + } + else { + const session = await getSessionFromRequest(event); + const user = await getSessionUser(session); + if (user === null) { + return new Response("Unauthorized", { status: 401 }); + } + + userId = user.id; + } + + const blob = data.get("blob"); + if (blob === null) { + return error(422, "Missing blob"); + } + + if (typeof blob == "string") { + return error(422, "Invalid blob"); + } + + const buffer = Buffer.from(await blob.arrayBuffer()); + + const [usage = { usage: 0 }] = await db + .select({ usage: sql`sum(length(${schema.chunks.blob}) - ${OVERHEAD})` }) + .from(schema.chunks) + .where(eq(schema.chunks.userId, userId)) + .groupBy(schema.chunks.userId); + const newUsage = usage.usage + (buffer.length - OVERHEAD); + + if (newUsage > 1000 * 1000 * 1000) { + return error(422, "Quota exceeded"); + } + + if (typeof vaultSlot != "undefined" && vaultSlot.maxSize > 0) { + const [slotUsage = { usage: 0 }] = await db + .select({ usage: sql`sum(length(${schema.chunks.blob}) - ${OVERHEAD})` }) + .from(schema.chunks) + .innerJoin(schema.slotChunks, eq(schema.slotChunks.chunkId, schema.chunks.id)) + .where(eq(schema.slotChunks.slotId, vaultSlot.id)) + .groupBy(schema.slotChunks.slotId); + const newUsage = slotUsage.usage + (buffer.length - OVERHEAD); + + if (newUsage > vaultSlot.maxSize) { + return error(422, "Slot is full"); + } + } + + const chunk = await db.transaction(async (tx) => { + const [chunk] = await tx.insert(schema.chunks).values({ + userId, + blob: buffer, + }).returning(); + if (typeof chunk == "undefined") { + throw new Error("Failed to persist chunk"); + } + + // Create slot-chunk relation + if (typeof vaultSlot != "undefined") { + await tx.insert(schema.slotChunks).values({ + slotId: vaultSlot.id, + chunkId: chunk.id, + }); + } + + return chunk; + }); + + return json({ + id: chunk.id, + }); +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..67e4f60 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,84 @@ + + +
+
+
+ + + Login + Log into your account and access your private data. + + + + + {#snippet children({ props, error })} + Username + + + {/snippet} + + + + {#snippet children({ props })} + Password + + + {/snippet} + + + + + + + Login + + + +
+
+
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..57d480b --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,89 @@ + + +
+
+
+ + + Register + Register your account and start protecting your privacy. + + + + + {#snippet children({ props, error })} + Username + + + {#if ! error} +

+ Your username cannot be changed later. +

+ {/if} + + {/snippet} +
+ + + {#snippet children({ props })} + Password + + + {/snippet} + +
+ + + + + Register + + +
+
+
+
diff --git a/src/routes/v/[token]/+page.svelte b/src/routes/v/[token]/+page.svelte new file mode 100644 index 0000000..a28ae1d --- /dev/null +++ b/src/routes/v/[token]/+page.svelte @@ -0,0 +1,143 @@ + + +{#if vault !== null} +
+
+ + + {vault.name} + +
+ {dayjs(vault.createdAt).fromNow()} +
+
+
+ + + {#each vault.slots as slot} + handleFile(slot, file)}/> + {/each} + +
+
+
+{/if} diff --git a/src/routes/v/[token]/UploadEntry.svelte b/src/routes/v/[token]/UploadEntry.svelte new file mode 100644 index 0000000..a556a12 --- /dev/null +++ b/src/routes/v/[token]/UploadEntry.svelte @@ -0,0 +1,87 @@ + + +
+
+
+ {props.vaultSlot.name} +
+ +
+ {#if ! props.vaultSlot.available} + + uploaded + + {/if} + + max. {formatFileSize(props.vaultSlot.maxSize)} + +
+
+ +
+ + + +
+
diff --git a/src/service-worker/index.ts b/src/service-worker/index.ts new file mode 100644 index 0000000..17bbae6 --- /dev/null +++ b/src/service-worker/index.ts @@ -0,0 +1,134 @@ +/// +/// +/// +/// + +import { openDB } from "$lib/keyval"; +import { type RouterOutput, trpc as makeTrpcClient, type TrpcClient } from "$lib/trpc/client"; +import { x25519 } from "@noble/curves/ed25519"; +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha2"; +import { download } from "~/features/storage/download"; +import { getDecryptedFileAttributes } from "~/features/storage/file"; +import { encodeFilename } from "~/service-worker/utils"; +import { decrypt } from "../lib/tulip"; +import { decodeBase64Url } from "../lib/utils/base64url"; + + +type RemoteFile = RouterOutput["files"]["files"][number]; + +// https://kit.svelte.dev/docs/service-workers#type-safety +const sw = self as unknown as ServiceWorkerGlobalScope; + +sw.addEventListener("install", (event) => { + event.waitUntil(sw.skipWaiting()); +}); + +sw.addEventListener("activate", async (event) => { + event.waitUntil(sw.clients.claim()); +}); + +sw.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + + if (url.pathname.startsWith("/sw/")) { + event.respondWith(handleSWRequest(event)); + } +}); + +async function handleSWRequest(event: FetchEvent) { + const url = new URL(event.request.url); + const trpc = makeTrpcClient({ fetch, url: { origin: url.origin } }); + const key = await getKey(); + + if (key === null) { + return new Response("not authenticated", { status: 401 }); + } + + if (url.pathname.startsWith("/sw/v/")) { + return handleVaultFileRequest(trpc, url, key); + } + + const [, id] = url.pathname.slice(1).split("/"); + if (typeof id == "undefined") { + return new Response("invalid token", { status: 400 }); + } + + const file = await trpc.file.query({ id }); + if (typeof file == "undefined") { + return new Response("not found", { status: 404 }); + } + + return respondWithFile(file, key, url.searchParams.get("dl") ? "download" : "view"); +} + +async function handleVaultFileRequest(trpc: TrpcClient, url: URL, key: Uint8Array) { + const [, , slotId] = url.pathname.slice(1).split("/"); + if (typeof slotId == "undefined") { + return new Response("invalid token", { status: 400 }); + } + + const slot = await trpc.slot.query({ id: slotId }); + if (typeof slot == "undefined") { + return new Response("slot not found", { status: 404 }); + } + + if (slot.fileId === null || slot.fileX25519PublicKey === null) { + return new Response("slot does not contain a file", { status: 400 }); + } + + const { x25519PrivateKey = null } = await trpc.auth.query() ?? {}; + if (x25519PrivateKey === null) { + return new Response("missing x25519PrivateKey", { status: 400 }); + } + + const file = await trpc.file.query({ id: slot.fileId }); + if (typeof file == "undefined") { + return new Response("file not found", { status: 404 }); + } + + const [decryptedX25519PrivateKey] = decrypt(key, decodeBase64Url(x25519PrivateKey)); + const sharedSecret = x25519.getSharedSecret(decryptedX25519PrivateKey, decodeBase64Url(slot.fileX25519PublicKey)); + const fileMasterKey = hkdf(sha256, sharedSecret, undefined, undefined, 32); + + return respondWithFile(file, fileMasterKey, url.searchParams.get("dl") ? "download" : "view"); +} + +function respondWithFile(file: RemoteFile, key: Uint8Array, mode: "view" | "download") { + const attributes = getDecryptedFileAttributes(key, file); + + const stream = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of download(key, file, attributes)) { + controller.enqueue(chunk); + } + + controller.close(); + } + catch (error) { + return controller.error(error); + } + }, + cancel() { + console.log("Stream canceled"); + }, + }); + + const headers: HeadersInit = { + "Content-Type": attributes.type.length > 0 ? attributes.type : "application/octet-stream", + "Content-Length": attributes.size.toString(), + }; + + if (mode === "download") { + headers["Content-Disposition"] = `attachment; filename*=utf-8''${encodeFilename(attributes.name)}`; + } + + return new Response(stream, { headers }); +} + +async function getKey(): Promise { + const keyval = await openDB(); + + return keyval?.get("masterKey"); +} diff --git a/src/service-worker/utils.ts b/src/service-worker/utils.ts new file mode 100644 index 0000000..ca70d14 --- /dev/null +++ b/src/service-worker/utils.ts @@ -0,0 +1,12 @@ +/** + * Encodes the given filename for RFC 5987 compliance. + * + * @param filename + */ +export function encodeFilename(filename: string) { + // RFC 5987 specifies using UTF-8 and percent encoding for extended filenames + // Use encodeURIComponent for non-ASCII characters and special characters + return encodeURIComponent(filename).replace(/['()]/g, (char) => ( + `%${char.charCodeAt(0).toString(16).toUpperCase()}` + )); +} diff --git a/src/style/app.css b/src/style/app.css new file mode 100644 index 0000000..b89d3d7 --- /dev/null +++ b/src/style/app.css @@ -0,0 +1,77 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + /*@media (prefers-color-scheme: dark) {*/ + /* :root {*/ + /* --background: 222.2 84% 4.9%;*/ + /* --foreground: 210 40% 98%;*/ + /* --card: 222.2 84% 4.9%;*/ + /* --card-foreground: 210 40% 98%;*/ + /* --popover: 222.2 84% 4.9%;*/ + /* --popover-foreground: 210 40% 98%;*/ + /* --primary: 217.2 91.2% 59.8%;*/ + /* --primary-foreground: 222.2 47.4% 11.2%;*/ + /* --secondary: 217.2 32.6% 17.5%;*/ + /* --secondary-foreground: 210 40% 98%;*/ + /* --muted: 217.2 32.6% 17.5%;*/ + /* --muted-foreground: 215 20.2% 65.1%;*/ + /* --accent: 217.2 32.6% 17.5%;*/ + /* --accent-foreground: 210 40% 98%;*/ + /* --destructive: 0 62.8% 30.6%;*/ + /* --destructive-foreground: 210 40% 98%;*/ + /* --border: 217.2 32.6% 17.5%;*/ + /* --input: 217.2 32.6% 17.5%;*/ + /* --ring: 224.3 76.3% 48%;*/ + /* --chart-1: 220 70% 50%;*/ + /* --chart-2: 160 60% 45%;*/ + /* --chart-3: 30 80% 55%;*/ + /* --chart-4: 280 65% 60%;*/ + /* --chart-5: 340 75% 55%;*/ + /* }*/ + /*}*/ +} + +@layer base { + * { + @apply border-border; + } + + html { + @apply h-full bg-foreground/5; + } + + body { + @apply min-h-full flex flex-col text-foreground; + } +} diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..825b9e6 Binary files /dev/null and b/static/favicon.png differ diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..b120a7a --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,31 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import adapter from "svelte-adapter-bun"; + + +/** @type {import("@sveltejs/kit").Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess({ script: true }), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + + files: { + serviceWorker: './src/service-worker/index.ts', + }, + + csrf: { + checkOrigin: false, + }, + + alias: { + "~/*": "./src/*", + }, + }, +}; + +export default config; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..f9e3dcc --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,103 @@ +import type { Config } from "tailwindcss"; +import tailwindcssAnimate from "tailwindcss-animate"; +import { fontFamily } from "tailwindcss/defaultTheme"; + + +const config: Config = { + darkMode: "media", + content: ["./src/**/*.{html,js,svelte,ts}"], + safelist: ["dark"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border) / )", + input: "hsl(var(--input) / )", + ring: "hsl(var(--ring) / )", + background: "hsl(var(--background) / )", + foreground: "hsl(var(--foreground) / )", + primary: { + DEFAULT: "hsl(var(--primary) / )", + foreground: "hsl(var(--primary-foreground) / )", + }, + secondary: { + DEFAULT: "hsl(var(--secondary) / )", + foreground: "hsl(var(--secondary-foreground) / )", + }, + destructive: { + DEFAULT: "hsl(var(--destructive) / )", + foreground: "hsl(var(--destructive-foreground) / )", + }, + muted: { + DEFAULT: "hsl(var(--muted) / )", + foreground: "hsl(var(--muted-foreground) / )", + }, + accent: { + DEFAULT: "hsl(var(--accent) / )", + foreground: "hsl(var(--accent-foreground) / )", + }, + popover: { + DEFAULT: "hsl(var(--popover) / )", + foreground: "hsl(var(--popover-foreground) / )", + }, + card: { + DEFAULT: "hsl(var(--card) / )", + foreground: "hsl(var(--card-foreground) / )", + }, + sidebar: { + DEFAULT: "hsl(var(--sidebar-background))", + foreground: "hsl(var(--sidebar-foreground))", + primary: "hsl(var(--sidebar-primary))", + "primary-foreground": "hsl(var(--sidebar-primary-foreground))", + accent: "hsl(var(--sidebar-accent))", + "accent-foreground": "hsl(var(--sidebar-accent-foreground))", + border: "hsl(var(--sidebar-border))", + ring: "hsl(var(--sidebar-ring))", + }, + }, + borderRadius: { + xl: "calc(var(--radius) + 4px)", + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: [...fontFamily.sans], + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + "caret-blink": { + "0%,70%,100%": { opacity: "1" }, + "20%,50%": { opacity: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + "caret-blink": "caret-blink 1.25s ease-out infinite", + }, + transitionDuration: { + "25": "25ms", + }, + scale: { + "97": ".97", + }, + }, + }, + plugins: [tailwindcssAnimate], +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d7a76f7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "ts-node": { + "require": [ + "tsconfig-paths/register" + ] + }, + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "baseUrl": "./", + "paths": { + "~/*": ["./src/*"], + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..89c1780 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vitest/config"; + + +export default defineConfig({ + plugins: [sveltekit()], + + test: { + include: ["src/**/*.{test,spec}.{js,ts}"], + }, +});