Encrypting Whistleblower Reports: Receipts, SealedBox, SecretBox
- 12 minutes readA whistleblower report needs a complete encryption protocol, not a checkbox that says “AES-256”. A reference design that has converged across mature open-source whistleblowing platforms pairs three primitives in a way every serious system should recognise: a 16-digit random receipt code (stored on the server only as a SHA-256 hash, shaped like a phone number so the reporter can hide it among contacts), libsodium SealedBox (Curve25519 + XSalsa20 + Poly1305) to wrap a per-submission data key to each authorised recipient’s public key, and libsodium SecretBox (XSalsa20 + Poly1305) to encrypt the submission body and attachments under that data key. Each recipient’s Curve25519 private key sits on the server encrypted under a symmetric key derived from the recipient’s password via Argon2ID tuned to 128 MB of memory and roughly one second of computation per login. As of April 2026, this is the protocol that production deployments serving anti-corruption activists, corporate compliance teams, and investigative newsrooms actually run.
Key Takeaways
- A per-submission data key is sealed to each recipient’s public key, so the database alone is ciphertext.
- The 16-digit receipt is the reporter’s only handle; the server stores only its SHA-256 hash.
- SealedBox wraps the data key; SecretBox encrypts the body; SecretStream with ~5% padding handles files.
- Recipient private keys sit at rest under Argon2ID at 128 MB / 1 second per login.
- “AES-256” on a data sheet is not a protocol. Ask for receipt design, key wrapping, KDF, and threat-model boundaries.
Why does whistleblower software need its own encryption protocol?
Generic web-application advice tells you to terminate TLS at the load balancer and turn on disk encryption. Both are necessary, neither is sufficient for a whistleblowing platform. TLS only protects bytes in motion, but a report spends most of its life sitting at rest in a database row, waiting for the named recipient to read it weeks or months after submission. The question to answer is whose key encrypted that row.
Full-disk encryption with LUKS or BitLocker protects the disks against a physical attacker who walks out of the data centre with the drive in a backpack. It does nothing against a compromised database administrator who runs pg_dump against a running server, or against a forensic image taken while the volume is mounted. The protection model has to assume that the data store itself can leak, and the encryption has to keep meaning anyway.
That is what per-recipient encryption gives you. Each report is encrypted to the public key of the recipients you nominated when the channel was configured, not to a single master key controlled by the operator. The platform owner, the IT admin, and the on-call DBA cannot read the report content, because none of them hold the matching private key. The receipt code is the symmetrical move on the reporter side: it lets a returning anonymous source check for replies and add follow-up evidence without ever creating a username, email address, or any other identifier that links them to a real-world identity. Messages flowing back from recipient to reporter need the same encryption envelope, because a one-way design that protects the submission but leaks the response is incomplete.

