mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-12 21:08:46 +00:00
Merge pull request #1 from kmc7468/add-client-signature-keypair
클라이언트 서명 시스템 도입
This commit is contained in:
@@ -5,4 +5,5 @@ JWT_SECRET=
|
||||
DATABASE_URL=
|
||||
JWT_ACCESS_TOKEN_EXPIRES=
|
||||
JWT_REFRESH_TOKEN_EXPIRES=
|
||||
PUBKEY_CHALLENGE_EXPIRES=
|
||||
USER_CLIENT_CHALLENGE_EXPIRES=
|
||||
TOKEN_UPGRADE_CHALLENGE_EXPIRES=
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Output
|
||||
/drizzle
|
||||
|
||||
@@ -10,7 +10,8 @@ services:
|
||||
- JWT_SECRET=${JWT_SECRET:?} # Required
|
||||
- JWT_ACCESS_TOKEN_EXPIRES
|
||||
- JWT_REFRESH_TOKEN_EXPIRES
|
||||
- PUBKEY_CHALLENGE_EXPIRES
|
||||
- USER_CLIENT_CHALLENGE_EXPIRES
|
||||
- TOKEN_UPGRADE_CHALLENGE_EXPIRES
|
||||
# SvelteKit
|
||||
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
||||
- XFF_DEPTH=${TRUST_PROXY:-}
|
||||
|
||||
20
drizzle/0001_silly_vanisher.sql
Normal file
20
drizzle/0001_silly_vanisher.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE `token_upgrade_challenge` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`refresh_token_id` text NOT NULL,
|
||||
`client_id` integer NOT NULL,
|
||||
`challenge` text NOT NULL,
|
||||
`allowed_ip` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`is_used` integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `client` RENAME COLUMN `public_key` TO `encryption_public_key`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `client_public_key_unique`;--> statement-breakpoint
|
||||
ALTER TABLE `client` ADD `signature_public_key` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `user_client_challenge` ADD `is_used` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`);
|
||||
611
drizzle/meta/0001_snapshot.json
Normal file
611
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "f5b74176-eb87-436d-8f32-6da01727b564",
|
||||
"prevId": "64e2c1ed-92bf-44d1-9094-7e3610b3224f",
|
||||
"tables": {
|
||||
"client": {
|
||||
"name": "client",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"encryption_public_key": {
|
||||
"name": "encryption_public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"signature_public_key": {
|
||||
"name": "signature_public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"client_encryption_public_key_unique": {
|
||||
"name": "client_encryption_public_key_unique",
|
||||
"columns": [
|
||||
"encryption_public_key"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"client_signature_public_key_unique": {
|
||||
"name": "client_signature_public_key_unique",
|
||||
"columns": [
|
||||
"signature_public_key"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"client_encryption_public_key_signature_public_key_unique": {
|
||||
"name": "client_encryption_public_key_signature_public_key_unique",
|
||||
"columns": [
|
||||
"encryption_public_key",
|
||||
"signature_public_key"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user_client": {
|
||||
"name": "user_client",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'challenging'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_client_user_id_user_id_fk": {
|
||||
"name": "user_client_user_id_user_id_fk",
|
||||
"tableFrom": "user_client",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"user_client_client_id_client_id_fk": {
|
||||
"name": "user_client_client_id_client_id_fk",
|
||||
"tableFrom": "user_client",
|
||||
"tableTo": "client",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_client_user_id_client_id_pk": {
|
||||
"columns": [
|
||||
"client_id",
|
||||
"user_id"
|
||||
],
|
||||
"name": "user_client_user_id_client_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user_client_challenge": {
|
||||
"name": "user_client_challenge",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"challenge": {
|
||||
"name": "challenge",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_ip": {
|
||||
"name": "allowed_ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_used": {
|
||||
"name": "is_used",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_client_challenge_challenge_unique": {
|
||||
"name": "user_client_challenge_challenge_unique",
|
||||
"columns": [
|
||||
"challenge"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"user_client_challenge_user_id_user_id_fk": {
|
||||
"name": "user_client_challenge_user_id_user_id_fk",
|
||||
"tableFrom": "user_client_challenge",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"user_client_challenge_client_id_client_id_fk": {
|
||||
"name": "user_client_challenge_client_id_client_id_fk",
|
||||
"tableFrom": "user_client_challenge",
|
||||
"tableTo": "client",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"client_master_encryption_key": {
|
||||
"name": "client_master_encryption_key",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"master_encryption_key_version": {
|
||||
"name": "master_encryption_key_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"encrypted_master_encryption_key": {
|
||||
"name": "encrypted_master_encryption_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"client_master_encryption_key_user_id_user_id_fk": {
|
||||
"name": "client_master_encryption_key_user_id_user_id_fk",
|
||||
"tableFrom": "client_master_encryption_key",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"client_master_encryption_key_client_id_client_id_fk": {
|
||||
"name": "client_master_encryption_key_client_id_client_id_fk",
|
||||
"tableFrom": "client_master_encryption_key",
|
||||
"tableTo": "client",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"client_master_encryption_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
|
||||
"name": "client_master_encryption_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
|
||||
"tableFrom": "client_master_encryption_key",
|
||||
"tableTo": "master_encryption_key",
|
||||
"columnsFrom": [
|
||||
"user_id",
|
||||
"master_encryption_key_version"
|
||||
],
|
||||
"columnsTo": [
|
||||
"user_id",
|
||||
"version"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"client_master_encryption_key_user_id_client_id_master_encryption_key_version_pk": {
|
||||
"columns": [
|
||||
"client_id",
|
||||
"master_encryption_key_version",
|
||||
"user_id"
|
||||
],
|
||||
"name": "client_master_encryption_key_user_id_client_id_master_encryption_key_version_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"master_encryption_key": {
|
||||
"name": "master_encryption_key",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retired_at": {
|
||||
"name": "retired_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"master_encryption_key_user_id_user_id_fk": {
|
||||
"name": "master_encryption_key_user_id_user_id_fk",
|
||||
"tableFrom": "master_encryption_key",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"master_encryption_key_created_by_client_id_fk": {
|
||||
"name": "master_encryption_key_created_by_client_id_fk",
|
||||
"tableFrom": "master_encryption_key",
|
||||
"tableTo": "client",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"master_encryption_key_user_id_version_pk": {
|
||||
"columns": [
|
||||
"user_id",
|
||||
"version"
|
||||
],
|
||||
"name": "master_encryption_key_user_id_version_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"refresh_token_user_id_client_id_unique": {
|
||||
"name": "refresh_token_user_id_client_id_unique",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"client_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"refresh_token_user_id_user_id_fk": {
|
||||
"name": "refresh_token_user_id_user_id_fk",
|
||||
"tableFrom": "refresh_token",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"refresh_token_client_id_client_id_fk": {
|
||||
"name": "refresh_token_client_id_client_id_fk",
|
||||
"tableFrom": "refresh_token",
|
||||
"tableTo": "client",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"token_upgrade_challenge": {
|
||||
"name": "token_upgrade_challenge",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token_id": {
|
||||
"name": "refresh_token_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"challenge": {
|
||||
"name": "challenge",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_ip": {
|
||||
"name": "allowed_ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_used": {
|
||||
"name": "is_used",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"token_upgrade_challenge_challenge_unique": {
|
||||
"name": "token_upgrade_challenge_challenge_unique",
|
||||
"columns": [
|
||||
"challenge"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": {
|
||||
"name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk",
|
||||
"tableFrom": "token_upgrade_challenge",
|
||||
"tableTo": "refresh_token",
|
||||
"columnsFrom": [
|
||||
"refresh_token_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"token_upgrade_challenge_client_id_client_id_fk": {
|
||||
"name": "token_upgrade_challenge_client_id_client_id_fk",
|
||||
"tableFrom": "token_upgrade_challenge",
|
||||
"tableTo": "client",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {
|
||||
"\"client\".\"public_key\"": "\"client\".\"encryption_public_key\""
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1735525637133,
|
||||
"tag": "0000_spicy_morgan_stark",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1735588850570,
|
||||
"tag": "0001_silly_vanisher",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { redirect, type ServerInit, type Handle } from "@sveltejs/kit";
|
||||
import schedule from "node-schedule";
|
||||
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
|
||||
import { migrateDB } from "$lib/server/db/drizzle";
|
||||
import { cleanupExpiredRefreshTokens } from "$lib/server/db/token";
|
||||
import {
|
||||
cleanupExpiredRefreshTokens,
|
||||
cleanupExpiredTokenUpgradeChallenges,
|
||||
} from "$lib/server/db/token";
|
||||
|
||||
export const init: ServerInit = () => {
|
||||
migrateDB();
|
||||
@@ -10,6 +13,7 @@ export const init: ServerInit = () => {
|
||||
schedule.scheduleJob("0 * * * *", () => {
|
||||
cleanupExpiredUserClientChallenges();
|
||||
cleanupExpiredRefreshTokens();
|
||||
cleanupExpiredTokenUpgradeChallenges();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ type Path = "/key/export";
|
||||
|
||||
interface KeyExportState {
|
||||
redirectPath: string;
|
||||
pubKeyBase64: string;
|
||||
privKeyBase64: string;
|
||||
|
||||
encryptKeyBase64: string;
|
||||
decryptKeyBase64: string;
|
||||
signKeyBase64: string;
|
||||
verifyKeyBase64: string;
|
||||
|
||||
mekDraft: ArrayBuffer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
import { Dexie, type EntityTable } from "dexie";
|
||||
|
||||
interface KeyPair {
|
||||
type: "publicKey" | "privateKey";
|
||||
type RSAKeyUsage = "encrypt" | "decrypt" | "sign" | "verify";
|
||||
|
||||
interface RSAKey {
|
||||
usage: RSAKeyUsage;
|
||||
key: CryptoKey;
|
||||
}
|
||||
|
||||
const keyStore = new Dexie("keyStore") as Dexie & {
|
||||
keyPair: EntityTable<KeyPair, "type">;
|
||||
rsaKey: EntityTable<RSAKey, "usage">;
|
||||
};
|
||||
|
||||
keyStore.version(1).stores({
|
||||
keyPair: "type",
|
||||
rsaKey: "usage",
|
||||
});
|
||||
|
||||
export const getKeyPairFromIndexedDB = async () => {
|
||||
const pubKey = await keyStore.keyPair.get("publicKey");
|
||||
const privKey = await keyStore.keyPair.get("privateKey");
|
||||
return {
|
||||
pubKey: pubKey?.key ?? null,
|
||||
privKey: privKey?.key ?? null,
|
||||
};
|
||||
export const getRSAKey = async (usage: RSAKeyUsage) => {
|
||||
const key = await keyStore.rsaKey.get(usage);
|
||||
return key?.key ?? null;
|
||||
};
|
||||
|
||||
export const storeKeyPairIntoIndexedDB = async (pubKey: CryptoKey, privKey: CryptoKey) => {
|
||||
if (!pubKey.extractable) throw new Error("Public key must be extractable");
|
||||
if (privKey.extractable) throw new Error("Private key must be non-extractable");
|
||||
|
||||
await keyStore.keyPair.bulkPut([
|
||||
{ type: "publicKey", key: pubKey },
|
||||
{ type: "privateKey", key: privKey },
|
||||
]);
|
||||
export const storeRSAKey = async (key: CryptoKey, usage: RSAKeyUsage) => {
|
||||
switch (usage) {
|
||||
case "encrypt":
|
||||
case "verify":
|
||||
if (key.type !== "public") {
|
||||
throw new Error("Public key required");
|
||||
} else if (!key.extractable) {
|
||||
throw new Error("Public key must be extractable");
|
||||
}
|
||||
break;
|
||||
case "decrypt":
|
||||
case "sign":
|
||||
if (key.type !== "private") {
|
||||
throw new Error("Private key required");
|
||||
} else if (key.extractable) {
|
||||
throw new Error("Private key must be non-extractable");
|
||||
}
|
||||
break;
|
||||
}
|
||||
await keyStore.rsaKey.put({ usage, key });
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type RSAKeyPurpose = "encryption" | "signature";
|
||||
export type RSAKeyType = "public" | "private";
|
||||
|
||||
export const encodeToBase64 = (data: ArrayBuffer) => {
|
||||
@@ -8,42 +9,42 @@ export const decodeFromBase64 = (data: string) => {
|
||||
return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
|
||||
};
|
||||
|
||||
export const generateRSAKeyPair = async () => {
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
export const generateRSAKeyPair = async (purpose: RSAKeyPurpose) => {
|
||||
return await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
name: purpose === "encryption" ? "RSA-OAEP" : "RSA-PSS",
|
||||
modulusLength: 4096,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
} satisfies RsaHashedKeyGenParams,
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
purpose === "encryption" ? ["encrypt", "decrypt"] : ["sign", "verify"],
|
||||
);
|
||||
return keyPair;
|
||||
};
|
||||
|
||||
export const makeRSAKeyNonextractable = async (key: CryptoKey, type: RSAKeyType) => {
|
||||
const { format, key: exportedKey } = await exportRSAKey(key, type);
|
||||
export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
|
||||
const { format, key: exportedKey } = await exportRSAKey(key);
|
||||
return await window.crypto.subtle.importKey(
|
||||
format,
|
||||
exportedKey,
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
hash: "SHA-256",
|
||||
} satisfies RsaHashedImportParams,
|
||||
key.algorithm,
|
||||
false,
|
||||
[type === "public" ? "encrypt" : "decrypt"],
|
||||
key.usages,
|
||||
);
|
||||
};
|
||||
|
||||
export const exportRSAKey = async (key: CryptoKey, type: RSAKeyType) => {
|
||||
const format = type === "public" ? ("spki" as const) : ("pkcs8" as const);
|
||||
export const exportRSAKey = async (key: CryptoKey) => {
|
||||
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
|
||||
return {
|
||||
format,
|
||||
key: await window.crypto.subtle.exportKey(format, key),
|
||||
};
|
||||
};
|
||||
|
||||
export const exportRSAKeyToBase64 = async (key: CryptoKey) => {
|
||||
return encodeToBase64((await exportRSAKey(key)).key);
|
||||
};
|
||||
|
||||
export const encryptRSAPlaintext = async (plaintext: ArrayBuffer, publicKey: CryptoKey) => {
|
||||
return await window.crypto.subtle.encrypt(
|
||||
{
|
||||
@@ -64,6 +65,17 @@ export const decryptRSACiphertext = async (ciphertext: ArrayBuffer, privateKey:
|
||||
);
|
||||
};
|
||||
|
||||
export const signRSAMessage = async (message: ArrayBuffer, privateKey: CryptoKey) => {
|
||||
return await window.crypto.subtle.sign(
|
||||
{
|
||||
name: "RSA-PSS",
|
||||
saltLength: 32,
|
||||
} satisfies RsaPssParams,
|
||||
privateKey,
|
||||
message,
|
||||
);
|
||||
};
|
||||
|
||||
export const generateAESKey = async () => {
|
||||
return await window.crypto.subtle.generateKey(
|
||||
{
|
||||
@@ -79,12 +91,9 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => {
|
||||
return await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
await exportAESKey(key),
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256,
|
||||
} satisfies AesKeyAlgorithm,
|
||||
key.algorithm,
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
key.usages,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { and, eq, gt, lte } from "drizzle-orm";
|
||||
import { and, or, eq, gt, lte, count } from "drizzle-orm";
|
||||
import db from "./drizzle";
|
||||
import { client, userClient, userClientChallenge } from "./schema";
|
||||
|
||||
export const createClient = async (pubKey: string, userId: number) => {
|
||||
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const insertRes = await tx.insert(client).values({ pubKey }).returning({ id: client.id });
|
||||
const clients = await tx
|
||||
.select()
|
||||
.from(client)
|
||||
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)));
|
||||
if (clients.length > 0) {
|
||||
throw new Error("Already used public key(s)");
|
||||
}
|
||||
|
||||
const insertRes = await tx
|
||||
.insert(client)
|
||||
.values({ encPubKey, sigPubKey })
|
||||
.returning({ id: client.id });
|
||||
const { id: clientId } = insertRes[0]!;
|
||||
await tx.insert(userClient).values({ userId, clientId });
|
||||
|
||||
@@ -12,11 +23,28 @@ export const createClient = async (pubKey: string, userId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getClientByPubKey = async (pubKey: string) => {
|
||||
const clients = await db.select().from(client).where(eq(client.pubKey, pubKey)).execute();
|
||||
export const getClient = async (clientId: number) => {
|
||||
const clients = await db.select().from(client).where(eq(client.id, clientId)).execute();
|
||||
return clients[0] ?? null;
|
||||
};
|
||||
|
||||
export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => {
|
||||
const clients = await db
|
||||
.select()
|
||||
.from(client)
|
||||
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
|
||||
.execute();
|
||||
return clients[0] ?? null;
|
||||
};
|
||||
|
||||
export const countClientByPubKey = async (pubKey: string) => {
|
||||
const clients = await db
|
||||
.select({ count: count() })
|
||||
.from(client)
|
||||
.where(or(eq(client.encPubKey, pubKey), eq(client.encPubKey, pubKey)));
|
||||
return clients[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
export const createUserClient = async (userId: number, clientId: number) => {
|
||||
await db.insert(userClient).values({ userId, clientId }).execute();
|
||||
};
|
||||
@@ -62,7 +90,7 @@ export const setUserClientStateToActive = async (userId: number, clientId: numbe
|
||||
.execute();
|
||||
};
|
||||
|
||||
export const createUserClientChallenge = async (
|
||||
export const registerUserClientChallenge = async (
|
||||
userId: number,
|
||||
clientId: number,
|
||||
answer: string,
|
||||
@@ -90,12 +118,21 @@ export const getUserClientChallenge = async (answer: string, ip: string) => {
|
||||
eq(userClientChallenge.answer, answer),
|
||||
eq(userClientChallenge.allowedIp, ip),
|
||||
gt(userClientChallenge.expiresAt, new Date()),
|
||||
eq(userClientChallenge.isUsed, false),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
return challenges[0] ?? null;
|
||||
};
|
||||
|
||||
export const markUserClientChallengeAsUsed = async (id: number) => {
|
||||
await db
|
||||
.update(userClientChallenge)
|
||||
.set({ isUsed: true })
|
||||
.where(eq(userClientChallenge.id, id))
|
||||
.execute();
|
||||
};
|
||||
|
||||
export const cleanupExpiredUserClientChallenges = async () => {
|
||||
await db
|
||||
.delete(userClientChallenge)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, primaryKey, unique } from "drizzle-orm/sqlite-core";
|
||||
import { user } from "./user";
|
||||
|
||||
export const client = sqliteTable("client", {
|
||||
export const client = sqliteTable(
|
||||
"client",
|
||||
{
|
||||
id: integer("id").primaryKey(),
|
||||
pubKey: text("public_key").notNull().unique(), // Base64
|
||||
});
|
||||
encPubKey: text("encryption_public_key").notNull().unique(), // Base64
|
||||
sigPubKey: text("signature_public_key").notNull().unique(), // Base64
|
||||
},
|
||||
(t) => ({
|
||||
unq: unique().on(t.encPubKey, t.sigPubKey),
|
||||
}),
|
||||
);
|
||||
|
||||
export const userClient = sqliteTable(
|
||||
"user_client",
|
||||
@@ -35,4 +42,5 @@ export const userClientChallenge = sqliteTable("user_client_challenge", {
|
||||
answer: text("challenge").notNull().unique(), // Base64
|
||||
allowedIp: text("allowed_ip").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
|
||||
});
|
||||
|
||||
@@ -16,3 +16,17 @@ export const refreshToken = sqliteTable(
|
||||
unq: unique().on(t.userId, t.clientId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tokenUpgradeChallenge = sqliteTable("token_upgrade_challenge", {
|
||||
id: integer("id").primaryKey(),
|
||||
refreshTokenId: text("refresh_token_id")
|
||||
.notNull()
|
||||
.references(() => refreshToken.id),
|
||||
clientId: integer("client_id")
|
||||
.notNull()
|
||||
.references(() => client.id),
|
||||
answer: text("challenge").notNull().unique(), // Base64
|
||||
allowedIp: text("allowed_ip").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import { eq, lte } from "drizzle-orm";
|
||||
import { and, eq, gt, lte } from "drizzle-orm";
|
||||
import ms from "ms";
|
||||
import env from "$lib/server/loadenv";
|
||||
import db from "./drizzle";
|
||||
import { refreshToken } from "./schema";
|
||||
import { refreshToken, tokenUpgradeChallenge } from "./schema";
|
||||
|
||||
const expiresIn = ms(env.jwt.refreshExp);
|
||||
const expiresAt = () => new Date(Date.now() + expiresIn);
|
||||
@@ -38,6 +38,10 @@ export const getRefreshToken = async (tokenId: string) => {
|
||||
};
|
||||
|
||||
export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(tokenUpgradeChallenge)
|
||||
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
|
||||
const res = await db
|
||||
.update(refreshToken)
|
||||
.set({
|
||||
@@ -47,6 +51,7 @@ export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string)
|
||||
.where(eq(refreshToken.id, oldTokenId))
|
||||
.execute();
|
||||
return res.changes > 0;
|
||||
});
|
||||
};
|
||||
|
||||
export const upgradeRefreshToken = async (
|
||||
@@ -54,7 +59,11 @@ export const upgradeRefreshToken = async (
|
||||
newTokenId: string,
|
||||
clientId: number,
|
||||
) => {
|
||||
const res = await db
|
||||
return await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(tokenUpgradeChallenge)
|
||||
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
|
||||
const res = await tx
|
||||
.update(refreshToken)
|
||||
.set({
|
||||
id: newTokenId,
|
||||
@@ -64,6 +73,7 @@ export const upgradeRefreshToken = async (
|
||||
.where(eq(refreshToken.id, oldTokenId))
|
||||
.execute();
|
||||
return res.changes > 0;
|
||||
});
|
||||
};
|
||||
|
||||
export const revokeRefreshToken = async (tokenId: string) => {
|
||||
@@ -73,3 +83,53 @@ export const revokeRefreshToken = async (tokenId: string) => {
|
||||
export const cleanupExpiredRefreshTokens = async () => {
|
||||
await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date())).execute();
|
||||
};
|
||||
|
||||
export const registerTokenUpgradeChallenge = async (
|
||||
tokenId: string,
|
||||
clientId: number,
|
||||
answer: string,
|
||||
allowedIp: string,
|
||||
expiresAt: Date,
|
||||
) => {
|
||||
await db
|
||||
.insert(tokenUpgradeChallenge)
|
||||
.values({
|
||||
refreshTokenId: tokenId,
|
||||
clientId,
|
||||
answer,
|
||||
allowedIp,
|
||||
expiresAt,
|
||||
})
|
||||
.execute();
|
||||
};
|
||||
|
||||
export const getTokenUpgradeChallenge = async (answer: string, ip: string) => {
|
||||
const challenges = await db
|
||||
.select()
|
||||
.from(tokenUpgradeChallenge)
|
||||
.where(
|
||||
and(
|
||||
eq(tokenUpgradeChallenge.answer, answer),
|
||||
eq(tokenUpgradeChallenge.allowedIp, ip),
|
||||
gt(tokenUpgradeChallenge.expiresAt, new Date()),
|
||||
eq(tokenUpgradeChallenge.isUsed, false),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
return challenges[0] ?? null;
|
||||
};
|
||||
|
||||
export const markTokenUpgradeChallengeAsUsed = async (id: number) => {
|
||||
await db
|
||||
.update(tokenUpgradeChallenge)
|
||||
.set({ isUsed: true })
|
||||
.where(eq(tokenUpgradeChallenge.id, id))
|
||||
.execute();
|
||||
};
|
||||
|
||||
export const cleanupExpiredTokenUpgradeChallenges = async () => {
|
||||
await db
|
||||
.delete(tokenUpgradeChallenge)
|
||||
.where(lte(tokenUpgradeChallenge.expiresAt, new Date()))
|
||||
.execute();
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
refreshExp: env.JWT_REFRESH_TOKEN_EXPIRES || "14d",
|
||||
},
|
||||
challenge: {
|
||||
pubKeyExp: env.PUBKEY_CHALLENGE_EXPIRES || "5m",
|
||||
userClientExp: env.USER_CLIENT_CHALLENGE_EXPIRES || "5m",
|
||||
tokenUpgradeExp: env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m",
|
||||
},
|
||||
};
|
||||
|
||||
36
src/lib/server/modules/crypto.ts
Normal file
36
src/lib/server/modules/crypto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto";
|
||||
import { promisify } from "util";
|
||||
|
||||
const makePubKeyToPem = (pubKey: string) =>
|
||||
`-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`;
|
||||
|
||||
export const verifyPubKey = (pubKey: string) => {
|
||||
const pubKeyPem = makePubKeyToPem(pubKey);
|
||||
const pubKeyObject = createPublicKey(pubKeyPem);
|
||||
return (
|
||||
pubKeyObject.asymmetricKeyType === "rsa" &&
|
||||
pubKeyObject.asymmetricKeyDetails?.modulusLength === 4096
|
||||
);
|
||||
};
|
||||
|
||||
export const encryptAsymmetric = (data: Buffer, encPubKey: string) => {
|
||||
return publicEncrypt({ key: makePubKeyToPem(encPubKey), oaepHash: "sha256" }, data);
|
||||
};
|
||||
|
||||
export const verifySignature = (data: string, signature: string, sigPubKey: string) => {
|
||||
return verify(
|
||||
"rsa-sha256",
|
||||
Buffer.from(data, "base64"),
|
||||
{
|
||||
key: makePubKeyToPem(sigPubKey),
|
||||
padding: constants.RSA_PKCS1_PSS_PADDING,
|
||||
},
|
||||
Buffer.from(signature, "base64"),
|
||||
);
|
||||
};
|
||||
|
||||
export const generateChallenge = async (length: number, encPubKey: string) => {
|
||||
const answer = await promisify(randomBytes)(length);
|
||||
const challenge = encryptAsymmetric(answer, encPubKey);
|
||||
return { answer, challenge };
|
||||
};
|
||||
@@ -1,16 +1,22 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import argon2 from "argon2";
|
||||
import ms from "ms";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getClientByPubKey, getUserClient } from "$lib/server/db/client";
|
||||
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
|
||||
import { getUserByEmail } from "$lib/server/db/user";
|
||||
import env from "$lib/server/loadenv";
|
||||
import {
|
||||
getRefreshToken,
|
||||
registerRefreshToken,
|
||||
rotateRefreshToken,
|
||||
upgradeRefreshToken,
|
||||
revokeRefreshToken,
|
||||
registerTokenUpgradeChallenge,
|
||||
getTokenUpgradeChallenge,
|
||||
markTokenUpgradeChallengeAsUsed,
|
||||
} from "$lib/server/db/token";
|
||||
import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth";
|
||||
import { verifySignature, generateChallenge } from "$lib/server/modules/crypto";
|
||||
|
||||
const verifyPassword = async (hash: string, password: string) => {
|
||||
return await argon2.verify(hash, password);
|
||||
@@ -30,23 +36,15 @@ const issueRefreshToken = async (userId: number, clientId?: number) => {
|
||||
return token;
|
||||
};
|
||||
|
||||
export const login = async (email: string, password: string, pubKey?: string) => {
|
||||
export const login = async (email: string, password: string) => {
|
||||
const user = await getUserByEmail(email);
|
||||
if (!user || !(await verifyPassword(user.password, password))) {
|
||||
error(401, "Invalid email or password");
|
||||
}
|
||||
|
||||
const client = pubKey ? await getClientByPubKey(pubKey) : undefined;
|
||||
const userClient = client ? await getUserClient(user.id, client.id) : undefined;
|
||||
if (client === null) {
|
||||
error(401, "Invalid public key");
|
||||
} else if (client && (!userClient || userClient.state === "challenging")) {
|
||||
error(401, "Unregistered public key");
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: issueAccessToken(user.id, client?.id),
|
||||
refreshToken: await issueRefreshToken(user.id, client?.id),
|
||||
accessToken: issueAccessToken(user.id),
|
||||
refreshToken: await issueRefreshToken(user.id),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -75,7 +73,7 @@ export const logout = async (refreshToken: string) => {
|
||||
await revokeRefreshToken(jti);
|
||||
};
|
||||
|
||||
export const refreshTokens = async (refreshToken: string) => {
|
||||
export const refreshToken = async (refreshToken: string) => {
|
||||
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
|
||||
const newJti = uuidv4();
|
||||
|
||||
@@ -88,20 +86,75 @@ export const refreshTokens = async (refreshToken: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const upgradeTokens = async (refreshToken: string, pubKey: string) => {
|
||||
const expiresIn = ms(env.challenge.tokenUpgradeExp);
|
||||
const expiresAt = () => new Date(Date.now() + expiresIn);
|
||||
|
||||
const createChallenge = async (
|
||||
ip: string,
|
||||
tokenId: string,
|
||||
clientId: number,
|
||||
encPubKey: string,
|
||||
) => {
|
||||
const { answer, challenge } = await generateChallenge(32, encPubKey);
|
||||
await registerTokenUpgradeChallenge(
|
||||
tokenId,
|
||||
clientId,
|
||||
answer.toString("base64"),
|
||||
ip,
|
||||
expiresAt(),
|
||||
);
|
||||
return challenge.toString("base64");
|
||||
};
|
||||
|
||||
export const createTokenUpgradeChallenge = async (
|
||||
refreshToken: string,
|
||||
ip: string,
|
||||
encPubKey: string,
|
||||
sigPubKey: string,
|
||||
) => {
|
||||
const { jti, userId, clientId } = await verifyRefreshToken(refreshToken);
|
||||
if (clientId) {
|
||||
error(403, "Forbidden");
|
||||
}
|
||||
|
||||
const client = await getClientByPubKeys(encPubKey, sigPubKey);
|
||||
const userClient = client ? await getUserClient(userId, client.id) : undefined;
|
||||
if (!client) {
|
||||
error(401, "Invalid public key(s)");
|
||||
} else if (!userClient || userClient.state === "challenging") {
|
||||
error(401, "Unregistered client");
|
||||
}
|
||||
|
||||
return { challenge: await createChallenge(ip, jti, client.id, encPubKey) };
|
||||
};
|
||||
|
||||
export const upgradeToken = async (
|
||||
refreshToken: string,
|
||||
ip: string,
|
||||
answer: string,
|
||||
sigAnswer: string,
|
||||
) => {
|
||||
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
|
||||
if (clientId) {
|
||||
error(403, "Forbidden");
|
||||
}
|
||||
|
||||
const client = await getClientByPubKey(pubKey);
|
||||
const userClient = client ? await getUserClient(userId, client.id) : undefined;
|
||||
if (!client) {
|
||||
error(401, "Invalid public key");
|
||||
} else if (client && (!userClient || userClient.state === "challenging")) {
|
||||
error(401, "Unregistered public key");
|
||||
const challenge = await getTokenUpgradeChallenge(answer, ip);
|
||||
if (!challenge) {
|
||||
error(401, "Invalid challenge answer");
|
||||
} else if (challenge.refreshTokenId !== oldJti) {
|
||||
error(403, "Forbidden");
|
||||
}
|
||||
|
||||
const client = await getClient(challenge.clientId);
|
||||
if (!client) {
|
||||
error(500, "Invalid challenge answer");
|
||||
} else if (!verifySignature(answer, sigAnswer, client.sigPubKey)) {
|
||||
error(401, "Invalid challenge answer signature");
|
||||
}
|
||||
|
||||
await markTokenUpgradeChallengeAsUsed(challenge.id);
|
||||
|
||||
const newJti = uuidv4();
|
||||
if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) {
|
||||
error(500, "Refresh token not found");
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { randomBytes, publicEncrypt, createPublicKey } from "crypto";
|
||||
import ms from "ms";
|
||||
import { promisify } from "util";
|
||||
import {
|
||||
createClient,
|
||||
getClientByPubKey,
|
||||
getClient,
|
||||
getClientByPubKeys,
|
||||
countClientByPubKey,
|
||||
createUserClient,
|
||||
getAllUserClients,
|
||||
getUserClient,
|
||||
createUserClientChallenge,
|
||||
getUserClientChallenge,
|
||||
setUserClientStateToPending,
|
||||
registerUserClientChallenge,
|
||||
getUserClientChallenge,
|
||||
markUserClientChallengeAsUsed,
|
||||
} from "$lib/server/db/client";
|
||||
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
|
||||
import { isInitialMekNeeded } from "$lib/server/modules/mek";
|
||||
import env from "$lib/server/loadenv";
|
||||
|
||||
@@ -25,45 +27,53 @@ export const getUserClientList = async (userId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
const expiresIn = ms(env.challenge.pubKeyExp);
|
||||
const expiresIn = ms(env.challenge.userClientExp);
|
||||
const expiresAt = () => new Date(Date.now() + expiresIn);
|
||||
|
||||
const generateChallenge = async (userId: number, ip: string, clientId: number, pubKey: string) => {
|
||||
const answer = await promisify(randomBytes)(32);
|
||||
const answerBase64 = answer.toString("base64");
|
||||
await createUserClientChallenge(userId, clientId, answerBase64, ip, expiresAt());
|
||||
|
||||
const pubKeyPem = `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`;
|
||||
const challenge = publicEncrypt({ key: pubKeyPem, oaepHash: "sha256" }, answer);
|
||||
const createUserClientChallenge = async (
|
||||
userId: number,
|
||||
ip: string,
|
||||
clientId: number,
|
||||
encPubKey: string,
|
||||
) => {
|
||||
const { answer, challenge } = await generateChallenge(32, encPubKey);
|
||||
await registerUserClientChallenge(userId, clientId, answer.toString("base64"), ip, expiresAt());
|
||||
return challenge.toString("base64");
|
||||
};
|
||||
|
||||
export const registerUserClient = async (userId: number, ip: string, pubKey: string) => {
|
||||
const client = await getClientByPubKey(pubKey);
|
||||
export const registerUserClient = async (
|
||||
userId: number,
|
||||
ip: string,
|
||||
encPubKey: string,
|
||||
sigPubKey: string,
|
||||
) => {
|
||||
let clientId;
|
||||
|
||||
const client = await getClientByPubKeys(encPubKey, sigPubKey);
|
||||
if (client) {
|
||||
const userClient = await getUserClient(userId, client.id);
|
||||
if (userClient) {
|
||||
error(409, "Public key already registered");
|
||||
error(409, "Client already registered");
|
||||
}
|
||||
|
||||
await createUserClient(userId, client.id);
|
||||
clientId = client.id;
|
||||
} else {
|
||||
const pubKeyPem = `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`;
|
||||
const pubKeyObject = createPublicKey(pubKeyPem);
|
||||
if (
|
||||
pubKeyObject.asymmetricKeyType !== "rsa" ||
|
||||
pubKeyObject.asymmetricKeyDetails?.modulusLength !== 4096
|
||||
if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
|
||||
error(400, "Invalid public key(s)");
|
||||
} else if (encPubKey === sigPubKey) {
|
||||
error(400, "Public keys must be different");
|
||||
} else if (
|
||||
(await countClientByPubKey(encPubKey)) > 0 ||
|
||||
(await countClientByPubKey(sigPubKey)) > 0
|
||||
) {
|
||||
error(400, "Invalid public key");
|
||||
error(409, "Public key(s) already registered");
|
||||
}
|
||||
|
||||
clientId = await createClient(pubKey, userId);
|
||||
clientId = await createClient(encPubKey, sigPubKey, userId);
|
||||
}
|
||||
|
||||
return await generateChallenge(userId, ip, clientId, pubKey);
|
||||
return { challenge: await createUserClientChallenge(userId, ip, clientId, encPubKey) };
|
||||
};
|
||||
|
||||
export const getUserClientStatus = async (userId: number, clientId: number) => {
|
||||
@@ -78,7 +88,12 @@ export const getUserClientStatus = async (userId: number, clientId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyUserClient = async (userId: number, ip: string, answer: string) => {
|
||||
export const verifyUserClient = async (
|
||||
userId: number,
|
||||
ip: string,
|
||||
answer: string,
|
||||
sigAnswer: string,
|
||||
) => {
|
||||
const challenge = await getUserClientChallenge(answer, ip);
|
||||
if (!challenge) {
|
||||
error(401, "Invalid challenge answer");
|
||||
@@ -86,5 +101,13 @@ export const verifyUserClient = async (userId: number, ip: string, answer: strin
|
||||
error(403, "Forbidden");
|
||||
}
|
||||
|
||||
const client = await getClient(challenge.clientId);
|
||||
if (!client) {
|
||||
error(500, "Invalid challenge answer");
|
||||
} else if (!verifySignature(answer, sigAnswer, client.sigPubKey)) {
|
||||
error(401, "Invalid challenge answer signature");
|
||||
}
|
||||
|
||||
await markUserClientChallengeAsUsed(challenge.id);
|
||||
await setUserClientStateToPending(userId, challenge.clientId);
|
||||
};
|
||||
|
||||
41
src/lib/services/auth.ts
Normal file
41
src/lib/services/auth.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
encodeToBase64,
|
||||
decodeFromBase64,
|
||||
decryptRSACiphertext,
|
||||
signRSAMessage,
|
||||
} from "$lib/modules/crypto";
|
||||
|
||||
export const requestTokenUpgrade = async (
|
||||
encryptKeyBase64: string,
|
||||
decryptKey: CryptoKey,
|
||||
verifyKeyBase64: string,
|
||||
signKey: CryptoKey,
|
||||
) => {
|
||||
let res = await fetch("/api/auth/upgradeToken", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
encPubKey: encryptKeyBase64,
|
||||
sigPubKey: verifyKeyBase64,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
|
||||
const { challenge } = await res.json();
|
||||
const answer = await decryptRSACiphertext(decodeFromBase64(challenge), decryptKey);
|
||||
const sigAnswer = await signRSAMessage(answer, signKey);
|
||||
|
||||
res = await fetch("/api/auth/upgradeToken/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
answer: encodeToBase64(answer),
|
||||
sigAnswer: encodeToBase64(sigAnswer),
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
42
src/lib/services/key.ts
Normal file
42
src/lib/services/key.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { callAPI } from "$lib/hooks";
|
||||
import {
|
||||
encodeToBase64,
|
||||
decodeFromBase64,
|
||||
decryptRSACiphertext,
|
||||
signRSAMessage,
|
||||
} from "$lib/modules/crypto";
|
||||
|
||||
export const requestClientRegistration = async (
|
||||
encryptKeyBase64: string,
|
||||
decryptKey: CryptoKey,
|
||||
verifyKeyBase64: string,
|
||||
signKey: CryptoKey,
|
||||
) => {
|
||||
let res = await callAPI("/api/client/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
encPubKey: encryptKeyBase64,
|
||||
sigPubKey: verifyKeyBase64,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
|
||||
const { challenge } = await res.json();
|
||||
const answer = await decryptRSACiphertext(decodeFromBase64(challenge), decryptKey);
|
||||
const sigAnswer = await signRSAMessage(answer, signKey);
|
||||
|
||||
res = await callAPI("/api/client/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
answer: encodeToBase64(answer),
|
||||
sigAnswer: encodeToBase64(sigAnswer),
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
@@ -1,4 +1,11 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const keyPairStore = writable<CryptoKeyPair | null>(null);
|
||||
export interface ClientKeys {
|
||||
encryptKey: CryptoKey;
|
||||
decryptKey: CryptoKey;
|
||||
signKey: CryptoKey;
|
||||
verifyKey: CryptoKey;
|
||||
}
|
||||
|
||||
export const clientKeyStore = writable<ClientKeys | null>(null);
|
||||
export const mekStore = writable<Map<number, CryptoKey>>(new Map());
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
||||
import { TextInput } from "$lib/components/inputs";
|
||||
import { refreshToken } from "$lib/hooks/callAPI";
|
||||
import { keyPairStore } from "$lib/stores";
|
||||
import { requestLogin } from "./service";
|
||||
import { clientKeyStore } from "$lib/stores";
|
||||
import { requestLogin, requestTokenUpgrade } from "./service";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -16,14 +16,20 @@
|
||||
const login = async () => {
|
||||
// TODO: Validation
|
||||
|
||||
if (await requestLogin(email, password, $keyPairStore)) {
|
||||
try {
|
||||
if (!(await requestLogin(email, password))) throw new Error("Failed to login");
|
||||
|
||||
if ($clientKeyStore && !(await requestTokenUpgrade($clientKeyStore)))
|
||||
throw new Error("Failed to upgrade token");
|
||||
|
||||
await goto(
|
||||
$keyPairStore
|
||||
$clientKeyStore
|
||||
? data.redirectPath
|
||||
: "/key/generate?redirect=" + encodeURIComponent(data.redirectPath),
|
||||
);
|
||||
} else {
|
||||
} catch (e) {
|
||||
// TODO: Alert
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,48 +1,38 @@
|
||||
import { encodeToBase64, exportRSAKey } from "$lib/modules/crypto";
|
||||
import { requestPubKeyRegistration } from "../../key/export/service";
|
||||
import { exportRSAKeyToBase64 } from "$lib/modules/crypto";
|
||||
import { requestTokenUpgrade as requestTokenUpgradeInternal } from "$lib/services/auth";
|
||||
import { requestClientRegistration } from "$lib/services/key";
|
||||
import type { ClientKeys } from "$lib/stores";
|
||||
|
||||
const callLoginAPI = async (email: string, password: string, pubKeyBase64?: string) => {
|
||||
return await fetch("/api/auth/login", {
|
||||
export const requestLogin = async (email: string, password: string) => {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
pubKey: pubKeyBase64,
|
||||
}),
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
|
||||
export const requestLogin = async (
|
||||
email: string,
|
||||
password: string,
|
||||
keyPair: CryptoKeyPair | null,
|
||||
registerPubKey = true,
|
||||
): Promise<boolean> => {
|
||||
const pubKeyBase64 = keyPair
|
||||
? encodeToBase64((await exportRSAKey(keyPair.publicKey, "public")).key)
|
||||
: undefined;
|
||||
let loginRes = await callLoginAPI(email, password, pubKeyBase64);
|
||||
if (loginRes.ok) {
|
||||
export const requestTokenUpgrade = async ({
|
||||
encryptKey,
|
||||
decryptKey,
|
||||
signKey,
|
||||
verifyKey,
|
||||
}: ClientKeys) => {
|
||||
const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey);
|
||||
const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey);
|
||||
if (await requestTokenUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
|
||||
return true;
|
||||
} else if (loginRes.status !== 401 || !keyPair || !registerPubKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { message } = await loginRes.json();
|
||||
if (message !== "Unregistered public key") {
|
||||
return false;
|
||||
}
|
||||
|
||||
loginRes = await callLoginAPI(email, password);
|
||||
if (!loginRes.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await requestPubKeyRegistration(pubKeyBase64!, keyPair.privateKey)) {
|
||||
return requestLogin(email, password, keyPair, false);
|
||||
if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
|
||||
return await requestTokenUpgradeInternal(
|
||||
encryptKeyBase64,
|
||||
decryptKey,
|
||||
verifyKeyBase64,
|
||||
signKey,
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { Button, TextButton } from "$lib/components/buttons";
|
||||
import { BottomDiv } from "$lib/components/divs";
|
||||
import { keyPairStore } from "$lib/stores";
|
||||
import { clientKeyStore } from "$lib/stores";
|
||||
import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte";
|
||||
import BeforeContinueModal from "./BeforeContinueModal.svelte";
|
||||
import {
|
||||
createBlobFromKeyPairBase64,
|
||||
requestPubKeyRegistration,
|
||||
storeKeyPairPersistently,
|
||||
exportClientKeys,
|
||||
requestClientRegistration,
|
||||
storeClientKeys,
|
||||
requestTokenUpgrade,
|
||||
requestInitialMekRegistration,
|
||||
} from "./service";
|
||||
@@ -22,8 +22,16 @@
|
||||
let isBeforeContinueBottomSheetOpen = $state(false);
|
||||
|
||||
const exportKeyPair = () => {
|
||||
const keyPairBlob = createBlobFromKeyPairBase64(data.pubKeyBase64, data.privKeyBase64);
|
||||
saveAs(keyPairBlob, "arkvalut-keypair.pem");
|
||||
const clientKeysExported = exportClientKeys(
|
||||
data.encryptKeyBase64,
|
||||
data.decryptKeyBase64,
|
||||
data.signKeyBase64,
|
||||
data.verifyKeyBase64,
|
||||
);
|
||||
const clientKeysBlob = new Blob([JSON.stringify(clientKeysExported)], {
|
||||
type: "application/json",
|
||||
});
|
||||
saveAs(clientKeysBlob, "arkvalut-clientkey.json");
|
||||
|
||||
if (!isBeforeContinueBottomSheetOpen) {
|
||||
setTimeout(() => {
|
||||
@@ -33,7 +41,7 @@
|
||||
};
|
||||
|
||||
const registerPubKey = async () => {
|
||||
if (!$keyPairStore) {
|
||||
if (!$clientKeyStore) {
|
||||
throw new Error("Failed to find key pair");
|
||||
}
|
||||
|
||||
@@ -41,15 +49,29 @@
|
||||
isBeforeContinueBottomSheetOpen = false;
|
||||
|
||||
try {
|
||||
if (!(await requestPubKeyRegistration(data.pubKeyBase64, $keyPairStore.privateKey)))
|
||||
throw new Error("Failed to register public key");
|
||||
if (
|
||||
!(await requestClientRegistration(
|
||||
data.encryptKeyBase64,
|
||||
$clientKeyStore.decryptKey,
|
||||
data.verifyKeyBase64,
|
||||
$clientKeyStore.signKey,
|
||||
))
|
||||
)
|
||||
throw new Error("Failed to register client");
|
||||
|
||||
await storeKeyPairPersistently($keyPairStore);
|
||||
await storeClientKeys($clientKeyStore);
|
||||
|
||||
if (!(await requestTokenUpgrade(data.pubKeyBase64)))
|
||||
if (
|
||||
!(await requestTokenUpgrade(
|
||||
data.encryptKeyBase64,
|
||||
$clientKeyStore.decryptKey,
|
||||
data.verifyKeyBase64,
|
||||
$clientKeyStore.signKey,
|
||||
))
|
||||
)
|
||||
throw new Error("Failed to upgrade token");
|
||||
|
||||
if (!(await requestInitialMekRegistration(data.mekDraft, $keyPairStore.publicKey)))
|
||||
if (!(await requestInitialMekRegistration(data.mekDraft, $clientKeyStore.encryptKey)))
|
||||
throw new Error("Failed to register initial MEK");
|
||||
|
||||
await goto(data.redirectPath);
|
||||
|
||||
@@ -1,68 +1,51 @@
|
||||
import { callAPI } from "$lib/hooks";
|
||||
import { storeKeyPairIntoIndexedDB } from "$lib/indexedDB";
|
||||
import {
|
||||
encodeToBase64,
|
||||
decodeFromBase64,
|
||||
encryptRSAPlaintext,
|
||||
decryptRSACiphertext,
|
||||
} from "$lib/modules/crypto";
|
||||
import { storeRSAKey } from "$lib/indexedDB";
|
||||
import { encodeToBase64, encryptRSAPlaintext } from "$lib/modules/crypto";
|
||||
import type { ClientKeys } from "$lib/stores";
|
||||
|
||||
export const createBlobFromKeyPairBase64 = (pubKeyBase64: string, privKeyBase64: string) => {
|
||||
const pubKeyFormatted = pubKeyBase64.match(/.{1,64}/g)?.join("\n");
|
||||
const privKeyFormatted = privKeyBase64.match(/.{1,64}/g)?.join("\n");
|
||||
if (!pubKeyFormatted || !privKeyFormatted) {
|
||||
throw new Error("Failed to format key pair");
|
||||
}
|
||||
export { requestTokenUpgrade } from "$lib/services/auth";
|
||||
export { requestClientRegistration } from "$lib/services/key";
|
||||
|
||||
const pubKeyPem = `-----BEGIN RSA PUBLIC KEY-----\n${pubKeyFormatted}\n-----END RSA PUBLIC KEY-----`;
|
||||
const privKeyPem = `-----BEGIN RSA PRIVATE KEY-----\n${privKeyFormatted}\n-----END RSA PRIVATE KEY-----`;
|
||||
return new Blob([`${pubKeyPem}\n${privKeyPem}\n`], { type: "text/plain" });
|
||||
type ExportedKeyPairs = {
|
||||
generator: "ArkVault";
|
||||
exportedAt: Date;
|
||||
} & {
|
||||
version: 1;
|
||||
encryptKey: string;
|
||||
decryptKey: string;
|
||||
signKey: string;
|
||||
verifyKey: string;
|
||||
};
|
||||
|
||||
export const requestPubKeyRegistration = async (pubKeyBase64: string, privateKey: CryptoKey) => {
|
||||
let res = await callAPI("/api/client/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ pubKey: pubKeyBase64 }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
|
||||
const data = await res.json();
|
||||
const challenge = data.challenge as string;
|
||||
const answer = await decryptRSACiphertext(decodeFromBase64(challenge), privateKey);
|
||||
|
||||
res = await callAPI("/api/client/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ answer: encodeToBase64(answer) }),
|
||||
});
|
||||
return res.ok;
|
||||
export const exportClientKeys = (
|
||||
encryptKeyBase64: string,
|
||||
decryptKeyBase64: string,
|
||||
signKeyBase64: string,
|
||||
verifyKeyBase64: string,
|
||||
) => {
|
||||
return {
|
||||
version: 1,
|
||||
generator: "ArkVault",
|
||||
exportedAt: new Date(),
|
||||
encryptKey: encryptKeyBase64,
|
||||
decryptKey: decryptKeyBase64,
|
||||
signKey: signKeyBase64,
|
||||
verifyKey: verifyKeyBase64,
|
||||
} satisfies ExportedKeyPairs;
|
||||
};
|
||||
|
||||
export const storeKeyPairPersistently = async (keyPair: CryptoKeyPair) => {
|
||||
await storeKeyPairIntoIndexedDB(keyPair.publicKey, keyPair.privateKey);
|
||||
};
|
||||
|
||||
export const requestTokenUpgrade = async (pubKeyBase64: string) => {
|
||||
const res = await fetch("/api/auth/upgradeToken", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ pubKey: pubKeyBase64 }),
|
||||
});
|
||||
return res.ok;
|
||||
export const storeClientKeys = async (clientKeys: ClientKeys) => {
|
||||
await storeRSAKey(clientKeys.encryptKey, "encrypt");
|
||||
await storeRSAKey(clientKeys.decryptKey, "decrypt");
|
||||
await storeRSAKey(clientKeys.signKey, "sign");
|
||||
await storeRSAKey(clientKeys.verifyKey, "verify");
|
||||
};
|
||||
|
||||
export const requestInitialMekRegistration = async (
|
||||
mekDraft: ArrayBuffer,
|
||||
publicKey: CryptoKey,
|
||||
encryptKey: CryptoKey,
|
||||
) => {
|
||||
const mekDraftEncrypted = await encryptRSAPlaintext(mekDraft, publicKey);
|
||||
const mekDraftEncrypted = await encryptRSAPlaintext(mekDraft, encryptKey);
|
||||
const res = await callAPI("/api/mek/register/initial", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
import { Button, TextButton } from "$lib/components/buttons";
|
||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
||||
import { gotoStateful } from "$lib/hooks";
|
||||
import { keyPairStore } from "$lib/stores";
|
||||
import { clientKeyStore } from "$lib/stores";
|
||||
import Order from "./Order.svelte";
|
||||
import { generateKeyPair, generateMekDraft } from "./service";
|
||||
import { generateClientKeys, generateMekDraft } from "./service";
|
||||
|
||||
import IconKey from "~icons/material-symbols/key";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// TODO: Update
|
||||
const orders = [
|
||||
{
|
||||
title: "암호 키는 공개 키와 개인 키로 구성돼요.",
|
||||
@@ -33,19 +34,18 @@
|
||||
const generate = async () => {
|
||||
// TODO: Loading indicator
|
||||
|
||||
const { pubKeyBase64, privKeyBase64 } = await generateKeyPair();
|
||||
const clientKeys = await generateClientKeys();
|
||||
const { mekDraft } = await generateMekDraft();
|
||||
|
||||
await gotoStateful("/key/export", {
|
||||
...clientKeys,
|
||||
redirectPath: data.redirectPath,
|
||||
pubKeyBase64,
|
||||
privKeyBase64,
|
||||
mekDraft,
|
||||
});
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if ($keyPairStore) {
|
||||
if ($clientKeyStore) {
|
||||
goto(data.redirectPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import {
|
||||
encodeToBase64,
|
||||
generateRSAKeyPair,
|
||||
makeRSAKeyNonextractable,
|
||||
exportRSAKey,
|
||||
exportRSAKeyToBase64,
|
||||
generateAESKey,
|
||||
makeAESKeyNonextractable,
|
||||
exportAESKey,
|
||||
} from "$lib/modules/crypto";
|
||||
import { keyPairStore, mekStore } from "$lib/stores";
|
||||
import { clientKeyStore, mekStore } from "$lib/stores";
|
||||
|
||||
export const generateKeyPair = async () => {
|
||||
const keyPair = await generateRSAKeyPair();
|
||||
const privKeySecured = await makeRSAKeyNonextractable(keyPair.privateKey, "private");
|
||||
export const generateClientKeys = async () => {
|
||||
const encKeyPair = await generateRSAKeyPair("encryption");
|
||||
const sigKeyPair = await generateRSAKeyPair("signature");
|
||||
|
||||
keyPairStore.set({
|
||||
publicKey: keyPair.publicKey,
|
||||
privateKey: privKeySecured,
|
||||
clientKeyStore.set({
|
||||
encryptKey: encKeyPair.publicKey,
|
||||
decryptKey: await makeRSAKeyNonextractable(encKeyPair.privateKey),
|
||||
signKey: await makeRSAKeyNonextractable(sigKeyPair.privateKey),
|
||||
verifyKey: sigKeyPair.publicKey,
|
||||
});
|
||||
|
||||
return {
|
||||
pubKeyBase64: encodeToBase64((await exportRSAKey(keyPair.publicKey, "public")).key),
|
||||
privKeyBase64: encodeToBase64((await exportRSAKey(keyPair.privateKey, "private")).key),
|
||||
encryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.publicKey),
|
||||
decryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.privateKey),
|
||||
signKeyBase64: await exportRSAKeyToBase64(sigKeyPair.privateKey),
|
||||
verifyKeyBase64: await exportRSAKeyToBase64(sigKeyPair.publicKey),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,7 +32,7 @@ export const generateMekDraft = async () => {
|
||||
const mekSecured = await makeAESKeyNonextractable(mek);
|
||||
|
||||
mekStore.update((meks) => {
|
||||
meks.set(meks.size, mekSecured);
|
||||
meks.set(0, mekSecured);
|
||||
return meks;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getKeyPairFromIndexedDB } from "$lib/indexedDB";
|
||||
import { keyPairStore } from "$lib/stores";
|
||||
import "../app.css";
|
||||
import { prepareClientKeyStore } from "./services";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(async () => {
|
||||
const { pubKey, privKey } = await getKeyPairFromIndexedDB();
|
||||
if (pubKey && privKey) {
|
||||
keyPairStore.set({ publicKey: pubKey, privateKey: privKey });
|
||||
} else if (!["/auth", "/key/generate"].some((path) => location.pathname.startsWith(path))) {
|
||||
onMount(() => {
|
||||
prepareClientKeyStore().then(async (ok) => {
|
||||
if (!ok && !["/auth", "/key"].some((path) => location.pathname.startsWith(path))) {
|
||||
await goto(
|
||||
"/key/generate?redirect=" + encodeURIComponent(location.pathname + location.search),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -10,14 +10,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
.object({
|
||||
email: z.string().email().nonempty(),
|
||||
password: z.string().nonempty(),
|
||||
pubKey: z.string().base64().nonempty().optional(),
|
||||
})
|
||||
.safeParse(await request.json());
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const { email, password } = zodRes.data;
|
||||
|
||||
const { email, password, pubKey } = zodRes.data;
|
||||
const { accessToken, refreshToken } = await login(email.trim(), password.trim(), pubKey?.trim());
|
||||
|
||||
const { accessToken, refreshToken } = await login(email.trim(), password.trim());
|
||||
cookies.set("accessToken", accessToken, {
|
||||
path: "/",
|
||||
maxAge: Math.floor(ms(env.jwt.accessExp) / 1000),
|
||||
@@ -28,5 +26,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
maxAge: Math.floor(ms(env.jwt.refreshExp) / 1000),
|
||||
sameSite: "strict",
|
||||
});
|
||||
|
||||
return text("Logged in", { headers: { "Content-Type": "text/plain" } });
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ export const POST: RequestHandler = async ({ cookies }) => {
|
||||
if (!token) error(401, "Refresh token not found");
|
||||
|
||||
await logout(token.trim());
|
||||
|
||||
cookies.delete("accessToken", { path: "/" });
|
||||
cookies.delete("refreshToken", { path: "/api/auth" });
|
||||
|
||||
return text("Logged out", { headers: { "Content-Type": "text/plain" } });
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { refreshTokens } from "$lib/server/services/auth";
|
||||
import { refreshToken as doRefreshToken } from "$lib/server/services/auth";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
const token = cookies.get("refreshToken");
|
||||
if (!token) error(401, "Refresh token not found");
|
||||
|
||||
const { accessToken, refreshToken } = await refreshTokens(token.trim());
|
||||
|
||||
const { accessToken, refreshToken } = await doRefreshToken(token.trim());
|
||||
cookies.set("accessToken", accessToken, {
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
@@ -16,5 +15,6 @@ export const POST: RequestHandler = async ({ cookies }) => {
|
||||
path: "/api/auth",
|
||||
sameSite: "strict",
|
||||
});
|
||||
|
||||
return text("Token refreshed", { headers: { "Content-Type": "text/plain" } });
|
||||
};
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { upgradeTokens } from "$lib/server/services/auth";
|
||||
import { createTokenUpgradeChallenge } from "$lib/server/services/auth";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
||||
const token = cookies.get("refreshToken");
|
||||
if (!token) error(401, "Refresh token not found");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
pubKey: z.string().base64().nonempty(),
|
||||
encPubKey: z.string().base64().nonempty(),
|
||||
sigPubKey: z.string().base64().nonempty(),
|
||||
})
|
||||
.safeParse(await request.json());
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const { encPubKey, sigPubKey } = zodRes.data;
|
||||
|
||||
const { pubKey } = zodRes.data;
|
||||
const { accessToken, refreshToken } = await upgradeTokens(token.trim(), pubKey.trim());
|
||||
|
||||
cookies.set("accessToken", accessToken, {
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
cookies.set("refreshToken", refreshToken, {
|
||||
path: "/api/auth",
|
||||
sameSite: "strict",
|
||||
});
|
||||
return text("Token upgraded", { headers: { "Content-Type": "text/plain" } });
|
||||
const { challenge } = await createTokenUpgradeChallenge(
|
||||
token.trim(),
|
||||
getClientAddress(),
|
||||
encPubKey.trim(),
|
||||
sigPubKey.trim(),
|
||||
);
|
||||
return json({ challenge });
|
||||
};
|
||||
|
||||
35
src/routes/api/auth/upgradeToken/verify/+server.ts
Normal file
35
src/routes/api/auth/upgradeToken/verify/+server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { upgradeToken } from "$lib/server/services/auth";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
||||
const token = cookies.get("refreshToken");
|
||||
if (!token) error(401, "Refresh token not found");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
answer: z.string().base64().nonempty(),
|
||||
sigAnswer: z.string().base64().nonempty(),
|
||||
})
|
||||
.safeParse(await request.json());
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const { answer, sigAnswer } = zodRes.data;
|
||||
|
||||
const { accessToken, refreshToken } = await upgradeToken(
|
||||
token.trim(),
|
||||
getClientAddress(),
|
||||
answer.trim(),
|
||||
sigAnswer.trim(),
|
||||
);
|
||||
cookies.set("accessToken", accessToken, {
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
cookies.set("refreshToken", refreshToken, {
|
||||
path: "/api/auth",
|
||||
sameSite: "strict",
|
||||
});
|
||||
|
||||
return text("Token upgraded", { headers: { "Content-Type": "text/plain" } });
|
||||
};
|
||||
@@ -12,12 +12,18 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
pubKey: z.string().base64().nonempty(),
|
||||
encPubKey: z.string().base64().nonempty(),
|
||||
sigPubKey: z.string().base64().nonempty(),
|
||||
})
|
||||
.safeParse(await request.json());
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const { pubKey } = zodRes.data;
|
||||
const { encPubKey, sigPubKey } = zodRes.data;
|
||||
|
||||
const challenge = await registerUserClient(userId, getClientAddress(), pubKey.trim());
|
||||
const { challenge } = await registerUserClient(
|
||||
userId,
|
||||
getClientAddress(),
|
||||
encPubKey.trim(),
|
||||
sigPubKey.trim(),
|
||||
);
|
||||
return json({ challenge });
|
||||
};
|
||||
|
||||
@@ -13,11 +13,12 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
const zodRes = z
|
||||
.object({
|
||||
answer: z.string().base64().nonempty(),
|
||||
sigAnswer: z.string().base64().nonempty(),
|
||||
})
|
||||
.safeParse(await request.json());
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const { answer } = zodRes.data;
|
||||
const { answer, sigAnswer } = zodRes.data;
|
||||
|
||||
await verifyUserClient(userId, getClientAddress(), answer.trim());
|
||||
await verifyUserClient(userId, getClientAddress(), answer.trim(), sigAnswer.trim());
|
||||
return text("Client verified", { headers: { "Content-Type": "text/plain" } });
|
||||
};
|
||||
|
||||
15
src/routes/services.ts
Normal file
15
src/routes/services.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getRSAKey } from "$lib/indexedDB";
|
||||
import { clientKeyStore } from "$lib/stores";
|
||||
|
||||
export const prepareClientKeyStore = async () => {
|
||||
const encryptKey = await getRSAKey("encrypt");
|
||||
const decryptKey = await getRSAKey("decrypt");
|
||||
const signKey = await getRSAKey("sign");
|
||||
const verifyKey = await getRSAKey("verify");
|
||||
if (encryptKey && decryptKey && signKey && verifyKey) {
|
||||
clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey });
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user