← All work CODE · 2026

Aegis Link - Building a true E2EE Messenger the Server Can't Read

Inspired by other military-graded ultra-secure ephemeral messenging apps - many difficult words, but one simple function - security

The premise is simple and slightly paranoid: what if the server genuinely <em>couldn't</em> read your messages - not because of a privacy policy, but because of mathematics?

Aegis Link - Building a true E2EE Messenger the Server Can&#x27;t Read

BLAZOR WASM · ASP.NET CORE 10 · NACL.JS · SIGNALR · SQLITE

Aegis Link: Building a Messenger the Server Cannot Read

podeszwa.dev · May 2026 · 8 min read

Demonstration video

The idea behind Aegis Link is simple. What if the server genuinely could not read your messages, not because of policy, but because it never had the keys in the first place? That is the main constraint the project was built around. Every message is encrypted in the browser before it leaves the device. The server only forwards ciphertext it cannot open. If someone accessed the database, they would find public keys and very little else.

The project is called V.E.I.L., short for Verified Encrypted Instant Link. It is a Blazor WebAssembly PWA backed by an ASP.NET Core 10 API and a SignalR relay. The cryptography runs entirely in the browser through NaCl (libsodium).

Check out the Figma UI file

The Blind Relay Model

Most messaging systems ask you to trust the server with readable content. Aegis Link does not. It works as a blind relay. The server receives encrypted payloads and forwards them without having any way to decrypt them.

The server stores one mapping: AegisId → PublicKey. An Aegis ID is an 8-character Base32 string derived from the SHA-256 hash of a user's public keypair. That keypair is generated in the browser, and the private key is never sent to the server. There is no message history on the backend and no decrypt path in transit.

The downside is that the system gives the user more responsibility. There is no forgot password flow because there is no password to reset. If you lose the device and lose the key material, you lose the identity. That is why the killswitch, a one-tap wipe, is treated as an important feature rather than an extra.

The Cryptography

Key exchange uses ECDH through nacl.box.before() on Curve25519. Both participants compute the same shared secret independently using their own private key and the other person's public key. Nothing needs to be transmitted except the public material.

// Both sides run this independently. Same result, no transmission needed.
computeSharedKey: function (mySecretKeyB64, partnerPublicKeyB64) {
    const mySecretKey   = Uint8Array.from(atob(mySecretKeyB64), c => c.charCodeAt(0));
    const partnerPubKey = Uint8Array.from(atob(partnerPublicKeyB64), c => c.charCodeAt(0));
    const sharedKey     = nacl.box.before(partnerPubKey, mySecretKey);
    return btoa(String.fromCharCode(...sharedKey));
},

Messages are encrypted with XSalsa20-Poly1305 using nacl.secretbox. A fresh random nonce is generated for each message and prepended to the ciphertext before base64 encoding. What the server receives is just encrypted data and the nonce required for decryption on the other side.

boxEncrypt: function (plaintext, sharedKeyB64) {
    const keyBytes      = Uint8Array.from(atob(sharedKeyB64), c => c.charCodeAt(0));
    const nonce         = nacl.randomBytes(nacl.secretbox.nonceLength);
    const messageBytes  = nacl.util.decodeUTF8(plaintext);
    const encrypted     = nacl.secretbox(messageBytes, nonce, keyBytes);

    // Pack nonce + ciphertext. The recipient separates the nonce before decrypting.
    const combined = new Uint8Array(nonce.length + encrypted.length);
    combined.set(nonce);
    combined.set(encrypted, nonce.length);
    return btoa(String.fromCharCode(...combined));
},

One design choice worth pointing out is that all cryptographic operations live in crypto-interop.js, not in C#. That boundary makes sense in Blazor. The .NET side coordinates the application flow, while the JavaScript side performs the encryption. The goal is to keep private key operations inside the browser environment where they belong.

The Aegis ID and Room ID

Identity in this system is a deterministic 8-character string derived from a public key. The process is simple: hash the key with SHA-256, take the first 40 bits, and encode them as Base32. There are no accounts and no email addresses involved. The same derivation also runs server-side in C# so the backend can verify that a registration request is not claiming an ID that does not match the submitted key.

Chat rooms are derived in a similar way. Both participants compute the same room ID by hashing a sorted concatenation of their two Aegis IDs. Sorting first means the output stays the same no matter who starts the chat.

computeRoomId: async function (myAegisId, partnerAegisId) {
    // Normalise and sort. Order must not affect the output.
    const sorted  = [myAegisId.trim().toUpperCase(), partnerAegisId.trim().toUpperCase()]
                        .sort()
                        .join(':');
    const encoder = new TextEncoder();
    const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(sorted));
    const hash = new Uint8Array(hashBuffer);

    // First 16 bytes as 32-char hex. Short enough for a group name, still unique enough here.
    return Array.from(hash.slice(0, 16), b => b.toString(16).padStart(2, '0')).join('');
},

The Hub: Blind by Design

The SignalR hub is intentionally small. It does not try to understand message content. It validates that session IDs are correctly formed, checks that the sending connection joined the room, and forwards the payload to the other members of the group.

// Blind relay. Forwards ciphertext and has no decrypt path.
public async Task SendMessage(string sessionId, string payload)
{
    if (!SessionIdPattern.IsMatch(sessionId))
        throw new HubException("Invalid session ID format.");

    if (!_tracker.IsMember(Context.ConnectionId, sessionId))
        throw new HubException("You must join the session before sending messages.");

    await Clients.GroupExcept(sessionId, Context.ConnectionId)
        .SendAsync("ReceiveMessage", payload);
}

The HubSessionTracker is implemented as a ConcurrentDictionary<string, ConcurrentDictionary<string, byte>>. In practice, it is a thread-safe set-of-sets that maps connection IDs to the rooms they joined. The inner byte value is just a lightweight way of expressing membership without adding extra structure. It is cleaned up when the connection closes.

Stack Summary

Layer Tech Why
Client Blazor WebAssembly Keeps cryptographic work in the browser. No server-side render path for private keys.
Crypto NaCl.js (libsodium) Proven primitives, small API surface, and harder to misuse than rolling custom crypto.
Transport ASP.NET Core SignalR Simple real-time relay with built-in group routing.
API ASP.NET Core 10 Small API surface for identity registration and lookup.
Storage EF Core + SQLite Stores public keys only. Message content stays on the client side.

What Does Not Exist Yet

Right now, the receiving user still has to manually enter the initiator's Aegis ID to join the same session. There is no invite link or push flow yet, and that is probably the biggest usability issue at the moment. A proper invite process with out-of-band QR verification is on the roadmap, along with opt-in persistent storage and server-side deregistration once there is a proper proof-of-possession scheme in place. Right now, submitting a public key alone is not enough proof that the sender controls the matching private key.

There are still rough edges, but the foundation is there and the direction of the project is clear.

Downloadable Resources