The previous chapter mentioned briefly that the Rsa protection level "encrypts with AES-256-GCM and wraps the key with RSA-OAEP." But why go through those two steps instead of just encrypting directly with RSA? This chapter builds an intuitive understanding of symmetric, asymmetric, and hybrid encryption — and then examines why AEAD layers integrity on top of confidentiality. We'll follow the theory right into Quipu-Log's crypto.rs to see how it's implemented.
Data is encrypted with fast AES-256-GCM (symmetric), and only the secret key is wrapped with the RSA public key (asymmetric). That's hybrid/key wrapping — each algorithm doing the job it's actually good at.
First: symmetric vs. asymmetric encryption
Let's nail down the core difference between the two.
Symmetric encryption uses the same key for both encryption and decryption. AES is the canonical example. It's fast and imposes no size limit on the data. The weakness is sharing — how do you securely hand the key to the other party? The server and client both need to know the same secret key, but if they've never met before and there's no secure channel, key exchange itself becomes the hard problem.
Asymmetric encryption uses a key pair: a public key and a private key. Encrypt with the public key; only the private key can decrypt. RSA is the canonical example. The public key can literally be made public — it doesn't matter who sees it. That solves the "how do we exchange keys securely" problem. The downside: it's slow, and there's a strict upper limit on how much data RSA can encrypt directly (RSA-2048 ≈ 245 bytes maximum). Encrypting a file of any real size directly with RSA is basically off the table.
Think of the public key as a locked mailbox slot. Anyone can drop a letter in (= encrypt with the public key), but only the owner with the key (= private key) can take letters out. The mailbox itself can sit in a public place — that's fine.
Why not encrypt directly with RSA — the birth of hybrid/key wrapping
Put the strengths and weaknesses side by side and the optimal strategy becomes obvious.
| Symmetric (AES) | Asymmetric (RSA) | |
|---|---|---|
| Speed | Very fast | Tens to hundreds of times slower |
| Size limit | None | Yes (RSA-2048 ≈ 245 bytes) |
| Key exchange problem | Yes | No (public key can be published) |
The solution is to combine the best of both. Encrypt the data itself with symmetric (AES), and wrap only that symmetric key (the DEK, Data Encryption Key) with asymmetric (RSA). This is hybrid/key wrapping.
Let's look at exactly how this flow is implemented in the actual code.
crates/quipu-core/src/crypto.rs — KeyRing::protect (Rsa branch)// 1. DEK (Data Encryption Key): generate a random 32-byte symmetric key
let mut dek = [0u8; 32];
rand::thread_rng().fill_bytes(&mut dek);
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce);
// 2. Encrypt the data with AES-256-GCM
let cipher = Aes256Gcm::new_from_slice(&dek).expect("32-byte key");
let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce), value.canonical_bytes().as_slice())?;
// 3. Wrap the DEK with the RSA public key (OAEP padding) — DEK is 32 bytes, well within RSA's limit
let wrapped_key = key.encrypt(&mut rand::thread_rng(), Oaep::new::<Sha256>(), &dek)?;
The DEK is only 32 bytes, so RSA's size limit is never a concern. Large data is handled quickly by AES. A fresh random DEK and nonce are generated for every record, so encrypting the same value twice always produces a different ciphertext.
AEAD: encryption and tamper detection in one pass
AES-256-GCM is more than a straightforward encryption algorithm — it belongs to the AEAD (Authenticated Encryption with Associated Data) family. GCM stands for Galois/Counter Mode, and the defining property of this mode is that it produces an authentication tag alongside the ciphertext.
Here's how it works in brief. On encryption, it outputs ciphertext plus an authentication tag. On decryption, it verifies the tag first. If the tag doesn't match — meaning the ciphertext was modified even by a single bit — decryption fails outright.
The core guarantee of AEAD: if anyone modifies the encrypted data, decryption fails. If an attacker flips a single bit in the ciphertext sitting on disk, decrypt() returns an error — it does not silently hand back a corrupted value. This is a cryptographic guarantee that is independent of the tamper detection covered in Part 5 (the Merkle tree). Both defenses work in parallel, layered on top of each other.
This behavior is verified directly in the test suite.
crates/quipu-core/src/crypto.rs — tamper detection testlet mut ct = b64::decode(&ciphertext).unwrap();
ct[0] ^= 1; // flip one bit in the first byte
let tampered = StoredValue::Rsa { key_version, wrapped_key, nonce,
ciphertext: b64::encode(&ct) };
assert!(ring.decrypt(&tampered).is_err(), "GCM must reject a flipped bit");
HMAC — keyed hash
HMAC stands for Hash-based Message Authentication Code. Let's make the difference from keyless SHA-256 concrete.
| SHA-256 | HMAC-SHA-256 | |
|---|---|---|
| Key | none | secret key required |
| Same input → same output? | always | only with the same key |
| Offline dictionary attack | possible (public algorithm) | impossible without the key |
| Use case | medium-entropy values | low-entropy values (SSN, etc.) |
HMAC mixes the key into the data before hashing. So without the server's key, even if you know the SSN, you can't reproduce the digest.
crates/quipu-core/src/crypto.rs — HMAC computationtype HmacSha256 = Hmac<Sha256>;
pub fn hmac_hex_with(&self, version: KeyVersion, data: &[u8]) -> Result<String> {
let mut mac = <HmacSha256 as Mac>::new_from_slice(self.mac_of(version)?)
.map_err(|e| Error::Crypto(e.to_string()))?;
mac.update(data);
Ok(hex(&mac.finalize().into_bytes()))
}
RSA-OAEP: the safe padding for asymmetric encryption
Quipu-Log uses OAEP (Optimal Asymmetric Encryption Padding) for RSA encryption. The older PKCS#1 v1.5 encryption padding has a well-known vulnerability (the Bleichenbacher attack), so modern RSA encryption uses OAEP.
That said, signatures (checkpoint signatures) use PKCS#1 v1.5 — because signatures must be deterministic (same data → same signature), and PSS is non-deterministic. So the library uses two different RSA padding schemes, each where it belongs: OAEP for encryption, PKCS#1 v1.5 for signing.
KeyRing manages all of this
KeyRing is the struct that tracks RSA key pairs and HMAC keys by version number. The highest version is the active key used for new writes; lower versions are kept around to read records encrypted under older keys.
crates/quipu-core/src/crypto.rs — KeyRing structurepub struct KeyRing {
rsa: BTreeMap<KeyVersion, RsaPair>, // version → (public key, private key?)
macs: BTreeMap<KeyVersion, Vec<u8>>, // version → HMAC key bytes
}
// active key: keys().next_back() — BTreeMap is ascending order, so highest version
In a write-only deployment, RsaPair's private field is None. With only the public key, encryption (write) is possible but decryption (read) is not. That asymmetry is the subject of the next chapter — Ch. 27 write-only deploy.
Recap
- Symmetric (AES) is fast but requires key sharing. Asymmetric (RSA) handles key sharing elegantly but is slow with a strict size limit.
- Hybrid/key wrapping: data goes through AES-256-GCM; only the DEK is wrapped with RSA-OAEP — the best of both worlds.
- AES-256-GCM is AEAD — encryption and tamper detection in one. Even a single flipped bit surfaces as a decryption failure.
- HMAC is a keyed hash — offline dictionary attacks are impossible without the key. It closes the low-entropy vulnerability of SHA-256.
- KeyRing manages versioned RSA and HMAC keys. The highest version is active; lower versions serve reads on older records.
① Name two reasons why encrypting data directly with RSA is not a good idea.
② Why is AES-GCM better than plain AES-CTR? (Hint: AEAD)
③ Is the HMAC value for the same plaintext always the same, or can it differ? Compare with SHA-256 and explain.