When you need a lock anyone can close but only you can open
You're building a secure messaging app. Alice wants to send Bob a secret message, but the network is public. Anyone can intercept the traffic. You can't send the message in plain text, and you can't rely on a shared password because Alice and Bob haven't met yet. You need a way for Alice to lock the message so only Bob can unlock it, even if a hacker captures the data in transit.
That's asymmetric encryption. RSA is the classic algorithm for this job. It gives you two keys: a public key that anyone can use to encrypt data, and a private key that only you hold to decrypt it.
The padlock and the key
Think of the public key as a padlock you can buy at a hardware store. You can hand out copies of this padlock to anyone. Anyone can snap the lock shut, but nobody can open it without the metal key. The private key is that metal key. You keep it in your pocket.
If Alice wants to send Bob a secret, she uses Bob's public padlock to lock the message. Bob uses his private key to open it. Even if a hacker steals the locked box, they can't get inside. The math behind RSA makes it computationally infeasible to derive the private key from the public key, provided the key is large enough.
Minimal working example
The rsa crate handles the heavy lifting. It forces you to use secure defaults, which prevents common mistakes.
use rsa::{RsaPrivateKey, Oaep, PaddingScheme};
use rsa::rand_core::OsRng;
/// Demonstrates basic RSA encryption and decryption with OAEP padding.
fn main() {
// Use the OS random number generator for cryptographic security.
// Never use a predictable RNG for key generation or padding.
let mut rng = OsRng;
// Generate a 2048-bit private key.
// 2048 bits is the current standard for security.
let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
// Derive the public key from the private key.
// In production, you'd share this public key with others.
let public_key = private_key.to_public_key();
// The message to encrypt.
let message = b"Secret handshake";
// OAEP is the standard padding scheme for RSA.
// Raw RSA is insecure; always use padding.
let padding = PaddingScheme::new(Oaep::new::<sha2::Sha256>());
// Encrypt with the public key.
// The RNG is needed to generate random padding bytes.
let encrypted = public_key.encrypt(&mut rng, padding.clone(), message).unwrap();
// Decrypt with the private key.
let decrypted = private_key.decrypt(padding, &encrypted).unwrap();
println!("Decrypted: {}", String::from_utf8_lossy(&decrypted));
}
The rsa crate forces you to wrap your padding choice in a PaddingScheme. You can't pass raw padding structs to encrypt. This design prevents accidental misuse of raw RSA, which is mathematically deterministic and vulnerable to attacks. Always use PaddingScheme::new(...).
The compiler won't let you encrypt raw bytes. Trust that constraint.
What happens under the hood
Key generation finds two large prime numbers. This is computationally expensive. That's why you generate keys once and reuse them. You don't generate a new RSA key for every message.
Padding is where the security lives. OAEP (Optimal Asymmetric Encryption Padding) adds random bytes to your message before encryption. It uses a hash function, like SHA-256, to mask the data. This ensures the same message encrypts to a completely different ciphertext every time. Without padding, an attacker could detect patterns or forge messages.
Encryption performs modular exponentiation. It raises the padded message to a power modulo the public modulus. Decryption does the inverse operation using the private exponent. The math relies on the difficulty of factoring large numbers. If you can factor the modulus back into its prime components, you can derive the private key. Current computers can't factor 2048-bit numbers in any reasonable timeframe.
OAEP turns a deterministic math function into a probabilistic shield. Never skip the padding.
Realistic key management
Real applications don't generate keys in memory and forget them. You need to store keys and load them later. The standard format is PEM, which is base64-encoded data with header and footer lines.
use rsa::{RsaPrivateKey, RsaPublicKey, Oaep, PaddingScheme};
use rsa::rand_core::OsRng;
use rsa::pkcs8::DecodePrivateKey;
/// Loads a private key from PEM and decrypts a message.
fn decrypt_with_pem(pem_data: &str, ciphertext: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Parse the PEM string into a private key.
// This is how you load keys from files or config.
let private_key = RsaPrivateKey::from_pkcs8_pem(pem_data)?;
// Reconstruct the padding scheme.
// Both sides must use the same padding configuration.
let padding = PaddingScheme::new(Oaep::new::<sha2::Sha256>());
// Decrypt the data.
let plaintext = private_key.decrypt(padding, ciphertext)?;
Ok(plaintext)
}
/// Generates a key pair and exports the private key as PEM.
fn generate_and_export() -> Result<String, Box<dyn std::error::Error>> {
let mut rng = OsRng;
// Generate the key.
let private_key = RsaPrivateKey::new(&mut rng, 2048)?;
// Export as PKCS#8 PEM.
// This is the modern standard for private key storage.
let pem = private_key.to_pkcs8_pem(rsa::pkcs8::LineEnding::default())?;
Ok(pem.into_string())
}
Use PKCS#8 format for private keys whenever possible. The rsa crate supports from_pkcs8_pem. PKCS#8 is the modern standard that works across different algorithms. PKCS#1 is legacy. If you're generating keys for storage, export them as PKCS#8 PEM.
Export keys as PKCS#8 PEM. It's the lingua franca of modern crypto.
Pitfalls and limits
RSA has a hard size limit. A 2048-bit key can only encrypt a small amount of data. The limit depends on the padding scheme. With OAEP and SHA-256, the maximum message size is 190 bytes. The math is simple: key size in bytes minus the padding overhead. 2048 bits is 256 bytes. OAEP overhead is 2 times the hash length plus 2 bytes. SHA-256 is 32 bytes. Overhead is 66 bytes. 256 minus 66 equals 190.
If you try to encrypt more than 190 bytes, the crate returns a MessageTooLong error. You cannot encrypt a file with RSA. You cannot encrypt a JSON payload with RSA. You can only encrypt small secrets, like symmetric keys or tokens.
If you pass a type that doesn't implement RngCore to encrypt, the compiler rejects you with E0277 (trait bound not satisfied). You must use a cryptographically secure random number generator. OsRng is the safe choice. ThreadRng is not secure for crypto.
Never use raw RSA. The rsa crate makes this hard, but older libraries might allow it. Raw RSA is deterministic. Encrypting the same message twice produces the same ciphertext. Attackers can exploit this to recover the message. OAEP adds randomness to break this pattern.
RSA has a tiny throat. If your message doesn't fit, you're using the wrong tool.
Decision matrix
Use RSA for key exchange when you need to securely transmit a symmetric key over an insecure channel. Use RSA for digital signatures when you need to verify the authenticity and integrity of a message. Use AES-GCM for bulk encryption when you are encrypting files, database records, or large messages. Use hybrid encryption for application data when you need the security of asymmetric keys with the speed of symmetric encryption.
Hybrid encryption is the standard pattern for real apps. Generate a random AES key. Encrypt the large data with AES. Encrypt the AES key with RSA. Send both the encrypted data and the encrypted key. The recipient uses RSA to unlock the AES key, then uses AES to decrypt the data. This gives you the best of both worlds.
RSA is a lock, not a suitcase. Use it to secure the key, not the cargo.