Why End-to-End Encryption
Data that only the right parties can read
The Problem
Transport encryption (TLS) protects data in transit. At-rest encryption protects data on disk. But both leave data readable at the server.
The server is a liability.
When the server can read data, server breaches expose data. Server operators can access data. Legal demands can compel data access. The server becomes a single point of compromise.
For business-sensitive data—financial transactions, personal records, strategic documents—this is unacceptable. The only entity that should read the data is the intended recipient.
We needed encryption where:
- Only the communicating parties can decrypt
- The server never has access to plaintext
- Compromise of the server doesn't compromise data
- Key management is user-controlled
Current Options
| Option | Pros | Cons |
|---|---|---|
| Transport Encryption (TLS)Encrypt data in transit between client and server. |
|
|
| At-Rest EncryptionEncrypt data stored on disk. |
|
|
| End-to-End EncryptionOnly communicating parties can decrypt. |
|
|
Future Outlook
End-to-end encryption is becoming table stakes for sensitive applications.
Regulation is pushing toward E2EE.
GDPR, CCPA, and industry-specific regulations increasingly require data protection beyond transport encryption. "We encrypted it" is no longer sufficient; "we can't read it" is the new standard.
Homomorphic encryption and secure multi-party computation are emerging for computation on encrypted data. These will eventually allow E2EE without sacrificing server-side features, but they're not production-ready for most use cases.
The near-term future is E2EE for storage and messaging, with client-side processing for search and analytics. The server becomes untrusted infrastructure.
Our Decision
✓Why we chose this
- Maximum breach protectionServer compromise doesn't expose user data
- Regulatory complianceMeets strictest data protection requirements
- User trustUsers know only they can access their data
- Operator protectionCan't be compelled to provide data you can't access
×Trade-offs we accept
- Key management complexityUsers must manage keys; loss means permanent data loss
- No server-side featuresSearch, indexing, and processing require client-side implementation
- Implementation difficultyCryptography is easy to get wrong
Motivation
When we handle business data, we take on responsibility for its protection. E2EE is the strongest guarantee we can provide.
By design, we cannot read customer data. We cannot be forced to provide it. We cannot accidentally leak it. The cryptographic guarantee is stronger than any policy or promise.
This isn't just security—it's a business model. Customers trust us precisely because we've made it impossible for us to betray that trust.
Recommendation
Use established encryption libraries—never roll your own crypto:
- NaCl/libsodium for general-purpose authenticated encryption
- Age for file encryption
- RustCrypto for Rust implementations (audited, no-std capable)
For Converge's architecture (gRPC + NATS):
- Transport layer: mTLS everywhere—gRPC services, NATS connections
- Field-level encryption: Encrypt sensitive Protobuf fields before transmission
- At-rest encryption: Encrypt before writing to Firestore/storage
- Key management: Customer-managed keys stored in their infrastructure
The server handles encrypted blobs. Decryption happens at authorized endpoints only.
Design for key loss: provide recovery mechanisms that don't compromise E2EE (backup keys, recovery codes, trusted devices).
Examples
use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng},
ChaCha20Poly1305, Nonce,
};
use rand::RngCore;
/// Encrypt a sensitive field before storing or transmitting
pub fn encrypt_field(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, CryptoError> {
let cipher = ChaCha20Poly1305::new(key.into());
// Generate random nonce (12 bytes for ChaCha20-Poly1305)
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher.encrypt(nonce, plaintext)
.map_err(|_| CryptoError::EncryptionFailed)?;
// Prepend nonce to ciphertext for storage
let mut result = nonce_bytes.to_vec();
result.extend(ciphertext);
Ok(result)
}
/// Decrypt a field - only at authorized service boundaries
pub fn decrypt_field(encrypted: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, CryptoError> {
if encrypted.len() < 12 {
return Err(CryptoError::InvalidCiphertext);
}
let (nonce_bytes, ciphertext) = encrypted.split_at(12);
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = Nonce::from_slice(nonce_bytes);
cipher.decrypt(nonce, ciphertext)
.map_err(|_| CryptoError::DecryptionFailed)
}Field-level encryption using ChaCha20-Poly1305 (AEAD). Sensitive Protobuf fields are encrypted before gRPC transmission or NATS publish. Intermediate services see only ciphertext.
use tonic::{Request, Response, Status};
impl MyService for ServiceImpl {
async fn process_sensitive_data(
&self,
request: Request<SensitiveRequest>,
) -> Result<Response<ProcessedResponse>, Status> {
let req = request.into_inner();
// Encrypted field arrives as bytes - decrypt only here
let customer_key = self.key_store.get_key(&req.customer_id).await?;
let plaintext = decrypt_field(&req.encrypted_payload, &customer_key)
.map_err(|_| Status::invalid_argument("decryption failed"))?;
// Process plaintext...
let result = self.processor.handle(&plaintext).await?;
// Re-encrypt for response if needed
let encrypted_result = encrypt_field(&result, &customer_key)?;
Ok(Response::new(ProcessedResponse {
encrypted_result,
}))
}
}Decryption happens at the authorized service boundary. Data flows encrypted through NATS/gRPC—only the final processing service has access to plaintext.