diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 8e8778cd..5c795626 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/app/api/agent/[agentId]/backup/helpers.ts b/app/api/agent/[agentId]/backup/helpers.ts index 139597f9..7b5c2a5b 100644 --- a/app/api/agent/[agentId]/backup/helpers.ts +++ b/app/api/agent/[agentId]/backup/helpers.ts @@ -1,2 +1,40 @@ +import fs from "node:fs"; +import forge from "node-forge"; +export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string): Promise { + const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8"); + const privateKey = forge.pki.privateKeyFromPem(privateKeyPem); + + // Decrypt AES key with RSA-OAEP + const encryptedAesKey = forge.util.hexToBytes(aesKeyHex); + const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", { + md: forge.md.sha256.create(), + mgf1: { md: forge.md.sha256.create() }, + }); + + // Read encrypted file content + const encryptedBuffer = Buffer.from(await file.arrayBuffer()); + const iv = forge.util.hexToBytes(ivHex); + + // AES decryption + const decipher = forge.cipher.createDecipher("AES-CBC", aesKey); + decipher.start({ iv }); + decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary"))); + const success = decipher.finish(); + + if (!success) { + throw new Error("Decryption failed"); + } + + const decryptedBytes = decipher.output.getBytes(); + const decryptedBuffer = Buffer.from(decryptedBytes, "binary"); + + // Return a File so you can use file.arrayBuffer() later + return new File( + [decryptedBuffer], + file.name.replace(/\.enc$/, ".dump"), // rename if needed + {type: "application/octet-stream"} + ); +} + diff --git a/app/api/agent/[agentId]/backup/route.ts b/app/api/agent/[agentId]/backup/route.ts index 0c2ad351..4163d6e4 100644 --- a/app/api/agent/[agentId]/backup/route.ts +++ b/app/api/agent/[agentId]/backup/route.ts @@ -3,12 +3,13 @@ import {isUuidv4} from "@/utils/verify-uuid"; import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action"; import {v4 as uuidv4} from "uuid"; import {eventEmitter} from "../../../events/route"; +import * as drizzleDb from "@/db"; import {db} from "@/db"; import {Backup} from "@/db/schema/07_database"; import {and, eq} from "drizzle-orm"; -import * as drizzleDb from "@/db"; import {env} from "@/env.mjs"; import {withUpdatedAt} from "@/db/utils"; +import {decryptedDump} from "./helpers"; export async function POST( request: Request, @@ -27,9 +28,13 @@ export async function POST( const agentId = (await params).agentId; const formData = await request.formData(); + const aesKeyHex = formData.get("aes_key") as string; + const ivHex = formData.get("iv") as string; const generatedId = formData.get("generatedId") as string | null; const method = formData.get("method") as string | null; + + if (!generatedId || !isUuidv4(generatedId)) { return NextResponse.json( {error: "generatedId is not a valid UUID"}, @@ -102,6 +107,11 @@ export async function POST( if (status === "success") { const file = formData.get("file") as File | null; + if (!aesKeyHex || !ivHex) { + return NextResponse.json({error: "Missing fields"}, {status: 400}); + } + + if (!file) { return NextResponse.json( {error: "File is required for successful backup"}, @@ -109,9 +119,13 @@ export async function POST( ); } + const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex); + const uuid = uuidv4(); const fileName = `${uuid}.dump`; - const buffer = Buffer.from(await file.arrayBuffer()); + // const buffer = Buffer.from(await fileDecrypted.arrayBuffer()); + const buffer = Buffer.from(await decryptedFile.arrayBuffer()); + // const buffer = fileDecrypted const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); if (!settings) { @@ -173,4 +187,5 @@ export async function POST( {status: 500} ); } -} \ No newline at end of file +} + diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 604ae9b1..732c75bd 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -124,13 +124,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat try { if (settings.storage == "local") { - data = await getFileUrlPresignedLocal(fileName!) + data = await getFileUrlPresignedLocal({fileName: fileName!}) } else if (settings.storage == "s3") { data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`); } - if (data?.data?.success) { urlBackup = data.data.value ?? ""; } else { diff --git a/app/api/agent/[agentId]/status/route.ts b/app/api/agent/[agentId]/status/route.ts index 3b0d2193..0705054b 100644 --- a/app/api/agent/[agentId]/status/route.ts +++ b/app/api/agent/[agentId]/status/route.ts @@ -20,7 +20,7 @@ export type Body = { // Function to test the get file url presigned local export async function GET(request: Request) { - const url = await getFileUrlPresignedLocal("d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump") + const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"}) return Response.json({ message: url }) diff --git a/app/api/files/[fileName]/route.ts b/app/api/files/[fileName]/route.ts index 36eeb8e8..c3f91e5e 100644 --- a/app/api/files/[fileName]/route.ts +++ b/app/api/files/[fileName]/route.ts @@ -12,16 +12,21 @@ export async function GET( const expires = searchParams.get('expires'); const fileName = (await params).fileName - const privateLocalDir = "private/uploads/files/"; - const filePath = path.join(privateLocalDir, fileName); + console.log(token); + console.log(fileName); + + const uploadsDir = "private/uploads/files/"; + const keysDir = "private/keys/"; + const uploadPath = path.join(uploadsDir, fileName); + const keyPath = path.join(keysDir, fileName); const crypto = require('crypto'); - if (!fs.existsSync(filePath)) { - return NextResponse.json( - {error: 'File not found'}, - {status: 404} - ); + let filePath = uploadPath; + if (!fs.existsSync(uploadPath)) { + if (fs.existsSync(keyPath)) filePath = keyPath; + else + return NextResponse.json({error: "File not found"}, {status: 404}); } const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex'); diff --git a/package.json b/package.json index 55081378..857b3106 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "next-safe-action": "^7.10.8", "next-themes": "^0.4.6", "node-cron": "^4.2.1", + "node-forge": "^1.3.1", "nodemailer": "^7.0.3", "npm-check-updates": "^18.0.1", "pg": "^8.16.0", @@ -95,6 +96,7 @@ "@tailwindcss/postcss": "^4.1.7", "@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/node": "^22.15.18", + "@types/node-forge": "^1", "@types/pg": "^8.15.2", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", diff --git a/src/components/wrappers/dashboard/admin/admin-settings-tab/admin-settings-tab.tsx b/src/components/wrappers/dashboard/admin/admin-settings-tab/admin-settings-tab.tsx new file mode 100644 index 00000000..a6f2fa6c --- /dev/null +++ b/src/components/wrappers/dashboard/admin/admin-settings-tab/admin-settings-tab.tsx @@ -0,0 +1,52 @@ +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; +import {Button} from "@/components/ui/button"; +import {Download} from "lucide-react"; +import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action"; +import {SafeActionResult} from "next-safe-action"; +import {ZodString} from "zod"; +import {ServerActionResult} from "@/types/action-type"; +import {toast} from "sonner"; + +export type AdminSettingsTabProps = {}; + +export const AdminSettingsTab = (props: AdminSettingsTabProps) => { + + const handleDownloadKey = async () => { + + let url: string = ""; + const data = await getFileUrlPresignedLocal({dir: "private/keys/", fileName: "server_public.pem"}) + if (data?.data?.success) { + url = data.data.value ?? ""; + } else { + // @ts-ignore + const errorMessage = data?.data?.actionError?.message || "Failed to get file!"; + toast.error(errorMessage); + } + window.open(url, "_self"); + }; + + return ( +
+ + + Instance settings + Manage portabase settings + + +
+
+

