The server has been compromised. The attacker dumped process memory and read every file on disk. In an ordinary setup, every piece of plaintext the server held is now exposed. But in Quipu-Log's write-only deployment mode, that's not the case — even if the server is completely taken over, the plaintext of RSA-protected fields is out of reach. The private key was never there.
You only need the RSA public key to write. Without the private key, decryption is impossible. A server that holds only the public key can write data but can't read it back — that's a write-only server.
A second look at RSA's asymmetry
Recall the core property of RSA from Ch. 25: encrypt with the public key, decrypt with the private key only. That directionality is the foundation of this deployment model.
The key boundary in code
The comments in quipu-server state this design decision plainly.
crates/quipu-server/src/lib.rs/// Key boundary: the server only ever needs the HMAC key and the RSA *public*
/// key (append + hash-based search). The private key is optional; without it
/// RSA-protected values come back as ciphertext for the querying client to
/// decrypt, keeping plaintext recovery out of the server's blast radius.
The server config file makes the boundary explicit too.
quipu-server config.json — write-only configuration{
"keys": {
"hmac_key_file": "/etc/quipu/hmac.key", // HMAC key: needed for write + search
"public_key_pem_file": "/etc/quipu/rsa-pub.pem" // public key only: can encrypt
/* private_key_pem_file omitted = write-only mode */
}
}
With no private_key_pem_file set, the server starts with a KeyRing that has no private key. Calling KeyRing::decrypt() returns "RSA private key version N is not in the key ring". This is not an oversight — it's intentional.
What happens at query time
When an audit client sends POST /v1/logs/query, what happens to RSA-protected fields? They come back in the response as ciphertext, not decrypted.
quipu-server README — write-only query response example// Server response: RSA fields are returned as ciphertext structs
{ "Rsa": {
"wrapped_key": "AQID...",
"nonce": "BAUG...",
"ciphertext": "ZW5j..."
}}
// Client calls KeyRing::decrypt() with its private key to recover plaintext
The client holds the private key, so it can call KeyRing::decrypt() to recover plaintext from the ciphertext. That plaintext exists in client memory only for the brief window while it's being used — it never exists at any stage on the server.
Shrinking the blast radius
"Blast radius" is a security term for how much an attacker gains in the event of a breach. The write-only deployment deliberately shrinks it.
In the worst case where the server is completely taken over, what an attacker can walk away with:
• method, url, content from log rows (always plaintext) ← this cannot be prevented
• Digests from HMAC/Sha256-protected fields ← irreversible without the HMAC key
• Ciphertext from RSA-protected fields ← undecryptable without the private key
The plaintext of RSA-protected fields does not exist on the server, so it cannot be taken.
Server with a private key: convenience traded for security
Write-only mode has costs. Contains search (substring matching) on RSA fields can't be done server-side — decryption and comparison aren't possible without the private key. The workaround is to narrow candidates with a blind n-gram index and have the client decrypt to confirm.
If you put the private key on the server (set private_key_pem_file and plaintext_cache: true), the server can decrypt RSA fields directly to handle Contains queries. But at that point the server can see RSA-protected field plaintext, and the blast radius expands again.
| Write-only (public key only) | Standard (private key included) | |
|---|---|---|
| RSA field encryption (write) | yes ✓ | yes ✓ |
| RSA field decryption (read) | no — client performs it | yes ✓ |
| RSA field Contains search | n-gram index for candidates + client decrypts | server handles it directly |
| RSA plaintext exposed on server breach | no ✓ | yes (in cache) |
Imagine a public mailbox. There's a slot where anyone can drop a letter in (= encrypt with the public key), but only the owner with the key (= private key) can open it and take letters out. Stealing the entire mailbox doesn't help — the letters can't be opened without the key. That's write-only deployment.
What about checkpoint signatures?
There's an interesting side effect. Without a private key, checkpoint signing is also impossible. KeyRing::can_sign() checks whether the active RSA version has a private key, and if not, the signing step is skipped.
crates/quipu-core/src/crypto.rs — can_signpub fn can_sign(&self) -> bool {
self.active_rsa_version()
.is_some_and(|v| self.has_rsa_private(v))
}
A write-only server cannot sign checkpoints. In environments that rely on external anchoring, the private key should live outside the server — in an HSM or a dedicated signing service — and signing requests should be sent to it rather than handled locally.
Recap
- RSA's asymmetry: encrypt with the public key, decrypt with the private key only.
- A server holding only the public key can write but cannot decrypt = write-only deployment.
- Even on full server compromise, RSA field plaintext is unrecoverable — blast radius is contained.
- The trade-off: no server-side
Containssearch, no checkpoint signing. - If you need convenient server-side search, you can put the private key on the server — but do it eyes open, knowing the trade-off.
① What does the client receive when it queries an RSA-protected field from a write-only server? What steps does the client take next?
② If the server is breached, list what an attacker can and cannot obtain.
③ If the HMAC key were also removed from the server, what additional functionality would be lost?