End-to-end encryption for Java, made simple.
X25519 key exchange • AES-256-GCM • HKDF key derivation • Sender Keys for groups • Streaming encryption
A protocol similar to what Signal and WhatsApp use under the hood — as a lightweight, embeddable Java API.
Maven:
<dependency>
<groupId>me.wisterk</groupId>
<artifactId>wipher</artifactId>
<version>1.0.0</version>
</dependency>Gradle:
implementation 'me.wisterk:wipher:1.0.0'Imagine Alice wants to send Bob a secret message through a server. The server should deliver the message but never be able to read it.
Alice ──[encrypted]──► Server ──[encrypted]──► Bob
│
can't read [X]
This is called end-to-end encryption (E2E) — only the sender and receiver can read the content. The server, the network, anyone who intercepts traffic — sees only meaningless ciphertext.
Wipher provides the building blocks to make this work.
When Alice installs the app, her device generates an X25519 key pair:
- Private key — stays on her device forever, never transmitted
- Public key — safe to share with anyone (like a phone number)
var alice = Wipher.inMemory();
var alicePublicKey = alice.getPublicKey(); // safe to send anywhereThe private key is just a random 32-byte number. The public key is mathematically derived from it, but you cannot reverse-compute the private key from the public one.
Alice and Bob exchange public keys through the server. The server sees both public keys — that's fine, they're useless without the private keys.
// Alice knows Bob's public key (received from server)
alice.establishSession("bob", bobPublicKey);
// Bob knows Alice's public key (received from server)
bob.establishSession("alice", alicePublicKey);Behind the scenes, each side computes a shared secret using their own private key and the other's public key:
Alice computes: SharedSecret = DH(alice_private, bob_public)
Bob computes: SharedSecret = DH(bob_private, alice_public)
Both get the SAME result — without ever transmitting it.
This is the magic of Diffie-Hellman: two people arrive at an identical secret number by combining their private data with the other's public data. An eavesdropper who sees both public keys cannot compute this secret — it's the discrete logarithm problem, which has no known efficient solution.
The raw DH shared secret is not directly suitable as an encryption key. We pass it through HKDF (HMAC-based Key Derivation Function, RFC 5869) to produce a clean, uniform 256-bit AES key:
DH shared secret (32 bytes, non-uniform)
│
▼
HKDF-SHA256
│
▼
AES-256 key (32 bytes, uniform)
Think of it like refining crude oil into gasoline — the raw material is valuable but needs processing before use.
Now both sides have the same AES key. Every message is encrypted with AES-256-GCM:
// Alice encrypts
var encrypted = alice.encrypt("bob", "Hello Bob!");
// Bob decrypts
String text = bob.decrypt("alice", encrypted);
// -> "Hello Bob!"GCM (Galois/Counter Mode) provides two things at once:
- Confidentiality — the message is unreadable without the key
- Integrity — if anyone tampers with even a single bit, decryption fails with an error (authentication tag mismatch)
Each message gets a unique random nonce (12 bytes), so encrypting the same text twice produces completely different ciphertext.
Alice sends: MSG a4Bf9x2Kp7...QmR8w==
Server sees: MSG a4Bf9x2Kp7...QmR8w== <- meaningless bytes
Bob receives: MSG a4Bf9x2Kp7...QmR8w==
Bob decrypts: "Hello Bob!" <- only Bob can read
Groups are harder. You can't just do DH between 50 people. Wipher uses the Sender Keys model (same as Signal):
Each group member generates a sender key — a random AES key used to encrypt their messages:
Alice's sender key: [random 32 bytes]
Bob's sender key: [random 32 bytes]
Katya's sender key: [random 32 bytes]
Each member distributes their sender key to all others via pairwise E2E sessions (the 1-on-1 encryption described above):
Alice → Bob: encrypt_e2e(alice_bob_session, alice_sender_key)
Alice → Katya: encrypt_e2e(alice_katya_session, alice_sender_key)
The server relays these encrypted key blobs but cannot read them.
After setup, everyone knows everyone's sender key:
alice.createGroup("team");
alice.addGroupMember("team", "bob", bobSenderKey);
alice.addGroupMember("team", "katya", katyaSenderKey);When Alice sends a message, she encrypts it once with her sender key:
var msg = alice.encryptGroup("team", "Hello team!");
// Server broadcasts this single ciphertext to all membersBob and Katya both know Alice's sender key, so both can decrypt.
If Katya is removed, her sender key is compromised (she still has everyone's old keys). Solution: key rotation.
byte[] newKey = alice.removeGroupMember("team", "katya");
// Alice generates a new sender key and sends it to remaining members
// Katya has the old key — useless for new messagesRSA is asymmetric encryption — public key encrypts, private key decrypts. It works, but:
| RSA | DH + AES (Wipher) | |
|---|---|---|
| Speed | ~0.001 GB/s | ~4 GB/s |
| Message size limit | ~200 bytes per operation | Unlimited |
| Forward secrecy | No | Yes (with key rotation) |
| Use case | Encrypting small data (keys, signatures) | Encrypting everything |
RSA is 4000x slower and can only encrypt tiny chunks. In practice, even RSA-based systems use RSA only to exchange an AES key, then switch to AES for actual data — which is essentially what Wipher does, but with DH instead of RSA (faster, smaller keys, same security).
TLS protects data in transit (client ↔ server). But the server still sees plaintext:
TLS: Alice ──[encrypted]──> Server (decrypts, reads, re-encrypts) ──[encrypted]──> Bob
Wipher: Alice ──[encrypted]──> Server (can't decrypt) ──[encrypted]──> Bob
TLS trusts the server. Wipher doesn't trust anyone except the endpoints.
AES-256 has 2^256 possible keys. If you tried one trillion keys per second, it would take longer than the age of the universe — by a factor of 10^50. The energy required to count to 2^256 exceeds the total energy output of the Sun over its lifetime.
No known mathematical attack breaks AES-256 or X25519 significantly faster than brute force. This could theoretically change with new mathematical discoveries, but as of today, these are considered secure by every major cryptographic institution (NIST, NSA, GCHQ).
X25519 (elliptic curve DH) would be vulnerable to a sufficiently powerful quantum computer running Shor's algorithm. AES-256 remains safe (Grover's algorithm only halves the effective key length: 256 → 128 bits, still infeasible). Post-quantum key exchange algorithms (Kyber/ML-KEM) exist but are not yet implemented in Wipher.
Yes. If someone has physical access to the device and can extract the private key from memory — game over. Encryption protects data in transit, not at rest on a compromised device. Use device encryption (FileVault, BitLocker) and secure enclaves for key storage in production.
// Create instances (each represents a device)
var alice = Wipher.inMemory();
var bob = Wipher.inMemory();
// Exchange public keys (through any channel)
alice.establishSession("bob", bob.getPublicKey());
bob.establishSession("alice", alice.getPublicKey());
// Encrypt & decrypt
var encrypted = alice.encrypt("bob", "Secret message");
String decrypted = bob.decrypt("alice", encrypted);byte[] fileBytes = Files.readAllBytes(Path.of("document.pdf"));
var encryptedFile = alice.encrypt("bob", fileBytes);
// Bob decrypts
byte[] decryptedFile = bob.decryptBytes("alice", encryptedFile);// Setup
alice.createGroup("project");
bob.createGroup("project");
// Exchange sender keys (via pairwise E2E)
alice.addGroupMember("project", "bob", bob.getGroupSenderKey("project"));
bob.addGroupMember("project", "alice", alice.getGroupSenderKey("project"));
// Broadcast
var msg = alice.encryptGroup("project", "Hello team!");
bob.decryptGroup("project", "alice", msg); // → "Hello team!"For large files (videos, archives, backups) loading everything into memory is not an option. Wipher splits data into 64KB chunks, each independently encrypted with its own nonce and GCM auth tag. Memory usage stays constant regardless of file size.
// Alice encrypts a 2 GB video — only 64 KB in memory at a time
InputStream encrypted = alice.encryptStream("bob",
new FileInputStream("movie.mkv"));
// Bob decrypts — also streaming, never loads full file
InputStream decrypted = bob.decryptStream("alice", encrypted);
Files.copy(decrypted, Path.of("movie-decrypted.mkv"));Wire format (per chunk):
[4 bytes: size] [12 bytes: nonce] [size+16 bytes: ciphertext + GCM tag]
[4 bytes: size] [12 bytes: nonce] [size+16 bytes: ciphertext + GCM tag]
...
[4 bytes: 0x00] — end of stream
Each chunk is tamper-proof — if a single bit is modified, decryption fails immediately at that chunk without processing the rest.
Works for groups too:
// Alice broadcasts a file to the group
InputStream encrypted = alice.encryptGroupStream("team",
new FileInputStream("report.pdf"));
// Bob decrypts
InputStream decrypted = bob.decryptGroupStream("team", "alice", encrypted);Custom chunk size for fine-tuning throughput vs. memory:
// 1 KB chunks (low memory, more overhead)
WipherEncryptingStream.wrap(key, inputStream, 1024);
// 1 MB chunks (more memory, less overhead)
WipherEncryptingStream.wrap(key, inputStream, 1024 * 1024);Wipher needs to persist identity keys and session state. Five built-in implementations cover most deployment scenarios:
Everything in heap. Lost on restart. Good for tests and ephemeral processes.
var wipher = Wipher.inMemory();Keys and sessions stored as files on disk. Simple, no dependencies.
var wipher = Wipher.fileBased(Path.of(System.getProperty("user.home"), ".wipher"));~/.wipher/
├── identity.pub — public key (X.509)
├── identity.key — private key (PKCS#8)
└── sessions/
├── alice.session
└── bob.session
Any SQL database — PostgreSQL, MySQL, SQLite, H2. Auto-creates tables.
// With DataSource (HikariCP, Spring, etc.)
var wipher = Wipher.fromDatabase(dataSource);
// Or with raw JDBC URL
var wipher = Wipher.fromDatabase("jdbc:postgresql://localhost/wipher", "user", "pass");Identity from env vars, sessions in memory. Built for Docker and serverless.
var wipher = Wipher.fromEnvironments();export WIPHER_PUBLIC_KEY=MCowBQYDK2Vu...
export WIPHER_PRIVATE_KEY=MC4CAQAwBQYD...On first run without env vars, Wipher generates keys and prints ready-to-use export commands to stderr.
Wraps any store with AES-256-GCM encryption at rest. Even if the disk or database is compromised, data is useless without the passphrase.
var wipher = Wipher.encrypted(
Wipher.fileBased(Path.of("~/.wipher")),
"my-strong-passphrase"
);Implement WipherKeyStore for Redis, S3, HSM, or anything else:
var wipher = Wipher.create(new MyCustomKeyStore());- Java 21+
- No external dependencies (uses
java.securityandjavax.crypto)