Download Public Key

+

+ Used for encrypting communications with this instance. +

+
+ +
+
+
+
+ ); +}; diff --git a/src/components/wrappers/dashboard/admin/admin-tabs.tsx b/src/components/wrappers/dashboard/admin/admin-tabs.tsx index b64bb14c..6b670f42 100644 --- a/src/components/wrappers/dashboard/admin/admin-tabs.tsx +++ b/src/components/wrappers/dashboard/admin/admin-tabs.tsx @@ -8,6 +8,7 @@ import {Setting} from "@/db/schema/01_setting"; import {useEffect, useState} from "react"; import {useRouter, useSearchParams} from "next/navigation"; import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-tab/admin-user-table"; +import {AdminSettingsTab} from "@/components/wrappers/dashboard/admin/admin-settings-tab/admin-settings-tab"; export type AdminTabsProps = { users: UserWithAccounts[]; @@ -41,6 +42,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => { Storage + + Settings + @@ -52,6 +56,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => { + + + ); }; diff --git a/src/features/dashboard/backup/columns.tsx b/src/features/dashboard/backup/columns.tsx index c7715ed8..1e0b700c 100644 --- a/src/features/dashboard/backup/columns.tsx +++ b/src/features/dashboard/backup/columns.tsx @@ -136,7 +136,7 @@ export function backupColumns(isAlreadyRestore: boolean, settings: Setting, data }, readonly [], ServerActionResult, object> | undefined if (settings.storage == "local") { - data = await getFileUrlPresignedLocal(fileName!) + data = await getFileUrlPresignedLocal({fileName:fileName!}) } else if (settings.storage == "s3") { data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`); } diff --git a/src/features/upload/private/upload.action.ts b/src/features/upload/private/upload.action.ts index a221ee39..ea4322e3 100644 --- a/src/features/upload/private/upload.action.ts +++ b/src/features/upload/private/upload.action.ts @@ -131,10 +131,13 @@ export async function getFileUrlPresignedS3(fileName: string) { export const getFileUrlPresignedLocal = action - .schema(z.string()) + .schema(z.object({ + dir: z.string().optional(), + fileName: z.string() + })) .action(async ({parsedInput}): Promise> => { try { - const filePath = path.join(privateLocalDir, parsedInput); + const filePath = path.join(parsedInput.dir ? parsedInput.dir : privateLocalDir, parsedInput.fileName); await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true}); if (!fs.existsSync(filePath)) { @@ -145,14 +148,14 @@ export const getFileUrlPresignedLocal = action const baseUrl = getServerUrl(); const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute - const token = crypto.createHash("sha256").update(`${parsedInput}${expiresAt}`).digest("hex"); + const token = crypto.createHash("sha256").update(`${parsedInput.fileName}${expiresAt}`).digest("hex"); return { success: true, - value: `${baseUrl}/api/files/${parsedInput}?token=${token}&expires=${expiresAt}`, + value: `${baseUrl}/api/files/${parsedInput.fileName}?token=${token}&expires=${expiresAt}`, actionSuccess: { message: "Successfully retrieved presigned URL Local", - messageParams: {fileName: parsedInput}, + messageParams: {fileName: parsedInput.fileName}, }, }; } catch (error) { @@ -163,7 +166,7 @@ export const getFileUrlPresignedLocal = action message: "Failed to generate presigned URL", status: 500, cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {fileName: parsedInput}, + messageParams: {fileName: parsedInput.fileName}, }, }; } diff --git a/src/lib/tasks/database/index.ts b/src/lib/tasks/database/index.ts index 07ba0841..03af061a 100644 --- a/src/lib/tasks/database/index.ts +++ b/src/lib/tasks/database/index.ts @@ -15,7 +15,6 @@ export const retentionCleanTask = async () => { retentionPolicy: true, backups: { where: isNull(drizzleDb.schemas.backup.deletedAt), - }, }, }); diff --git a/src/utils/init.ts b/src/utils/init.ts index 1fe275f7..f5b36782 100644 --- a/src/utils/init.ts +++ b/src/utils/init.ts @@ -3,11 +3,13 @@ import {db, makeMigration} from "@/db"; import {eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {retentionJob} from "@/lib/tasks"; +import {generateRSAKeys} from "@/utils/rsa-keys"; export async function init() { consoleAscii(); console.log("====Init Functions===="); + await generateRSAKeys(); await makeMigration(); await createDefaultOrganization(); await createSettingsIfNotExist() @@ -21,7 +23,6 @@ async function setupCronJobs() { console.log(`==== Cron job started ====`); } - async function createSettingsIfNotExist() { const configSettings = { name: "system", diff --git a/src/utils/rsa-keys.ts b/src/utils/rsa-keys.ts new file mode 100644 index 00000000..e6f5df9a --- /dev/null +++ b/src/utils/rsa-keys.ts @@ -0,0 +1,39 @@ +import {promises as fs} from 'fs'; +import path from 'path'; +import {generateKeyPair} from 'crypto'; +import {promisify} from 'util'; + +const generateKeyPairAsync = promisify(generateKeyPair); + +/** + * Generate RSA keypair into a directory (default: ./private). + * - Skips generation if both files already exist. + * - Private key mode 0o600. Public key mode 0o644. + * @param {string} [dir] path to directory + * @returns {Promise<{privateKeyPath:string, publicKeyPath:string}>} + */ +export async function generateRSAKeys(dir = path.join(process.cwd(), 'private/keys')) { + await fs.mkdir(dir, {recursive: true}); + + const privateKeyPath = path.join(dir, 'server_private.pem'); + const publicKeyPath = path.join(dir, 'server_public.pem'); + + try { + await fs.access(privateKeyPath); + await fs.access(publicKeyPath); + console.log('RSA keys already exist. Skipping generation.'); + return {privateKeyPath, publicKeyPath}; + } catch { + } + + const {publicKey, privateKey} = await generateKeyPairAsync('rsa', { + modulusLength: 2048, + publicKeyEncoding: {type: 'pkcs1', format: 'pem'}, + privateKeyEncoding: {type: 'pkcs1', format: 'pem'}, + }); + + await fs.writeFile(privateKeyPath, privateKey, {mode: 0o600}); + await fs.writeFile(publicKeyPath, publicKey, {mode: 0o644}); + + return {privateKeyPath, publicKeyPath}; +} diff --git a/yarn.lock b/yarn.lock index 8f2ae0cc..0bae08af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3539,6 +3539,15 @@ __metadata: languageName: node linkType: hard +"@types/node-forge@npm:^1": + version: 1.3.14 + resolution: "@types/node-forge@npm:1.3.14" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/da6158fd34fa7652aa7f8164508f97a76b558724ab292f13c257e39d54d95d4d77604e8fb14dc454a867f1aeec7af70118294889195ec4400cecbb8a5c77a212 + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0": version: 24.1.0 resolution: "@types/node@npm:24.1.0" @@ -8218,6 +8227,13 @@ __metadata: languageName: node linkType: hard +"node-forge@npm:^1.3.1": + version: 1.3.1 + resolution: "node-forge@npm:1.3.1" + checksum: 10c0/e882819b251a4321f9fc1d67c85d1501d3004b4ee889af822fd07f64de3d1a8e272ff00b689570af0465d65d6bf5074df9c76e900e0aff23e60b847f2a46fbe8 + languageName: node + linkType: hard + "node-gyp-build@npm:^4.8.4": version: 4.8.4 resolution: "node-gyp-build@npm:4.8.4" @@ -8778,6 +8794,7 @@ __metadata: "@tanstack/react-table": "npm:^8.21.3" "@types/eslint-plugin-tailwindcss": "npm:^3.17.0" "@types/node": "npm:^22.15.18" + "@types/node-forge": "npm:^1" "@types/nodemailer": "npm:^6.4.17" "@types/pg": "npm:^8.15.2" "@types/react": "npm:^19.1.4" @@ -8809,6 +8826,7 @@ __metadata: next-safe-action: "npm:^7.10.8" next-themes: "npm:^0.4.6" node-cron: "npm:^4.2.1" + node-forge: "npm:^1.3.1" nodemailer: "npm:^7.0.3" npm-check-updates: "npm:^18.0.1" pg: "npm:^8.16.0"