Merge pull request #1 from kmc7468/add-client-signature-keypair

클라이언트 서명 시스템 도입
This commit is contained in:
static
2024-12-31 06:53:49 +09:00
committed by GitHub
36 changed files with 1331 additions and 284 deletions

View File

@@ -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=

View File

@@ -2,3 +2,6 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
# Output
/drizzle

View File

@@ -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:-}

View 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`);

View 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": {}
}
}

View File

@@ -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
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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",
},
};

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

View File

@@ -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");

View File

@@ -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
View 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
View 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;
};

View File

@@ -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());

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

@@ -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()}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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
View 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;
}
};