From 57fcfbc9b62922d0f44a84c3ce90ab2da93c1b93 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Thu, 27 Jun 2024 07:42:13 +1000 Subject: [PATCH] Use scrypt for api keys (#33) --- .../migration.sql | 8 +- apps/web/prisma/schema.prisma | 5 +- .../app/(dashboard)/api-keys/add-api-key.tsx | 98 +++++++++++++------ .../(dashboard)/api-keys/delete-api-key.tsx | 95 +++++++++++++----- apps/web/src/server/auth.ts | 17 +--- apps/web/src/server/crypto.ts | 19 ++++ apps/web/src/server/nanoid.ts | 6 ++ apps/web/src/server/public-api/auth.ts | 23 +++-- apps/web/src/server/service/api-service.ts | 55 ++++++----- .../server/service/ses-settings-service.ts | 6 +- 10 files changed, 219 insertions(+), 113 deletions(-) rename apps/web/prisma/migrations/{20240619211232_init => 20240626213358_init}/migration.sql (94%) create mode 100644 apps/web/src/server/crypto.ts create mode 100644 apps/web/src/server/nanoid.ts diff --git a/apps/web/prisma/migrations/20240619211232_init/migration.sql b/apps/web/prisma/migrations/20240626213358_init/migration.sql similarity index 94% rename from apps/web/prisma/migrations/20240619211232_init/migration.sql rename to apps/web/prisma/migrations/20240626213358_init/migration.sql index 0bdddae..aac4e12 100644 --- a/apps/web/prisma/migrations/20240619211232_init/migration.sql +++ b/apps/web/prisma/migrations/20240626213358_init/migration.sql @@ -8,7 +8,7 @@ CREATE TYPE "DomainStatus" AS ENUM ('NOT_STARTED', 'PENDING', 'SUCCESS', 'FAILED CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING'); -- CreateEnum -CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED', 'FAILED'); +CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'DELIVERY_DELAYED', 'BOUNCED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERED', 'OPENED', 'CLICKED', 'COMPLAINED', 'FAILED'); -- CreateTable CREATE TABLE "AppSetting" ( @@ -132,6 +132,7 @@ CREATE TABLE "Domain" ( -- CreateTable CREATE TABLE "ApiKey" ( "id" SERIAL NOT NULL, + "clientId" TEXT NOT NULL, "tokenHash" TEXT NOT NULL, "partialToken" TEXT NOT NULL, "name" TEXT NOT NULL, @@ -180,6 +181,9 @@ CREATE TABLE "EmailEvent" ( -- CreateIndex CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region"); +-- CreateIndex +CREATE UNIQUE INDEX "SesSetting_idPrefix_key" ON "SesSetting"("idPrefix"); + -- CreateIndex CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); @@ -202,7 +206,7 @@ CREATE UNIQUE INDEX "TeamUser_teamId_userId_key" ON "TeamUser"("teamId", "userId CREATE UNIQUE INDEX "Domain_name_key" ON "Domain"("name"); -- CreateIndex -CREATE UNIQUE INDEX "ApiKey_tokenHash_key" ON "ApiKey"("tokenHash"); +CREATE UNIQUE INDEX "ApiKey_clientId_key" ON "ApiKey"("clientId"); -- CreateIndex CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 0a43f3f..10a32d6 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -22,7 +22,7 @@ model AppSetting { model SesSetting { id String @id @default(cuid()) region String @unique - idPrefix String + idPrefix String @unique topic String topicArn String? callbackUrl String @@ -149,7 +149,8 @@ enum ApiPermission { model ApiKey { id Int @id @default(autoincrement()) - tokenHash String @unique + clientId String @unique + tokenHash String partialToken String name String permission ApiPermission @default(SENDING) diff --git a/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx b/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx index b347029..9c8e236 100644 --- a/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx +++ b/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx @@ -16,27 +16,52 @@ import { api } from "~/trpc/react"; import { useState } from "react"; import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react"; import { toast } from "@unsend/ui/src/toaster"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@unsend/ui/src/form"; + +const apiKeySchema = z.object({ + name: z.string({ required_error: "Name is required" }).min(1, { + message: "Name is required", + }), +}); export default function AddApiKey() { const [open, setOpen] = useState(false); - const [name, setName] = useState(""); const [apiKey, setApiKey] = useState(""); - const addDomainMutation = api.apiKey.createToken.useMutation(); + const createApiKeyMutation = api.apiKey.createToken.useMutation(); const [isCopied, setIsCopied] = useState(false); const [showApiKey, setShowApiKey] = useState(false); const utils = api.useUtils(); - function handleSave() { - addDomainMutation.mutate( + const apiKeyForm = useForm>({ + resolver: zodResolver(apiKeySchema), + defaultValues: { + name: "", + }, + }); + + function handleSave(values: z.infer) { + createApiKeyMutation.mutate( { - name, + name: values.name, permission: "FULL", }, { onSuccess: (data) => { utils.apiKey.invalidate(); setApiKey(data); + apiKeyForm.reset(); }, } ); @@ -53,8 +78,8 @@ export default function AddApiKey() { function copyAndClose() { handleCopy(); setApiKey(""); - setName(""); setOpen(false); + setShowApiKey(false); toast.success("API key copied to clipboard"); } @@ -70,7 +95,7 @@ export default function AddApiKey() { {apiKey ? ( - + Copy API key @@ -80,7 +105,7 @@ export default function AddApiKey() {

{apiKey}

) : (
- {Array.from({ length: 30 }).map((_, index) => ( + {Array.from({ length: 40 }).map((_, index) => (
Close @@ -132,27 +157,42 @@ export default function AddApiKey() { Create a new API key
- - setName(e.target.value)} - value={name} - /> +
+ + ( + + API key name + + + + {formState.errors.name ? ( + + ) : ( + + Use a name to easily identify this API key. + + )} + + )} + /> +
+ +
+ +
- - - )} diff --git a/apps/web/src/app/(dashboard)/api-keys/delete-api-key.tsx b/apps/web/src/app/(dashboard)/api-keys/delete-api-key.tsx index 456a35b..4a66aaa 100644 --- a/apps/web/src/app/(dashboard)/api-keys/delete-api-key.tsx +++ b/apps/web/src/app/(dashboard)/api-keys/delete-api-key.tsx @@ -2,32 +2,56 @@ import { Button } from "@unsend/ui/src/button"; import { Input } from "@unsend/ui/src/input"; -import { Label } from "@unsend/ui/src/label"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@unsend/ui/src/dialog"; import { api } from "~/trpc/react"; import React, { useState } from "react"; -import { ApiKey, Domain } from "@prisma/client"; +import { ApiKey } from "@prisma/client"; import { toast } from "@unsend/ui/src/toaster"; import { Trash2 } from "lucide-react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@unsend/ui/src/form"; + +const apiKeySchema = z.object({ + name: z.string(), +}); export const DeleteApiKey: React.FC<{ apiKey: Partial & { id: number }; }> = ({ apiKey }) => { const [open, setOpen] = useState(false); - const [domainName, setDomainName] = useState(""); const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation(); const utils = api.useUtils(); - function handleSave() { + const apiKeyForm = useForm>({ + resolver: zodResolver(apiKeySchema), + }); + + async function onDomainDelete(values: z.infer) { + if (values.name !== apiKey.name) { + apiKeyForm.setError("name", { + message: "Name does not match", + }); + return; + } + deleteApiKeyMutation.mutate( { id: apiKey.id, @@ -42,6 +66,8 @@ export const DeleteApiKey: React.FC<{ ); } + const name = apiKeyForm.watch("name"); + return (
- - setDomainName(e.target.value)} - value={domainName} - /> +
+ + ( + + name + + + + {formState.errors.name ? ( + + ) : ( + + . + + )} + + )} + /> +
+ +
+ +
- - -
); diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 0ebee85..46a9459 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -1,6 +1,5 @@ import { PrismaAdapter } from "@auth/prisma-adapter"; import { - AuthOptions, getServerSession, type DefaultSession, type NextAuthOptions, @@ -9,7 +8,9 @@ import { type Adapter } from "next-auth/adapters"; import GitHubProvider from "next-auth/providers/github"; import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; +import { Provider } from "next-auth/providers/index"; +import { sendSignUpEmail } from "~/server/mailer"; import { env } from "~/env"; import { db } from "~/server/db"; @@ -116,17 +117,3 @@ export const authOptions: NextAuthOptions = { * @see https://next-auth.js.org/configuration/nextjs */ export const getServerAuthSession = () => getServerSession(authOptions); - -import { createHash } from "crypto"; -import { sendSignUpEmail } from "./mailer"; -import { Provider } from "next-auth/providers/index"; - -/** - * Hashes a token using SHA-256. - * - * @param {string} token - The token to be hashed. - * @returns {string} The hashed token. - */ -export function hashToken(token: string) { - return createHash("sha256").update(token).digest("hex"); -} diff --git a/apps/web/src/server/crypto.ts b/apps/web/src/server/crypto.ts new file mode 100644 index 0000000..d932293 --- /dev/null +++ b/apps/web/src/server/crypto.ts @@ -0,0 +1,19 @@ +import { randomBytes, scryptSync } from "crypto"; + +export const createSecureHash = async (key: string) => { + const data = new TextEncoder().encode(key); + const salt = randomBytes(16).toString("hex"); + + const derivedKey = scryptSync(data, salt, 64); + + return `${salt}:${derivedKey.toString("hex")}`; +}; + +export const verifySecureHash = async (key: string, hash: string) => { + const data = new TextEncoder().encode(key); + + const [salt, storedHash] = hash.split(":"); + const derivedKey = scryptSync(data, String(salt), 64); + + return storedHash === derivedKey.toString("hex"); +}; diff --git a/apps/web/src/server/nanoid.ts b/apps/web/src/server/nanoid.ts new file mode 100644 index 0000000..1cc1a5e --- /dev/null +++ b/apps/web/src/server/nanoid.ts @@ -0,0 +1,6 @@ +import { customAlphabet } from "nanoid"; + +export const smallNanoid = customAlphabet( + "1234567890abcdefghijklmnopqrstuvwxyz", + 10 +); diff --git a/apps/web/src/server/public-api/auth.ts b/apps/web/src/server/public-api/auth.ts index 8b7b941..08e6227 100644 --- a/apps/web/src/server/public-api/auth.ts +++ b/apps/web/src/server/public-api/auth.ts @@ -1,9 +1,9 @@ import TTLCache from "@isaacs/ttlcache"; import { Context } from "hono"; -import { hashToken } from "../auth"; import { db } from "../db"; import { UnsendApiError } from "./api-error"; import { env } from "~/env"; +import { getTeamAndApiKey } from "../service/api-service"; const rateLimitCache = new TTLCache({ ttl: 1000, // 1 second @@ -34,17 +34,16 @@ export const getTeamFromToken = async (c: Context) => { checkRateLimit(token); - const hashedToken = hashToken(token); + const teamAndApiKey = await getTeamAndApiKey(token); - const team = await db.team.findFirst({ - where: { - apiKeys: { - some: { - tokenHash: hashedToken, - }, - }, - }, - }); + if (!teamAndApiKey) { + throw new UnsendApiError({ + code: "FORBIDDEN", + message: "Invalid API token", + }); + } + + const { team, apiKey } = teamAndApiKey; if (!team) { throw new UnsendApiError({ @@ -57,7 +56,7 @@ export const getTeamFromToken = async (c: Context) => { db.apiKey .update({ where: { - tokenHash: hashedToken, + id: apiKey.id, }, data: { lastUsed: new Date(), diff --git a/apps/web/src/server/service/api-service.ts b/apps/web/src/server/service/api-service.ts index a215a27..2d0d17a 100644 --- a/apps/web/src/server/service/api-service.ts +++ b/apps/web/src/server/service/api-service.ts @@ -1,7 +1,8 @@ import { ApiPermission } from "@prisma/client"; import { db } from "../db"; import { randomBytes } from "crypto"; -import { hashToken } from "../auth"; +import { smallNanoid } from "../nanoid"; +import { createSecureHash, verifySecureHash } from "../crypto"; export async function addApiKey({ name, @@ -13,8 +14,11 @@ export async function addApiKey({ teamId: number; }) { try { - const token = `us_${randomBytes(20).toString("hex")}`; - const hashedToken = hashToken(token); + const clientId = smallNanoid(10); + const token = randomBytes(16).toString("hex"); + const hashedToken = await createSecureHash(token); + + const apiKey = `us_${clientId}_${token}`; await db.apiKey.create({ data: { @@ -22,39 +26,46 @@ export async function addApiKey({ permission: permission, teamId, tokenHash: hashedToken, - partialToken: `${token.slice(0, 8)}...${token.slice(-5)}`, + partialToken: `${apiKey.slice(0, 6)}...${apiKey.slice(-3)}`, + clientId, }, }); - return token; + return apiKey; } catch (error) { console.error("Error adding API key:", error); throw error; } } -export async function retrieveApiKey(token: string) { - const hashedToken = hashToken(token); +export async function getTeamAndApiKey(apiKey: string) { + const [, clientId, token] = apiKey.split("_") as [string, string, string]; + + const apiKeyRow = await db.apiKey.findUnique({ + where: { + clientId, + }, + }); + + if (!apiKeyRow) { + return null; + } try { - const apiKey = await db.apiKey.findUnique({ + const isValid = await verifySecureHash(token, apiKeyRow.tokenHash); + if (!isValid) { + return null; + } + + const team = await db.team.findUnique({ where: { - tokenHash: hashedToken, - }, - select: { - id: true, - name: true, - permission: true, - teamId: true, - partialToken: true, + id: apiKeyRow.teamId, }, }); - if (!apiKey) { - throw new Error("API Key not found"); - } - return apiKey; + + return { team, apiKey: apiKeyRow }; } catch (error) { - console.error("Error retrieving API key:", error); - throw error; + console.error("Error verifying API key:", error); + return null; } } diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index ca72949..17c74ca 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -1,13 +1,11 @@ import { SesSetting } from "@prisma/client"; import { db } from "../db"; import { env } from "~/env"; -import { customAlphabet } from "nanoid"; import * as sns from "~/server/aws/sns"; import * as ses from "~/server/aws/ses"; import { EventType } from "@aws-sdk/client-sesv2"; import { EmailQueueService } from "./email-queue-service"; - -const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 10); +import { smallNanoid } from "../nanoid"; const GENERAL_EVENTS: EventType[] = [ "BOUNCE", @@ -75,7 +73,7 @@ export class SesSettingsService { ); } - const idPrefix = nanoid(10); + const idPrefix = smallNanoid(10); const setting = await db.sesSetting.create({ data: {