This document provides a detailed breakdown of all features in the Secure Chat Application, including purpose, user flows, backend logic, API interactions, data models, and error handling.
- Text Messaging
- File Sharing
- Voice Calls
- Secure Vault
- Profile Management
- Session Management
- Multi-Account Support
- Local AI Assistant
- Secure Vault Multi-Factor Authentication (MFA)
- Multi-Device Support
- Cross-Device Chat Syncing
- Backup & Restore
Enable users to send and receive end-to-end encrypted text messages in real-time.
- User selects a peer from session list
- User types message in input field
- User presses Send or Enter
- Message appears immediately in chat (optimistic UI)
- Status changes from "sending" → "delivered"
WebSocket Frame:
{
"t": "MSG",
"sid": "session_id",
"data": {
"payload": "encrypted_base64_string"
}
}Decrypted Payload:
{
"t": "MSG",
"data": {
"text": "Hello!",
"id": "uuid-1234",
"timestamp": 1704067200000,
"replyTo": null
}
}Database (SQLite):
INSERT INTO messages (id, sid, sender, text, type, timestamp, status, is_read)
VALUES ('uuid-1234', 'session_id', 'me', 'Hello!', 'text', 1704067200000, 1, 0);In-Memory (ChatClient):
interface ChatMessage {
id: string;
sessionId: string;
sender: "me" | "other";
text: string;
timestamp: number;
status: 1 | 2; // Pending | Delivered
}GIF Support:
- Tenor Integration: Support for Tenor GIF URLs via
LinkPreview. - Display: GIFs are rendered cleanly using an
imageOnlymode in the link previewer, removing metadata cards for a native feel.
Message Deletion:
- Hard Delete: Messages are permanently removed from the local database (no "message deleted" placeholder).
- Local-Only Deletion: Deleting a message sent by another user removes it only from the local device; no network packet is sent.
- Unsend (Retraction): Deleting your own message performs a local hard delete AND sends a
DELETEpacket to the peer to remove it from their device.
| Error | Cause | Recovery |
|---|---|---|
| Peer offline | No active WebSocket connection | Queue locally, auto-retry on PEER_ONLINE |
| Encryption failure | Missing session key | Show error, prompt reconnection |
| Network timeout | Socket closed mid-send | Auto-reconnect, resend from queue |
| Invalid session | Session not found in DB | Show error, navigate to session list |
Allow users to securely share images, videos, audio, and documents with encryption.
- User clicks attach icon (📎)
- Native file picker opens
- User selects file
- File is previewed (if image)
- User confirms send
- File uploads in chunks
- Peer sees file preview with download button
- Peer clicks download
- File transfers in chunks with progress bar
- File available for viewing/saving
Sender Side:
Receiver Side:
FILE_INFO Frame (encrypted in MSG):
{
"t": "FILE_INFO",
"data": {
"name": "vacation.jpg",
"size": 2048576,
"type": "image/jpeg",
"thumbnail": "data:image/png;base64,...",
"messageId": "uuid-5678"
}
}FILE_REQ_CHUNK:
{
"t": "FILE_REQ_CHUNK",
"data": {
"messageId": "uuid-5678",
"chunkIndex": 0
}
}FILE_CHUNK:
{
"t": "FILE_CHUNK",
"data": {
"messageId": "uuid-5678",
"chunkIndex": 0,
"payload": "base64-data",
"isLast": false
}
}messages table:
INSERT INTO messages (id, sid, sender, text, type, timestamp, status)
VALUES ('uuid-5678', 'session_id', 'me', '{"filename":"vacation.jpg","size":2048576}', 'image', timestamp, 1);media table:
INSERT INTO media (filename, original_name, file_size, mime_type, message_id, status, thumbnail)
VALUES ('vault_abc123', 'vacation.jpg', 2048576, 'image/jpeg', 'uuid-5678', 'pending', 'data:...');| Error | Cause | Recovery |
|---|---|---|
| Read failure | File permissions | Show error toast |
| Chunk timeout | Network drop during transfer | Resume from last successful chunk |
| Vault full | Storage limit reached | Show "Storage full" error |
| Corrupted chunk | Network corruption | Request chunk again |
Enable real-time, end-to-end encrypted voice and video communication between peers using WebRTC.
- User clicks phone/video icon in chat header
- App requests TURN credentials from server
- Peer receives incoming call notification overlay
- Peer accepts call → both establish WebRTC RTCPeerConnection
- SDP offer/answer + ICE candidates exchanged (encrypted via session AES key)
- WebRTC connection established via TURN relay; media streams through TURN server
- Either user hangs up → call duration logged
- Own linked devices notified of call end via SYNC_CALL_END
- TURN Credentials: Client sends
GET_TURN_CREDSto server; receives ephemeral HMAC-signed credentials - Signaling (E2E encrypted): All SDP offers/answers and ICE candidates are encrypted with the session's AES-GCM key before being sent as
RTC_OFFER,RTC_ANSWER,RTC_ICEframes. The relay server cannot read signaling data. - Media via TURN: Media streams are relayed through the TURN server (no direct P2P connection)
- Media Encryption: Audio/video streams use mandatory DTLS-SRTP (WebRTC standard)
RTC_OFFER / RTC_ANSWER:
{
"t": "RTC_OFFER", // or RTC_ANSWER
"sid": "session_id",
"data": {
"payload": "encrypted_sdp_json"
}
}RTC_ICE:
{
"t": "RTC_ICE",
"sid": "session_id",
"data": {
"payload": "encrypted_candidate_json"
}
}Call Logs:
INSERT INTO messages (id, sid, sender, text, type, timestamp)
VALUES ('uuid-call', 'session_id', 'me', '{"duration": 120, "mode": "video"}', 'call', timestamp);| Error | Cause | Recovery |
|---|---|---|
| Mic permission denied | User denied prompt | Show error, disable call button |
| Stream failure | MediaRecorder error | Auto-terminate call, notify peer |
| Network drop | WebSocket closed | Auto-hang up, show "Call ended" |
Provide encrypted local storage for passwords, notes, and sensitive files.
- User clicks vault icon
- User is presented with the Secure Vault MFA challenge
- User enters the 6-digit TOTP code
- Code validates against local secure storage
- Master Key (mnemonic) retrieved from secure storage
- Vault encryption key derived from Master Key
- Items decrypted and displayed
Key Derivation:
// 1. Verify MFA (Access Control)
const isValidMfa = await mfaService.verifyUserToken(email, mfaToken);
if (!isValidMfa) throw new Error("Invalid MFA Code");
// 2. Retrieve Master Key (Encryption Key Source)
const mnemonic = await getKeyFromSecureStorage("MASTER_KEY");
// 3. Derive Vault Key
const salt = getStoredSalt();
const vaultKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
await importKey(mnemonic),
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);Vault Item:
interface VaultItem {
id: string;
type: "password" | "note" | "file";
title: string;
encryptedData: string; // Base64 encrypted JSON
createdAt: number;
}Storage: Separate from main database, stored in Capacitor SecureStorage or Vault-specific file.
| Error | Cause | Recovery |
|---|---|---|
| Wrong passphrase | Decryption failure | Show "Invalid passphrase" error |
| Vault corrupted | File corruption | Show error, offer reset vault option |
| Storage full | Disk space | Show error, suggest deleting items |
Allow users to set display name and avatar.
- User opens settings/profile
- Edit display name or upload avatar
- Increment version number
- Broadcast
PROFILE_VERSIONto all sessions - Peers see "Profile updated" notification
- Peers request
GET_PROFILE - User sends
PROFILE_DATA - Peer updates local cache
PROFILE_VERSION:
{
"t": "PROFILE_VERSION",
"data": {
"name_version": 2,
"avatar_version": 1
}
}PROFILE_DATA:
{
"t": "PROFILE_DATA",
"data": {
"name": "Yog Mehta",
"avatar": "data:image/png;base64,...",
"name_version": 2,
"avatar_version": 1
}
}me table:
UPDATE me SET public_name = 'Yog Mehta', name_version = name_version + 1;sessions table (peer's device):
UPDATE sessions SET peer_name = 'Yog Mehta', peer_name_ver = 2 WHERE sid = ?;- Viewing Profile: Users can click a contact's avatar in the chat header or session list to open the User Profile modal.
- Shared Media: The modal aggregates and displays all shared media (images, videos, files) for that session.
- Local Customizations:
- Alias Name: Set a custom local alias for the contact (updates
alias_namein the database). - Notes: Add private, locally-saved notes about the contact (updates
notesin the database). - These changes are synced across the user's own devices via the
MANIFESTprotocol but are never sent to the peer.
- Alias Name: Set a custom local alias for the contact (updates
Manage active peer connections, online/offline status, and reconnection.
- User views session list
- Sessions show online/offline indicators
- Click session to open chat
- If peer offline, messages queued
- When peer comes online, queue syncs automatically
Session Creation:
- Triggered by
FRIEND_REQUEST+FRIEND_ACCEPThandshake - Session ID =
SHA-256(sortedEmail1 + ":" + sortedEmail2)— deterministic, same on both devices - ECDH key exchange completes; per-device AES-GCM keys derived and stored in SQLite
- Session row inserted into
sessionstable - Emit
session_created→ triggers MANIFEST broadcast to own devices
Reconnection (on app restart or network recovery):
- On
WS_CONNECTED, load all sessions from SQLite - Send
AUTH { token, publicKey }to server - Server responds with
SESSION_LISTshowing which sessions have online peers SessionService.handleSessionList()updates presence and re-derives keys if peers rotated their keys (e.g., after reinstall)coordinateSync(sid)pushes missed messages via MANIFEST to any online peers
SELECT sid, peer_name, peer_email, keyJWK FROM sessions ORDER BY sid DESC;Allow users to sign in with multiple Google accounts and switch between them.
- User clicks "Add Account" in settings
- Google sign-in flow
- New account added to account list
- User clicks "Switch Account"
- App lock screen shows account list
- Select account → Enter PIN/biometric
- App switches to selected account's database and sessions
// Phase 1: Unlock local DB immediately (no network)
const { pubKey, token } = await ChatClient.switchAccountLocal(email);
// → UI is interactive immediately
// Phase 2: Connect WebSocket in background
ChatClient.switchAccountConnect(email, pubKey, token).catch(() => {});
// → Server authenticates silently; SESSION_LIST + PENDING_REQUESTS arriveSafeStorage:
{
"chatapp_accounts": [
{
"email": "[email protected]",
"token": "sess:...",
"lastActive": 1704067200000
},
{
"email": "[email protected]",
"token": "sess:...",
"lastActive": 1703980800000
}
]
}Provide privacy-preserving, on-device AI capabilities using the Qwen3.5 0.8B model (Q4_K_M GGUF quantization) without sending data to external servers.
- Smart Compose: Polishes and professionalizes drafted messages before sending.
- Quick Replies: Generates contextually relevant short replies (Positive, Negative, Interrogative) based on recent chat history.
- Summarize: Provides concise, bulleted summaries of a chat conversation.
- Electron (Windows/Linux) only: The service requires
window.llama— a llama.cpp backend exposed via Electron's preload script. Inference will throw if run outside Electron. CapacitorFilesystemAPIs are used for model file download and storage.
| Error | Cause | Recovery |
|---|---|---|
| Download Failed | Network drop or no disk space | Prompt user to retry the 500MB+ download. |
| Native Init Failed | Unsupported device architecture | Fallback disabled; notify user device unsupported. |
| Generation Timeout | Process takes too long/Out of RAM | Cancel context and notify user. |
Enhance local Secure Vault security by requiring a dynamically generated 6-digit code to view encrypted passwords and files.
- Requirement: When the user opens the Secure Vault, the client-side app (
SecureChatWindow) checks local storage to see if MFA is provisioned. - Prompt: If provisioned, the user is presented with a local MFA challenge screen before they can unlock the vault.
- Verification: The user enters the 6-digit code from their authenticator app.
- Acceptance: The local app verifies the code against the stored secret returning access to the Secure Vault. The server is completely unaware of and uninvolved in this process.
- Algorithm: Time-Based One-Time Password (TOTP) based on HMAC-SHA1.
- Parameters: 6 digits, 30-second period.
- Storage: The MFA secret is maintained securely in the device's native keychain (via
SafeStorage), completely locally.
| Error | Cause | Recovery |
|---|---|---|
| Invalid Code | Code expired, or typed wrongly. | Warn user to retry immediately. |
| Desynchronized | Device clock is out of sync. | Code verifies via sliding Window (+/- 1). |
Enable users to access their account securely across multiple authenticated devices (e.g., Phone and Desktop) without explicit device-to-device linking protocols.
- Instead of single device keys, sessions now construct robust key pools involving arrays of
ownPubKeysandpeerPubKeys. - Every authenticated device generates its own keys. On connecting to the server, the client shares its own public key to the server. The backend then routes encrypted payloads directly to all applicable devices for a user.
- User 1 (Device A) comes online and sends its public key to the server.
- User 1 (Device B) comes online and sends its public key to the server.
- User 2 (Device A) comes online and sends its public key to the server.
- The server sends an online frame for User 1 (Device A) to User 2 (Device A), and vice versa.
- The server sends an online frame for User 1 (Device B) to User 2 (Device A), and vice versa.
- User 2 sends a message ("Hi"). The client encrypts the payload separately for User 1 (Device A) as well as User 1 (Device B).
- If User 1 (Device A) goes offline, the server sends an offline packet to User 1 (Device B) and User 2 (Device A).
Seamlessly synchronize app state — messages, block list, contact aliases, pending requests, and profile — across a user's own linked devices using a single encrypted MANIFEST frame. When a peer contact comes online, both sides also push any missed messages to each other via the same MANIFEST mechanism.
MANIFEST is used for two distinct relationships: own linked devices and peer contacts. The same frame type is used but the payload content differs.
Sent only to sessions where peerEmailHash matches the current user's own email hash (i.e. the user's other linked devices). Carries all sections:
| Section | Contents | Merge Strategy |
|---|---|---|
blocks |
All block/unblock entries (action: block|unblock) with timestamps |
Newer timestamp wins |
requests |
All pending requests (action: pending|accepted|denied) with timestamps |
Newer timestamp wins |
aliases |
Per-session custom names/avatars with timestamps | Newer timestamp wins |
profile |
Display name, avatar, and version numbers | Highest version wins |
messages |
All messages newer than last_manifest_sync for that own-device session |
Insert-or-ignore on ID |
When a peer contact (Client B) comes online, Client A pushes any messages the peer may have missed while offline — and Client B does the same back. Only the messages section is sent (no blocks, aliases, or profile). This is symmetric: both sides push, both sides merge.
Client A (online) Client B (just came online)
| |
|<-------- PEER_ONLINE (sid) -------------| (server notifies both)
| |
|-- MANIFEST { messages: [...] } -------->| Client A pushes missed msgs
|<-- MANIFEST { messages: [...] } --------| Client B pushes missed msgs
| |
| Both sides INSERT OR IGNORE by msg ID |
If a device has no new messages since last_manifest_sync, it skips sending (no empty pushes).
| Trigger | Function called | Path |
|---|---|---|
SESSION_LIST received |
broadcastManifestToOwnDevices + sendManifestToPeer per online peer |
Own + Peers |
PEER_ONLINE received |
broadcastManifestToOwnDevices + sendManifestToPeer(sid) |
Own + Peer |
| After sending a message | broadcastManifestToOwnDevices |
Own devices only |
| After receiving a message | broadcastManifestToOwnDevices |
Own devices only |
| Block / unblock action | broadcastManifestToOwnDevices |
Own devices only |
| Profile update | broadcastManifestToOwnDevices |
Own devices only |
| New session created | sendManifestToPeer(sid) |
Peer only |
MessageService processes only the sections present in the received manifest:
blocks: Each entry upserted intoblocked_usersif timestamp is newer. Emitsblock_list_updated.requests: Each entry upserted intopending_requestsif timestamp is newer. Emitspending_requests_updated.aliases: Updates session rows viaalias_timestampguard.profile: Updated only if incoming version numbers exceed local.messages: Each message inserted withINSERT OR IGNORE— safe to receive duplicates.
-- New columns in the sessions table:
alias_timestamp INTEGER DEFAULT 0 -- Last-write timestamp for alias merging
last_manifest_sync INTEGER DEFAULT 0 -- Last time a manifest was sent TO this session
-- New column in blocked_users:
action TEXT DEFAULT 'block' -- 'block' | 'unblock' (tombstone so unblocks propagate across devices)
-- New column in pending_requests:
action TEXT DEFAULT 'pending' -- 'pending' | 'accepted' | 'denied'Allow users to export all local app data (messages, vault media, cryptographic keys) into a single encrypted file, and later restore it on a new device or after reinstallation.
Creating a backup:
- User opens Settings and selects "Create Backup".
- User enters a backup passphrase (or PIN).
BackupService.generateEncryptedBackup()builds a ZIP containing:- Full SQLite database export (
db_export.json) for all tables. - Cryptographic identity keys (
master_key.txt,identity_priv.json,identity_pub.json). - All encrypted vault media files (
media/folder). - Account metadata (
metadata.json).
- Full SQLite database export (
- The ZIP is encrypted with AES-GCM-256 using a PBKDF2-derived key (100,000 iterations, SHA-256) from the passphrase.
- Final file format:
[16-byte salt] + [12-byte IV] + [AES-GCM ciphertext]. - The encrypted blob is saved to the device (via browser download or Capacitor Filesystem).
Restoring from backup:
- User opens the app (on a new/reinstalled device) and selects "Restore from Backup".
- User selects the
.bakfile and enters the backup passphrase. BackupService.restoreFromEncryptedBackup()decrypts and unzips the file.- Account email is read from
metadata.json. - Master Key is restored to
SafeStorageand the per-account SQLite database is initialized. - All database tables are cleared and repopulated from the exported JSON in dependency order.
- Identity keys are restored to
SafeStorage. - All vault media files are restored to the
VAULT_DIRon the Capacitor Filesystem. - A stub account entry is added so the account is recognized at next login.
// Key derivation
const aesKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
await crypto.subtle.importKey("raw", passwordBytes, { name: "PBKDF2" }, false, ["deriveKey"]),
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
// File format: salt (16) + iv (12) + ciphertext
const finalBuffer = new Uint8Array(16 + 12 + encryptedData.byteLength);
finalBuffer.set(salt, 0);
finalBuffer.set(iv, 16);
finalBuffer.set(new Uint8Array(encryptedData), 28);| Content | Included in Backup |
|---|---|
| Chat history | ✅ Yes (messages, sessions, media tables) |
| Vault passwords/notes | ✅ Yes (included in messages/media DB export) |
| Vault media files | ✅ Yes (raw encrypted blobs from Filesystem) |
| Identity keys | ✅ Yes (master_key, identity_priv/pub) |
| Auth session tokens | ❌ No (user must sign in again after restore) |
| Server-side data | ❌ N/A (zero server storage) |
| Error | Cause | Recovery |
|---|---|---|
| Wrong passphrase | AES-GCM decryption fails | Throw "Decryption failed. Incorrect backup code." |
| Corrupt backup file | File truncated or tampered | Throw decryption or ZIP parse error |
| Missing email | metadata.json absent or malformed |
Throw "Could not find account email in backup" |
| Storage error | Filesystem write failure | Surface underlying Capacitor error |