This document describes the complete user journeys through the Secure Chat Application (CryptNode), from first-time installation to advanced features.
- Onboarding & First Login
- App Lock & Session Lock
- Account Switching (Multi-Account)
- Peer Session Establishment (Friend Request Flow)
- Encrypted Message Transmission
- Message Editing & Deletion
- Encrypted File Transfer
- Encrypted Voice & Video Calls (WebRTC)
- Cross-Device Sync (MANIFEST Protocol)
- Fault Handling & Recovery
- Block & Unfriend Flows
flowchart TD
START([User Opens App]) --> CHECK{Has stored accounts?}
CHECK -->|No| GOOGLE[Google Sign-In Button]
CHECK -->|Yes| ACCOUNTS[Account Selector Screen]
GOOGLE --> OAUTH[Google OAuth Consent]
OAUTH --> TOKEN[Receive id_token]
TOKEN --> SETUP[AuthService.login - Setup Device Keys]
SETUP --> DBINIT["Open/Create Encrypted SQLite DB\n(keyed by BIP39 mnemonic)"]
DBINIT --> WSCONN[Connect WebSocket]
WSCONN --> AUTH["Send AUTH {token, publicKey}"]
AUTH --> VERIFY[Server Validates Token]
VERIFY --> SUCCESS{Valid?}
SUCCESS -->|Yes| AUTHSUCCESS["AUTH_SUCCESS {email, sessionToken}"]
SUCCESS -->|No| ERROR[Show Error]
ERROR --> GOOGLE
AUTHSUCCESS --> SAVETOKEN[Save session token to SafeStorage]
SAVETOKEN --> SESSLIST[Receive SESSION_LIST]
SESSLIST --> PENDINGREQ[Receive PENDING_REQUESTS]
PENDINGREQ --> HOME([Home Screen])
ACCOUNTS --> LOCK[App Lock PIN Screen]
LOCK -->|Correct PIN| PHASE1["switchAccountLocal - Unlock DB"]
PHASE1 --> HOME2([Home Screen - Immediately Visible])
HOME2 --> BGCONN["switchAccountConnect - Connect WS in background"]
style START fill:#4CAF50,color:#fff
style HOME fill:#4CAF50,color:#fff
style HOME2 fill:#4CAF50,color:#fff
style ERROR fill:#f44336,color:#fff
- App checks
SafeStoragefor existing accounts - User taps "Sign in with Google" → Google OAuth consent screen
- App receives
id_tokenfrom Google AuthService.login(id_token):- Generates a new ECDH P-256 identity key pair (or loads existing)
- Generates a 12-word BIP39 mnemonic as SQLite encryption key
- Opens the user's encrypted SQLite database
- Connects WebSocket to relay server
- On
WS_CONNECTED: loads sessions from SQLite, sendsAUTH { token, publicKey } - Server validates token, issues HMAC-signed session token
- Client receives
AUTH_SUCCESS, saves new session token - Server pushes
SESSION_LIST(ongoing sessions) andPENDING_REQUESTS(stored friend requests) - Home screen renders with session list
- User opens app → Account Selector shows all saved accounts
- User taps an account → App Lock PIN screen appears
- User enters PIN →
switchAccountLocal(email)(Phase 1):- Validates session token from
SafeStorage - Loads identity keys
- Opens SQLite database
- UI becomes interactive immediately
- Validates session token from
switchAccountConnect(email, ...)runs in background (Phase 2):- Connects WebSocket
- Authenticates with server
- Loads sessions and pending requests
| Action | Clears Session Token | Disconnects WS | Returns to Login |
|---|---|---|---|
| Lock Session | ❌ No | ✅ Yes | ✅ Yes (Account Selector) |
| Logout | ✅ Yes | ✅ Yes | ✅ Yes (Login Screen) |
| Delete Account | ✅ Yes | ✅ Yes | ✅ Yes (Login Screen) |
flowchart TD
USER[User Clicks Lock] --> LOCK["ChatClient.lockSession()"]
LOCK --> CLEAR["Clear: authToken, userEmail,\nidentityKeyPair in memory"]
CLEAR --> DISC[Disconnect WebSocket]
DISC --> EMIT["emit('auth_error', {isManualLogout: true})"]
EMIT --> SELECTOR[Account Selector / Login Screen]
SELECTOR --> RESUME[User selects account]
RESUME --> PHASE1["switchAccountLocal - Instant DB unlock"]
PHASE1 --> HOME[Home Screen visible]
HOME --> PHASE2["switchAccountConnect - Background WS auth"]
style USER fill:#2196F3,color:#fff
style HOME fill:#4CAF50,color:#fff
Key behavior: lockSession() does not wipe the stored session token. The user can unlock instantly with their PIN without re-authenticating with Google.
The App Lock screen is shown when:
- The user manually clicks "Lock" from the sidebar
- The user selects a different account from the account selector
- The app detects it was backgrounded (optional, platform-dependent)
The PIN is verified locally (no network call). On success, Phase 1 local unlock runs immediately.
flowchart TD
SIDEBAR[User opens sidebar] --> SWITCH[Clicks 'Switch Account']
SWITCH --> SELECTOR[Account List Overlay]
SELECTOR --> SELECT[Select target account]
SELECT --> LOCK[App Lock PIN Screen for target account]
LOCK -->|PIN Correct| PHASE1["switchAccountLocal(email)\nClose WS, open new DB, load keys"]
PHASE1 --> UI[UI refreshes with new account's data]
UI --> PHASE2["switchAccountConnect() in background\nNew WS connection + AUTH"]
PHASE2 --> SYNC[Receive SESSION_LIST + PENDING_REQUESTS]
Key Design: The two-phase switch pattern (switchAccountLocal then switchAccountConnect) ensures the user sees their data instantly without waiting for network authentication.
sequenceDiagram
participant UA as User A (Initiator)
participant S as Relay Server
participant UB as User B (Target)
UA->>S: GET_PUBLIC_KEY {targetEmail}
S-->>UA: PUBLIC_KEY {publicKeys: [pubB64A, pubB64B, ...], targetEmail}
note over UA: For each of User B's device keys,<br/>encrypt profile using derived ECDH shared key
UA->>S: FRIEND_REQUEST {targetEmail, payloads: [{publicKey, encryptedPacket}, ...]}
S->>S: Store in pending_requests table
S-->>UA: REQUEST_SENT
S-->>UB: FRIEND_REQUEST (forwarded if online)
note over UB: Iterate payloads, find the one<br/>matching own public key, decrypt
UB->>UB: Show incoming request UI
UB->>S: FRIEND_ACCEPT {targetEmail, payloads: [...]}
S->>S: Register friends record with SID
S-->>UB: FRIEND_ACCEPTED_ACK
S-->>UA: FRIEND_ACCEPTED {payloads: [...]}
note over UA,UB: Both derive AES-GCM-256 session key<br/>from ECDH shared secret.<br/>Key stored in SQLite. Session active.
- Initiate: User A enters User B's email →
SessionService.connectToPeer(email)→ sendsGET_PUBLIC_KEY - Key Retrieval: Server returns all of User B's registered device public keys
- Friend Request: For each remote public key, User A:
- Derives a temporary AES-GCM key via ECDH
- Encrypts their own profile
{ email, name, avatar, nameVersion, avatarVersion, timestamp } - Packages
{ publicKey, encryptedPacket }into apayloadsarray
- Offline Delivery: If User B is offline, the server stores the payloads in SQLite for delivery on next login
- Request Receipt: User B's device finds its payload (by matching its public key), decrypts the profile, stores in
pending_requeststable, shows modal - Accept Decision:
- Accept: User B sends
FRIEND_ACCEPTwith their own encrypted profile payloads for each of User A's device keys - Deny: User B sends
FRIEND_DENY. Server queuesFRIEND_DENIEDnotification for User A
- Accept: User B sends
- Session Finalization (
finalizeSession):- For each new public key, derive AES-GCM-256 session key from ECDH shared secret
- Store keys as JWK in SQLite
sessionstable - Initialize
WorkerManagerwith the keys - Emit
session_created
- SID Derivation: Session ID =
SHA-256(lowerEmail1 + ":" + lowerEmail2)(sorted alphabetically), ensuring determinism across both devices
If a peer reinstalls the app (changing their public key), the server sends updated keys via SESSION_LIST. SessionService.handleSessionList() detects the key change and automatically re-derives all session keys — no manual reconnection needed.
flowchart TD
SEND[User presses Send] --> GENID[Generate UUID message ID]
GENID --> SAVE["Save to SQLite (status=1 pending)"]
SAVE --> ENCRYPT["Encrypt for each peer device key\nAES-GCM + random 12-byte IV"]
ENCRYPT --> TRANSMIT["Send MSG {sid, data.payloads: {pubKey: blob}}"]
TRANSMIT --> RELAY{Server relays}
RELAY -->|Peer Online| DELIVER["Deliver to correct device by pubKey"]
RELAY -->|Peer Offline| DFAILED[DELIVERED_FAILED]
DELIVER --> PDECRYPT[Peer decrypts with local session key]
PDECRYPT --> PSAVE["Peer saves to SQLite (status=2 delivered)"]
PSAVE --> ACK["Peer sends DELIVERED back"]
ACK --> UPDATE["Update status to 2 in SQLite"]
UPDATE --> UI[Checkmark shown in UI]
DFAILED --> QUEUE["Message stays status=1"]
QUEUE --> WAIT[Wait for PEER_ONLINE event]
WAIT --> RETRY["syncPendingMessages() re-sends"]
RETRY --> DELIVER
style SEND fill:#4CAF50,color:#fff
style UI fill:#4CAF50,color:#fff
The encrypted payload, when decrypted, contains an inner JSON object:
{
"t": "MSG",
"data": {
"text": "Hello, world!",
"id": "uuid-1234",
"timestamp": 1704067200000,
"replyTo": { "id": "...", "text": "..." }
}
}The t field inside the decrypted payload is the inner message type and can be: MSG, FILE_INFO, FILE_REQ_CHUNK, FILE_CHUNK, CALL_START, CALL_ACCEPT, CALL_END, CALL_BUSY, DELETE, EDIT, REACTION, MANIFEST, PROFILE_VERSION, GET_PROFILE, PROFILE_DATA, SYNC_CALL_ACCEPT, SYNC_CALL_END.
- User right-clicks (desktop) or long-presses (mobile) a message they sent → taps "Edit"
- Input field is pre-filled with original text
- User modifies text and confirms
MessageService.editMessage(sid, messageId, newText):- Updates text in local SQLite
- Encrypts
{ t: "EDIT", data: { id, text, timestamp } }and sends to peer
- Peer receives
EDITinner packet, updates their local SQLite - UI updates message bubble (may show "(edited)" label)
flowchart TD
LONGPRESS[Long-press message] --> CHECK{Whose message?}
CHECK -->|My message| MYDELETE["Hard delete from local SQLite\n+ Send DELETE packet to peer"]
MYDELETE --> UPDATE["Update UI - remove bubble"]
MYDELETE --> PEERRECV[Peer receives DELETE packet]
PEERRECV --> PEERDELETE[Peer hard deletes locally]
CHECK -->|Peer's message| LOCALONLY["Hard delete from local SQLite only\n(no network packet)"]
LOCALONLY --> UPDATE
Important: Both delete paths are hard deletes — no "message deleted" placeholder is shown.
- User opens a session → options menu → "Delete Chat"
ChatClient.deleteChat(sid)→MessageService.deleteChatLocally(sid)→DELETE FROM messages WHERE sid = ?- Session entry in SQLite is preserved, so the contact remains in the list without messages
- No network packet is sent; this is purely local
flowchart TD
START([User attaches file]) --> PICKER[File picker opens]
PICKER --> READ[Read file as Blob]
READ --> COMPRESS[Optional: compress image/video]
COMPRESS --> VAULT[Save encrypted Base64 to Vault storage]
VAULT --> THUMB[Generate thumbnail if image/video]
THUMB --> MSGDB[Create message record in SQLite]
MSGDB --> META["Encrypt FILE_INFO\n{name, size, type, thumbnail, messageId}"]
META --> SEND["Send as MSG frame to peer"]
SEND --> PEER[Peer receives FILE_INFO]
PEER --> PREVIEW[Peer shows preview + Download button]
PREVIEW --> WAIT{User clicks Download}
WAIT --> REQ["Send FILE_REQ_CHUNK {messageId, index=0}"]
REQ --> READCHUNK["Sender reads 250KB chunk from Vault"]
READCHUNK --> ENCCHUNK[Encrypt chunk with session key]
ENCCHUNK --> SENDCHUNK["Send FILE_CHUNK {messageId, index, payload, isLast}"]
SENDCHUNK --> DECODE[Peer decodes + decrypts chunk]
DECODE --> APPEND[Append to local file buffer]
APPEND --> MORE{More chunks?}
MORE -->|Yes| REQ
MORE -->|No| COMPLETE["Mark as 'downloaded' in SQLite"]
style START fill:#4CAF50,color:#fff
style COMPLETE fill:#4CAF50,color:#fff
Chunk size: 250KB per chunk (~256,000 bytes). Files are never fully loaded into RAM on the sender side — streamed from Vault → encrypt → send.
sequenceDiagram
participant Caller
participant Server as Relay Server
participant Receiver
Caller->>Server: GET_TURN_CREDS
Server-->>Caller: TURN_CREDS {urls, username, credential}
Caller->>Caller: Request mic/camera permission
Caller->>Caller: Create RTCPeerConnection with ICE config
Caller->>Caller: Encrypt CALL_START {type: "Audio"|"Video"}
Caller->>Server: MSG {CALL_START} (encrypted in session key)
Server-->>Receiver: MSG {CALL_START}
Receiver->>Receiver: Show incoming call UI
Receiver-->>Caller: (Accept action)
Receiver->>Receiver: Request mic/camera permission
Receiver->>Receiver: Create RTCPeerConnection
Receiver->>Receiver: Encrypt CALL_ACCEPT
Receiver->>Server: MSG {CALL_ACCEPT}
Server-->>Caller: MSG {CALL_ACCEPT}
Caller->>Caller: Create SDP Offer
Caller->>Caller: Encrypt SDP Offer
Caller->>Server: RTC_OFFER {payload: encryptedSDP}
Server-->>Receiver: RTC_OFFER
Receiver->>Receiver: Decrypt + set Remote Description
Receiver->>Receiver: Create SDP Answer
Receiver->>Receiver: Encrypt SDP Answer
Receiver->>Server: RTC_ANSWER {payload: encryptedSDP}
Server-->>Caller: RTC_ANSWER
loop ICE Candidates
Caller->>Server: RTC_ICE {payload: encryptedCandidate}
Server-->>Receiver: RTC_ICE
Receiver->>Server: RTC_ICE {payload: encryptedCandidate}
Server-->>Caller: RTC_ICE
end
note over Caller,Receiver: WebRTC connection established via TURN relay.<br/>Audio/Video streams use DTLS-SRTP encryption.<br/>Server sees only opaque WebSocket frames.
Caller->>Server: MSG {CALL_END} (to end call)
Server-->>Receiver: MSG {CALL_END}
note over Caller,Receiver: Both sides clean up RTCPeerConnection.<br/>Call duration logged in messages table.
When a call is accepted or ended on one device, the MessageService.broadcastSyncCallAction() sends a SYNC_CALL_ACCEPT or SYNC_CALL_END inner packet to the user's own linked devices so they can dismiss incoming call notifications.
flowchart LR
subgraph "Own Device Sync"
D1[Device A] -->|MANIFEST: blocks+requests+aliases+profile+messages| D2[Device B]
D2 -->|MANIFEST: ...| D1
end
subgraph "Peer Message Push"
D1 -->|MANIFEST: messages only| PeerDevice[Peer's Device]
PeerDevice -->|MANIFEST: messages only| D1
end
| Trigger | Recipients | Sections Sent |
|---|---|---|
SESSION_LIST received on login |
Own devices + all online peers | Full (own) + messages (peers) |
PEER_ONLINE event |
Own devices + that peer | Full (own) + messages (peer) |
| Message sent/received | Own devices | Full |
| Block / unblock action | Own devices | Full |
| Profile updated | Own devices | Full |
| New session created | New peer | Messages only |
{
"t": "MSG",
"data": {
"type": "MANIFEST",
"manifest": {
"blocks": [
{ "email": "[email protected]", "action": "block", "timestamp": 1704067200000 },
{ "email": "[email protected]", "action": "unblock", "timestamp": 1704067300000 }
],
"requests": [
{ "email": "[email protected]", "name": "...", "avatar": "...", "publicKey": "...", "senderHash": "...", "action": "pending", "timestamp": 1704067200000 }
],
"aliases": [
{ "sid": "...", "aliasName": "Work Friend", "aliasAvatar": "", "timestamp": 1704067200000 }
],
"profile": {
"name": "Yog Mehta", "avatar": "data:image/...", "nameVersion": 2, "avatarVersion": 1
},
"messages": [
{ "id": "...", "sid": "...", "sender": "me", "text": "...", "timestamp": ... }
]
}
}
}Each section is merged independently:
| Section | Merge Logic |
|---|---|
blocks |
Compare timestamp — newer wins (supports both block AND unblock tombstones) |
requests |
Compare timestamp — newer wins |
aliases |
alias_timestamp column guards against applying older updates |
profile |
Highest nameVersion / avatarVersion wins |
messages |
INSERT OR IGNORE by message ID — safe to receive duplicates |
flowchart TD
CONNECTED[Connected State] --> DISC[Network Lost / WS Closed]
DISC --> DETECT["SocketManager: onclose fired"]
DETECT --> RETRY[Auto-reconnect with backoff]
RETRY --> ATTEMPT{Reconnect attempt}
ATTEMPT -->|Fail| WAIT[Wait 1-30 seconds]
WAIT --> RETRY
ATTEMPT -->|Success| WS_CONN["WS_CONNECTED event"]
WS_CONN --> LOADS[Load sessions from SQLite]
LOADS --> REAUTH["Send AUTH {token, publicKey}"]
REAUTH --> SESSLIST[Receive SESSION_LIST]
SESSLIST --> SYNC[Trigger MANIFEST sync + pending messages]
SYNC --> CONNECTED
- Server responds with
ERROR { message: "Auth failed" }or"Authentication required" ChatClient.handleFramecatches auth errors and callsauthService.logout()- User is returned to login screen with a notification
- Message sent while peer is offline receives
DELIVERED_FAILEDfrom server - Message stays
status = 1(pending) in SQLite - When
PEER_ONLINEevent fires,syncPendingMessages()re-queries allstatus=1messages and retries - Also on
SESSION_LISTreceipt:coordinateSync(sid)pushes missed messages via MANIFEST
- User opens contact profile → taps "Block"
SessionService.blockUser(email):- Sends
BLOCK_USER { targetEmail }to server - Inserts email and hash into local
blocked_userstable - Clears any pending requests from that user
- Emits
block_list_changed
- Sends
MessageService.broadcastManifestToOwnDevices()propagates the block to own linked devices via theblockssection withaction: "block"- Server sends
USER_BLOCKED_EVENTto the peer (or stores for offline delivery)
- Unblocking is local-only — no network packet is sent to the server
SessionService.unblockUser(email)removes entries from localblocked_usersbroadcastManifestToOwnDevices()propagates anaction: "unblock"tombstone to own devices
- User opens contact profile → taps "Remove Connection"
ChatClient.removeConnection(targetHash, sid):- Sends
UNFRIEND { targetHash }to server - Marks session as offline and not connected in memory
- Server relays
UNFRIENDED { senderHash }to the peer
- Sends
- The session record remains in SQLite (for chat history) but is marked inactive
- If an active call is in progress, it ends automatically
UNFRIENDED { senderHash }frame: Client identifies all sessions matching that peer hash and callsremoveConnectionfor eachSYNC_UNFRIEND { targetHash, sid }: Relayed from own devices to sync the unfriend actionSYNC_BLOCK { targetHash }: Own-device sync for block actions from another device