diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 531cf19..d3e0bc3 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -18,16 +18,18 @@
- [x] Mesaj düzenleme/silme
- [x] IDOR güvenlik açığı düzeltildi
-### Faz 3: Dosya Yükleme (R2) 🔲
+### Faz 3: Dosya Yükleme (R2) ✅
- [x] Cloudflare R2 entegrasyonu
- [x] Dosya upload/download API
- [x] Dosya listeleme UI
-### Faz 4: Client-side Encryption 🔲
-- [ ] Web Crypto API wrapper
-- [ ] Encrypt/decrypt utilities
-- [ ] Şifreli mesaj oluşturma akışı
-- [ ] Decrypt sayfası (alıcı için)
+### Faz 4: Client-side Encryption ✅
+- [x] Web Crypto API wrapper
+- [x] Encrypt/decrypt utilities
+- [x] Şifreli mesaj oluşturma akışı
+- [x] Decrypt sayfası (alıcı için)
+- [x] Şifreli mesaj düzenleme
+- [x] Dosya adı şifreleme
### Faz 5: Check-in & Cron 🔲
- [ ] Check-in API endpoint
@@ -54,6 +56,23 @@
- [ ] Production deployment
- [ ] Monitoring setup
+### Sonradan Yapılacaklar
+ 1. Static Salt in Key Derivation
+ - Severity: Medium
+ - Location: src/lib/crypto.ts (Lines 13-13)
+ - Line Content:
+
+ 1 salt: encoder.encode("dead-man-switch-salt"),
+ - Description:
+ The deriveKeyFromPassword function uses a hardcoded, static salt ("dead-man-switch-salt") for all users and messages. In the event of a database compromise, this allows an attacker to
+ perform mass rainbow table or dictionary attacks to crack passwords for all users simultaneously. A unique salt per encryption operation is required to force attackers to crack each
+ password individually.
+ - Recommendation:
+ 1. Generate a random, cryptographically secure salt (e.g., 16 bytes) for each new message or file encryption operation.
+ 2. Store this salt alongside the ciphertext (e.g., as a prefix to the encrypted string or in a separate database column).
+ 3. Update deriveKeyFromPassword to accept salt as a parameter.
+ 4. Update the encrypt and decrypt flows to pass this unique salt during key derivation.
+
## Tamamlanan
- ✅ Teknoloji stack seçimi
- ✅ Memory bank oluşturma
@@ -61,6 +80,7 @@
- ✅ Faz 1: Auth sistemi
- ✅ Faz 2: Mesaj CRUD
- ✅ Faz 3: Dosya Yükleme (R2)
+- ✅ Faz 4: Client-side Encryption
## Bilinen Sorunlar
(henüz yok)
diff --git a/prisma/migrations/20251224123000_add_encryption/migration.sql b/prisma/migrations/20251224123000_add_encryption/migration.sql
new file mode 100644
index 0000000..a76319c
--- /dev/null
+++ b/prisma/migrations/20251224123000_add_encryption/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Message" ADD COLUMN "isEncrypted" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4a362b8..c51f057 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -50,6 +50,7 @@ model Message {
title String
content String? @db.Text
status String @default("DRAFT")
+ isEncrypted Boolean @default(false)
lastPing DateTime @default(now())
checkInterval Int @default(24)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
diff --git a/src/app/api/messages/[messageId]/attachments/route.ts b/src/app/api/messages/[messageId]/attachments/route.ts
new file mode 100644
index 0000000..6d462dd
--- /dev/null
+++ b/src/app/api/messages/[messageId]/attachments/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth/options";
+import { prisma } from "@/lib/prisma";
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ messageId: string }> }
+) {
+ const { messageId } = await params;
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.id) {
+ return new NextResponse("Unauthorized", { status: 401 });
+ }
+
+ const message = await prisma.message.findUnique({
+ where: { id: messageId, userId: session.user.id },
+ include: { attachments: true },
+ });
+
+ if (!message) {
+ return new NextResponse("Not found", { status: 404 });
+ }
+
+ return NextResponse.json(
+ message.attachments.map((a) => ({ id: a.id, fileName: a.fileName }))
+ );
+}
diff --git a/src/app/api/messages/[messageId]/route.ts b/src/app/api/messages/[messageId]/route.ts
index e824650..a2347aa 100644
--- a/src/app/api/messages/[messageId]/route.ts
+++ b/src/app/api/messages/[messageId]/route.ts
@@ -16,7 +16,7 @@ export async function PATCH(
try {
const body = await req.json();
- const { title, content, recipients, checkInterval } = body;
+ const { title, content, recipients, checkInterval, isEncrypted } = body;
const existingMessage = await prisma.message.findUnique({
where: { id: messageId, userId: session.user.id },
@@ -40,6 +40,7 @@ export async function PATCH(
title,
content,
checkInterval,
+ isEncrypted: isEncrypted || existingMessage.isEncrypted,
recipients: {
create: recipients.map((email: string) => ({ email })),
},
diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts
index b5e8fd8..f90896d 100644
--- a/src/app/api/messages/route.ts
+++ b/src/app/api/messages/route.ts
@@ -12,7 +12,7 @@ export async function POST(req: Request) {
try {
const body = await req.json();
- const { title, content, recipients, checkInterval } = body;
+ const { title, content, recipients, checkInterval, isEncrypted } = body;
const message = await prisma.message.create({
data: {
@@ -20,6 +20,7 @@ export async function POST(req: Request) {
title,
content,
checkInterval,
+ isEncrypted: isEncrypted || false,
recipients: {
create: recipients.map((email: string) => ({ email })),
},
diff --git a/src/app/dashboard/messages/[messageId]/page.tsx b/src/app/dashboard/messages/[messageId]/page.tsx
index 0be2715..e987a00 100644
--- a/src/app/dashboard/messages/[messageId]/page.tsx
+++ b/src/app/dashboard/messages/[messageId]/page.tsx
@@ -38,6 +38,7 @@ export default async function EditMessagePage({
checkInterval: message.checkInterval,
recipients: message.recipients.map((r) => r.email),
attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })),
+ isEncrypted: message.isEncrypted,
};
return (
diff --git a/src/app/dashboard/messages/new/page.tsx b/src/app/dashboard/messages/new/page.tsx
index 39bf59e..b2ddbe7 100644
--- a/src/app/dashboard/messages/new/page.tsx
+++ b/src/app/dashboard/messages/new/page.tsx
@@ -2,9 +2,10 @@ import MessageForm from "@/components/messages/message-form";
export default function NewMessagePage() {
return (
-
-
Yeni Ölü Adam Anahtarı Oluştur
-
+
- );
+ );
}
diff --git a/src/app/decrypt/page.tsx b/src/app/decrypt/page.tsx
new file mode 100644
index 0000000..2e4a9d7
--- /dev/null
+++ b/src/app/decrypt/page.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Lock, Unlock } from "lucide-react";
+import { deriveKeyFromPassword, decrypt, decryptFile } from "@/lib/crypto";
+
+export default function DecryptPage() {
+ const [encryptedContent, setEncryptedContent] = useState("");
+ const [password, setPassword] = useState("");
+ const [decryptedContent, setDecryptedContent] = useState
(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const handleDecrypt = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const key = await deriveKeyFromPassword(password);
+ const decrypted = await decrypt(encryptedContent, key);
+ setDecryptedContent(decrypted);
+ } catch {
+ setError("Şifre çözme başarısız. Şifre veya içerik hatalı olabilir.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleFileDecrypt = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file || !password) return;
+
+ setLoading(true);
+ setError(null);
+ try {
+ const key = await deriveKeyFromPassword(password);
+ const decryptedData = await decryptFile(file, key);
+
+ let originalName = file.name;
+ if (file.name.endsWith(".enc")) {
+ const encryptedName = file.name.slice(0, -4);
+ try {
+ originalName = await decrypt(encryptedName, key);
+ } catch {
+ originalName = "decrypted_file";
+ }
+ } else {
+ originalName = file.name.replace(".encrypted", "");
+ }
+
+ const blob = new Blob([decryptedData]);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = originalName;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ setError("Dosya şifre çözme başarısız.");
+ } finally {
+ setLoading(false);
+ e.target.value = "";
+ }
+ };
+
+ return (
+
+
+
+
+ Şifreli Mesaj Çöz
+
+
+
+
+
+ setPassword(e.target.value)}
+ placeholder="Şifreyi girin"
+ type="password"
+ />
+
+
+
+
+
+
+
+
+ {decryptedContent && (
+
+
+
+ {decryptedContent}
+
+
+ )}
+
+
+
+
+
+
+
+
+ Önce şifreyi girin, sonra .encrypted uzantılı dosyayı seçin
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/attachments/file-upload.tsx b/src/components/attachments/file-upload.tsx
index 0f763de..121bca6 100644
--- a/src/components/attachments/file-upload.tsx
+++ b/src/components/attachments/file-upload.tsx
@@ -2,9 +2,13 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { deriveKeyFromPassword, encrypt, decrypt, encryptFile, decryptFile } from "@/lib/crypto";
interface FileUploadProps {
messageId: string;
+ isEncrypted?: boolean;
+ password?: string;
onUploadComplete?: () => void;
}
@@ -13,7 +17,7 @@ interface Attachment {
fileName: string;
}
-export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
+export function FileUpload({ messageId, isEncrypted, password, onUploadComplete }: FileUploadProps) {
const [uploading, setUploading] = useState(false);
const handleFileChange = async (e: React.ChangeEvent) => {
@@ -22,12 +26,23 @@ export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
setUploading(true);
try {
+ let fileToUpload: Blob = file;
+ let fileName = file.name;
+ let contentType = file.type;
+
+ if (isEncrypted && password) {
+ const key = await deriveKeyFromPassword(password);
+ fileToUpload = await encryptFile(file, key);
+ fileName = (await encrypt(file.name, key)) + ".enc";
+ contentType = "application/octet-stream";
+ }
+
const res = await fetch("/api/attachments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- fileName: file.name,
- contentType: file.type,
+ fileName,
+ contentType,
messageId,
}),
});
@@ -38,8 +53,8 @@ export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
await fetch(uploadUrl, {
method: "PUT",
- body: file,
- headers: { "Content-Type": file.type },
+ body: fileToUpload,
+ headers: { "Content-Type": contentType },
});
onUploadComplete?.();
@@ -72,13 +87,46 @@ export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
interface AttachmentListProps {
attachments: Attachment[];
onDelete?: (id: string) => void;
+ isEncrypted?: boolean;
+ password?: string;
}
-export function AttachmentList({ attachments, onDelete }: AttachmentListProps) {
+export function AttachmentList({ attachments, onDelete, isEncrypted, password }: AttachmentListProps) {
+ const [downloading, setDownloading] = useState(null);
+
const handleDownload = async (id: string, fileName: string) => {
- const res = await fetch(`/api/attachments/${id}`);
- const { downloadUrl } = await res.json();
- window.open(downloadUrl, "_blank");
+ setDownloading(id);
+ try {
+ const res = await fetch(`/api/attachments/${id}`);
+ const { downloadUrl } = await res.json();
+
+ if (isEncrypted && password && fileName.endsWith(".enc")) {
+ const fileRes = await fetch(downloadUrl);
+ const encryptedBlob = await fileRes.blob();
+ const key = await deriveKeyFromPassword(password);
+ const decryptedData = await decryptFile(encryptedBlob, key);
+
+ let originalName = "decrypted_file";
+ try {
+ const encryptedName = fileName.slice(0, -4);
+ originalName = await decrypt(encryptedName, key);
+ } catch {}
+
+ const blob = new Blob([decryptedData]);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = originalName;
+ a.click();
+ URL.revokeObjectURL(url);
+ } else {
+ window.open(downloadUrl, "_blank");
+ }
+ } catch (error) {
+ console.error("Download error:", error);
+ } finally {
+ setDownloading(null);
+ }
};
const handleDelete = async (id: string) => {
@@ -92,16 +140,21 @@ export function AttachmentList({ attachments, onDelete }: AttachmentListProps) {
{attachments.map((att) => (
-
- {att.fileName}
+
+ {att.fileName.endsWith(".enc") ? "🔒 Şifreli dosya" : att.fileName}
+
{onDelete && (
+ {!initialData?.isEncrypted && (
+
+ )}
+ {initialData?.isEncrypted && (
+
+
+
+ Bu mesaj şifrelenmiş (kaydetmek için şifre gerekli)
+
+
setPassword(e.target.value)}
+ placeholder="Şifre"
+ required
+ />
+
+ )}
{initialData?.id && (
setAttachments(attachments.filter((a) => a.id !== id))}
+ isEncrypted={isEncrypted}
+ password={password}
/>
)}
{pendingFiles.length > 0 && (
@@ -224,7 +346,15 @@ export default function MessageForm({ initialData }: MessageFormProps) {
{initialData?.id ? (
router.refresh()}
+ isEncrypted={isEncrypted}
+ password={password}
+ onUploadComplete={async () => {
+ const res = await fetch(`/api/messages/${initialData.id}/attachments`);
+ if (res.ok) {
+ const data = await res.json();
+ setAttachments(data);
+ }
+ }}
/>
) : (
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
new file mode 100644
index 0000000..6d75911
--- /dev/null
+++ b/src/lib/crypto.ts
@@ -0,0 +1,96 @@
+export async function deriveKeyFromPassword(password: string): Promise
{
+ const encoder = new TextEncoder();
+ const keyMaterial = await crypto.subtle.importKey(
+ "raw",
+ encoder.encode(password),
+ "PBKDF2",
+ false,
+ ["deriveBits", "deriveKey"]
+ );
+ const key = await crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt: encoder.encode("dead-man-switch-salt"),
+ iterations: 100000,
+ hash: "SHA-256",
+ },
+ keyMaterial,
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["encrypt", "decrypt"]
+ );
+ const exported = await crypto.subtle.exportKey("raw", key);
+ return btoa(String.fromCharCode(...new Uint8Array(exported)));
+}
+
+export async function generateKey(): Promise {
+ const key = await crypto.subtle.generateKey(
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["encrypt", "decrypt"]
+ );
+ const exported = await crypto.subtle.exportKey("raw", key);
+ return btoa(String.fromCharCode(...new Uint8Array(exported)));
+}
+
+export async function importKey(keyString: string): Promise {
+ const keyData = Uint8Array.from(atob(keyString), (c) => c.charCodeAt(0));
+ return crypto.subtle.importKey(
+ "raw",
+ keyData,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt", "decrypt"]
+ );
+}
+
+export async function encrypt(data: string, keyString: string): Promise {
+ const key = await importKey(keyString);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const encoded = new TextEncoder().encode(data);
+ const encrypted = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ key,
+ encoded
+ );
+ const combined = new Uint8Array(iv.length + encrypted.byteLength);
+ combined.set(iv);
+ combined.set(new Uint8Array(encrypted), iv.length);
+ return btoa(String.fromCharCode(...combined));
+}
+
+export async function decrypt(encryptedData: string, keyString: string): Promise {
+ const key = await importKey(keyString);
+ const combined = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0));
+ const iv = combined.slice(0, 12);
+ const data = combined.slice(12);
+ const decrypted = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv },
+ key,
+ data
+ );
+ return new TextDecoder().decode(decrypted);
+}
+
+export async function encryptFile(file: File, keyString: string): Promise {
+ const key = await importKey(keyString);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const arrayBuffer = await file.arrayBuffer();
+ const encrypted = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ key,
+ arrayBuffer
+ );
+ const combined = new Uint8Array(iv.length + encrypted.byteLength);
+ combined.set(iv);
+ combined.set(new Uint8Array(encrypted), iv.length);
+ return new Blob([combined], { type: "application/octet-stream" });
+}
+
+export async function decryptFile(encryptedBlob: Blob, keyString: string): Promise {
+ const key = await importKey(keyString);
+ const combined = new Uint8Array(await encryptedBlob.arrayBuffer());
+ const iv = combined.slice(0, 12);
+ const data = combined.slice(12);
+ return crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
+}