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] 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;
|
||||
@@ -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 }))
|
||||
);
|
||||
}
|
||||
@@ -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 })),
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 Ölü Adam Anahtarı Oluştur</h1>
|
||||
<div className="container mx-auto py-10 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<MessageForm />
|
||||
</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 { 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) => {
|
||||
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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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