Encryption Implementation Reference
The encryption that runs is narrower than the encryption that is written, and the gap is what this page is for. libsodium sealed boxes are the only message encryption Railgun executes — and they execute only on the client path where the native libsignal module is absent. On the Electron desktop build, where libsignal does load, the handler that encrypts direct messages is still a placeholder that base64-encodes the plaintext. Community and channel messages are not encrypted on any path. The Signal-style protocol described in the second half of this page is written and reviewed, and not wired into any client you can download.
This is an architecture reference, not an independent security audit. Sections 1 and 2 describe what ships. Sections 4 through 7 describe a design Railgun is building toward and are labelled Planned throughout. Nothing here should be read as a claim that a Railgun client currently enforces a property marked planned.
What ships today
| Path | Mechanism | Status |
|---|---|---|
| Direct messages — browser and development builds | libsodium sealed box — X25519 key agreement, XSalsa20-Poly1305 authenticated encryption. Selected when the native libsignal module is absent. | Encrypted |
| Direct messages — Electron desktop build | libsignal loads, so encryptDm is routed to the Electron IPC handler — a placeholder that base64-encodes the plaintext. The relay receives readable text. | Not encrypted |
| Community & channel messages | Sent as base64-wrapped plaintext. Channel encryption is written but returns a plaintext marker. | Not encrypted |
| X3DH + Double Ratchet | Implemented against libsignal, imported as a type only. No runtime path instantiates it. | Planned |
| Private key storage | Keys are generated and held on the device. No code path transmits private or identity key material. | Implemented |
1. Sealed boxes — the encryption that runs
Where Railgun encrypts a direct message at all, it does it with a libsodium sealed box — the crypto_box_seal construction. It combines X25519 key agreement with XSalsa20-Poly1305 authenticated encryption. This is real, well-reviewed cryptography. It is also a deliberately simple construction, and its limits matter. Which clients reach it is section 2: the browser and development builds do; the Electron desktop build does not.
How a sealed box is built
// Sender has only the recipient's public key, PK_r
(pk_e, sk_e) = crypto_box_keypair() // fresh, single-use
nonce = BLAKE2b(pk_e || PK_r) // derived, not random
ct = crypto_box(plaintext, nonce, PK_r, sk_e)
sealed = pk_e || ct // ephemeral key is prepended
erase(sk_e)
The recipient recovers the ephemeral public key from the front of the message, recomputes the same shared secret with their own private key, and opens the box. The Poly1305 tag means a modified ciphertext fails to open rather than decrypting to garbage.
What this gives you
- • Confidentiality against the relay and anyone on the wire.
- • Integrity — tampering is detected, not silently decrypted.
- • Sender anonymity: the ephemeral key reveals nothing about who sent it.
- • No session setup, so it works when the recipient is offline.
What this does not give you
- • No forward secrecy. The recipient's long-term private key opens every message ever sent to them. If it leaks, captured ciphertext leaks with it.
- • No sender authentication. A sealed box is anonymous by construction; it proves nothing about who wrote it.
- • No post-compromise recovery. There is no ratchet to move past a key compromise.
Those three gaps are exactly what X3DH and the Double Ratchet exist to close, which is why that work is underway. Until it lands, the honest summary of a sealed-box DM is: strong against a passive network observer and against the relay, weak against an adversary who eventually obtains a recipient's device key. That summary applies only where this code actually runs.
2. What is not encrypted
Community and channel messages — the bulk of what most Railgun users actually type — are not encrypted on any client path today. The channel encryption function exists, and it is candid about itself: it logs a warning and returns the plaintext wrapped in base64 behind a marker string, so that nobody downstream mistakes it for ciphertext.
// What encryptChannel actually returns
logger.warn("Channel encryption not yet implemented")
ciphertext = base64("[DEVCRYPTO:PLAINTEXT]" + plaintext)
The practical consequence: for community and channel content, the Railgun server is in the same position as any ordinary chat server. It can read what you write there. Group key distribution — a per-channel key handed to members inside sealed boxes — is the planned fix and is not shipped.
Direct messages on the Electron desktop build
The second gap is direct messages on the client most people run, and it is easy to state backwards, so here is the selection logic as the code executes it. initCrypto() asks the main process whether libsignal loaded. @signalapp/libsignal-client is a real dependency and is required at startup, so on a shipped Electron build it does load — and that answer is what selects the Electron IPC implementation instead of the sealed-box one. Its encryptDm forwards to the crypto:encryptDm handler, which is a placeholder:
// crypto:encryptDm, Electron main process
const plaintextBytes = new TextEncoder().encode(plaintext)
ciphertext: toBase64(plaintextBytes) // not encryption
So the sealed boxes in section 1 are reached when libsignal is absent — in the browser and in development. On the desktop client, a direct message reaches the relay as base64-encoded plaintext. libsignal is loaded there to generate an identity key and a prekey bundle; it establishes no session and ratchets nothing — crypto:ensureDmSession is a debug log with no body.
3. Curve25519 — the underlying curve
Both the sealed boxes that ship and the X3DH design that does not rest on the same elliptic curve: Curve25519, designed by Daniel J. Bernstein for high-speed Diffie-Hellman key exchange. This section describes the curve itself, and applies to both.
The Curve Equation
y² = x³ + 486662x² + x
over the prime field 𝔽ₚ where p = 2²⁵⁵ − 19
The prime 2²⁵⁵ − 19 was chosen because it enables extremely fast modular arithmetic. The coefficient 486662 was chosen to be the smallest value that produces a safe curve with the required security properties.
Key Generation
1. Private key: Generate 32 random bytes using a CSPRNG (cryptographically secure pseudorandom number generator). Clamp the key by clearing the lowest 3 bits and the highest bit, and setting the second-highest bit. This ensures the key is a multiple of 8 and in the valid range.
a = random(32 bytes)
a[0] &= 248 // Clear low 3 bits
a[31] &= 127 // Clear high bit
a[31] |= 64 // Set second-highest bit
2. Public key: Compute the scalar multiplication of the private key with the base point G = 9.
A = a · G // Scalar multiplication on Curve25519
The security relies on the Elliptic Curve Discrete Logarithm Problem (ECDLP): given A and G, it is computationally infeasible to recover a. Curve25519 provides approximately 128 bits of security.
Diffie-Hellman Key Exchange
Two parties (Alice and Bob) can compute a shared secret without ever transmitting it:
Alice: a (private), A = a·G (public)
Bob: b (private), B = b·G (public)
Alice computes: S = a · B = a · (b·G)
Bob computes: S = b · A = b · (a·G)
Both arrive at S = ab·G — the same shared secret
An eavesdropper who knows A and B (both public) cannot compute S without solving the ECDLP. This is the Computational Diffie-Hellman (CDH) assumption.
A sealed box is this exchange with one side made ephemeral and thrown away. X3DH, below, is this exchange performed four times over different key pairs so that the result authenticates both parties as well as hiding the content.
4. X3DH — Extended Triple Diffie-Hellman
PLANNEDX3DH solves a hard problem: how do two people establish an encrypted session when one of them is offline? Traditional Diffie-Hellman requires both parties to be online. X3DH uses pre-uploaded key bundles to enable asynchronous key agreement.
Key Types
Identity Key (IK)
Long-term Curve25519 key pair. Generated once, identifies the user cryptographically. Never changes unless the user re-registers.
Signed Pre-Key (SPK)
Medium-term Curve25519 key pair, signed by the Identity Key. Rotated periodically. The signature proves it belongs to the identity key holder.
One-Time Pre-Key (OPK)
Ephemeral Curve25519 key pairs uploaded in batches. Each is used once then deleted. Provides an extra layer of forward secrecy for the initial message.
Ephemeral Key (EK)
A fresh Curve25519 key pair generated by the sender for each new session. Never stored server-side.
The X3DH Handshake
When Alice wants to message Bob (who may be offline), she fetches Bob's pre-key bundle from the server and performs four Diffie-Hellman computations:
// Alice has: IKₐ (her identity key), EKₐ (fresh ephemeral)
// Bob published: IKᵦ, SPKᵦ, OPKᵦ
DH1 = DH(IKₐ, SPKᵦ) // her identity ↔ his signed pre-key
DH2 = DH(EKₐ, IKᵦ) // her ephemeral ↔ his identity
DH3 = DH(EKₐ, SPKᵦ) // her ephemeral ↔ his signed pre-key
DH4 = DH(EKₐ, OPKᵦ) // her ephemeral ↔ his one-time pre-key
SK = KDF(DH1 || DH2 || DH3 || DH4)
// SK is the shared secret, derived via HKDF-SHA-256
Why four DH operations?
- DH1 provides mutual authentication (both identity keys involved)
- DH2 ensures the ephemeral key is tied to Bob's identity
- DH3 provides forward secrecy via the ephemeral key
- DH4 provides additional forward secrecy. If no OPK is available, X3DH still works with just DH1–DH3
DH1 is the operation a sealed box does not have. It is why the shipping path cannot tell you who a message came from.
KDF — Key Derivation Function
The raw DH outputs are combined using HKDF-SHA-256 (HMAC-based Key Derivation Function). HKDF extracts entropy from the concatenated DH outputs and expands it into a uniformly random shared secret:
PRK = HKDF-Extract(salt="", input=DH1||DH2||DH3||DH4)
SK = HKDF-Expand(PRK, info="RailGunX3DH", length=32)
5. The Double Ratchet Algorithm
PLANNEDOnce X3DH establishes an initial shared secret, the Double Ratchet takes over. It uses two interlocking "ratchets" to derive a new unique key for every message. The design protects prior messages and is intended to recover protection for future messages after a fresh ratchet step.
DH Ratchet (Asymmetric)
Each time the conversation direction changes (Alice → Bob, then Bob → Alice), a new Diffie-Hellman key exchange occurs using fresh ephemeral keys. This "ratchets" the root key forward:
dh_out = DH(my_ratchet_key, their_ratchet_key)
root_key, chain_key = KDF(root_key, dh_out)
This is what would provide post-compromise security: an attacker who steals the current state loses access once a new DH ratchet step occurs.
Symmetric Ratchet (Hash Chain)
Between DH ratchet steps, a hash-based ratchet derives a new message key from the chain key for each message:
message_key = HMAC-SHA256(chain_key, 0x01)
chain_key = HMAC-SHA256(chain_key, 0x02)
The old chain key is deleted after each step. The message key encrypts exactly one message, then is deleted. This is the forward secrecy mechanism.
Ratchet Progression
Visualized, the Double Ratchet looks like this. Each arrow is a one-way function — you can go forward but never backward:
Root Key Chain:
RK₀ ──DH──→ RK₁ ──DH──→ RK₂ ──DH──→ RK₃ ...
Each RK step produces a Chain Key:
↓ ↓ ↓
CK₀ CK₁ CK₂
↓ ↓ ↓
Each CK step produces Message Keys:
CK₀→MK₀ CK₁→MK₃ CK₂→MK₅
CK₀→MK₁ CK₁→MK₄ CK₂→MK₆
CK₀→MK₂ CK₂→MK₇
Each MKₙ would encrypt exactly one message and then be deleted, so an attacker who obtains MK₄ could not derive MK₃ or MK₅. On the sealed-box path that ships today there is no per-message key to steal and no chain to walk — there is one long-term key that opens everything.
6. Forward secrecy — what it would take
PLANNEDForward secrecy means that compromising long-term keys does not compromise past session keys. Railgun does not have it today. Two mechanisms would provide it, and both belong to the planned protocol above:
Ephemeral keys in X3DH
The sender generates a fresh ephemeral key pair for every new session and deletes the private half after the handshake. An attacker who later steals both parties' identity keys still cannot reconstruct the session secret. Sealed boxes do use a fresh ephemeral key on the sender side — but the recipient's half of the exchange is their long-term key, which is precisely why the property does not hold.
Ratchet key deletion
The Double Ratchet deletes old chain keys and message keys after use. The KDF chain is one-way — given CKₙ, you can compute CKₙ₊₁ but not CKₙ₋₁, so a compromise at time T reveals nothing sent before T.
Post-compromise security
The DH ratchet also provides future secrecy: an attacker who compromises current session state loses it at the next ratchet step, which introduces randomness they do not have. Without a ratchet, a compromised key stays useful to the attacker indefinitely.
7. Relay and Queue Architecture
Railgun relays envelopes and may temporarily queue them for offline delivery. The relay is built so it does not need message plaintext, but today it receives plaintext in two cases: channel messages, which no client encrypts, and direct messages from the Electron desktop build, whose IPC handler base64-encodes rather than encrypts. A direct message from the sealed-box path arrives as ciphertext the relay cannot open.
What the Server Sees
Server holds:
- • Public key bundles
- • User registration info
- • Community/channel metadata and membership
- • Sealed-box ciphertext it cannot open (DMs from the browser/dev path)
- • Base64-encoded DM plaintext from the Electron desktop build
- • Channel message content, until channel encryption ships
- • Authentication audit records
Server never receives:
- • Private or identity keys
- • Client session state
- • A durable copy of your message history
Messages are relayed to online recipients in real time over WebSocket. For offline devices, envelopes are queued in Redis with a 30-day maximum TTL and deleted once the device acknowledges them. There is no server-side message archive — client history lives in local storage on your own device, so a lost device is a lost history.
8. Security Properties Summary
| Property | Mechanism | Status |
|---|---|---|
| DM confidentiality | Sealed boxes on the browser/development path only; the Electron desktop handler base64-encodes | Partial |
| DM integrity | Poly1305 tag on the sealed-box path; the Electron desktop path sends unauthenticated base64 | Partial |
| Keys stay on device | Local key store; no code path transmits private keys | Implemented |
| Offline delivery | Envelopes queued in Redis with a 30-day TTL, deleted on ack | Implemented |
| Channel confidentiality | Group key distribution is not implemented; content is sent readable | Not implemented |
| Sender authentication | Sealed boxes are anonymous by construction | Not implemented |
| Forward secrecy | Requires the Double Ratchet, which no client instantiates | Planned |
| Post-compromise recovery | Requires the DH ratchet, which no client instantiates | Planned |
| Independent audit | No completed third-party cryptographic audit is claimed | Not completed |
| Post-quantum guarantee | No blanket post-quantum security guarantee is claimed | Not claimed |
Where this page comes from
Every status above was read out of the client source rather than from a design document. The client repository is not currently public, so we cannot ask you to check it yourself — which is a reason to weigh this page as a disclosure, not as proof. Questions about a specific claim are welcome at security@railgun.chat.