From ea80881566d9dd9ee031e0924fbc264758271166 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:56:56 +0000 Subject: [PATCH] feat: Encrypt/decrypt S3 keys --- .env.example | 2 + apps/desktop/src-tauri/src/lib.rs | 5 +- .../src/routes/(window-chrome)/(main).tsx | 2 +- .../src/routes/(window-chrome)/settings.tsx | 2 +- .../settings/apps/s3-config.tsx | 16 +- .../app/api/desktop/s3/config/get/route.ts | 14 +- apps/web/app/api/desktop/s3/config/route.ts | 189 ++++++------------ apps/web/app/api/upload/signed/route.ts | 105 ++++++---- apps/web/utils/cors.ts | 29 +++ apps/web/utils/s3.ts | 38 +++- packages/database/crypto.ts | 87 ++++++++ packages/database/schema.ts | 24 ++- packages/ui-solid/src/auto-imports.d.ts | 4 + 13 files changed, 336 insertions(+), 181 deletions(-) create mode 100644 apps/web/utils/cors.ts create mode 100644 packages/database/crypto.ts diff --git a/.env.example b/.env.example index 55ea2603..6bcbb876 100644 --- a/.env.example +++ b/.env.example @@ -38,7 +38,9 @@ NEXT_PUBLIC_LOCAL_MODE=false # which will automatically be created on pnpm dev. # It is used for local development only. # For production, use the DATABASE_URL from your DB provider. Must include https for production use, mysql:// for local. +# DATABASE_ENCRYPTION_KEY is used to encrypt sensitive data in the database (like S3 credentials). Not required for local development. DATABASE_URL=mysql://root:@localhost:3306/planetscale +DATABASE_ENCRYPTION_KEY= # This is the secret for the NextAuth.js authentication library. # It is used for local development only. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e0a8980c..cec1e300 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1500,7 +1500,10 @@ fn list_screenshots(app: AppHandle) -> Result Result { if let Err(e) = AuthStore::fetch_and_update_plan(&app).await { - return Err(format!("Failed to update plan information: {}", e)); + return Err(format!( + "Failed to update plan information. Try signing out and signing back in: {}", + e + )); } let auth = AuthStore::get(&app).map_err(|e| e.to_string())?; diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index bfe9bd0e..3cf4f29c 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -146,7 +146,7 @@ export default function () { commands.showWindow({ Settings: { page: "apps" } }) } > - + diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index 29e9bf9e..e09384e0 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -33,7 +33,7 @@ export default function Settings(props: RouteSectionProps) { { href: "apps", name: "Cap Apps", - icon: IconLucideCable, + icon: IconLucideLayoutGrid, }, { href: "feedback", diff --git a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx index 887586fa..34693353 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx @@ -120,10 +120,24 @@ export default function S3ConfigPage() { ) : (
+
+

+ It should take under 10 minutes to set up and connect your S3 + bucket to Cap. View the{" "} + + S3 Config Guide + {" "} + to get started. +

