diff --git a/database.types.ts b/database.types.ts new file mode 100644 index 0000000..8d39734 Binary files /dev/null and b/database.types.ts differ diff --git a/package-lock.json b/package-lock.json index 18fcb15..92a1894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "^0.468.0", "next": "15.0.4", "next-themes": "^0.4.4", + "random-words": "^2.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^2.5.5", @@ -5774,6 +5775,14 @@ } ] }, + "node_modules/random-words": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/random-words/-/random-words-2.0.1.tgz", + "integrity": "sha512-nZNJAmgcFmtJMTDDIUCm/iK4R6RydC6NvALvWhYItXQrgYGk1F7Gww416LpVROFQtfVd5TaLEf4WuSsko03N7w==", + "dependencies": { + "seedrandom": "^3.0.5" + } + }, "node_modules/react": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", @@ -5971,6 +5980,11 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", diff --git a/package.json b/package.json index 5f5cb8a..75bb1d9 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lucide-react": "^0.468.0", "next": "15.0.4", "next-themes": "^0.4.4", + "random-words": "^2.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^2.5.5", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 7479f63..d7fa4d9 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -32,7 +32,7 @@ export async function POST(request: Request) { // Fetch our custom user data const {data: userData, error: userError} = await supabase .from('users') - .select('*') + .select('*, public_key') .eq('uuid', user?.id) .single() diff --git a/src/app/api/user/create/thread/route.ts b/src/app/api/user/create/thread/route.ts new file mode 100644 index 0000000..14f5acd --- /dev/null +++ b/src/app/api/user/create/thread/route.ts @@ -0,0 +1,58 @@ +import {NextResponse} from "next/server"; +import {createClient} from "@/lib/supabase/server"; +import getUserByUUID from "@/lib/api/helpers/getUserByUUID"; +import updateUserRequests from "@/lib/api/helpers/updateUserRequests"; + +export async function POST(req: Request) { + const {participant} = await req.json(); + + if (!participant) { + return NextResponse.json({error: 'Participant not found'}, {status: 400}); + } + + const supabase = await createClient() + + const {data: {user}, error: userError} = await supabase.auth.getUser() + console.log("From user: ", user?.id) + if (userError) { + return NextResponse.json( + {error: userError}, + {status: userError?.status} + ) + } else if (!user) { + return NextResponse.json( + {error: "User not found"}, + {status: 401} + ) + } + + /** First we need to check if the requested participant is in the user's request array */ + const dbUser = await getUserByUUID(supabase, user.id) + + if (!dbUser) { + return NextResponse.json( + {error: "User not found"}, + {status: 401} + ) + } + + const requests = dbUser.requests as string[] + + if (!requests.includes(participant)) { + return NextResponse.json({error: "Requested user not in requests array."}, {status: 400}) + } else if (participant === dbUser.suuid) { + return NextResponse.json({error: "Cannot add self to a new thread"}, {status: 400}) + } + + /** Then we can create the thread */ + + const {error} = await supabase.rpc('create_private_thread', { + participant_suuid: participant + }); + + if (error) { + return NextResponse.json({error}, {status: 500}); + } + + return NextResponse.json({success: true}, {status: 200}); +} \ No newline at end of file diff --git a/src/app/api/user/get/threads/route.ts b/src/app/api/user/get/threads/route.ts index e08385c..7d14dd9 100644 --- a/src/app/api/user/get/threads/route.ts +++ b/src/app/api/user/get/threads/route.ts @@ -26,8 +26,8 @@ export async function GET() { } ) - if (data.length === 0) { - return NextResponse.json({threads: []}, {status: 200}); + if (error) { + return NextResponse.json({error}, {status: 400}) } return NextResponse.json({threads: data}, {status: 200}); diff --git a/src/app/api/user/search/user/route.ts b/src/app/api/user/search/user/route.ts index cdcec2f..4c9e1b6 100644 --- a/src/app/api/user/search/user/route.ts +++ b/src/app/api/user/search/user/route.ts @@ -28,15 +28,12 @@ export async function GET(request: Request) { ) } - const rpcResult = await supabase.rpc('search_users', { + const {data, error} = await supabase.rpc('search_users', { search_term: uuid }); - const {data, error} = rpcResult; if (error) { return NextResponse.json({error: error}, {status: 500}); - } else if (data.length === 0) { - return NextResponse.json({user: []}, {status: 200}); } return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200}); diff --git a/src/app/api/user/send/message/route.ts b/src/app/api/user/send/message/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/user/send/request/route.ts b/src/app/api/user/send/request/route.ts index b4a4e37..525997e 100644 --- a/src/app/api/user/send/request/route.ts +++ b/src/app/api/user/send/request/route.ts @@ -2,28 +2,7 @@ import {createClient} from "@/lib/supabase/server"; import {NextResponse} from "next/server"; import {SupabaseClient} from "@supabase/supabase-js"; import getUserByUUID from "@/lib/api/helpers/getUserByUUID"; - -async function updateUserRequests(searchTerm: string, requestSuuid: string, supabase: SupabaseClient) { - try { - - const {data, error} = await supabase.rpc('update_user_requests', { - search_term: searchTerm, - new_request: requestSuuid - }); - - if (error) { - throw error; - } - - return {success: true, data}; - } catch (error) { - console.error('Error updating user requests:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }; - } -} +import updateUserRequests from "@/lib/api/helpers/updateUserRequests"; export async function POST(request: Request) { try { @@ -49,7 +28,7 @@ export async function POST(request: Request) { const userSuuid = getUser.suuid; if (userSuuid === searchTerm) { - return NextResponse.json({success: false, hint: "Used self SUUID"}, {status: 409}); + return NextResponse.json({success: false, hint: "Cannot send request to self"}, {status: 409}); } const result = await updateUserRequests(searchTerm, userSuuid, supabase); diff --git a/src/app/api/user/send/update/key/route.ts b/src/app/api/user/send/update/key/route.ts new file mode 100644 index 0000000..5b38a5c --- /dev/null +++ b/src/app/api/user/send/update/key/route.ts @@ -0,0 +1,24 @@ +// app/api/user/keys/update/route.ts +import {createClient} from "@/lib/supabase/server"; +import {NextResponse} from "next/server"; + +export async function POST(request: Request) { + try { + const {publicKey} = await request.json(); + const supabase = await createClient(); + + const {error} = await supabase + .from('users') + .update({public_key: publicKey}) + .eq('uuid', (await supabase.auth.getUser()).data.user?.id); + + if (error) throw error; + + return NextResponse.json({success: true}); + } catch (error) { + return NextResponse.json( + {error: 'Failed to update public key'}, + {status: 500} + ); + } +} \ No newline at end of file diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 250f329..4fabf1f 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -75,6 +75,7 @@ export default function AuthPage() { message: string; action?: ToastActionElement | undefined; } + if (!isLogin) { response = await Register(username, password); } else { diff --git a/src/app/auth/login/register.ts b/src/app/auth/login/register.ts index 2559477..b1c395e 100644 --- a/src/app/auth/login/register.ts +++ b/src/app/auth/login/register.ts @@ -1,3 +1,5 @@ +import {CryptoManager} from "@/lib/crypto/keys"; + /** * * @param username - The unique username of that user. This will be checked for collision. @@ -6,13 +8,19 @@ */ export default async function Register(username: string, password: string) { try { + const keyPair = await CryptoManager.generateUserKeys(); + await CryptoManager.storePrivateKey(keyPair.privateKey); + + // Export public key for server + const exportedPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + // Sends the request to the API let res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({username, password}), // Stringifies the JSON + body: JSON.stringify({username, password, publicKey: exportedPublic}), // Stringifies the JSON }); // Default error handler, if not OK just return whatever the API returned diff --git a/src/app/page.tsx b/src/app/page.tsx index caa4e0a..2457b1a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -17,11 +17,16 @@ import { AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import {CryptoManager} from "@/lib/crypto/keys"; +import UpdateKey from "@/lib/crypto/helpers/updateKey"; export default function SiPher() { const {theme, systemTheme} = useTheme(); const [mounted, setMounted] = useState(false); + /** CryptoManager Alert */ + const [privateKeyPresent, setPrivateKeyPresent] = useState(true); + /** Consent Form states */ const [showConsentForm, setShowConsentForm] = useState(false); const [formError, setFormError] = useState(""); @@ -37,6 +42,15 @@ export default function SiPher() { setMounted(true); }, []); + useEffect(() => { + CryptoManager.getPrivateKey().then((res) => { + if (!res) { + console.log(res) + setPrivateKeyPresent(false); + } + }) + }, []) + /** * @param search_term Either the SUUID or username (If not indexable, will return false.) */ @@ -73,11 +87,15 @@ export default function SiPher() { "Content-Type": "application/json" }, body: JSON.stringify({ - searchTerm: user, // SUUID or username - }) + searchTerm: user, // SUUID or username + }) }); - if (!req.ok) return false; + if (!req.ok) { + const res = await req.json(); + setFormError(res.hint); + return false; + } const {sent} = await req.json() as { sent: boolean }; // If the user does not exist, just return it @@ -96,43 +114,96 @@ export default function SiPher() { const currentTheme = getTheme(); - return ( - <> - { - if (!open) setFormError(""); - }}> - - - - Consent Form - - + const MainPageAlerts = () => { + return ( + <> + { + if (!open) setFormError(""); + }}> + + + + Consent Form + + { + formError ? ( + {formError} + ) : null + } + Are you sure you want to contact {inputValue}? - + By continuing, {inputValue} will receive a notification to accept it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it. - - - - { - setShowConsentForm(false); - setInputDisabled(false); - }} - >Cancel - { - sendRequest(inputValue).then((result) => { - if (!result) setFormError("Could not send notification for whatever reason. Sorry."); - }); - setInputDisabled(false); - }} - >Continue - - - + + + + { + setShowConsentForm(false); + setInputDisabled(false); + }} + >Cancel + { + sendRequest(inputValue); + setInputDisabled(false); + setShowConsentForm(false); + }} + >Continue + + + + + + + + + Private Key Missing + + This app could not retrieve your private key, which means it's either lost, never stored or corrupted. Want to try again or insert it from a backup? + You can also regenerate it if you do not have it backed up, but this would mean that you'll loose access to all old messages. + + + + { + setShowConsentForm(false); + setInputDisabled(false); + }} + >Cancel + { + sendRequest(inputValue).then((result) => { + if (!result) setFormError("Could not send notification for whatever reason. Sorry."); + }); + setInputDisabled(false); + }} + >Try Again + { + UpdateKey().then((result) => { + if (result.status !== 200) { + return; + } + setPrivateKeyPresent(true) + }) + }} + >Regenerate + + + + + ) + } + + return ( + <> + +
@@ -153,7 +224,8 @@ export default function SiPher() {

Where shadows dance and secrets nest, Silent Whisper serves as the dark sanctuary for those - who value discretion above all. Born from ancient corvid traditions, this messenger’s haven ensures your + who value discretion above all. Born from ancient corvid traditions, this messenger’s haven ensures + your whispers remain unheard by all but their intended recipients.

diff --git a/src/components/main/realtime/request.tsx b/src/components/main/realtime/request.tsx index d91b339..a6fa683 100644 --- a/src/components/main/realtime/request.tsx +++ b/src/components/main/realtime/request.tsx @@ -27,6 +27,7 @@ export function RealtimeRequests( table: 'users', filter: `uuid=eq.${user.uuid}`, }, async (payload) => { + console.log(payload) if (payload.new.requests !== payload.old.requests) { try { setRequests(payload.new.requests) diff --git a/src/components/main/realtime/threads.tsx b/src/components/main/realtime/threads.tsx new file mode 100644 index 0000000..8665dcf --- /dev/null +++ b/src/components/main/realtime/threads.tsx @@ -0,0 +1,66 @@ +// hooks/useRealtime.ts +import {useEffect} from 'react' +import {createBrowserClient} from '@/lib/supabase/browser' +import {useUser} from '@/contexts/user' +import {useToast} from '@/hooks/use-toast' + +interface UseRealtimeProps { + setThreads: React.Dispatch>; + threads: SiPher.Messages[] +} + +export function useRealtime({setThreads}: UseRealtimeProps) { + const supabase = createBrowserClient(); + const {user, updateUser} = useUser(); + const {toast} = useToast(); + + const fetchAndUpdateThreads = async () => { + try { + const response = await fetch("/api/user/get/threads"); + if (response.ok) { + const {threads} = await response.json(); + console.log('Setting threads:', threads); + setThreads(threads); + } + } catch (error) { + console.error('Error fetching threads:', error); + } + }; + + useEffect(() => { + if (!user) return; + + const userUpdate = supabase + .channel("request updates") + .on("postgres_changes", { + event: "*", + schema: 'public', + table: 'users', + filter: `uuid=eq.${user.uuid}`, + }, async (payload) => { + if (payload.eventType === "UPDATE") { + // This will also handle updates for the threads, but only for the user that accepted the request. + // Why? Because the function that creates the thread will also update the current user request field and remove + // the corresponding request. + if (payload.new.requests !== payload.old.requests) { + updateUser({ + ...user, + requests: payload.new.requests + }) + } + } + }).subscribe() + + const threadUpdate = supabase + .channel("thread updates") + .on("postgres_changes", { + event: "*", + schema: 'public', + // Using on this one because it's easier + table: "thread_participants", + filter: `user_uuid=${user.uuid}`, + }, async (payload) => { + console.log(payload) + }).subscribe() + }, [user?.uuid]); +} \ No newline at end of file diff --git a/src/components/main/sidebar/rightsidebar.tsx b/src/components/main/sidebar/rightsidebar.tsx new file mode 100644 index 0000000..dee01d3 --- /dev/null +++ b/src/components/main/sidebar/rightsidebar.tsx @@ -0,0 +1,202 @@ +import React, {useCallback, useEffect, useState} from "react"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; +import {Avatar, AvatarFallback} from "@/components/ui/avatar"; +import {Separator} from "@/components/ui/separator"; +import {ScrollArea} from "@/components/ui/scroll-area"; +import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; +import {Check, LogOut, Mail, MailPlus, X} from "lucide-react"; +import {Button} from "@/components/ui/button"; +import {GearIcon} from "@radix-ui/react-icons"; +import Link from "next/link"; +import {useRealtime} from "@/components/main/realtime/threads"; +import {useUser} from "@/contexts/user"; + +interface RightSidebarContentProps { + isDarkMode: boolean; +} + +export default function RightSidebarContent( + { + isDarkMode, + }: RightSidebarContentProps) { + + const [selectedThreads, setSelectedThreads] = useState(""); + const [threadMenu, setThreadMenu] = useState([]); + const [pendingRequest, setPendingRequest] = useState(0); + + + const [threads, setThreads] = useState([]); + useRealtime( + {setThreads} + ); + const [copied, setCopied] = useState(false); + + const {user} = useUser(); + const {username, suuid, requests = []} = user; + + + // No need for separate requests state since it's in user object + const pendingRequests = requests?.length ?? 0; + + // Move fetch to separate function + const fetchThreads = useCallback(async () => { + try { + const req = await fetch("/api/user/get/threads") + if (req.ok) { + const {threads} = await req.json() as { threads: SiPher.Messages[] | [] } + setThreads(threads) + } else { + setThreads([]) + } + } catch (error) { + console.log(error); + setThreads([]) + } + }, []); + + useEffect(() => { + fetchThreads(); + }, [fetchThreads]); + + const handleAccept = async (request: string) => { + try { + const response = await fetch("/api/user/create/thread", { + method: "POST", + body: JSON.stringify({participant: request}), + }); + if (response.ok) { + // Optionally refresh threads after successful creation + fetchThreads(); + } + } catch (error) { + console.error('Error accepting request:', error); + } + } + + + return ( + <> +
+ + + + + Copied SUUID to clipboard! + + + +
{ + setCopied(true) + navigator.clipboard.writeText(suuid) + }} + className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200 cursor-pointer select-none`}> + + {username.charAt(0)} + +
+

{username}

+

${suuid}

+
+
+ + + + +
+ + + +
+
+ + ) +} \ No newline at end of file diff --git a/src/components/main/sidebar/sidebar.tsx b/src/components/main/sidebar/sidebar.tsx index 36fac85..db71f99 100644 --- a/src/components/main/sidebar/sidebar.tsx +++ b/src/components/main/sidebar/sidebar.tsx @@ -1,23 +1,15 @@ "use client" -import React, {useCallback, useEffect, useState} from "react" -import {usePathname} from "next/navigation" +import React from "react" import Link from "next/link" import {AnimatePresence, motion} from "framer-motion" -import {Check, LogOut, Mail, MailPlus, X} from "lucide-react" +import {X} from "lucide-react" import {Button} from "@/components/ui/button" -import {Avatar, AvatarFallback} from "@/components/ui/avatar" -import {Separator} from "@/components/ui/separator" -import {ScrollArea} from "@/components/ui/scroll-area" -import {GearIcon} from "@radix-ui/react-icons" import Image from "next/image"; import MobileHeader from "@/components/main/sidebar/mobile"; -import {useUser} from "@/contexts/user"; import {useRefs, useUIState} from "@/hooks/shared-states"; import {useToast} from "@/hooks/use-toast"; import {useTheme} from "next-themes"; -import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; -import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; -import {RealtimeRequests} from "@/components/main/realtime/request"; +import RightSidebarContent from "@/components/main/sidebar/rightsidebar"; type SidebarProps = { children?: React.ReactNode @@ -28,213 +20,24 @@ function Sidebar( children }: SidebarProps ) { - const pathname = usePathname() - - const [selectedThreads, setSelectedThreads] = useState(""); - const [threads, setThreads] = useState([]); - const [threadMenu, setThreadMenu] = useState([]); - const [copied, setCopied] = useState(false); const {theme, systemTheme} = useTheme(); const {toast} = useToast(); const {isDrawerOpen, setIsDrawerOpen} = useUIState(); const {drawerRef} = useRefs(); - const [requests, setRequests] = useState([]); - const [pendingRequest, setPendingRequest] = useState(0); - - const {user, getUser} = useUser(); - - const { - username, - suuid - } = user; - - useEffect(() => { - setPendingRequest(requests.length || 0); - }, [requests, setPendingRequest]); - - useEffect(() => { - setPendingRequest(user.requests.length); - setRequests(user.requests); - }, []); - - useEffect(() => { - const getThreads = async () => { - try { - const req = await fetch("/api/user/get/threads") - if (req.ok) { - const {threads} = await req.json() as { threads: SiPher.Messages[] | [] } - setThreads(threads) - } else { - setThreads([]) - toast({ - title: "Error", - description: "An unknown error occurred", - variant: "destructive", - duration: 5000, - }) - } - } catch (error) { - setThreads([]) - } - } - - getThreads() - return () => setThreads([]) - }, [toast]) - - const generateThreads = useCallback(() => { - threads.map(async (thread) => { - if (thread.participants.length > 2) { - return ( -
  • - - - -
  • - ) - } else { - const fetchOtherUser = async () => { - await getUser("fetchOtherUser - const", thread.id) - } - } - }) - }, [threads]) - const isDarkMode = theme === "system" ? systemTheme === "dark" : theme === "dark" - const RightSidebarContent = () => ( -
    - - - - - Copied SUUID to clipboard! - - - -
    { - setCopied(true) - navigator.clipboard.writeText(suuid) - }} - className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200 cursor-pointer select-none`}> - - {username.charAt(0)} - -
    -

    {username}

    -

    ${suuid}

    -
    -
    - - - - -
    - - - -
    -
    - ) + + const handleAcceptRequest = async () => { + + } return ( <> -
    - + {isDrawerOpen && ( @@ -276,7 +81,9 @@ function Sidebar( Close menu - +
    )} diff --git a/src/lib/api/helpers/getUserByUUID.ts b/src/lib/api/helpers/getUserByUUID.ts index e5aefbb..3c6fee2 100644 --- a/src/lib/api/helpers/getUserByUUID.ts +++ b/src/lib/api/helpers/getUserByUUID.ts @@ -3,9 +3,9 @@ import {SupabaseClient} from "@supabase/supabase-js"; export default async function getUserByUUID(supabase: SupabaseClient, uuid: string) { const {data: userData, error: userError} = await supabase .from('users') - .select('*') + .select('*, public_key') .eq('uuid', uuid) - .single(); + .single() if (userError) throw userError; return userData; diff --git a/src/lib/api/helpers/updateUserRequests.ts b/src/lib/api/helpers/updateUserRequests.ts new file mode 100644 index 0000000..2f2f8b8 --- /dev/null +++ b/src/lib/api/helpers/updateUserRequests.ts @@ -0,0 +1,23 @@ +import {SupabaseClient} from "@supabase/supabase-js"; + +export default async function updateUserRequests(searchTerm: string, requestSuuid: string, supabase: SupabaseClient) { + try { + + const {data, error} = await supabase.rpc('update_user_requests', { + search_term: searchTerm, + new_request: requestSuuid + }); + + if (error) { + throw error; + } + + return {success: true, data}; + } catch (error) { + console.error('Error updating user requests:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} \ No newline at end of file diff --git a/src/lib/crypto/helpers/updateKey.ts b/src/lib/crypto/helpers/updateKey.ts new file mode 100644 index 0000000..2b78c01 --- /dev/null +++ b/src/lib/crypto/helpers/updateKey.ts @@ -0,0 +1,27 @@ +"use client"; + +import {CryptoManager} from "@/lib/crypto/keys"; + +export default async function UpdateKey() { + const keyPair = await CryptoManager.generateUserKeys(); + await CryptoManager.storePrivateKey(keyPair.privateKey); + const exportedPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + + const req = await fetch("/api/user/send/update/key", { + method: "POST", + body: JSON.stringify({publicKey: exportedPublic}), + }) + + if(req.status !== 200) { + await CryptoManager.deletePrivateKey(); + return { + status: req.status, + message: "Failed to update public key", + } + } + + return { + status: 200, + message: "Successfully updated keys", + } +} \ No newline at end of file diff --git a/src/lib/crypto/keys.ts b/src/lib/crypto/keys.ts new file mode 100644 index 0000000..ddbd315 --- /dev/null +++ b/src/lib/crypto/keys.ts @@ -0,0 +1,192 @@ +"use client" + +/** + * @filedoc: When creating this, I thought that using PBKDF2 would be the best choice, which it isn't since I would have + * to share passwords between user, and to do that I would have to pass the password through the server, which would defeat + * both PBKDF2 and E2EE methods. + * So I went with a better approach: Using public/private keys and signing messages with the public user's key and my own + * key + */ + +/** + * A kinda-simple CryptoManager to handle keys and encrypt/decrypt messages. + * Uses IndexedDB to store private keys securely. + */ +export class CryptoManager { + private static readonly DB_NAME = 'SipherKeyStore'; + private static readonly DB_VERSION = 1; + private static readonly STORE_NAME = 'keys'; + private static readonly KEY_ID = 'private_key'; + + /** + * Opens db and creates the object store if needed. + * @returns {Promise} A promise that resolves to the database instance. + */ + private static async openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.DB_NAME, this.DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + db.createObjectStore(this.STORE_NAME); + }; + }); + } + + /** + * Generates a fresh RSA key pair. Yay, new keys! + * @returns {Promise} The generated RSA key pair. + */ + static async generateUserKeys(): Promise { + return await crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt", "decrypt"] + ); + } + + /** + * Stores the private key. + * @param {CryptoKey} privateKey - The private key to store. + * @returns {Promise} + */ + static async storePrivateKey(privateKey: CryptoKey): Promise { + const exportedPrivate = await crypto.subtle.exportKey('jwk', privateKey); + const db = await this.openDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.STORE_NAME, 'readwrite'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.put(exportedPrivate, this.KEY_ID); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + + transaction.oncomplete = () => db.close(); + }); + } + + /** + * Deletes the private key. + * @param {CryptoKey} privateKey - The private key to store. + * @returns {Promise} + */ + static async deletePrivateKey(): Promise { + const db = await this.openDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.STORE_NAME, 'readwrite'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.delete(this.KEY_ID); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + + transaction.oncomplete = () => db.close(); + }); + } + + /** + * Gets the stored private key from IndexedDB. Might return `null` if nothing's there. + * @returns {Promise} The private key or `null` if not found. + */ + static async getPrivateKey(): Promise { + try { + const db = await this.openDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.STORE_NAME, 'readonly'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.get(this.KEY_ID); + + request.onerror = () => reject(request.error); + request.onsuccess = async () => { + if (!request.result) { + resolve(null); + return; + } + + const privateKey = await crypto.subtle.importKey( + 'jwk', + request.result, + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + true, + ["decrypt"] + ); + resolve(privateKey); + }; + + transaction.oncomplete = () => db.close(); + }); + } catch (error) { + console.error('Oops! Error retrieving private key:', error); + return null; + } + } + + /** + * Encrypts a message using the recipient's public key. + * @param {string} message - The message you wanna encrypt. + * @param {JsonWebKey} recipientPublicKey - The recipient's public key in JWK format. + * @returns {Promise} The encrypted message in base64 format. + */ + static async encryptMessage(message: string, recipientPublicKey: JsonWebKey): Promise { + const publicKey = await crypto.subtle.importKey( + 'jwk', + recipientPublicKey, + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + true, + ["encrypt"] + ); + + const encoder = new TextEncoder(); + const encrypted = await crypto.subtle.encrypt( + { + name: "RSA-OAEP", + }, + publicKey, + encoder.encode(message) + ); + + return btoa(String.fromCharCode(...new Uint8Array(encrypted))); + } + + /** + * Decrypts a message using your own private key. + * @param {string} encryptedMessage - The encrypted message (base64 format). + * @returns {Promise} The decrypted message. + * @throws Will throw an error if no private key is found. + */ + static async decryptMessage(encryptedMessage: string): Promise { + const privateKey = await this.getPrivateKey(); + if (!privateKey) throw new Error("No private key found"); + + const encrypted = new Uint8Array( + atob(encryptedMessage).split('').map((char) => char.charCodeAt(0)) + ); + + const decrypted = await crypto.subtle.decrypt( + { + name: "RSA-OAEP", + }, + privateKey, + encrypted + ); + + return new TextDecoder().decode(decrypted); + } +} diff --git a/src/types/user.d.ts b/src/types/user.d.ts index 295d0f7..bab656b 100644 --- a/src/types/user.d.ts +++ b/src/types/user.d.ts @@ -1,9 +1,10 @@ +import {Json} from "../../database.types"; + declare global { namespace SiPher { type Messages = { - id: string; + thread_id: string; participants: string[]; - name?: string; messages: { id: string; content: string; @@ -12,20 +13,13 @@ declare global { } type User = { - /** Represents the unique username of a user. */ - username: string, - /** The encrypted password of said user. */ - password: string, - /** Unique UUID, long */ - uuid: string, - /** Short UUID, for index reasons */ - suuid: string, - /** Created at timestamp in UTC */ - created_at: string, - /** Messages field */ - messages: Messages[] - /** Consent Requests */ - requests: string[] // Only accessible to the current user logged in. Will contain an array of SUUIDs + created_at: string + indexable: boolean | null + public_key: Json | null + requests: string[] | null + suuid: string + username: string + uuid: string } } } diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 0000000..6eaf894 --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.0.0 \ No newline at end of file