Skip to content

Repository files navigation

EPIC — SecureMsg

Alpha and the Cryptmunks 4life

An end-to-end encrypted messaging system with a blockchain audit trail, built as a university module project.


Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        Clients                              │
│  ┌──────────────────┐        ┌──────────────────────────┐   │
│  │  Web Client      │        │  Qt Desktop Client       │   │
│  │  (JS + WebCrypto)│        │  (C++ + libsodium)       │   │
│  └────────┬─────────┘        └────────────┬─────────────┘   │
│           │  HTTPS + cookie auth          │                  │
└───────────┼───────────────────────────────┼─────────────────┘
            │                               │
            ▼                               ▼
┌───────────────────────────────────────────────────────────┐
│                   FastAPI Backend                         │
│   /api/auth  │  /api/messages  │  /api/blockchain         │
│                  PostgreSQL (SQLAlchemy + asyncpg)         │
│                  Redis (blockchain write queue + auth rate-limit counters) │
└──────────────────────────────┬────────────────────────────┘
                               │  Async worker
                               ▼
                   ┌───────────────────────┐
                   │  Ethereum Sepolia     │
                   │  MessageDigest.sol    │
                   │  (keccak256 hashes)   │
                   └───────────────────────┘

Key security properties

Property Mechanism
End-to-end encryption Signal Protocol implemented in Python crypto daemon; C++ client sends/receives opaque ciphertext via IPC socket
Message authenticity X3DH: DH1 = DH(IK_A, SPK_B) — mutual auth baked into handshake; Ed25519 signature on SPK proves bundle came from Bob
Forward secrecy Double Ratchet symmetric ratchet — fresh AES-256-GCM key per message, erased after use
Post-compromise security Double Ratchet DH ratchet — new X25519 keypair injected every round-trip, heals ratchet state after one undisturbed reply
Password storage Argon2id (64 MiB, 3 iterations, parallelism 4)
At-rest key protection (web client) Private keys (IK, SPK, OPKs) encrypted in IndexedDB under an AES-256-GCM wrapping key derived from the user's password via PBKDF2-SHA256 (600k iterations) → HKDF-SHA256 (domain-separated with EPIC-v1-wrap-key); wrapping key held in JS memory only, never persisted
Session tokens Short-lived JWTs (HS256, 30 min expiry) stored in an httpOnly, Secure, SameSite=Strict cookie — never readable by JavaScript
CSRF protection Double-submit cookie pattern — server sets a readable csrf_token cookie on login; every state-changing request must echo it in the X-CSRF-Token header
Brute-force protection slowapi: /register and /login rate-limited to 5 req/min per IP (HTTP 429 + Retry-After); 5 consecutive failures trigger a 1-hour lockout via Redis TTL counter
HTTP security headers X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Content-Security-Policy: default-src 'none'; frame-ancestors 'none', Strict-Transport-Security: max-age=31536000; includeSubDomains (production only) — applied globally via middleware
Cover traffic (web client) After WebSocket connection, server sends randomised 256-byte heartbeat frames at uniform [3, 10] s intervals; frames are same size as padded message payloads, reducing timing correlation between send and receive events
TOFU key pinning (web client) On first contact, the SHA-256 fingerprint of the recipient's X25519 IK is stored in IndexedDB; every subsequent lookup compares against the pin — mismatch triggers a visible security warning and blocks the contact
Tamper-evidence keccak256 hash of each ciphertext anchored to Ethereum; in-app "Verify" button confirms on-chain vs local digest and links to Etherscan

Repository Structure

