feat: phase 4 completed

This commit is contained in:
Yusuf İpek
2025-12-27 14:28:33 +03:00
parent 70d246bc17
commit 226935e67d
12 changed files with 510 additions and 31 deletions
+26 -6
View File
@@ -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)
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Message" ADD COLUMN "isEncrypted" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -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)
@@ -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 }))
);
}
+2 -1
View File
@@ -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 })),
},
+2 -1
View File
@@ -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 })),
},
@@ -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 (
+5 -4
View File
@@ -2,9 +2,10 @@ import MessageForm from "@/components/messages/message-form";
export default function NewMessagePage() {
return (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-bold mb-6 text-center">Yeni Ö Adam Anahtarı Oluştur</h1>
<MessageForm />
<div className="container mx-auto py-10 px-4">
<div className="max-w-3xl mx-auto">
<MessageForm />
</div>
</div>
);
);
}
+144
View File
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="container mx-auto py-10">
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5" /> Şifreli Mesaj Çöz
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Şifre</Label>
<Input
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Şifreyi girin"
type="password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="encrypted">Şifreli Mesaj</Label>
<Textarea
id="encrypted"
value={encryptedContent}
onChange={(e) => setEncryptedContent(e.target.value)}
placeholder="Şifreli içeriği buraya yapıştırın"
rows={5}
/>
</div>
<Button onClick={handleDecrypt} disabled={loading || !password || !encryptedContent}>
<Unlock className="h-4 w-4 mr-2" /> Mesajı Çöz
</Button>
{decryptedContent && (
<div className="space-y-2">
<Label>Çözülmüş Mesaj</Label>
<div className="p-4 bg-muted rounded-md whitespace-pre-wrap">
{decryptedContent}
</div>
</div>
)}
<hr className="my-4" />
<div className="space-y-2">
<Label>Şifreli Dosya Çöz</Label>
<input
type="file"
id="encrypted-file"
className="hidden"
onChange={handleFileDecrypt}
disabled={loading || !password}
/>
<Button asChild variant="outline" disabled={!password}>
<label htmlFor="encrypted-file" className="cursor-pointer">
Şifreli Dosya Seç
</label>
</Button>
<p className="text-sm text-muted-foreground">
Önce şifreyi girin, sonra .encrypted uzantılı dosyayı seçin
</p>
</div>
{error && (
<div className="p-3 bg-destructive/10 text-destructive rounded-md text-sm">
{error}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+64 -11
View File
@@ -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<HTMLInputElement>) => {
@@ -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<string | null>(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) {
<ul className="space-y-2">
{attachments.map((att) => (
<li key={att.id} className="flex items-center gap-2 text-sm">
<span className="flex-1">{att.fileName}</span>
<span className="flex-1 truncate">
{att.fileName.endsWith(".enc") ? "🔒 Şifreli dosya" : att.fileName}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDownload(att.id, att.fileName)}
disabled={downloading === att.id}
>
İndir
{downloading === att.id ? "..." : "İndir"}
</Button>
{onDelete && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDelete(att.id)}
+138 -8
View File
@@ -6,9 +6,10 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
import { Plus, Trash2 } from "lucide-react";
import { Plus, Trash2, Lock, Unlock } from "lucide-react";
import { useRouter } from "next/navigation";
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
import { deriveKeyFromPassword, encrypt, decrypt, encryptFile } from "@/lib/crypto";
interface Attachment {
id: string;
@@ -23,6 +24,7 @@ interface MessageFormProps {
recipients: string[];
checkInterval: number;
attachments?: Attachment[];
isEncrypted?: boolean;
};
}
@@ -33,6 +35,10 @@ export default function MessageForm({ initialData }: MessageFormProps) {
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24);
const [attachments, setAttachments] = useState<Attachment[]>(initialData?.attachments || []);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isEncrypted, setIsEncrypted] = useState(initialData?.isEncrypted || false);
const [password, setPassword] = useState("");
const [isDecrypted, setIsDecrypted] = useState(!initialData?.isEncrypted);
const [decryptError, setDecryptError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
@@ -64,11 +70,41 @@ export default function MessageForm({ initialData }: MessageFormProps) {
}
};
const handleDecrypt = async () => {
if (!password) return;
setDecryptError(null);
setLoading(true);
try {
const key = await deriveKeyFromPassword(password);
const decryptedContent = await decrypt(content, key);
setContent(decryptedContent);
setIsDecrypted(true);
} catch {
setDecryptError("Şifre hatalı veya içerik çözülemedi");
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isEncrypted && !password) {
alert("Şifreleme için şifre girmelisiniz");
return;
}
setLoading(true);
try {
let encryptionKey: string | null = null;
let finalContent = content;
if (isEncrypted) {
encryptionKey = await deriveKeyFromPassword(password);
finalContent = await encrypt(content, encryptionKey);
}
const url = initialData?.id
? `/api/messages/${initialData.id}`
: "/api/messages";
@@ -78,19 +114,34 @@ export default function MessageForm({ initialData }: MessageFormProps) {
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content, recipients, checkInterval }),
body: JSON.stringify({
title,
content: finalContent,
recipients,
checkInterval,
isEncrypted
}),
});
if (response.ok) {
const message = await response.json();
for (const file of pendingFiles) {
let fileToUpload: Blob = file;
let fileName = file.name;
if (isEncrypted && encryptionKey) {
fileToUpload = await encryptFile(file, encryptionKey);
const encryptedFileName = await encrypt(file.name, encryptionKey);
fileName = encryptedFileName + ".enc";
}
const uploadRes = await fetch("/api/attachments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileName,
contentType: isEncrypted ? "application/octet-stream" : file.type,
messageId: message.id,
}),
});
@@ -98,8 +149,8 @@ export default function MessageForm({ initialData }: MessageFormProps) {
const { uploadUrl } = await uploadRes.json();
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
body: fileToUpload,
headers: { "Content-Type": isEncrypted ? "application/octet-stream" : file.type },
});
}
}
@@ -114,9 +165,38 @@ export default function MessageForm({ initialData }: MessageFormProps) {
}
};
if (initialData?.isEncrypted && !isDecrypted) {
return (
<Card className="w-full max-w-3xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5" /> Şifreli Mesaj
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Bu mesaj şifrelenmiş. Düzenlemek için şifreyi girin.
</p>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Şifre"
/>
{decryptError && (
<p className="text-sm text-destructive">{decryptError}</p>
)}
<Button onClick={handleDecrypt} disabled={loading || !password}>
<Unlock className="h-4 w-4 mr-2" /> Şifreyi Çöz
</Button>
</CardContent>
</Card>
);
}
return (
<form onSubmit={handleSubmit}>
<Card className="w-full max-w-2xl mx-auto">
<Card className="w-full max-w-3xl mx-auto">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{initialData ? "Mesajı Düzenle" : "Yeni Mesaj Oluştur"}</CardTitle>
{initialData && (
@@ -196,12 +276,54 @@ export default function MessageForm({ initialData }: MessageFormProps) {
required
/>
</div>
{!initialData?.isEncrypted && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="encrypted"
checked={isEncrypted}
onChange={(e) => setIsEncrypted(e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="encrypted" className="flex items-center gap-1 cursor-pointer">
<Lock className="h-4 w-4" /> Şifreli mesaj
</Label>
</div>
{isEncrypted && (
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Şifre"
required={isEncrypted}
/>
)}
</div>
)}
{initialData?.isEncrypted && (
<div className="space-y-2">
<div className="p-3 bg-muted rounded-md flex items-center gap-2">
<Lock className="h-4 w-4" />
<span className="text-sm">Bu mesaj şifrelenmiş (kaydetmek için şifre gerekli)</span>
</div>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Şifre"
required
/>
</div>
)}
<div className="space-y-2">
<Label>Dosyalar</Label>
{initialData?.id && (
<AttachmentList
attachments={attachments}
onDelete={(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 ? (
<FileUpload
messageId={initialData.id}
onUploadComplete={() => 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);
}
}}
/>
) : (
<div>
+96
View File
@@ -0,0 +1,96 @@
export async function deriveKeyFromPassword(password: string): Promise<string> {
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<string> {
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<CryptoKey> {
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<string> {
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<string> {
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<Blob> {
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<ArrayBuffer> {
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);
}