All work

2025 · Case study · Phill Morgan

End-to-End Encrypted Messaging Application

A zero-knowledge messaging platform on the Signal Protocol, with real-time delivery through Socket.IO and Redis and cross-platform mobile support via Capacitor.

Available for portfolio review on request.

Stack

  • Vue 3
  • TypeScript
  • Node.js
  • Express
  • Socket.IO
  • PostgreSQL
  • Redis
  • libsignal-protocol
  • Capacitor
  • Pinia
  • Argon2

Role & Scope

Solo build. Protocol implementation (Signal via libsignal), real-time backend, cross-platform client, encrypted backup and recovery.

Overview

A messaging application where the server has zero knowledge of message content. Messages are encrypted on the sender’s device using the Signal Protocol and can only be decrypted by the intended recipient. The server relays opaque ciphertext and manages key distribution. It never sees plaintext.

The app runs as a responsive web client with a WhatsApp-style interface, and is Capacitor-ready for native iOS and Android builds with access to platform-level secure storage.

Key decisions

libsignal-protocol over rolling my own crypto. The fastest way to get a messaging product to market is not to write end-to-end encryption. I used @privacyresearch/libsignal-protocol-typescript for the full Signal Protocol stack: X3DH key agreement, Double Ratchet, pre-key management. Writing any of this from scratch would have been weeks of work, every line of it a potential weakness, and the end result would have been a worse version of a protocol that has already been audited by cryptographers and deployed to billions of users. The trade: I accept the maintenance surface of a third-party crypto library. Mitigation: the library is the reference TypeScript port of Signal’s own spec, well-maintained, and the project structure isolates it behind a thin internal interface so a future swap is viable.

Vue 3 with Pinia over React. React is the default reflex for this class of app. Vue 3’s Composition API was a better fit for how I wanted to model the state: encryption sessions, conversation lists, presence, and connection lifecycle all live in separate Pinia stores with clear boundaries, and the reactive primitives handle the many small state dependencies (is-online, is-typing, is-decrypting) without the ceremony React’s hooks would have needed. The trade is a smaller third-party ecosystem than React’s; the app didn’t hit any gaps in it.

Redis offline queue with TTL over persistent message storage. Messages from offline users need to be queued somewhere. The naïve approach is a database table: every queued message a row, delivered on reconnect. I used Redis with a 30-day TTL instead. Messages are ciphertext to the server regardless, so there’s no content-value in persisting them forever; once a recipient has reconnected and drained their queue, the copies on the server are redundant. Redis gives O(1) push and pop, natural TTL-based expiration, and a much smaller data-at-rest footprint. The trade: a permanently-offline recipient loses messages older than 30 days, which is documented and matches the threat model.

Signal Protocol Implementation

The encryption layer uses @privacyresearch/libsignal-protocol-typescript, implementing the Signal Protocol stack:

  • X3DH key agreement: Extended Triple Diffie-Hellman for asynchronous session setup, so messages can be sent to offline recipients using pre-key bundles
  • Double Ratchet Algorithm: forward secrecy and break-in recovery on every message, so a compromised key can’t decrypt past or future messages
  • Pre-key management: clients generate batches of one-time pre-keys and upload them; senders consume these to start new sessions without needing the recipient online
  • TOFU identity verification: Trust On First Use, with automatic detection and security alerts when a contact’s identity key changes, flagging likely MITM attacks

Key material is generated at registration and never leaves the client device. On native, Capacitor secure storage writes keys to the iOS Keychain or Android KeyStore. On web, keys are encrypted with AES-GCM before landing in IndexedDB.

Encrypted Backup and Recovery

Users can create encrypted backups of their keys and message history, protected with AES-256-GCM derived from a user-chosen password via PBKDF2. Backups restore on a new device, re-establishing encryption sessions without the server ever touching unencrypted key material.

A recovery modal walks users through restoration if they lose access to their device.

Real-Time Message Relay

Socket.IO handles transport, with JWT authentication on the WebSocket handshake:

  • Message delivery: encrypted payloads are pushed immediately to connected recipients
  • Offline queuing: messages for disconnected users sit in Redis with a thirty-day TTL, delivered on reconnect
  • Presence tracking: online/offline status in Redis sets, broadcast to contacts on state change
  • Rate limiting: sixty messages per minute and ten connections per minute per user
  • Horizontal scaling: Redis pub/sub lets multiple Socket.IO instances share connections, so a client can be routed to the right server regardless of which node it’s attached to

Backend Architecture

The Express server handles four concerns: authentication, message relay, key distribution, and contacts.

  • Authentication: Argon2id password hashing, JWT access tokens with refresh rotation, state in PostgreSQL
  • Key distribution: pre-key bundle endpoints for session setup, server only storing public keys
  • Message relay: encrypted payloads persisted in PostgreSQL and pushed through Socket.IO
  • Database: PostgreSQL with connection pooling, storing users, pre-keys, and session metadata, never plaintext messages

Contact Exchange

New contacts are added one of two ways, both designed to verify identity keys and prevent interception:

  • QR code scanning: the app generates a QR containing the user’s username and public identity key; scanning with the device camera verifies the key matches the server record, blocking MITM substitution
  • Contact codes: a manual fallback in @username#FINGERPRINT format (last eight characters of the identity key), for times when scanning a QR isn’t practical

There’s no user directory and no search. That’s a deliberate privacy choice. Users exchange contact info directly.

Frontend

The Vue 3 frontend uses the Composition API throughout, with Pinia stores managing conversations, encryption sessions, and connection lifecycle:

  • Chat view: responsive layout with a conversation list sidebar and message panel, sliding to a single-column view on mobile
  • Message rendering: decrypted messages with timestamps and encryption status indicators
  • Settings: backup creation and restoration, account info, and security warnings for key changes

The interface follows the WhatsApp pattern: conversation list with last-message previews on the left, active conversation on the right, smooth transitions between views on mobile.