EPIC/
├── backend/                   Python FastAPI service
│   ├── main.py                App entry point, routers, CORS
│   ├── requirements.txt
│   ├── Dockerfile
│   ├── alembic.ini            Database migration config
│   ├── alembic/
│   │   ├── env.py             Async migration environment
│   │   └── versions/          Migration files (generated by Alembic)
│   └── app/
│       ├── config.py          Settings from environment variables
│       ├── database.py        SQLAlchemy async engine + session
│       ├── models/
│       │   ├── user.py        User ORM model
│       │   ├── message.py     UserKey, Message, MessageAccess ORM models
│       │   ├── signal.py      Signal Protocol models — SignedPrekey,
│       │   │                  OneTimePrekey, RatchetSession, SkippedMessageKey
│       │   └── revocation.py  Revocation event model
│       ├── schemas/
│       │   ├── auth.py        Pydantic schemas for auth endpoints
│       │   └── message.py     Pydantic schemas for message endpoints
│       ├── routers/
│       │   ├── auth.py        POST /register, POST /login, GET /me, prekey endpoints
│       │   ├── messages.py    POST /send, GET /inbox, GET /{id}
│       │   └── blockchain.py  GET /status/{id}, GET /verify/{id}
│       ├── middleware/
│       │   ├── csrf.py           Double-submit CSRF validation (all state-changing requests)
│       │   └── security_headers.py  X-Content-Type-Options, X-Frame-Options, CSP, HSTS, etc.
│       └── services/
│           ├── auth_service.py   Argon2id hashing + JWT lifecycle
│           ├── rate_limit.py     slowapi Limiter singleton (shared by all routers)
│           └── redis_service.py  Redis client init/close + auth failure counters
│
├── frontend/                  Browser-based web client
│   ├── index.html             Single-page app shell
│   ├── app.js                 Application logic (auth, send, receive)
│   ├── crypto.js              Web Crypto API wrapper (encrypt/sign/store)
│   └── styles.css             Placeholder dark-theme stylesheet
│
├── client-cpp/                Qt6 desktop client
│   ├── CMakeLists.txt         Build system (Qt6 + libcurl + OpenSSL + libsodium)
│   └── src/
│       ├── main.cpp                   Entry point — launches the login window
│       ├── Client.hpp/.cpp           REST/HTTP controller (libcurl) — talks to the FastAPI backend
│       ├── CryptoDaemonClient.hpp/.cpp  TCP client for the local crypto daemon
│       ├── User.hpp/.cpp             User identity + public keys
│       ├── Message.hpp/.cpp          Encrypted message data holder
│       ├── MessageStore.hpp/.cpp     Per-contact conversation cache (no crypto)
│       ├── MessageItemDelegate.hpp/.cpp  Custom painter for message-thread rows
│       ├── NetworkUtils.hpp/.cpp     Cross-platform BSD-socket helpers
│       ├── TLSVerifier.hpp/.cpp      Standalone OpenSSL certificate check
│       ├── LoginWindow.hpp/.cpp      Login / register UI window
│       └── MainWindow.hpp/.cpp       Main chat UI window
│
├── crypto-daemon/             Python service handling X3DH, Double Ratchet,
│                              key storage; runs locally alongside the C++ client
│                              and listens on TCP 127.0.0.1:DAEMON_PORT (see CryptoDaemonClient)
│
├── blockchain/                Hardhat project (Solidity / Ethereum)
│   ├── package.json
│   ├── hardhat.config.js      Sepolia testnet config
│   ├── contracts/
│   │   └── MessageDigestRegistry.sol  Stores keccak256 hashes on-chain
│   ├── scripts/
│   │   └── deployRegistry.js  Deploy to Sepolia, print contract address
│   └── test/
│       └── MessageDigest.test.js  Mocha/Chai unit tests
│
├── docker-compose.yml         Spins up API + PostgreSQL + Redis
└── README.md                  This file

Prerequisites

Component Requirements
Backend Python 3.12+, Docker Engine + Compose v2 (v2.27.0+) — spins up FastAPI, PostgreSQL 16, Redis 7
Frontend Any modern browser (Chrome 133+ / Firefox 130+ for X25519)
C++ client Qt 6.6+ (Core, Widgets, Network, WebSockets), CMake 3.20+, libcurl, OpenSSL, libsodium (vcpkg port unofficial-sodium)
Blockchain Node.js 20+, npm
Crypto Daemon Python 3.12+, cryptography package

Running the Backend (Docker — recommended)

# 1. Copy environment template
cp backend/.env.example backend/.env
# Edit backend/.env — set JWT_SECRET_KEY at minimum

# 2. Start all services
docker compose up --build

# API available at http://localhost:8000
# Interactive docs at http://localhost:8000/docs

Running without Docker

cd backend
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

# Set environment variables (or create a .env file)
export DATABASE_URL="postgresql+asyncpg://user:pass@localhost:5432/securemsg"
export REDIS_URL="redis://:yourpassword@localhost:6379/0"
export JWT_SECRET_KEY="your-secret"

# Run migrations
alembic upgrade head

# Start the server
uvicorn main:app --reload

Database migrations

All migrations are already in backend/alembic/versions/. Apply them all with:

alembic upgrade head

This runs all migrations in order, following the chain below:

