Backend for a privacy-first, end-to-end encrypted messaging application.
Built with Java 17 and Spring Boot 3, this server is intentionally message-blind: it stores, routes, and expires data, but never has access to plaintext content. All cryptographic operations happen exclusively on the client.
CryptoMessage is designed around a simple premise: the server should be the least trusted party in the system.
In most messaging platforms, the operator is an implicit third party — they can read your messages, retain them indefinitely, and comply with third-party data requests. CryptoMessage takes the opposite stance. The server is a dumb relay: it holds ciphertext it cannot decrypt, and it forgets data on a fixed schedule regardless of user action.
This puts the responsibility — and the control — back in the hands of the user. Your privacy depends on your passphrase and your device, not on trusting this server.
Encryption is handled entirely on the client (frontend). The server never sees a plaintext message, passphrase, or private key.
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ 1. Key generation (RSA-OAEP + AES-GCM) │
│ ├── Public key ──────────────────────────────► stored on server (plaintext) │
│ └── Private key ──► encrypted with passphrase ► stored on server (ciphertext) │
│ │
│ 2. Sending a message │
│ └── content encrypted once per participant │
│ ├── { userId_A: encrypt(content, pubKey_A) } │
│ └── { userId_B: encrypt(content, pubKey_B) } │
│ │ │
└──────────────────────────────────────────┼──────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ Receives: Map<userId, encryptedContent> │
│ Stores: opaque ciphertext blobs, routed by userId │
│ Returns: the blob addressed to the requesting user │
│ │
└─────────────────────────────────────────────────────────────────┘
Each participant receives only their own encrypted copy. The server cannot reconstruct the original message even if it tried — it holds two unrelated ciphertexts.
Note on design evolution: The first version of this project performed RSA and AES encryption on the server side. As the architecture matured, it became clear that server-side encryption fundamentally contradicts a zero-trust model — the server knowing the plaintext even momentarily defeats the purpose. Encryption was migrated entirely to the client, reducing the server's role to authenticated storage and delivery of opaque data.
A scheduler runs nightly (03:00 server time) and permanently deletes data on a fixed schedule. There is no opt-out and no manual override — this is by design.
| Data | Deleted after |
|---|---|
| Messages | 15 days |
| Empty chats | 30 days |
| Inactive accounts | 45 days of inactivity |
Deletion is hard — no soft-delete, no archive, no audit log. Once the window passes, the data is gone.
| Layer | Technology |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 3.3.2 |
| Security | Spring Security · JWT (JJWT 0.12.5) |
| Persistence | Spring Data JPA · Hibernate |
| Database | MariaDB (dev) · MySQL (prod) |
| API Docs | SpringDoc OpenAPI (Swagger UI) |
| Build | Gradle 8.14.4 |
| Container | Docker (eclipse-temurin:17-jdk) |
src/main/java/com/cryptomessage/server/
│
├── controller/ # REST endpoints + GlobalExceptionHandler
├── services/ # Business logic, JWT, scheduler
├── repositories/ # Spring Data JPA interfaces
├── model/
│ ├── entity/ # JPA entities (AppUser, Chat, Message, Contact)
│ ├── dto/ # Request/response records
│ └── mapper/ # Entity → DTO mapping
└── config/
├── security/ # JWT filter, CORS, AuthConfig
└── exceptions/ # Custom exception types
All endpoints (except auth) require a Bearer token in the Authorization header.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /register |
Create account (sends public key + encrypted private key) | ✗ |
| POST | /login |
Authenticate, receive JWT + key material | ✗ |
| GET | /verify |
Validate token, refresh user data | ✓ |
Register / Login payload note: The client generates the key pair locally before registering. The server stores the public key in plaintext and the private key already encrypted by the client — it never touches the raw private key.
| Method | Endpoint | Description |
|---|---|---|
| POST | / |
Initiate a chat with another user |
| GET | / |
List your chats (optional ?status=PENDING|ACCEPTED) |
| POST | /{chatId}/accept |
Accept a pending chat (creates contacts bidirectionally) |
Chats start as PENDING. The initiator can send one message before the other party accepts. This prevents unsolicited message spam while allowing a first contact.
| Method | Endpoint | Description |
|---|---|---|
| POST | / |
Send an encrypted message |
| GET | /chat/{chatId} |
Retrieve messages for a chat |
| PATCH | /chat/{chatId}/read |
Mark all messages in a chat as read |
The request body for sending a message:
{
"chatId": 1,
"encryptedContentByUser": {
"101": "<ciphertext for user 101>",
"102": "<ciphertext for user 102>"
}
}Both participants must be present as keys. The server validates this and rejects incomplete payloads.
| Method | Endpoint | Description |
|---|---|---|
| POST | /search |
Look up a user by username |
| GET | / |
List your contacts |
| POST | / |
Add a contact (requires accepted chat) |
| DELETE | /{contactId} |
Remove a contact |
- Java 17+
- MariaDB running locally
- A base64-encoded 256-bit secret for JWT
# Database (dev profile)
DB_URL_DEV=jdbc:mariadb://localhost:3306/cryptomessage
DB_USER_DEV=your_user
DB_PASSWORD_DEV=your_password
# JWT
JWT_SK=<base64-encoded 256-bit secret>
# Generate one with: openssl rand -base64 32# Activate the dev profile (uses MariaDB, enables show-sql, ddl-auto=update)
./gradlew bootRun --args='--spring.profiles.active=dev'Swagger UI will be available at http://localhost:8080/swagger-ui/index.html.
docker build -t cryptomessage-server .
docker run -p 8080:8080 \
-e JWT_SK=<your-secret> \
-e DB_URL=<jdbc-url> \
-e DB_USER=<user> \
-e DB_PASSWORD=<password> \
cryptomessage-serverThe production profile is active by default in the container (MySQL driver, ddl-auto=validate, show-sql=false).
- Passwords are hashed with BCrypt before storage. The server never stores or transmits plaintext passphrases.
- Private keys reach the server already encrypted by the client (AES-GCM, key derived from the user's passphrase). The server stores them to allow multi-device login — the client re-derives the decryption key locally.
- JWT is stateless (HMAC-SHA256). Token expiration is set via
jwt.expiration-ms(default: 24 hours). - CORS origins are configured via the
CORS_ALLOWED_ORIGINSenvironment variable. Falls back to wildcard in dev. - Message validation enforces that every outgoing message includes a ciphertext entry for each chat participant, preventing partial or malformed payloads from reaching the database.
MIT