How we built zero-knowledge, agent-to-agent email encryption
The architecture behind AgenticEmail end-to-end encryption, and the three problems that were harder than the cryptography: the curve that would not run, sender spoofing, and the zero-knowledge tradeoffs.
Dvir Atias
Founder
On this page
- The constraint that shaped everything: zero-knowledge
- Choosing the crypto: JOSE, not PGP
- Challenge 1: the curve that would not run
- Challenge 2: a signature is not an identity
- Challenge 3: what you give up for zero-knowledge
- The wire format
- What the server can and cannot see
- What this does not defend against
- The honest limits
- Turn it on
- Frequently asked questions
- Is AgenticEmail email always end-to-end encrypted?
- Why P-256 instead of X25519?
- How do you stop someone from forging the sender?
- Can the AI reply and search features work on encrypted mail?
- Where can I read the full architecture?
We built end-to-end encryption so that two AI agents can email each other and neither AWS nor AgenticEmail can read the content. The cryptography is the easy part; every mainstream language ships a solid library for it. The hard parts were a curve that would not run on our runtime, the difference between a valid signature and a real identity, and deciding honestly what to switch off when the server can no longer read a message. This is the architecture and those three problems.
First, the scope, stated plainly: encryption is opt-in, the default is plaintext, and it works between two AgenticEmail inboxes that have each published a key. You cannot end-to-end encrypt to an external Gmail or Outlook address, because those systems do not speak this protocol. If that framing is new, our explainer on encrypted email for AI agents covers the why.
In short. AgenticEmail is API-first email infrastructure for AI agents. Here is how the encryption works and where the hard parts were:
- Zero-knowledge: private keys are generated on the client and never touch our servers, which store and relay ciphertext only.
- We moved from X25519 to P-256 because our runtime, Bun, could not perform the key agreement.
- A valid signature is not an identity, so we bind each one to the sender's published key.
- Because the server cannot read encrypted mail, search and AI replies are switched off on it by design.
- It protects against a passive operator and data at rest, not one that actively substitutes keys. We cover that boundary honestly below.
The constraint that shaped everything: zero-knowledge
The goal was not "encrypt the database." It was that the platform must never be able to read message content, even if it wanted to. That single rule, zero-knowledge, decided the whole architecture.
If the server never holds a private key and never sees plaintext, then encryption and decryption cannot happen on the server. They have to happen on the client, in the SDK or CLI, on the machine that owns the key. The server's entire role shrinks to two jobs: store and serve public keys, and relay and store ciphertext it cannot open. That is a feature, not a limitation. A server that has no key cannot leak one from its database, and cannot be compelled to produce plaintext it does not hold. It is not a magic wand, though: because the same server hands out the public keys that agents encrypt to, an operator that turned actively malicious could still tamper with key distribution. That is the one boundary we treat carefully, and we come back to it below.
So the private key is generated on the client and never leaves it. Only the public keys are ever uploaded.
Choosing the crypto: JOSE, not PGP
Email encryption has two established answers, PGP and S/MIME, and both are painful to automate. PGP wants GnuPG and keyrings on disk; S/MIME wants X.509 certificates and a certificate authority. Neither was built for a program that spins up an inbox and sends a message a second later. We wrote up that whole comparison in PGP encryption and a modern alternative.
We used JOSE instead, the JSON Object Signing and Encryption family. Its keys are plain JSON (JWKs), its signatures are JWS, its encryption is JWE, and every language has a mature library, so there is no shelling out to a binary and no keyring on disk.
Each inbox gets an identity with two keypairs: one for encryption and one for signing. Keeping them separate matters, because you want to authenticate the sender and hide the content with different keys.
The envelope is sign-then-encrypt. The payload is a JSON object with the real subject, text, HTML, and attachments. We sign it with the sender's Ed25519 key into a JWS, and critically, we put the sender's public signing key and address into the JWS header so a recipient can verify without a separate key fetch:
{ "alg": "EdDSA", "jwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." }, "sender": "agent@inbox.agenticemail.dev" }Then that signed blob becomes the plaintext of a JWE. We use a General JSON JWE so a single ciphertext can carry a separately wrapped key for every recipient, which is what lets to, cc, and bcc all decrypt the same message. The real subject rides inside the encrypted payload, so it never appears in a header. This is the exact gap that PGP and S/MIME leave open, and closing it was a design goal, not an afterthought.
Challenge 1: the curve that would not run
The original plan specified X25519 for the encryption keypair. It is the modern default, it is fast, and it is what you reach for when you want ECDH without thinking. We wrote the code, ran the round-trip test, and it failed on the very first deriveBits call with a flat NotSupportedError.
The cause was the runtime, not the cryptography. We run the API on Bun, and at the time Bun's WebCrypto did not support deriveBits over X25519. The algorithm was correct; the platform underneath it could not perform the key agreement. This is the kind of problem that does not show up in a design doc. It shows up when the test suite goes red.
We switched the encryption keypair to P-256 with ECDH-ES+A256KW for key wrapping and A256GCM for content encryption. P-256 is a NIST curve that every runtime we care about, Bun, Node, and browsers, agrees on. We kept Ed25519 for signing, because that path never touched the broken code. The lesson we took: pick primitives your slowest runtime can actually execute, not the ones that look best on paper. The encryption header the code emits today reflects that decision:
{ "enc": "A256GCM", "typ": "agm-e2e-v1" }with each recipient's content key wrapped using ECDH-ES+A256KW over their P-256 public key.
Challenge 2: a signature is not an identity
The first version verified the JWS signature and called the sender authenticated. A security review caught the hole before it shipped, and it is a subtle one worth spelling out.
A JWS proves that the payload was signed by the key in its header. It does not prove that the key belongs to the address in the From line. Nothing stopped an attacker from generating their own keypair, signing a message with it, putting their own key in the header, and setting From to someone else. Every signature check would pass, because the message really was signed by the key it claimed to be signed by. The signature was valid and the identity was a lie.
The fix binds the signature to a published identity. Verification is now default-on, and after decrypting, the SDK resolves the published signing key for the message's From address and compares it to the key embedded in the JWS. If they do not match, the message is flagged as unverified. A valid signature from the wrong key no longer counts. The rule became: trust the key the address published, not the key the message asserts about itself.
Challenge 3: what you give up for zero-knowledge
This was the hardest conversation, because it was not a bug, it was a tradeoff we had to be honest about.
The moment the server can no longer read a message, every feature that depended on reading it stops working. On an encrypted message, the server cannot generate a search index, cannot suggest an AI reply, cannot auto-parse attachments, cannot build a preview snippet, and cannot even thread by subject, because the subject is inside the ciphertext. There is no clever workaround. Reading the content is exactly the capability we removed on purpose.
So we switched those features off for encrypted messages, deliberately and visibly. An encrypted row stores ciphertext with a null subject, no snippet, and the encrypted flag set; the inbound path checks that flag and skips snippet generation, attachment parsing, and reply suggestions. The alternative, keeping a readable copy so the nice features still work, would have quietly broken the entire promise. Zero-knowledge that has an exception is not zero-knowledge.
This is the tradeoff in one line: on encrypted mail you get confidentiality the platform cannot break, and you give up the server-side conveniences that need plaintext. That is the deal, and hiding it would have been dishonest.
The wire format
An encrypted message is still a real email. The outer MIME carries the routing headers, a placeholder subject, a header that marks it as encrypted, and a single application/jose body part holding the JWE.
From: agent-a@inbox.agenticemail.dev
To: agent-b@inbox.agenticemail.dev
Subject: [encrypted message]
X-AgenticEmail-Encrypted: v1
Content-Type: application/jose
{ "protected": "...", "recipients": [ ... ], "iv": "...", "ciphertext": "...", "tag": "..." }The placeholder subject is what an unaware mail client shows; the real subject is sealed inside the JWE. Here is the full flow, sender to registry to relay to recipient:
You can also open the interactive version to pan and zoom. In plain text the same flow looks like this:
Sender agent (SDK) Recipient agent (SDK)
holds private key holds private key
| sign (Ed25519 JWS) ^ decrypt + verify sender
| encrypt to recipient key (P-256 JWE) |
v |
[ JWE ciphertext ] --> AgenticEmail API + AWS --> [ JWE ciphertext ]
relays + stores ciphertext
only, cannot read content
^ |
publish key | | resolve recipient key
| v
Public Key Registry (public JWKs only)What the server can and cannot see
Sealed inside the JWE: the subject, the text and HTML bodies, and attachments.
Visible to the server and to AWS: the From, To, and Cc addresses, the timing, and the message size. This metadata is inherent to how SMTP delivers mail, and we do not claim to hide it. If who-talked-to-whom-and-when is part of your threat model, no content encryption alone solves that, and saying otherwise would be a lie of omission.
What this does not defend against
Being precise about the trust boundary matters more than sounding bulletproof, so here is where the guarantee stops.
- An actively malicious operator (key substitution). Zero-knowledge here means the operator cannot read stored ciphertext, and a database breach exposes no content. It does not remove the operator from the trust path entirely, because agents fetch each other's public keys from our discovery endpoint. An operator that actively wanted to intercept could serve a substituted key in place of a recipient's, decrypt, and re-encrypt, the classic man-in-the-middle. Closing that fully needs out-of-band key verification or key transparency, a public append-only log of published keys that clients can audit; that is the direction, and it is not shipped today. The honest claim now is: safe against a passive or honest-but-curious operator and against data at rest, not against one that maliciously tampers with key distribution.
- Forward secrecy is limited. ECDH-ES derives a fresh ephemeral key on the sender side for every message, so one message's content key does not unlock another. But the recipient's encryption key is long-lived, so if a recipient private key is ever compromised, past messages encrypted to it can be read. There is no post-compromise recovery yet.
- Key rotation and revocation are basic. A key can be re-published and there is a revoked flag, but there is no automatic rotation or propagation to mail already sent.
None of this is unique to us; it is the set of tradeoffs every real end-to-end system negotiates. Naming them plainly, instead of hiding behind the phrase "end-to-end encrypted," is the point.
The honest limits
- Opt-in, plaintext default. Nothing is encrypted unless both sides have published keys and the sender enabled it.
- Agent-to-agent only. No end-to-end encryption to an external Gmail or Outlook address.
- All-or-nothing per message. If one recipient on a message has no published key, the whole message is sent plaintext rather than half-encrypted.
- Key custody is yours. The private key lives only on your client. Lose it and old encrypted mail is unrecoverable, because there is no server-side copy to restore from. That is the direct cost of zero-knowledge.
Turn it on
Encryption is a flag on the SDK client. Generate an identity once, keep the private key safe on your side, and let the client publish the public key and encrypt for you:
import { AgenticEmail, generateIdentity } from "agenticemail";
const identity = await generateIdentity("agent@yourteam.agenticemail.dev");
const client = new AgenticEmail({
apiKey: process.env.AGENTICEMAIL_API_KEY!,
e2e: { identity, autoPublish: true },
});From there, messages.send encrypts to any recipient that has published a key and falls back to plaintext otherwise, and messages.get decrypts and verifies the sender for you. Create an API key to try it, and the docs have the full reference.
Frequently asked questions
Is AgenticEmail email always end-to-end encrypted?
No. Encryption is opt-in and the default is plaintext. It applies only when both inboxes have published keys and the sender turns it on, and only between two AgenticEmail inboxes. Mail to an external address like Gmail is sent plaintext.
Why P-256 instead of X25519?
The design started with X25519, but Bun's WebCrypto could not perform deriveBits over X25519 at the time, so the key agreement failed. We moved the encryption keypair to P-256 with ECDH-ES, which runs identically on Bun, Node, and browsers, and kept Ed25519 for signing.
How do you stop someone from forging the sender?
A valid signature only proves the payload was signed by the key in its header, not that the key belongs to the sender address. After decrypting, the SDK resolves the published signing key for the From address and compares it to the key in the message. A mismatch marks the message unverified, so a signature from the wrong key does not pass.
Can the AI reply and search features work on encrypted mail?
No, and that is by design. Those features need to read the content, and the whole point of zero-knowledge is that the server cannot. On encrypted messages we switch off search, AI reply suggestions, attachment auto-parse, and snippets rather than keep a readable copy that would break the guarantee.
Where can I read the full architecture?
The developer docs cover the API and SDK usage at agenticemail.dev/docs, and the concepts sit alongside our writeups on end-to-end encrypted email and the broader email encryption guide.