e32ea88cfd28  (root — users, messages, user_keys, message_access)
├── 8662fc698943  — identity keys on users
├── a1b2c3d4e5f6  — Signal Protocol tables + blockchain registry columns + user_keys constraint fix
│   └── c8d9e0f1a2b3  — blockchain_batch_index on messages
│       └── d1e2f3a4b5c6  — message forwarding support
├── ee9a6e06e815  — stub (applied directly to DB on main branch)
└── ffff00000000  — merge head (joins all three branches above)
    └── a7b8c9d0e1f2  — one-time prekey key_id widened to bigint
        └── f4a5b6c7d8e9  — messages.deleted_for_recipient (delete-for-recipient)

Running the Frontend

The web client is static HTML/JS — no build step required.

# Serve with any static server, e.g.:
cd frontend
npx serve .
# Open http://localhost:3000

# Or open index.html directly in a browser
# (note: IndexedDB and SubtleCrypto require a secure context — use localhost)

Building the C++ Client

cd client-cpp
mkdir build && cd build

cmake .. \
  -DCMAKE_BUILD_TYPE=Debug \
  -DQt6_DIR=/path/to/qt6/lib/cmake/Qt6   # omit if Qt6 is on PATH

cmake --build . --parallel

./SecureMsg

To run two clients on one host, point a second build at a second daemon instance by passing the daemon's TCP port at configure time:

cmake .. -DDAEMON_PORT=47292   # default is 47291

Dependencies (install first):

  • Qt6 (including the WebSockets module): Download from qt.io or winget install Qt.Qt.6.6.3 on Windows
  • libcurl: brew install curl / apt install libcurl4-openssl-dev / vcpkg install curl
  • OpenSSL: brew install openssl / apt install libssl-dev / vcpkg install openssl
  • libsodium (CMake finds it via the vcpkg port unofficial-sodium): vcpkg install libsodium

Deploying the Smart Contract

cd blockchain
npm install

# Copy and configure .env
cp .env.example .env
# Set SEPOLIA_RPC_URL (Infura/Alchemy), DEPLOYER_PRIVATE_KEY, ETHERSCAN_API_KEY

# Compile
npx hardhat compile

# Run local tests
npx hardhat test

# Deploy to Sepolia testnet
node scripts/deployRegistry.js

# Verify on Etherscan (paste address from deploy output)
npx hardhat verify --network sepolia <CONTRACT_ADDRESS>

After deployment, set CONTRACT_ADDRESS in backend/.env.


API Reference

Method Endpoint Auth Description
POST /api/auth/register No Register account, upload X25519 identity key and Ed25519 signing key — rate limited 5 req/min
POST /api/auth/login No Verify credentials; sets httpOnly access_token cookie and readable csrf_token cookie — rate limited 5 req/min; 5 failures → 1-hour lockout
POST /api/auth/logout Cookie + CSRF Clear session cookies
GET /api/auth/me Cookie Current user profile
POST /api/auth/prekeys Cookie + CSRF Upload signed prekey + batch of one-time prekeys
GET /api/auth/user/{u}/keybundle Cookie Fetch full X3DH key bundle for a user (IK + SPK + one OPK)
POST /api/messages/send Cookie + CSRF Send encrypted message
GET /api/messages/inbox Cookie Retrieve received messages
GET /api/messages/{id} Cookie Get a specific message
GET /api/blockchain/status/{id} Cookie Check on-chain status
GET /api/blockchain/verify/{id} Cookie Tamper-evidence check
GET /public/verify/{conversation_id} None Standalone tamper-evidence check — no login required
POST /api/conversations/{id}/close Cookie + CSRF Flush remaining batch and record final closing digest on-chain
POST /api/conversations/{id}/revoke/{user_id} Cookie + CSRF Revoke a participant's access and record event on-chain

Full interactive docs: http://localhost:8000/docs (Swagger UI)


Encryption Scheme

Key bundle — what each user publishes to the server

Before a user can receive messages, their client generates and uploads a key bundle:

Key Type Purpose
Identity Key (IK) Long-term X25519 keypair Used in X3DH DH operations; never rotates
Signed Prekey (SPK) X25519 keypair + Ed25519 signature by IK Medium-term; rotated ~weekly
One-Time Prekeys (OPKs) Pool of ~100 X25519 keypairs Each used exactly once, then deleted

The Ed25519 signature on the SPK lets Alice verify the bundle was created by Bob, not substituted by the server.

X3DH — Initial key agreement (first message to a new contact)

X3DH (Extended Triple Diffie-Hellman) lets Alice establish a shared secret with Bob without Bob being online, using only his pre-published key bundle.

