mirror of
https://github.com/yusufipk/dead-man-switch-2.0.git
synced 2026-09-11 09:26:07 +00:00
feat: phase 4 completed
This commit is contained in:
+26
-6
@@ -18,16 +18,18 @@
|
|||||||
- [x] Mesaj düzenleme/silme
|
- [x] Mesaj düzenleme/silme
|
||||||
- [x] IDOR güvenlik açığı düzeltildi
|
- [x] IDOR güvenlik açığı düzeltildi
|
||||||
|
|
||||||
### Faz 3: Dosya Yükleme (R2) 🔲
|
### Faz 3: Dosya Yükleme (R2) ✅
|
||||||
- [x] Cloudflare R2 entegrasyonu
|
- [x] Cloudflare R2 entegrasyonu
|
||||||
- [x] Dosya upload/download API
|
- [x] Dosya upload/download API
|
||||||
- [x] Dosya listeleme UI
|
- [x] Dosya listeleme UI
|
||||||
|
|
||||||
### Faz 4: Client-side Encryption 🔲
|
### Faz 4: Client-side Encryption ✅
|
||||||
- [ ] Web Crypto API wrapper
|
- [x] Web Crypto API wrapper
|
||||||
- [ ] Encrypt/decrypt utilities
|
- [x] Encrypt/decrypt utilities
|
||||||
- [ ] Şifreli mesaj oluşturma akışı
|
- [x] Şifreli mesaj oluşturma akışı
|
||||||
- [ ] Decrypt sayfası (alıcı için)
|
- [x] Decrypt sayfası (alıcı için)
|
||||||
|
- [x] Şifreli mesaj düzenleme
|
||||||
|
- [x] Dosya adı şifreleme
|
||||||
|
|
||||||
### Faz 5: Check-in & Cron 🔲
|
### Faz 5: Check-in & Cron 🔲
|
||||||
- [ ] Check-in API endpoint
|
- [ ] Check-in API endpoint
|
||||||
@@ -54,6 +56,23 @@
|
|||||||
- [ ] Production deployment
|
- [ ] Production deployment
|
||||||
- [ ] Monitoring setup
|
- [ ] 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
|
## Tamamlanan
|
||||||
- ✅ Teknoloji stack seçimi
|
- ✅ Teknoloji stack seçimi
|
||||||
- ✅ Memory bank oluşturma
|
- ✅ Memory bank oluşturma
|
||||||
@@ -61,6 +80,7 @@
|
|||||||
- ✅ Faz 1: Auth sistemi
|
- ✅ Faz 1: Auth sistemi
|
||||||
- ✅ Faz 2: Mesaj CRUD
|
- ✅ Faz 2: Mesaj CRUD
|
||||||
- ✅ Faz 3: Dosya Yükleme (R2)
|
- ✅ Faz 3: Dosya Yükleme (R2)
|
||||||
|
- ✅ Faz 4: Client-side Encryption
|
||||||
|
|
||||||
## Bilinen Sorunlar
|
## Bilinen Sorunlar
|
||||||
(henüz yok)
|
(henüz yok)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Message" ADD COLUMN "isEncrypted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -50,6 +50,7 @@ model Message {
|
|||||||
title String
|
title String
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
status String @default("DRAFT")
|
status String @default("DRAFT")
|
||||||
|
isEncrypted Boolean @default(false)
|
||||||
lastPing DateTime @default(now())
|
lastPing DateTime @default(now())
|
||||||
checkInterval Int @default(24)
|
checkInterval Int @default(24)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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 }))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { title, content, recipients, checkInterval } = body;
|
const { title, content, recipients, checkInterval, isEncrypted } = body;
|
||||||
|
|
||||||
const existingMessage = await prisma.message.findUnique({
|
const existingMessage = await prisma.message.findUnique({
|
||||||
where: { id: messageId, userId: session.user.id },
|
where: { id: messageId, userId: session.user.id },
|
||||||
@@ -40,6 +40,7 @@ export async function PATCH(
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
checkInterval,
|
checkInterval,
|
||||||
|
isEncrypted: isEncrypted || existingMessage.isEncrypted,
|
||||||
recipients: {
|
recipients: {
|
||||||
create: recipients.map((email: string) => ({ email })),
|
create: recipients.map((email: string) => ({ email })),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { title, content, recipients, checkInterval } = body;
|
const { title, content, recipients, checkInterval, isEncrypted } = body;
|
||||||
|
|
||||||
const message = await prisma.message.create({
|
const message = await prisma.message.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -20,6 +20,7 @@ export async function POST(req: Request) {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
checkInterval,
|
checkInterval,
|
||||||
|
isEncrypted: isEncrypted || false,
|
||||||
recipients: {
|
recipients: {
|
||||||
create: recipients.map((email: string) => ({ email })),
|
create: recipients.map((email: string) => ({ email })),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default async function EditMessagePage({
|
|||||||
checkInterval: message.checkInterval,
|
checkInterval: message.checkInterval,
|
||||||
recipients: message.recipients.map((r) => r.email),
|
recipients: message.recipients.map((r) => r.email),
|
||||||
attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })),
|
attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })),
|
||||||
|
isEncrypted: message.isEncrypted,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import MessageForm from "@/components/messages/message-form";
|
|||||||
|
|
||||||
export default function NewMessagePage() {
|
export default function NewMessagePage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10">
|
<div className="container mx-auto py-10 px-4">
|
||||||
<h1 className="text-2xl font-bold mb-6 text-center">Yeni Ölü Adam Anahtarı Oluştur</h1>
|
<div className="max-w-3xl mx-auto">
|
||||||
<MessageForm />
|
<MessageForm />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { deriveKeyFromPassword, encrypt, decrypt, encryptFile, decryptFile } from "@/lib/crypto";
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
isEncrypted?: boolean;
|
||||||
|
password?: string;
|
||||||
onUploadComplete?: () => void;
|
onUploadComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +17,7 @@ interface Attachment {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
|
export function FileUpload({ messageId, isEncrypted, password, onUploadComplete }: FileUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -22,12 +26,23 @@ export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
|
|||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
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", {
|
const res = await fetch("/api/attachments", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fileName: file.name,
|
fileName,
|
||||||
contentType: file.type,
|
contentType,
|
||||||
messageId,
|
messageId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -38,8 +53,8 @@ export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
|
|||||||
|
|
||||||
await fetch(uploadUrl, {
|
await fetch(uploadUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: file,
|
body: fileToUpload,
|
||||||
headers: { "Content-Type": file.type },
|
headers: { "Content-Type": contentType },
|
||||||
});
|
});
|
||||||
|
|
||||||
onUploadComplete?.();
|
onUploadComplete?.();
|
||||||
@@ -72,13 +87,46 @@ export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
|
|||||||
interface AttachmentListProps {
|
interface AttachmentListProps {
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
onDelete?: (id: string) => void;
|
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 handleDownload = async (id: string, fileName: string) => {
|
||||||
const res = await fetch(`/api/attachments/${id}`);
|
setDownloading(id);
|
||||||
const { downloadUrl } = await res.json();
|
try {
|
||||||
window.open(downloadUrl, "_blank");
|
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) => {
|
const handleDelete = async (id: string) => {
|
||||||
@@ -92,16 +140,21 @@ export function AttachmentList({ attachments, onDelete }: AttachmentListProps) {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{attachments.map((att) => (
|
{attachments.map((att) => (
|
||||||
<li key={att.id} className="flex items-center gap-2 text-sm">
|
<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
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDownload(att.id, att.fileName)}
|
onClick={() => handleDownload(att.id, att.fileName)}
|
||||||
|
disabled={downloading === att.id}
|
||||||
>
|
>
|
||||||
İndir
|
{downloading === att.id ? "..." : "İndir"}
|
||||||
</Button>
|
</Button>
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(att.id)}
|
onClick={() => handleDelete(att.id)}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
|
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 { useRouter } from "next/navigation";
|
||||||
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
|
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
|
||||||
|
import { deriveKeyFromPassword, encrypt, decrypt, encryptFile } from "@/lib/crypto";
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,7 @@ interface MessageFormProps {
|
|||||||
recipients: string[];
|
recipients: string[];
|
||||||
checkInterval: number;
|
checkInterval: number;
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
|
isEncrypted?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,10 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24);
|
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24);
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>(initialData?.attachments || []);
|
const [attachments, setAttachments] = useState<Attachment[]>(initialData?.attachments || []);
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
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 [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isEncrypted && !password) {
|
||||||
|
alert("Şifreleme için şifre girmelisiniz");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let encryptionKey: string | null = null;
|
||||||
|
let finalContent = content;
|
||||||
|
|
||||||
|
if (isEncrypted) {
|
||||||
|
encryptionKey = await deriveKeyFromPassword(password);
|
||||||
|
finalContent = await encrypt(content, encryptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
const url = initialData?.id
|
const url = initialData?.id
|
||||||
? `/api/messages/${initialData.id}`
|
? `/api/messages/${initialData.id}`
|
||||||
: "/api/messages";
|
: "/api/messages";
|
||||||
@@ -78,19 +114,34 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ title, content, recipients, checkInterval }),
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
content: finalContent,
|
||||||
|
recipients,
|
||||||
|
checkInterval,
|
||||||
|
isEncrypted
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const message = await response.json();
|
const message = await response.json();
|
||||||
|
|
||||||
for (const file of pendingFiles) {
|
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", {
|
const uploadRes = await fetch("/api/attachments", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fileName: file.name,
|
fileName,
|
||||||
contentType: file.type,
|
contentType: isEncrypted ? "application/octet-stream" : file.type,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -98,8 +149,8 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
const { uploadUrl } = await uploadRes.json();
|
const { uploadUrl } = await uploadRes.json();
|
||||||
await fetch(uploadUrl, {
|
await fetch(uploadUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: file,
|
body: fileToUpload,
|
||||||
headers: { "Content-Type": file.type },
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>{initialData ? "Mesajı Düzenle" : "Yeni Mesaj Oluştur"}</CardTitle>
|
<CardTitle>{initialData ? "Mesajı Düzenle" : "Yeni Mesaj Oluştur"}</CardTitle>
|
||||||
{initialData && (
|
{initialData && (
|
||||||
@@ -196,12 +276,54 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label>Dosyalar</Label>
|
<Label>Dosyalar</Label>
|
||||||
{initialData?.id && (
|
{initialData?.id && (
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
onDelete={(id) => setAttachments(attachments.filter((a) => a.id !== id))}
|
onDelete={(id) => setAttachments(attachments.filter((a) => a.id !== id))}
|
||||||
|
isEncrypted={isEncrypted}
|
||||||
|
password={password}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pendingFiles.length > 0 && (
|
{pendingFiles.length > 0 && (
|
||||||
@@ -224,7 +346,15 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
{initialData?.id ? (
|
{initialData?.id ? (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
messageId={initialData.id}
|
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>
|
<div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user