HTTPCrypt Encryption Protocol
Overview
Rspamd implements the HTTPCrypt protocol for encrypting client-server communications. HTTPCrypt is a lightweight encryption protocol that predates RFC 8439 but uses similar cryptographic primitives: X25519 for key exchange, XChaCha20 for encryption, and Poly1305 for authentication. However, it employs custom key derivation methods that differ from standard IETF approaches.
The protocol provides:
- Authenticated encryption using XChaCha20-Poly1305
- Forward secrecy through ephemeral key exchange
- Compact wire format optimized for HTTP communication
- Strong security based on modern elliptic curve cryptography
Cryptographic Primitives
HTTPCrypt combines several well-established cryptographic algorithms:
X25519 (Curve25519)
Used for Elliptic Curve Diffie-Hellman (ECDH) key exchange. X25519 provides:
- 128-bit security level
- Fast constant-time operations
- Small key size (32 bytes)
- Protection against side-channel attacks
XChaCha20
A stream cipher variant of ChaCha20 with extended nonce size:
- 24-byte (192-bit) nonces instead of ChaCha20's 12-byte nonces
- 256-bit keys
- High performance on all platforms
- No timing side channels
Poly1305
A cryptographic message authentication code (MAC):
- 128-bit security level
- One-time MAC requiring unique key per message
- Fast computation
- Used in encrypt-then-MAC construction
HChaCha20
A key derivation function based on ChaCha20:
- Takes 32-byte key and 16-byte nonce
- Produces 32-byte derived key
- Used for XChaCha20 nonce extension and custom key derivation
Blake2b
A cryptographic hash function used for key identification:
- Faster than SHA-2 and SHA-3
- Provides 512-bit output (though only first 5 bytes used for key IDs)
Key Generation and Management
Long-term Server Keys
The server maintains a long-term key pair for identification and key exchange:
rspamadm keypair
This generates:
keypair {
privkey = "e4gr3yuw4xiy6dikdpqus8cmxj8c6pqstt448ycwhewhhrtxdahy";
id = "gnyieumi6sp6d3yaq13q4u6xycmiqaw7iahsrz97acpposod1x8zogynnishtgxr47o815dgsz9t69d66jcm1drjei4a5d";
pubkey = "fg8uwtce9sta43sdwzddb11iez5thcskiufj4ug8esyfniqq5iiy";
type = "kex";
algorithm = "curve25519";
encoding = "base32";
}
Key Components:
- privkey: Server's private key (32 bytes, base32-encoded)
- pubkey: Server's public key (32 bytes, base32-encoded)
- id: Full key identifier derived from the public key
- algorithm: Always
curve25519
for HTTPCrypt - encoding: Always
base32
(RFC 4648 base32 without padding)
Ephemeral Client Keys
For each encrypted request, the client generates a fresh ephemeral keypair:
let local_sk = SecretKey::generate(&mut OsRng);
let local_pk = local_sk.public_key();
This provides forward secrecy: even if the server's long-term key is compromised, past communications remain secure because ephemeral keys are discarded immediately after use.
Key Identification Header
The client sends a Key
header to identify which server public key was used and provide the client's ephemeral public key:
Key: <short_key_id>=<client_ephemeral_pubkey>
Where:
- short_key_id: First 5 bytes of Blake2b hash of server's public key, base32-encoded
- client_ephemeral_pubkey: Client's ephemeral public key, base32-encoded
Example:
Key: kbr3m=k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay
The short key ID allows the server to quickly identify which of its keys should be used when it has multiple keys configured.
Session Key Derivation (ECDH)
HTTPCrypt uses a custom ECDH key derivation that differs from standard X25519 usage:
Step 1: Scalar Multiplication
Standard X25519 scalar multiplication is performed between the client's ephemeral private key and the server's public key:
// Clamp the private key according to X25519 spec
let e = Scalar::from_bytes_mod_order(clamp_integer(local_sk.to_bytes()));
let p = MontgomeryPoint(remote_pk);
let shared_point = e * p;
Key Clamping: The private key is "clamped" before use:
- Clear bits 0, 1, 2 of the first byte (sets lowest 3 bits to 0)
- Clear bit 7 of the last byte (clears top bit)
- Set bit 6 of the last byte (sets second-highest bit)
This ensures the scalar is in the correct range for X25519 and avoids small subgroup attacks.
Step 2: HChaCha20 Key Derivation
This is where HTTPCrypt diverges from standard practice.
Standard X25519 usage (RFC 7748) would use the shared point directly or hash it with additional context. HTTPCrypt instead applies HChaCha20 with a zero nonce:
fn rspamd_x25519_ecdh(point: MontgomeryPoint) -> RspamdNM {
let n0 = [0u8; 16]; // Zero nonce
hchacha::<U10>(&point.to_bytes().into(), &n0)
}
HChaCha20 Operation:
-
Initialize ChaCha20 state with:
- Constants: "expand 32-byte k" (ChaCha20 constants)
- Key: The 32-byte shared point from X25519
- Nonce: 16 zero bytes
-
Execute 20 rounds of ChaCha20 permutation (10 double-rounds)
-
Output: Concatenate state words [0..3] and [12..15] (first 128 bits + last 128 bits)
This produces a 32-byte shared secret (called nm
in the code, standing for "NaCl-style shared secret").
Security Note: While non-standard, this approach is cryptographically sound. HChaCha20 acts as a key derivation function, providing additional mixing and ensuring the derived key is uniformly distributed.
Encryption Process
Message Authentication Code (MAC) Key Derivation
Before encrypting, a one-time Poly1305 MAC key must be derived. HTTPCrypt uses a custom method:
pub fn new(key: RspamdNM, nonce: chacha20::XNonce) -> Self {
let mut chacha = XChaCha20::new_from_slices(key.as_slice(), nonce.as_slice()).unwrap();
let mut mac_key: GenericArray<u8, U64> = GenericArray::default(); // 64 zero bytes
chacha.apply_keystream(mac_key.as_mut());
let poly = Poly1305::new_from_slice(mac_key.split_at(32).0).unwrap();
// chacha context remains positioned for encryption
}
Process:
-
Initialize XChaCha20 with:
- Shared secret (32 bytes) as key
- Random nonce (24 bytes)
-
Generate 64 zero bytes
-
Encrypt these zero bytes with XChaCha20, producing keystream bytes
-
Take first 32 bytes as the Poly1305 MAC key
-
Important: The XChaCha20 cipher context is now positioned at byte offset 64
This approach is similar to the original ChaCha20-Poly1305 construction but uses 64 bytes instead of the RFC's 32 bytes for key material derivation.
Encryption Algorithm
With the MAC key derived and cipher positioned, encryption proceeds:
pub fn encrypt_in_place(mut self, data: &mut [u8]) -> Tag {
// Encrypt
self.enc_ctx.apply_keystream(data);
// Authenticate (encrypt-then-MAC)
self.mac_ctx.compute_unpadded(data)
}
Process:
- Encrypt the plaintext using XChaCha20 (which is already positioned at offset 64)
- Compute MAC over the ciphertext using Poly1305
- Return the 16-byte authentication tag
This is an encrypt-then-MAC construction, which is the secure approach (as opposed to MAC-then-encrypt or encrypt-and-MAC).
Wire Format
The encrypted message format is:
+----------------------+
| Nonce (24 bytes) |
+----------------------+
| Tag (16 bytes) |
+----------------------+
| Ciphertext (N bytes)|
+----------------------+
Field Details:
- Nonce (24 bytes): Random nonce generated for this message, sent in plaintext
- Tag (16 bytes): Poly1305 authentication tag over the ciphertext
- Ciphertext (N bytes): XChaCha20-encrypted payload
Total overhead: 40 bytes per message.
Decryption Process
Decryption reverses the encryption process with authentication verification:
pub fn decrypt_in_place(&mut self, data: &mut [u8], tag: &Tag) -> Result<usize, RspamdError> {
// Verify MAC first
let computed = self.mac_ctx.clone().compute_unpadded(data);
if computed != *tag {
return Err(RspamdError::EncryptionError("Authentication failed".to_string()));
}
// Decrypt only if authentication succeeds
self.enc_ctx.apply_keystream(&mut data[..]);
Ok(computed.len())
}
Process:
-
Parse the message:
- Extract 24-byte nonce
- Extract 16-byte tag
- Remaining bytes are ciphertext
-
Derive shared secret using same ECDH process
-
Initialize XChaCha20 with shared secret and nonce
-
Derive MAC key (64 zero bytes through cipher)
-
Verify authentication tag:
- Compute Poly1305 MAC over ciphertext
- Compare with received tag using constant-time comparison
- If mismatch, fail immediately and return error
-
Decrypt ciphertext (only if authentication succeeds):
- Re-initialize cipher (or use positioned context)
- Apply XChaCha20 keystream to ciphertext
Security: Authentication is verified before decryption to prevent oracle attacks and ensure ciphertext integrity.
HTTPCrypt Protocol Flow
Client Request Encryption
When a client sends an encrypted request:
1. Prepare Inner Request
Build a complete HTTP request as plaintext:
POST /checkv2 HTTP/1.1
From: user@example.com
IP: 192.0.2.1
Content-Length: 1234
<message body>
This inner request contains all the actual headers and body.
2. Generate Ephemeral Keypair
let local_sk = SecretKey::generate(&mut OsRng);
let local_pk = local_sk.public_key();
3. Perform ECDH
let ec_point = rspamd_x25519_scalarmult(server_public_key, &local_sk)?;
let shared_secret = rspamd_x25519_ecdh(ec_point);
4. Encrypt Inner Request
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
let cbox = RspamdSecretbox::new(shared_secret, nonce);
let tag = cbox.encrypt_in_place(&mut ciphertext);
5. Build Outer Request
POST /checkv2 HTTP/1.1
Content-Length: <encrypted_length>
Key: kbr3m=k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay
<nonce (24 bytes)><tag (16 bytes)><ciphertext>
The Key
header contains:
- Short ID of server's public key (5 bytes, base32)
- Client's ephemeral public key (32 bytes, base32)
Server Response Decryption
The server can also encrypt responses using the same shared secret:
1. Server Uses Same Shared Secret
The server computes:
let ec_point = rspamd_x25519_scalarmult(client_ephemeral_pk, &server_sk)?;
let shared_secret = rspamd_x25519_ecdh(ec_point);
This produces the same shared secret due to ECDH properties:
- Client:
ECDH(client_ephemeral_sk, server_pk) = shared_secret
- Server:
ECDH(server_sk, client_ephemeral_pk) = shared_secret
2. Encrypt Response
HTTP/1.1 200 OK
Content-Length: <encrypted_length>
<nonce (24 bytes)><tag (16 bytes)><encrypted JSON response>
3. Client Decrypts
The client uses the stored shared secret (from step 3 of request encryption) to decrypt the response.
Complete Example
Client sends:
POST /checkv2 HTTP/1.1
Host: rspamd.example.com
Content-Type: application/octet-stream
Content-Length: 1540
Key: kbr3m=k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay
<24-byte nonce><16-byte tag><1500 bytes encrypted inner request>
Server responds:
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 556
<24-byte nonce><16-byte tag><500 bytes encrypted JSON response>
Security Considerations
Strengths
-
Forward Secrecy: Ephemeral key exchange ensures past messages cannot be decrypted even if server's long-term key is compromised.
-
Authenticated Encryption: Poly1305 MAC provides strong authentication, preventing tampering and detecting corruption.
-
Modern Cryptography: Based on well-analyzed primitives (Curve25519, ChaCha20, Poly1305).
-
Constant-Time Operations: Implementations use constant-time comparison for MACs and constant-time X25519.
-
Nonce Collision Resistance: 24-byte nonces (XChaCha20) provide enormous keyspace (2^192), making collisions virtually impossible with random generation.
Limitations and Considerations
-
Non-Standard Key Derivation: The use of HChaCha20 with zero nonce for ECDH key derivation is non-standard. While not insecure, it differs from RFC 7748 recommendations.
-
No Replay Protection: HTTPCrypt itself doesn't provide replay protection. Applications should implement this at a higher layer if needed.
-
No Key Confirmation: The protocol doesn't include explicit key confirmation. Authentication failures are detected during MAC verification.
-
MAC Key Derivation: Uses 64 zero bytes through cipher instead of RFC 8439's 32 bytes. This is a design choice from the pre-RFC era but remains secure.
-
Single-Use Ephemeral Keys: Each request requires a new ephemeral keypair generation, which adds computational cost but ensures forward secrecy.
-
No Explicit Version Negotiation: The protocol has no version field. Any changes would require out-of-band negotiation.
Attack Resistance
Chosen Ciphertext Attacks (CCA):
- Protected by encrypt-then-MAC construction
- MAC verification before decryption prevents oracle attacks
Key Compromise:
- Forward secrecy limits damage from server key compromise
- Past communications remain secure
Timing Attacks:
- Constant-time operations in X25519 and MAC verification
- Constant-time comparison prevents timing leaks
Small Subgroup Attacks:
- Prevented by X25519 key clamping
- Curve25519 is designed to resist these attacks
Implementation Notes
Key Encoding
All keys use RFC 4648 base32 encoding without padding:
- Alphabet:
abcdefghijklmnopqrstuvwxyz234567
- Case insensitive for decoding
- No padding characters (=)
- Compact representation (32 bytes → 52 characters)
Memory Safety
Implementations should:
- Zero sensitive data: Clear private keys, shared secrets, and MAC keys after use
- Use secure allocators: Consider using locked memory for key material
- Avoid copying keys: Use move semantics or references when possible
Rust example:
use crypto_box::cipher::zeroize::Zeroizing;
type RspamdNM = Zeroizing<GenericArray<u8, U32>>;
The Zeroizing
wrapper ensures the memory is zeroed when dropped.
Error Handling
Critical errors that must be handled:
- Authentication Failure: Never proceed with decryption if MAC verification fails
- Invalid Key Length: Reject keys that aren't exactly 32 bytes
- Malformed Messages: Check minimum message length (24 + 16 bytes) before parsing
- Base32 Decode Errors: Handle invalid base32 encoding gracefully
Performance Considerations
Fast operations:
- X25519 scalar multiplication: ~50-100 microseconds on modern CPUs
- XChaCha20 encryption: ~1-2 GB/s throughput
- Poly1305 MAC: ~1-2 GB/s throughput
Slow operations:
- Ephemeral keypair generation: Requires random number generation
- Key encoding/decoding: Base32 conversion adds overhead
Optimization tips:
- Reuse server keypair (don't regenerate for each request)
- Consider connection pooling to amortize key exchange costs
- Use vectorized implementations of ChaCha20 when available
Testing
Implementations should verify:
- Known Answer Tests (KAT): Test against known test vectors
- Round-trip: Encrypt and decrypt should return original plaintext
- Authentication: Modified ciphertext should fail MAC verification
- Interoperability: Test against reference implementations (Rust/Go clients, Rspamd server)
Compatibility
HTTPCrypt is implemented in:
- Rspamd server (C): Built-in support in all workers
- rspamc client (C): Command-line client
- Rspamd proxy: Can act as encryption bridge
- rspamdclient-rs (Rust): Full client library
- rspamdclient-go (Go): Full client library
All implementations use the same wire format and are fully interoperable.
Code References
For implementation details, see:
- Rust client: rspamdclient-rs/src/protocol/encryption.rs
- Go client: rspamdclient-go/protocol/encryption.go
- Original Paper: HTTPCrypt Protocol Specification
Further Reading
- RFC 7748 - Elliptic Curves for Security (X25519)
- RFC 8439 - ChaCha20 and Poly1305 for IETF Protocols
- ChaCha20-Poly1305 AEAD - Original ChaCha20-Poly1305 specification
- XChaCha20-Poly1305 - Extended nonce variant
Summary
HTTPCrypt provides lightweight, secure encryption for Rspamd client-server communications. Its design prioritizes:
- Simplicity: Minimal protocol overhead and complexity
- Performance: Fast symmetric encryption with reasonable key exchange costs
- Security: Modern cryptographic primitives with forward secrecy
- Compatibility: Base32 encoding and HTTP-friendly design
While it uses non-standard key derivation methods, the protocol is cryptographically sound and has been in production use for years. The main trade-off is non-standardization in exchange for a simpler, more compact protocol designed specifically for HTTPCrypt's use case.