Alice                                Server                         Bob
  │                                     │                            │
  │  GET /api/auth/user/bob/keybundle ─►│                            │
  │◄── { IK_B, SPK_B, sig_B, OPK_B } ──│  (Bob published these      │
  │                                     │   when he registered)      │
  │  Verify Ed25519 sig_B over SPK_B    │                            │
  │  using IK_B                         │                            │
  │                                     │                            │
  │  Generate ephemeral keypair EK_A    │                            │
  │                                     │                            │
  │  DH1 = X25519(IK_A,  SPK_B)         │                            │
  │  DH2 = X25519(EK_A,  IK_B )         │                            │
  │  DH3 = X25519(EK_A,  SPK_B)         │                            │
  │  DH4 = X25519(EK_A,  OPK_B)  ← omitted if OPK pool empty        │
  │                                     │                            │
  │  SK = HKDF-SHA256(DH1‖DH2‖DH3‖DH4) │                            │
  │  SK seeds Double Ratchet root key   │                            │

Alice includes IK_A and EK_A in the first message header so Bob can recompute the same SK on his end.

Double Ratchet — Per-message key evolution

After X3DH establishes the root key, every message uses the Double Ratchet to derive a unique encryption key:

Sending a message
──────────────────
(CK_send_next, MK) = HKDF(CK_send_current)
ciphertext = AES-256-GCM(key=MK, plaintext, aad)
erase MK immediately after use
CK_send = CK_send_next

Message header attached to every ciphertext:
  ratchet_public_key   — sender's current DH ratchet public key
  PN                   — length of previous sending chain
  N                    — index of this message in the current chain

Associated data (AAD) bound into every AES-256-GCM call:
  IK_A ‖ IK_B ‖ ratchet_public_key ‖ PN ‖ N

DH ratchet step (on first message after receiving a reply):
  new X25519 keypair generated
  (root_key, CK_send) = HKDF(root_key, DH(new_sk, remote_ratchet_pk))
  — this re-seeds the chain from a fresh DH value, healing state
    after a compromise within one round-trip

Forward secrecy: message keys are erased after use, so a future compromise of ratchet state cannot decrypt past messages.

Post-compromise security: the DH ratchet injects fresh X25519 key material on every round-trip, bounding the damage from a state compromise to messages sent before the next undisturbed reply.

Out-of-order message handling

Messages can arrive out of order. When message N+3 arrives before N+1, the receiver advances the ratchet to decrypt N+3 and stores the skipped keys for N+1 and N+2 in the skipped_message_keys table. They are used when the late messages arrive. A maximum of 1000 skipped keys per session is enforced in application logic.

Message send flow

Sender (client)                          Server              Blockchain
    │                                      │                     │
    │  (if first message to recipient:)    │                     │
    ├── GET /user/{u}/keybundle ─────────►│                     │
    │◄── { IK_B, SPK_B, sig_B, OPK_B } ──│                     │
    ├── Run X3DH → seed ratchet root key  │                     │
    │                                      │                     │
    ├── Double Ratchet step:               │                     │
    │     (CK_next, MK) = HKDF(CK_send)   │                     │
    │     ciphertext = AES-256-GCM(MK, m) │                     │
    │     erase MK                         │                     │
    │                                      │                     │
    ├── POST /messages/send ─────────────►│                     │
    │   { ciphertext, nonce,               │                     │
    │     ratchet_public_key, PN, N }      │                     │
    │                                store in DB                 │
    │                                asyncio BackgroundTask      │
    │                                web3.py: recordDigest() ───►│
    │◄── { message_id } ─────────────────│   (15 s, async)      │

Message receive flow

Recipient (client)                       Server
    │                                      │
    ├── GET /messages/inbox ─────────────►│
    │◄── [ { ciphertext, nonce,            │
    │         ratchet_public_key, PN, N,  │
    │         sender IK } ]               │
    │                                      │
    ├── Locate or initialise ratchet       │
    │   session for sender                 │
    ├── Advance ratchet to chain N         │
    │   (store any skipped keys)           │
    └── AES-256-GCM.Open(ciphertext) → plaintext

Web Client Security Features

At-rest key wrapping