How does the receipt code work and what is it for?
The receipt is the most security-relevant UX choice in the entire system, and it is the one most likely to be redesigned badly by a team that has not thought about it carefully. At submission time the backend generates a uniformly random 16-digit number, shows it to the reporter exactly once, and tells them to write it down or memorise it. The backend then forgets the plaintext: only a SHA-256 hash is persisted, so a database leak yields a list of hashes rather than a list of usable login codes.
Sixteen decimal digits give about 53 bits of entropy. That is not sufficient by itself to resist a sustained offline attack, but the threat model here is online guessing against a server that is rate-limited, so 53 bits combined with proof-of-work on session creation, per-IP throttling, and per-receipt slowdown makes brute force impractical. The receipt also seeds the reporter’s session symmetric key for the messaging channel, which is how the platform can deliver replies without knowing who the reporter is.
The phone-number shape is the part that takes a moment to appreciate. A 16-digit code grouped as 1234 5678 9012 3456 looks identical to a long international phone number written in a contact list. A reporter can save it as “Aunt Maria” in their phone, and a relative or co-worker who picks up the device sees a contact, not a whistleblowing receipt. That is real-world threat modelling: the digital adversary is rarely the only one. A spouse, a colleague, or a border guard who takes a casual look at the phone is part of the threat surface, and the receipt’s visual disguise is what protects the reporter against them. Some teams want to “improve” the receipt by adding letters, dashes, or QR codes, and every one of those changes weakens the disguise.
How does the SealedBox + SecretBox envelope work?
The cryptographic flow is short enough to write down in five lines and rich enough that getting it right matters. At submission time, the backend (or the client, depending on how aggressive the threat model is) generates a fresh 256-bit symmetric key, call it K. The submission body and every attachment are encrypted under K using libsodium SecretBox, which runs XSalsa20 as the stream cipher and Poly1305 as the authentication tag. The case record stores the resulting ciphertext blobs.
For each authorised recipient, the backend wraps K with libsodium SealedBox to that recipient’s Curve25519 public key. SealedBox performs an X25519 key agreement under the hood, encrypts K with XSalsa20, and authenticates with Poly1305, giving you an envelope that anyone can produce against a public key but only the holder of the matching private key can open. The case record now contains one ciphertext body, one ciphertext blob per attachment, and one SealedBox-wrapped K per recipient.
When a recipient logs in, three things happen in sequence. The login derives a symmetric key from the recipient’s password via Argon2ID, that key decrypts the recipient’s stored Curve25519 private key, the private key opens the SealedBox to recover K, and K then decrypts the report body and attachments via SecretBox. File attachments specifically use libsodium SecretStream with around 5% random padding, so the on-disk size of an attachment does not leak the exact size of the underlying document. Pseudocode in any libsodium binding (PyNaCl, libsodium-go, sodium-native, libsodium-wrappers in TypeScript) maps to these calls one to one without renaming primitives.
| Layer | Primitive | What it protects |
|---|---|---|
| Per-submission body and metadata | SecretBox (XSalsa20 + Poly1305) | The actual report content under symmetric key K |
| File attachments | SecretStream with about 5% random padding | Streaming-encrypted files, with size obfuscation |
| Per-recipient key wrapping | SealedBox (Curve25519 + XSalsa20 + Poly1305) | The data key K, sealed to each recipient’s public key |
| Recipient private key at rest | SecretBox under an Argon2ID-derived key | The recipient’s Curve25519 private key on the backend |
How are recipient passwords and keys protected?
The Argon2ID parameters are the most overlooked part of the design, and they are exactly the place where a casual implementation slips into “memory-hard in name only”. The reference parameters used in current production deployments, verified as of April 2026, are 128 MB of memory and approximately 1 second of computation per login. Memory cost matters more than iteration count here, because GPU and ASIC attacks scale with cheap parallelism on small working sets, and 128 MB of working memory per guess is what makes commodity GPU brute force economically painful. One second of latency at login is at the upper edge of what users tolerate, which is the point: the attacker pays the same cost on every guess.
Each recipient gets a Curve25519 keypair generated on the backend at first login, after they choose a password through an account-activation link. The private key never leaves the server; instead, it is encrypted under a symmetric key that the server derives from the recipient’s password via Argon2ID. The KDF parameters used for the key-wrapping derivation are stronger than those used for password authentication, because the wrapping derivation only runs at login, while the authentication hash is checked more often. A password change rotates the symmetric wrapping key but leaves the Curve25519 keypair intact, so existing case access survives a password rotation: the old wrap is replaced by a new wrap of the same private key.
Argon2ID is the recommended default in 2026 because it is the hybrid variant of the Argon2 family, resistant to both GPU brute force and side-channel attacks. If a deployment needs different parameters, the lever to raise is memory cost first, then iteration count, then parallelism, and the calibration target is “about one second of login latency on the production hardware”. Re-tuning every couple of years is part of the job: GPU and ASIC budgets per dollar keep climbing, and the parameters that bought you a decade of headroom in 2020 do not buy you the same headroom in 2030.
When does the protocol fall short and what compensates?
The design is explicit about two boundaries, both worth quoting verbatim in any threat-model document that references this protocol.
A compromised database, in isolation, reveals only ciphertext. The attacker who walks off with pg_dump output gets SecretBox blobs, SealedBox-wrapped keys, Argon2ID-encrypted recipient private keys, and SHA-256 hashes of receipts. None of that is useful without a recipient session, and the recipient session requires the recipient’s password, which is not in the database in any recoverable form. This is the property that lets the platform survive the worst common-case breach.
A compromised live application server during an active recipient session is a different class of attack. An attacker with code execution on the running app can intercept the data key K while the recipient is logged in, capture the plaintext body and attachments as they decrypt, and read messages as they cross the wire. The protocol does not promise anything against this attacker, and it is honest about that. What compensates is the broader platform: Tor onion service intake removes network-level intermediaries, sandboxing isolates worker processes so a single compromised handler cannot read across cases, secure deletion and database vacuuming reduce the on-disk plaintext lifetime, and the audit trail makes anomalies investigable after the fact.

Operational practices extend the protection further. Backups must be encrypted at the same level the platform encrypts at rest; a plaintext SQL dump shipped to S3 destroys the entire model in one step. Recipient laptops should run full-disk encryption, because a stolen laptop with a cached session is a cheaper attack than compromising the server. Hardware-token authentication for admin accounts (FIDO2 / WebAuthn) shrinks the surface for the most dangerous role. Key recovery and key escrow are explicit choices: enabling them creates a recovery path at the cost of trusting a recovery principal, and that decision should be documented in the project’s privacy policy.
When NOT to Use This Design
- TLS in transit and disk encryption at rest are all you actually need for compliance. That posture is fine for a generic CRUD app, but it is not enough to claim “encrypted whistleblowing” without misleading reporters about the protection they have.
- Reporters must authenticate via enterprise SAML SSO. The receipt model is incompatible with reporter-side SSO because the receipt is, by design, the only authenticator and it must not be linkable to an identity provider.
- Submissions need to be searchable across the corpus from the database side. This protocol is non-deterministic by design and offers no ciphertext search; if searchable encryption is a hard requirement, you are designing a different system and should compare against deterministic-encryption or trusted-enclave approaches.
- Local law mandates law-enforcement-accessible escrow for stored content. Build the escrow path explicitly into the design from day one, with documented policy for who holds the escrow key, rather than retrofitting it onto a reference design that intentionally avoids it.
- Your evaluation budget is a vendor’s marketing PDF that says “AES-256 encryption”. Demand a protocol description at the level of detail this post uses, or treat the vendor as offering opaque encryption rather than verified encryption.
FAQ
Why not just use AES-256 and call it a day?
What happens if a recipient forgets their password?
Is the 16-digit receipt code secure enough?
Can I use a different KDF than Argon2ID?
Does this protocol protect against the platform operator?