Added all SQL scripts by using a python script to fetch them. Also added a "About" page and a skeleton to the chat page. Fixed the register function that was not setting the public_key on the database |
||
|---|---|---|
| .idea | ||
| public/logos | ||
| src | ||
| supabase | ||
| .eslintrc.json | ||
| .gitignore | ||
| components.json | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tailwind.config.ts | ||
| tsconfig.json | ||
Silent Whisper - SiPher
Video Demo:
Description:
I created this app mainly to learn more about design and improve my skills in this area, plus learn a bit more about how E2EE encryption works.
I ran into LOTS of problems (like, seriously, a ton) when starting the app, which made me use some workarounds to get it working 100%.
What does it do?
Here's what it does:
- You register your account with just a Username and Password - no email or obvious identification needed
- You share your SUUID with another user who then requests consent to start a chat
- Once a chat starts, you can send messages to that user, following this flow:
- You send a message
- It gets encrypted using RSA-OAEP with SHA-256 and then encoded in Base64 format
- It's sent to the server, stored in the database, and then triggers Supabase's Realtime to update both chats in real-time
- Rinse & Repeat
That's the basic functionality of the app - just encrypting messages and sending them to a server that eventually stores them and uses a websocket connection (not really sure if it's a websocket, but through debugging, I noticed that at least in development, it uses websocket). Nothing special or functionality that would make the app really secure or ideal for real use.
Design Choices
Tech Stack
For the tech stack, I decided to use:
- NextJs - Makes my life easier since Vercel can host it in a free plan
- Supabase - Has the Realtime feature, in which Vercel
And that's it, really. I only used those two to create this app. Along with obviously WebApis that are supported in browsers.
If curious, though, I use IntelliJ products to code because I like their products.
Front-End
I had a lot of trouble with the design, mainly because I wanted the app to be pretty, minimalist, and work well enough.
For the front-end design, I'll admit I used Claude (Anthropic) to make better decisions about the app, such as styling issues (Mainly trying to make it mobile compatible). Even though I used AI for help, I had in mind what I wanted: Similar to WhatsApp. With an empty margin and the app UI smaller than the total browser screen. This really helped make the design cleaner, for some reason.
I also decided that, in the main design, I wanted to use a more striking color with a deeper color - in this case, orange and black had a great contrast.
I did use ShadCn to make my life easier since it's a really good library for better development on the front-end. I also considered using bootstrap or other libraries such as MaterialUi, but ShadCn had the easiest setup, was more minimalistic and I could control the components in a better way.
Back-End
The back-end design was a bit easier to do, thanks to how easy Supabase and NextJS API routes are to use, so there wasn't much debate about this specific part. Even though I had many problems, mainly with RLS policies in Supabase, due to pure lack of experience with it. For a better experience, I also used Supabase's own AI to help debug scripts, drop functions, and request the best approach method for this project.
I debated myself a lot when making the SQL scripts, though. They changed way too much and probably this has a weird DB
structure. First I had in mind that each thread should be "indexable" (meaning, if the thread could be searched or not
for joining), then I changed it to each user being indexable or not (meaning a user could search for another using by
either using that user's SUUID or username) and I went with that.
Then I had to change the message structure due to forgetting that each message sent should be encrypted for the current
user too, else that user wouldn't be able to read what he sent to that user due to that message being encrypted only
with
the public key of the receiver end. With that, I also had to change the thread structures, making them separate in 3
tables:
- "message_threads" - The main table
- "thread_participants" - Holds the participants in each thread by indexing the thread id and user id
- "messages" - Holds the messages for both the user that sent them (By encrypting that message with the user's own public key for access) and the receiver. The front-end can differenciate between the sender/receiver by using the key " sender_uuid" and comparing the logged user's uuid with that key. Each message is indexed to the thread_id for retrieval
The main issue I did run into was: Supabase does not support username-only login.
So I had to improvise. I have a few domains that I bought some years ago and set the app to use that domain as a false
e-mail:
const domain = process.env.DOMAIN;
if (!domain) {
return NextResponse.json({
error: "Server is misconfigured, please check env variables and try again."
},
{
status: 500
})
} else if (!username || !password || !public_key) {
return NextResponse.json({
error: "Missing params"
}, {status: 400})
}
// First create the auth user
const {data: {user}, error: authError} = await supabase.auth.signUp({
email: `${username}@${domain}`, // Using username as email
password: password,
})
This function represents the register, but the login-flow also works in a similar way, you can check its script too.
Is this a breach on their policy? Well, I don't think it is... At least I hope it isn't.
But this works when setting a username-only login without having too much trouble.
Also, here's a cool badge:
Team MVPs
By team MVPs, I mean the functions that took the most work and time to get done and finished to a state where they worked well enough (as far as I could test).
-
This function really gave me A LOT of headaches, seriously, A LOT of headaches.
Starting with how the encryption would work, I first thought of something like PGP, but it would be VERY long and possibly conflict with Supabase when storing it since I didn't know how it would handle a very long context. I admit I asked Claude for help to decide the best method for this situation, and I still feel it's not as secure as I wanted, but it works perfectly and isn't too complex.
Another important point that I decided on design-wise is that both users would need to have the same message encrypted 2x. One from who sent it using their own public key (So that user can read their own message) and one for who will receive it using that user's public key (So they can also read the received message).
Here are the key functions with detailed explanations:
static async generateUserKeys(): Promise<CryptoKeyPair>: Generates a private and public key when called
static async storePrivateKey(privateKey: CryptoKey): Promise<void>: Stores the private key in the "IndexedDB" database
static async deletePrivateKey(): Promise<void>: Deletes the previously recorded private key. If there isn't one, returns an error.
static async getPrivateKey(): Promise<CryptoKey | null>: Returns the user's current key for message decryption. Returns "null" if there isn't a key
static async prepareAndSendMessage(message: string, senderPublicKey: JsonWebKey, recipientPublicKey: JsonWebKey, threadId: string): Promise<void>: Prepares the message for both users using the "encryptMessage" method, and then sends it to the " /api/user/send/message" API that invokes the SQL function in Supabase
static async decryptThreadMessages(messages: any[], userUuid: string): Promise<SiPher.DecryptedMessage[]>: Receives an array of messages (from Supabase's API) and decrypts both the sent and received messages using the current user's private key. For messages that the user themselves sent, decryption is also done using the current user's private key, since it was encrypted for both sender and recipient.
static async encryptMessage(message: string, recipientPublicKey: JsonWebKey): Promise<string>: Encrypts a message, returning a base64 encoded string after being encrypted using RSA-OAEP
static async exportPrivateKey(filename: string = 'private-key-backup'): Promise<{ text: string, file: File } | null>: Helper function to facilitate the backup of the current private key
static async validateKeyPair(privateKeyJwk: JsonWebKey, publicKeyJwk: JsonWebKey): Promise<boolean>: Validates the current private key with the public key stored in the database by encrypting a message with a timestamp, then trying to decrypt it afterward. Returns a boolean in both cases.
static async restoreFromBackup(privateKeyJwk: JsonWebKey, publicKeyJwk: JsonWebKey): Promise<boolean>: Helper function to restore a backup. Not currently being used.
private static async openDB(): Promise<IDBDatabase>: Private function to open the database connection.-
Seriously, the amount of trouble I had with SQL functions is unreal... Not just functions, but also RLS policies, realtime permissions, etc. I had to ask for help from Supabase AI (and a bit from Claude, since honestly, Supabase's doesn't give as much explanation for corrections and other stuff).
The main functions are:
CREATE OR REPLACE FUNCTION public.create_private_thread(participant_suuid TEXT) RETURNS UUIDCreates a private thread by getting the current user suuid (current_user_suuid) and the target user, checks if there's already a thread with those 2 participants and creates one if there isn't or returns an existing thread id
CREATE OR REPLACE FUNCTION public.get_thread(thread_uuid UUID, user_id UUID)Retrieves a thread using its uuid along with the user_id. If found, returns the thread information (thread_id, participants, participants_suuids, messages). If the thread doesn't exist, returns an empty value.
CREATE OR REPLACE FUNCTION public.get_user_threads(user_id UUID)Retrieves a user's threads using their own uuid, returning an array of existing threads
CREATE OR REPLACE FUNCTION public.send_message( thread_uuid UUID, sender_content TEXT, recipient_content TEXT ) RETURNS UUIDInserts both users' messages into the database, both encrypted with their respective keys
It's totally possible I forgot some functions or that others were deleted during development, so I included all the functions made, along with RLS policies and triggers. Some functions weren't mentioned because they weren't as problematic to make. There is also a high possibility of this app being really insecure since I am not too familiar with SQL (I always preferred NoSQL dbs.)
I will not document each page since I don't think it's necessary and that would make this README too long and cluttered.
-
I did re-use code of previous projects as inspiration. Mainly the middleware and some other styling (Such as the Sidebar).
I did not mention any API because the API routes mainly use supabase's functions to work, so I do not think it is necessary to mention them here.
For clarification, I did use AI to help me on this project:
- Claude - Helped with NextJs and React debugging (I don't know how to read the errors on react, sometimes it just outputs a simple message without explicit details on where the error happened), helping on some SQL functions too ( Mainly RLS issues on realtime). Also helped when I couldn't really fix the style of some components.
- Supabase's AI - I don't think it helped that much since, honestly, I don't think it's quite good at the purpose it was made to serve. Might be a skill issue on my part though. It helped mainly in debugging of some scripts that weren't working properly, since Supabase does not really support logs (at least, I never found where to look at)
You can check it out by using this link: https://sipher.space