Private keys generated in the browser are never stored in plaintext. On registration:

  1. PBKDF2-SHA256 (600 000 iterations) is run over the user's password + a 16-byte random salt.
  2. The resulting bits are passed through HKDF-SHA256 (info=EPIC-v1-wrap-key) for domain separation.
  3. The final AES-256-GCM key is used to wrapKey (PKCS#8 format) all three private keys — IK, SPK, and each OPK — before they are written to IndexedDB.
  4. The wrapping key is held in JavaScript memory only (wrappingKey variable) and cleared on logout.

On login, the same derivation is re-run from the entered password to unwrap the stored blobs. Users who registered before this feature was introduced see a dismissable migration banner prompting them to re-register.

TOFU key pinning

The first time a contact is added, their X25519 identity key fingerprint (SHA-256, hex-encoded) is stored in a separate tofu-store IndexedDB database. Every subsequent key bundle fetch compares the new fingerprint against the stored pin. A mismatch blocks the contact and shows a prominent red banner — the user must explicitly dismiss it. Fingerprints are displayed in the contact sidebar (⚠ for new, ✓ for pinned) and in the chat header.

WebSocket + cover traffic

After login the client opens a WebSocket connection (wss:// in production). The server immediately starts sending randomised 256-byte heartbeat frames at uniform [3, 10] s intervals. These frames have the same wire size as padded real-message frames, making it harder to correlate message timing from network observations alone. The client silently discards any frame whose type is not "new_message". If the socket closes, the client falls back to HTTP polling and attempts to reconnect after 5 s.

Blockchain verify UI

Each conversation header shows a "⛓ Verify" button once a contact has been added. Clicking it calls GET /public/verify/{conversation_id}, compares the local message digest against the on-chain record, and shows:

  • Chain verified — digest matches, with timestamp and an Etherscan link.
  • Mismatch — truncated on-chain vs local digests for inspection.

Environment Variables

Copy .env.example to .env in the repo root and fill in your values. Docker Compose reads these and injects them into the backend container.

# Required
JWT_SECRET_KEY=change-me-to-a-random-256-bit-value
REDIS_PASSWORD=change-me

# Blockchain (optional — omit to run without on-chain recording)
PRIVATE_KEY=0x...                                      # server wallet private key
RPC_URL=https://rpc2.sepolia.org                       # Sepolia JSON-RPC endpoint
CONTRACT_ADDRESS=0x...                                 # deployed MessageDigestRegistry address

Create blockchain/.env:

SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_PROJECT_ID
DEPLOYER_PRIVATE_KEY=0x...
ETHERSCAN_API_KEY=...

Blockchain — MessageDigestRegistry

MessageDigestRegistry.sol is a purpose-built contract that provides a per-conversation audit registry — every call to recordDigest() appends a DigestRecord (keccak256 hash + timestamp + recorder address + conversationId) to a public array and emits a DigestRecorded event.

Blockchain writes are handled natively by backend/app/services/blockchain_service.py using web3.py — no separate Node.js process is involved. After each message is saved, a FastAPI BackgroundTask calls record_conversation_digest() which signs and broadcasts the transaction using AsyncWeb3(AsyncHTTPProvider(...)). Verification (GET /api/verify/{conversation_id}) performs a gas-free eth_call via the same service.

Setup (compile & deploy — one-time)

cd blockchain
npm install          # installs hardhat, OZ contracts, ethers, dotenv

# Copy env template and fill in your Sepolia wallet key + RPC endpoint
cp ../.env.example .env
# Edit .env — set PRIVATE_KEY and RPC_URL

Compile

cd blockchain
npx hardhat compile
# Artifacts written to blockchain/artifacts/

Deploy to Sepolia

# From the blockchain/ directory (after compile):
node scripts/deployRegistry.js

# Output example:
#   Deployer address : 0xYourWallet
#   Deployer balance : 0.05 ETH
#   Deployment tx hash : 0xabc...
#   Waiting for 1 confirmation...
#   ✓ MessageDigestRegistry deployed
#     Contract address : 0x1234...
#     Block number     : 7654321
#     Etherscan        : https://sepolia.etherscan.io/tx/0xabc...
#     Deployed address written to: blockchain/deployedAddress.json

After deployment, set CONTRACT_ADDRESS (along with PRIVATE_KEY and RPC_URL) in the host environment or .env file — docker-compose injects them into the backend container at startup. Do not add them to backend/.env directly; use the repo-root .env.example as your template.

Etherscan link format

https://sepolia.etherscan.io/tx/{txHash}

Submission checklist

File Purpose
blockchain/contracts/MessageDigestRegistry.sol Production Solidity contract
blockchain/scripts/deployRegistry.js Standalone deploy script
blockchain/MessageDigestRegistryABI.json Must be included in submission zip
backend/app/services/blockchain_service.py Native web3.py integration (no Node.js)
blockchain/deployedAddress.json Written post-deploy; git-ignored
.env.example Environment variable template

blockchain/MessageDigestRegistryABI.json is a plain JSON array of all function and event signatures. Include it in the submission zip alongside the compiled artifacts.


Team

EPIC — Alpha and the Cryptmunks

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages