Working on Advanced Encryption Standard (AES), between agent backup and server.

This commit is contained in:
charlesgauthereau
2025-10-26 11:54:25 +01:00
parent 2a90d4933d
commit 7d6038e5f1
15 changed files with 200 additions and 22 deletions
Binary file not shown.
+38
View File
@@ -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"}
);
}
+17 -2
View File
@@ -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(
);
}
}
+1 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 -7
View File
@@ -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');
+2
View File
@@ -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>
);
};
+1 -1
View File
@@ -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}`);
}
+9 -6
View File
@@ -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},
},
};
}
-1
View File
@@ -15,7 +15,6 @@ export const retentionCleanTask = async () => {
retentionPolicy: true,
backups: {
where: isNull(drizzleDb.schemas.backup.deletedAt),
},
},
});
+2 -1
View File
@@ -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",
+39
View File
@@ -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};
}
+18
View File
@@ -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"