Skip to content

About

Open source messaging project with an emphasis on privacy.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

Β 

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

CryptoMessage

CryptoMessage β€” Server

End-to-end encrypted messaging. The server never sees a plaintext message, passphrase, or private key.

Java Spring Boot Spring Security Spring Data JPA WebSocket Gradle Docker License: MIT


Table of Contents


πŸ“‘ Overview

CryptoMessage Server is the backend for a privacy-first, end-to-end encrypted messaging application. Built with Java 17 and Spring Boot 3, it is intentionally message-blind: it authenticates, stores, routes, and expires data, but it never has access to plaintext content. All cryptographic operations β€” key generation, message encryption, message decryption β€” happen exclusively on the client, using the browser's native Web Crypto API.

This repository contains the backend of the CryptoMessage ecosystem. The React/TypeScript frontend lives in a separate repository.


πŸ”’ Philosophy

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 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. Privacy depends on the user's passphrase and device, not on trusting this server.

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.


πŸ” How Encryption Works

Encryption is handled entirely on the client. The server's role starts after content is already ciphertext.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  CLIENT                                                              β”‚
β”‚                                                                       β”‚
β”‚  1. Key generation (RSA-OAEP 2048 + 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, and nothing on this side of the wire ever calls a decrypt function.

See the frontend README's "Cryptography Architecture" section for the client-side primitives (RSA-OAEP, AES-GCM, PBKDF2) that produce these blobs.


πŸ› οΈ Tech Stack

Layer Technology Version
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) β€”
Realtime Spring WebSocket Β· STOMP β€”
API Docs SpringDoc OpenAPI (Swagger UI) 2.5.0
Build Gradle 8.14.4
Container Docker (eclipse-temurin:17-jdk) β€”

πŸ“ Project Structure

src/main/java/com/cryptomessage/server/
β”œβ”€β”€ ServerApplication.java
β”œβ”€β”€ controller/
β”‚   β”œβ”€β”€ AuthenticationController.java   # /api/v1/auth
β”‚   β”œβ”€β”€ ChatController.java             # /api/v1/chats
β”‚   β”œβ”€β”€ ContactController.java          # /api/v1/contacts
β”‚   β”œβ”€β”€ MessageController.java          # /api/v1/messages
β”‚   └── GlobalExceptionHandler.java     # Centralized error mapping β†’ ApiError
β”œβ”€β”€ services/
β”‚   β”œβ”€β”€ AuthenticationService.java      # Login + token verification
β”‚   β”œβ”€β”€ UserRegistrationService.java    # Registration + key material storage
β”‚   β”œβ”€β”€ JwtService.java                 # Token issuance / validation (HMAC-SHA256)
β”‚   β”œβ”€β”€ CurrentUserService.java         # Resolves the authenticated AppUser
β”‚   β”œβ”€β”€ ChatService.java                # Chat lifecycle (create/list/accept)
β”‚   β”œβ”€β”€ ContactService.java             # Contact search / add / remove
β”‚   β”œβ”€β”€ MessageService.java             # Send/list/mark-as-read
β”‚   β”œβ”€β”€ UserDetailsServiceImpl.java     # Spring Security UserDetailsService
β”‚   └── Scheduler.java                  # Nightly data-retention jobs
β”œβ”€β”€ repositories/                       # Spring Data JPA interfaces
β”‚   β”œβ”€β”€ UserRepository.java
β”‚   β”œβ”€β”€ ChatRepository.java
β”‚   β”œβ”€β”€ MessageRepository.java
β”‚   └── ContactRepository.java
β”œβ”€β”€ model/
β”‚   β”œβ”€β”€ entity/
β”‚   β”‚   β”œβ”€β”€ user/AppUser.java
β”‚   β”‚   β”œβ”€β”€ chat/Chat.java, ChatStatus.java
β”‚   β”‚   β”œβ”€β”€ message/Message.java, ContentByUserConverter.java
β”‚   β”‚   └── contact/Contact.java, ContactId.java
β”‚   β”œβ”€β”€ dto/                            # Request/response records, grouped by domain
β”‚   └── mapper/                         # Entity β†’ DTO mapping (ChatMapper, MessageMapper...)
└── config/
    β”œβ”€β”€ SecurityConfig.java             # Filter chain, stateless sessions, route rules
    β”œβ”€β”€ SwaggerConfig.java              # OpenAPI metadata
    β”œβ”€β”€ security/
    β”‚   β”œβ”€β”€ AuthConfig.java             # PasswordEncoder + AuthenticationManager beans
    β”‚   β”œβ”€β”€ CorsConfig.java             # CORS origins from env
    β”‚   └── JwtAuthenticationFilter.java
    β”œβ”€β”€ websocket/
    β”‚   β”œβ”€β”€ WebSocketConfig.java        # STOMP broker + endpoint registration
    β”‚   └── StompAuthChannelInterceptor.java  # Authenticates the STOMP CONNECT frame
    └── exceptions/
        β”œβ”€β”€ ConflictException.java      # β†’ 409
        └── ForbiddenException.java     # β†’ 403

