E2E Encryptionsecurity

Why End-to-End Encryption

Data that only the right parties can read

v1.2·10 min read·Kenneth Pernyér
securityencryptionprivacye2eecryptographyrustgrpcnats

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

OptionProsCons
Transport Encryption (TLS)Encrypt data in transit between client and server.
  • Universal support
  • Transparent to applications
  • Protects against network eavesdropping
  • Server sees plaintext
  • Server breach exposes data
  • No protection against server operator
At-Rest EncryptionEncrypt data stored on disk.
  • Protects against physical theft
  • Transparent to applications
  • Compliance checkbox for many regulations
  • Keys often managed by server
  • Running server has access to plaintext
  • Doesn't protect against application-level breaches
End-to-End EncryptionOnly communicating parties can decrypt.
  • Maximum data protection
  • Server breach doesn't expose data
  • Compliance with strictest standards
  • User control over their data
  • Complex key management
  • No server-side search/processing
  • Key loss means data loss
  • Higher implementation complexity

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):

  1. Transport layer: mTLS everywhere—gRPC services, NATS connections
  2. Field-level encryption: Encrypt sensitive Protobuf fields before transmission
  3. At-rest encryption: Encrypt before writing to Firestore/storage
  4. 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

src/crypto/field_encryption.rsrust
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.

src/service/protected_handler.rsrust
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.

Related Articles

Stockholm, Sweden

Version 1.2

Kenneth Pernyér signature