+
0 }); - let bucketId: string; - if (existingConfig) { - bucketId = existingConfig.id; - console.log("Updating existing S3 configuration"); - await db - .update(s3Buckets) - .set({ - accessKeyId, - secretAccessKey, - endpoint, - bucketName, - region, - }) - .where(eq(s3Buckets.id, bucketId)); - } else { - console.log("Creating new S3 configuration for user:", user.id); - bucketId = nanoId(); - await db.insert(s3Buckets).values({ - id: bucketId, - ownerId: user.id, - region: region || "us-east-1", - endpoint, - bucketName, - accessKeyId, - secretAccessKey, - }); - console.log("Successfully created new S3 configuration"); - } + // Encrypt sensitive data before storing + const encryptedData = { + id: existingBucket[0]?.id || nanoId(), + accessKeyId: encrypt(accessKeyId), + secretAccessKey: encrypt(secretAccessKey), + endpoint: endpoint ? encrypt(endpoint) : null, + bucketName: encrypt(bucketName), + region: encrypt(region), + ownerId: user.id, + }; + + console.log("[S3 Config] Encrypted data prepared", { id: encryptedData.id }); - console.log("Updating user's customBucket field"); await db - .update(users) - .set({ - customBucket: bucketId, - }) - .where(eq(users.id, user.id)); + .insert(s3Buckets) + .values(encryptedData) + .onDuplicateKeyUpdate({ + set: { + accessKeyId: encryptedData.accessKeyId, + secretAccessKey: encryptedData.secretAccessKey, + endpoint: encryptedData.endpoint, + bucketName: encryptedData.bucketName, + region: encryptedData.region, + }, + }); + + console.log("[S3 Config] Successfully saved S3 configuration"); - console.log("S3 configuration saved successfully"); return new Response(JSON.stringify({ success: true }), { status: 200, - headers: { - "Access-Control-Allow-Origin": - origin && allowedOrigins.includes(origin) - ? origin - : allowedOrigins.includes(originalOrigin) - ? originalOrigin - : "null", - "Access-Control-Allow-Credentials": "true", - }, + headers: getCorsHeaders(origin, originalOrigin), }); } catch (error) { - console.error("Error saving S3 config:", error); + console.error("[S3 Config] Error saving S3 config:", error); return new Response( - JSON.stringify({ error: "Failed to save S3 configuration" }), + JSON.stringify({ + error: "Failed to save S3 configuration", + details: error instanceof Error ? error.message : 'Unknown error' + }), { status: 500, - headers: { - "Access-Control-Allow-Origin": - origin && allowedOrigins.includes(origin) - ? origin - : allowedOrigins.includes(originalOrigin) - ? originalOrigin - : "null", - "Access-Control-Allow-Credentials": "true", - }, + headers: getCorsHeaders(origin, originalOrigin), } ); } diff --git a/apps/web/app/api/upload/signed/route.ts b/apps/web/app/api/upload/signed/route.ts index 4e834a68..7c07548f 100644 --- a/apps/web/app/api/upload/signed/route.ts +++ b/apps/web/app/api/upload/signed/route.ts @@ -9,6 +9,7 @@ import { s3Buckets } from "@cap/database/schema"; import { eq } from "drizzle-orm"; import { cookies } from "next/headers"; import type { NextRequest } from "next/server"; +import { decrypt } from "@cap/database/crypto"; export async function POST(request: NextRequest) { try { @@ -17,7 +18,6 @@ export async function POST(request: NextRequest) { if (!fileKey) { console.error("Missing required fields in /api/upload/signed/route.ts"); - return new Response( JSON.stringify({ error: "Missing required fields" }), { @@ -53,51 +53,82 @@ export async function POST(request: NextRequest) { }); } - const [bucket] = await db - .select() - .from(s3Buckets) - .where(eq(s3Buckets.ownerId, user.id)); + try { + const [bucket] = await db + .select() + .from(s3Buckets) + .where(eq(s3Buckets.ownerId, user.id)); + + // Create a decrypted config for S3 client + const s3Config = bucket ? { + endpoint: bucket.endpoint || undefined, + region: bucket.region, + accessKeyId: bucket.accessKeyId, + secretAccessKey: bucket.secretAccessKey, + } : null; - const s3Client = createS3Client(bucket); + console.log("Creating S3 client with config:", { + hasEndpoint: !!s3Config?.endpoint, + hasRegion: !!s3Config?.region, + hasAccessKey: !!s3Config?.accessKeyId, + hasSecretKey: !!s3Config?.secretAccessKey, + }); - const contentType = fileKey.endsWith(".aac") - ? "audio/aac" - : fileKey.endsWith(".webm") - ? "audio/webm" - : fileKey.endsWith(".mp4") - ? "video/mp4" - : fileKey.endsWith(".mp3") - ? "audio/mpeg" - : fileKey.endsWith(".m3u8") - ? "application/x-mpegURL" - : "video/mp2t"; + const s3Client = createS3Client(s3Config); - const Fields = { - "Content-Type": contentType, - "x-amz-meta-userid": user.id, - "x-amz-meta-duration": duration ?? "", - "x-amz-meta-bandwidth": bandwidth ?? "", - "x-amz-meta-resolution": resolution ?? "", - "x-amz-meta-videocodec": videoCodec ?? "", - "x-amz-meta-audiocodec": audioCodec ?? "", - }; + const contentType = fileKey.endsWith(".aac") + ? "audio/aac" + : fileKey.endsWith(".webm") + ? "audio/webm" + : fileKey.endsWith(".mp4") + ? "video/mp4" + : fileKey.endsWith(".mp3") + ? "audio/mpeg" + : fileKey.endsWith(".m3u8") + ? "application/x-mpegURL" + : "video/mp2t"; - const presignedPostData: PresignedPost = await createPresignedPost( - s3Client, - { Bucket: getS3Bucket(bucket), Key: fileKey, Fields, Expires: 1800 } - ); + const Fields = { + "Content-Type": contentType, + "x-amz-meta-userid": user.id, + "x-amz-meta-duration": duration ?? "", + "x-amz-meta-bandwidth": bandwidth ?? "", + "x-amz-meta-resolution": resolution ?? "", + "x-amz-meta-videocodec": videoCodec ?? "", + "x-amz-meta-audiocodec": audioCodec ?? "", + }; - console.log("Presigned URL created successfully"); + const bucketName = getS3Bucket(bucket); + console.log("Using bucket:", bucketName); + + const presignedPostData: PresignedPost = await createPresignedPost( + s3Client, + { + Bucket: bucketName, + Key: fileKey, + Fields, + Expires: 1800 + } + ); - return new Response(JSON.stringify({ presignedPostData }), { - headers: { - "Content-Type": "application/json", - }, - }); + console.log("Presigned URL created successfully"); + + return new Response(JSON.stringify({ presignedPostData }), { + headers: { + "Content-Type": "application/json", + }, + }); + } catch (s3Error) { + console.error("S3 operation failed:", s3Error); + throw new Error(`S3 operation failed: ${s3Error instanceof Error ? s3Error.message : 'Unknown error'}`); + } } catch (error) { console.error("Error creating presigned URL", error); return new Response( - JSON.stringify({ error: "Error creating presigned URL" }), + JSON.stringify({ + error: "Error creating presigned URL", + details: error instanceof Error ? error.message : String(error) + }), { status: 500, headers: { diff --git a/apps/web/utils/cors.ts b/apps/web/utils/cors.ts new file mode 100644 index 00000000..55b181aa --- /dev/null +++ b/apps/web/utils/cors.ts @@ -0,0 +1,29 @@ +export const allowedOrigins = [ + process.env.NEXT_PUBLIC_URL, + "http://localhost:3001", + "http://localhost:3000", + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", +]; + +export function getCorsHeaders(origin: string | null, originalOrigin: string) { + return { + "Access-Control-Allow-Origin": + origin && allowedOrigins.includes(origin) + ? origin + : allowedOrigins.includes(originalOrigin) + ? originalOrigin + : "null", + "Access-Control-Allow-Credentials": "true", + }; +} + +export function getOptionsHeaders(origin: string | null, originalOrigin: string, methods = "GET, OPTIONS") { + return { + ...getCorsHeaders(origin, originalOrigin), + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": + "Content-Type, Authorization, sentry-trace, baggage", + }; +} \ No newline at end of file diff --git a/apps/web/utils/s3.ts b/apps/web/utils/s3.ts index 80c571fb..7bcfe842 100644 --- a/apps/web/utils/s3.ts +++ b/apps/web/utils/s3.ts @@ -1,6 +1,7 @@ import { S3Client } from "@aws-sdk/client-s3"; import type { s3Buckets } from "@cap/database/schema"; import type { InferSelectModel } from "drizzle-orm"; +import { decrypt } from "@cap/database/crypto"; type S3Config = { endpoint?: string | null; @@ -13,14 +14,35 @@ export function createS3Client(config?: S3Config) { return new S3Client(getS3Config(config)); } +function tryDecrypt(text: string | null | undefined): string | undefined { + if (!text) return undefined; + try { + return decrypt(text); + } catch (error) { + // If decryption fails, assume the data is not encrypted yet + console.log("Decryption failed, using original value"); + return text; + } +} + export function getS3Config(config?: S3Config) { + if (!config) { + return { + endpoint: process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT, + region: process.env.NEXT_PUBLIC_CAP_AWS_REGION, + credentials: { + accessKeyId: process.env.CAP_AWS_ACCESS_KEY ?? "", + secretAccessKey: process.env.CAP_AWS_SECRET_KEY ?? "", + }, + }; + } + return { - endpoint: config?.endpoint ?? process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT, - region: config?.region ?? process.env.NEXT_PUBLIC_CAP_AWS_REGION, + endpoint: config.endpoint ? tryDecrypt(config.endpoint) : process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT, + region: tryDecrypt(config.region) ?? process.env.NEXT_PUBLIC_CAP_AWS_REGION, credentials: { - accessKeyId: config?.accessKeyId ?? process.env.CAP_AWS_ACCESS_KEY ?? "", - secretAccessKey: - config?.secretAccessKey ?? process.env.CAP_AWS_SECRET_KEY ?? "", + accessKeyId: tryDecrypt(config.accessKeyId) ?? process.env.CAP_AWS_ACCESS_KEY ?? "", + secretAccessKey: tryDecrypt(config.secretAccessKey) ?? process.env.CAP_AWS_SECRET_KEY ?? "", }, }; } @@ -28,5 +50,9 @@ export function getS3Config(config?: S3Config) { export function getS3Bucket( bucket?: InferSelectModel | null ) { - return bucket?.bucketName ?? (process.env.NEXT_PUBLIC_CAP_AWS_BUCKET || ""); + if (!bucket?.bucketName) { + return process.env.NEXT_PUBLIC_CAP_AWS_BUCKET || ""; + } + + return (tryDecrypt(bucket.bucketName) ?? process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) || ""; } diff --git a/packages/database/crypto.ts b/packages/database/crypto.ts new file mode 100644 index 00000000..151f1af8 --- /dev/null +++ b/packages/database/crypto.ts @@ -0,0 +1,87 @@ +import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const SALT_LENGTH = 16; +const TAG_LENGTH = 16; +const KEY_LENGTH = 32; +const ITERATIONS = 100000; + +const ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY as string; + +// Verify the encryption key is valid hex and correct length +try { + const keyBuffer = Buffer.from(ENCRYPTION_KEY, 'hex'); + if (keyBuffer.length !== KEY_LENGTH) { + throw new Error(`Encryption key must be ${KEY_LENGTH} bytes (${KEY_LENGTH * 2} hex characters)`); + } +} catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Invalid encryption key format: ${error.message}`); + } + throw new Error('Invalid encryption key format'); +} + +function deriveKey(salt: Buffer): Buffer { + return pbkdf2Sync( + ENCRYPTION_KEY, + salt, + ITERATIONS, + KEY_LENGTH, + 'sha256' + ); +} + +export function encrypt(text: string): string { + if (!text) { + throw new Error('Cannot encrypt empty or null text'); + } + + try { + const salt = randomBytes(SALT_LENGTH); + const iv = randomBytes(IV_LENGTH); + const key = deriveKey(salt); + + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + // Combine salt, IV, tag, and encrypted content + const result = Buffer.concat([salt, iv, tag, encrypted]); + return result.toString('base64'); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Encryption failed: ${error.message}`); + } + throw new Error('Encryption failed'); + } +} + +export function decrypt(encryptedText: string): string { + if (!encryptedText) { + throw new Error('Cannot decrypt empty or null text'); + } + + try { + const encrypted = Buffer.from(encryptedText, 'base64'); + + // Extract the components + const salt = encrypted.subarray(0, SALT_LENGTH); + const iv = encrypted.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const tag = encrypted.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + TAG_LENGTH); + const content = encrypted.subarray(SALT_LENGTH + IV_LENGTH + TAG_LENGTH); + + // Derive the same key using the extracted salt + const key = deriveKey(salt); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(content), decipher.final()]); + return decrypted.toString('utf8'); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Decryption failed: ${error.message}`); + } + throw new Error('Decryption failed'); + } +} \ No newline at end of file diff --git a/packages/database/schema.ts b/packages/database/schema.ts index ecca79e1..d2c1b311 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -27,6 +27,19 @@ const nanoIdNullable = customType<{ data: string; notNull: false }>({ }, }); +// Add a custom type for encrypted strings +const encryptedText = customType<{ data: string; notNull: true }>({ + dataType() { + return 'text'; + }, +}); + +const encryptedTextNullable = customType<{ data: string; notNull: false }>({ + dataType() { + return 'text'; + }, +}); + export const users = mysqlTable( "users", { @@ -252,11 +265,12 @@ export const comments = mysqlTable( export const s3Buckets = mysqlTable("s3_buckets", { id: nanoId("id").notNull().primaryKey().unique(), ownerId: nanoId("ownerId").notNull(), - region: varchar("region", { length: 255 }).notNull(), - endpoint: text("endpoint"), - bucketName: varchar("bucketName", { length: 255 }).notNull(), - accessKeyId: varchar("accessKeyId", { length: 255 }).notNull(), - secretAccessKey: varchar("secretAccessKey", { length: 255 }).notNull(), + // Use encryptedText for sensitive fields + region: encryptedText("region").notNull(), + endpoint: encryptedTextNullable("endpoint"), + bucketName: encryptedText("bucketName").notNull(), + accessKeyId: encryptedText("accessKeyId").notNull(), + secretAccessKey: encryptedText("secretAccessKey").notNull(), }); export const commentsRelations = relations(comments, ({ one }) => ({ diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 2ef6a89a..bff1f0e7 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -47,6 +47,8 @@ declare global { const IconCapUndo: typeof import('~icons/cap/undo.jsx')['default'] const IconCapUpload: typeof import('~icons/cap/upload.jsx')['default'] const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] + const IconLucideBlocks: typeof import('~icons/lucide/blocks.jsx')['default'] + const IconLucideBrickWall: typeof import('~icons/lucide/brick-wall.jsx')['default'] const IconLucideCable: typeof import('~icons/lucide/cable.jsx')['default'] const IconLucideCamera: typeof import('~icons/lucide/camera.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] @@ -56,8 +58,10 @@ declare global { const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] const IconLucideEye: typeof import('~icons/lucide/eye.jsx')['default'] const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] + const IconLucideLayoutGrid: typeof import('~icons/lucide/layout-grid.jsx')['default'] const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.jsx')['default'] + const IconLucidePlugZap: typeof import('~icons/lucide/plug-zap.jsx')['default'] const IconLucideRabbit: typeof import('~icons/lucide/rabbit.jsx')['default'] const IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw.jsx')['default'] const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default']