πŸ—ƒοΈ Domain Model

Entity Key fields Notes
AppUser username (unique), passphraseHash (BCrypt), publicKey, encryptedPrivateKey, lastSeen recordActivity() bumps lastSeen on every authenticated action β€” this is what keeps the account out of the inactivity-cleanup job.
Chat appUser1, appUser2, status (PENDING/ACCEPTED), initiatedBy Unique constraint on (user1_id, user2_id); a duplicate createChat call is caught and rethrown as a 409 Conflict.
Message chat, sender, contentByUser (Map<userId, ciphertext>), isRead contentByUser is persisted via a custom AttributeConverter (ContentByUserConverter) β€” the column itself is opaque TEXT, the server never parses the ciphertext values, only the map's keys.
Contact / ContactId Composite key (ownerId, contactId) Created bidirectionally when a chat is accepted.

πŸ“– API Reference

All endpoints except /api/v1/auth/** require a Bearer token in the Authorization header. Errors are returned as a consistent shape from GlobalExceptionHandler:

{
  "error": "FORBIDDEN",
  "message": "You are not a participant of this chat",
  "timestamp": "2026-07-17T03:00:00Z"
}

Authentication β€” /api/v1/auth

Method Endpoint Description Auth
POST /register Create account (username + passphrase + public key + client-encrypted private key) βœ—
POST /login Authenticate, receive JWT + key material βœ—
GET /verify Validate the current token, refresh user data βœ“

Register / Login payload note: the client generates the RSA key pair locally before registering. The server stores the public key in plaintext and the private key already encrypted by the client (AES-GCM) β€” it never touches the raw private key or the plaintext passphrase.

Chats β€” /api/v1/chats

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, which prevents unsolicited message spam while still allowing a first contact.

Messages β€” /api/v1/messages

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

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 before persisting anything.

Contacts β€” /api/v1/contacts

Method Endpoint Description
POST /search Look up a user by username
GET / List your contacts
POST / Add a contact (requires an accepted chat)
DELETE /{contactId} Remove a contact

Interactive docs are served by Swagger UI at /swagger-ui/index.html once the app is running.


πŸ”Œ Realtime

New messages and chat status changes are pushed live over STOMP-over-WebSocket instead of requiring a client reload.

  • Endpoint: ws(s)://<host>/ws β€” raw WebSocket, no SockJS fallback. Modern browsers and @stomp/stompjs support native WebSocket directly, so the extra long-polling complexity SockJS brings isn't needed for a private app with no legacy-browser requirement.

  • Auth model: the WebSocket handshake itself is anonymous (permitAll in SecurityConfig) because SockJS/raw-WS handshakes can't reliably carry custom headers from the browser. Real authentication happens on the STOMP CONNECT frame, intercepted by StompAuthChannelInterceptor, which mirrors JwtAuthenticationFilter's validation so there's a single source of truth for what makes a token valid. A missing or invalid token closes the connection before any subscription is accepted.

  • Subscriptions (per-user, via Spring's /user prefix):

    Destination Payload Fired on
    /user/queue/messages MessageResponse A message was sent in one of your chats (echoed to your own other sessions too)
    /user/queue/chats ChatResponse A chat was created (to the recipient) or accepted (to both participants)

    Both payloads are the same DTOs the REST endpoints already return β€” the server stays message-blind here too, relaying the same opaque ciphertext it stores, nothing more is decrypted or inspected to make this work.


πŸ›‘οΈ Security

  • 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, SessionCreationPolicy.STATELESS). Token expiration is set via jwt.expiration-ms (default: 24 hours).
  • CORS origins are configured via the CORS_ALLOWED_ORIGINS environment variable (comma-separated for multiple origins); falls back to a wildcard pattern 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.
  • Route rules: only /api/v1/auth/** and the WebSocket handshake at /ws/** are public; everything else requires a valid bearer token.

🧹 Data Retention

A scheduler runs nightly (starting 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.

Job Data Deleted after Cron
deleteOldMessages Messages 15 days 0 0 3 * * *
deleteEmptyChats Empty chats 30 days 0 15 3 * * *
deleteInactiveUsers Inactive accounts 45 days without recordActivity() 0 30 3 * * *

Deletion is hard β€” no soft-delete, no archive, no audit log. Once the window passes, the data is gone.


πŸš€ Getting Started

Prerequisites

  • Java 17+
  • MariaDB running locally
  • A base64-encoded 256-bit secret for JWT

Environment Variables

Set the following (e.g. via a .env file loaded by your IDE, or exported in your shell):

# 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

# Optional β€” comma-separated list; falls back to wildcard in dev if unset
CORS_ALLOWED_ORIGINS=http://localhost:5173

Run

# Activate the dev profile (MariaDB, show-sql enabled, ddl-auto=update)
./gradlew bootRun --args='--spring.profiles.active=dev'

Swagger UI will be available at http://localhost:8080/swagger-ui/index.html.

Variable Profile Description
DB_URL_DEV / DB_USER_DEV / DB_PASSWORD_DEV dev MariaDB connection
DB_URL / DB_USER / DB_PASSWORD prod MySQL connection
JWT_SK both Base64-encoded 256-bit HMAC secret
CORS_ALLOWED_ORIGINS both Comma-separated allowed origins for REST + WebSocket

🐳 Docker

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> \
  -e CORS_ALLOWED_ORIGINS=https://your-frontend.example.com \
  cryptomessage-server

The production profile is active by default in the container image (MySQL driver, ddl-auto=validate, show-sql=false). The Dockerfile uses a two-stage build β€” gradle:8.14.4-jdk17 to compile, eclipse-temurin:17-jdk to run β€” so the shipped image doesn't carry the Gradle toolchain.


βœ… Testing

./gradlew test

JUnit 5 is wired in via spring-boot-starter-test and spring-security-test. Test coverage currently focuses on the authentication and chat-creation service logic β€” see src/test/java/com/cryptomessage/server/services/.


🀝 Contributing

Contributions are welcome. Please open an issue before submitting a pull request to discuss the proposed change.

  1. Fork the repository
  2. Create a feature branch: git checkout -b feat/your-feature
  3. Commit using Conventional Commits: git commit -m 'feat: add your feature'
  4. Push and open a pull request

πŸ“„ License

This project is licensed under the MIT License β€” see the LICENSE file for details.

Copyright Β© 2025 Camilo Andres Castellanos Herrera


The server holds ciphertext it cannot read.

About

Open source messaging project with an emphasis on privacy.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages