mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Working on Advanced Encryption Standard (AES), between agent backup and server.
This commit is contained in:
Binary file not shown.
@@ -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<File> {
|
||||
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"}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -174,3 +188,4 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Instance settings</CardTitle>
|
||||
<CardDescription>Manage portabase settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Download Public Key</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for encrypting communications with this instance.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleDownloadKey} variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2"/>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
<TabsTrigger className="w-full" value="storage">
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value="settings">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
@@ -52,6 +56,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
||||
<TabsContent value="storage">
|
||||
<SettingsStorageTab settings={settings}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<AdminSettingsTab/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ export function backupColumns(isAlreadyRestore: boolean, settings: Setting, data
|
||||
}, readonly [], ServerActionResult<string>, 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}`);
|
||||
}
|
||||
|
||||
@@ -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<ServerActionResult<string>> => {
|
||||
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},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export const retentionCleanTask = async () => {
|
||||
retentionPolicy: true,
|
||||
backups: {
|
||||
where: isNull(drizzleDb.schemas.backup.deletedAt),
|
||||
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user