Alpha and the Cryptmunks 4life
An end-to-end encrypted messaging system with a blockchain audit trail, built as a university module project.
┌─────────────────────────────────────────────────────────────┐
│ 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) │
└───────────────────────┘
| 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 |
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
| 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 |
# 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/docscd 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 --reloadAll migrations are already in backend/alembic/versions/. Apply them all with:
alembic upgrade headThis 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)
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)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
./SecureMsgTo 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 47291Dependencies (install first):
- Qt6 (including the WebSockets module): Download from qt.io or
winget install Qt.Qt.6.6.3on 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
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.
| 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)
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 (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.
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.
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.
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) │
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
Private keys generated in the browser are never stored in plaintext. On registration:
- PBKDF2-SHA256 (600 000 iterations) is run over the user's password + a 16-byte random salt.
- The resulting bits are passed through HKDF-SHA256 (info=
EPIC-v1-wrap-key) for domain separation. - 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. - The wrapping key is held in JavaScript memory only (
wrappingKeyvariable) 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.
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.
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.
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.
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 addressCreate blockchain/.env:
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_PROJECT_ID
DEPLOYER_PRIVATE_KEY=0x...
ETHERSCAN_API_KEY=...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.
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_URLcd blockchain
npx hardhat compile
# Artifacts written to blockchain/artifacts/# 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.jsonAfter 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.
https://sepolia.etherscan.io/tx/{txHash}
| 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.jsonis a plain JSON array of all function and event signatures. Include it in the submission zip alongside the compiled artifacts.
EPIC — Alpha and the Cryptmunks