BeeBuzz E2E: from a 4KB limit to end-to-end encryption
BeeBuzz E2E is shaped by a hard limit: 4KB.
That’s the maximum size of a Web Push payload. You can’t encrypt a full notification and send it directly — it won’t fit. The architecture follows from that constraint.
The starting constraint
Encrypted content can’t travel inside the push payload. So it doesn’t.
- The encrypted content is stored as a separate attachment
- The server treats it as an opaque blob
- The push message carries only a retrieval token
{
"beebuzz": {
"id": "uuid-v7",
"token": "attachment-token"
}
}That’s all the server sees and delivers.
What the system requires from encryption
Given this model — encrypt on sender, decrypt on device — the requirements are strict:
- Encrypt once, target multiple recipients
- First-class multi-device support
- Available in Go (CLI) and TypeScript (Hive PWA)
- Predictable overhead per recipient
Alternatives
Three realistic options:
| Criteria | age | JWE (go-jose / jose / jwx¹) | libsodium (nacl / libsodium-wrappers) |
|---|---|---|---|
| Opinionated | ✅ no algorithm choices | ❌ many combinations | ✅ opinionated by design |
| Multi-recipient | ✅ built-in | ✅ built-in (JSON Serialization) | ❌ manual |
| Default curve | ✅ X25519 | ⚠️ NIST curves, X25519 not universal | ✅ X25519 (Curve25519) |
| Go library | ✅ age | ✅ go-jose, jwx | ⚠️ CGo binding, requires system libsodium |
| TS library | ✅ typage | ✅ jose | ⚠️ libsodium-wrappers (WASM) |
| Same author Go+TS | ✅ Filippo Valsorda | ❌ different authors | ❌ different authors |
| Runtime deps (Go) | ✅ none | ✅ none | ❌ external system dependency |
| Runtime deps (TS) | ✅ noble crypto | ✅ none | ⚠️ heavy WASM bundle |
| Recipient fingerprint in payload | ✅ none | ❌ kid header | ✅ none |
| Overhead per recipient | ~200B | higher (JSON encoding) | ~48B |
| IETF standard | ❌ no | ✅ RFC 7516 | ❌ no |
¹ lestrrat-go/jwx improves on go-jose by supporting ECDH-ES in multi-recipient mode. This doesn’t change the fundamental trade-off: JWE requires algorithm decisions; age removes them.
JWE is the strongest option on paper. In practice, it introduces decisions immediately.
In go-jose, ECDH-ES is not available in multi-recipient mode. You’re forced into ECDH-ES+A256KW — a key wrapping variant — and into DecryptMulti() instead of Decrypt(). (jwx fixes this, but adds even more algorithm choices to navigate.)
This isn’t theoretical flexibility. It shows up the moment you try multi-recipient encryption.
libsodium is closer in philosophy. It’s opinionated and actually more efficient per recipient (~48B vs ~200B). The problem is operational:
- Go requires CGo bindings and a system-level dependency
- Multi-recipient encryption isn’t native
That adds complexity exactly where it shouldn’t exist.
Why age
age is a modern encryption tool built on X25519. It removes decisions. That matters more than flexibility. JWE exposes algorithm choices and mode constraints immediately. age doesn’t. Multi-recipient encryption is native. No additional layers. No surprises.
A second factor is implementation coherence. The Go library (age) and the TypeScript library (typage) are maintained by the same author. This isn’t just about trust. It means the implementations are designed to interoperate — not just conform to a spec.
In BeeBuzz:
- Encryption happens in Go (CLI)
- Decryption happens in the browser (Hive PWA)
With age, that boundary is stable by design.
Two more properties matter:
- Ciphertext contains no recipient fingerprints
- Overhead is ~200 bytes per recipient
For BeeBuzz payload sizes, that overhead is negligible. age is already used in paw.pm. That’s not the reason for the choice. But it reduces integration risk.
This isn’t the only valid choice. It’s the one that minimizes decision surface — and has proven reliable in practice.
The flow
application/octet-streamStoring the private key in the browser
age solves encryption. Key storage is a separate problem. The Web Crypto API provides the right primitive: non-extractable keys (extractable: false).
These keys:
- Live inside the browser keystore
- Cannot be exported as raw bytes
- Are not readable by application code
That’s exactly what a device private key needs.
This approach worked well in testing. Then came Safari.
The Safari problem
On Chrome and Firefox:
- Key generation → OK
- Recipient derivation → OK
- Persistence → OK
On Safari:
- Storing a CryptoKey → appears OK
- Reading it later → sometimes null
No crash. No error. The key is just gone. This behavior is not well documented, but it’s reproducible. Direct persistence of CryptoKey is unreliable on WebKit. That breaks E2E.
The solution: wrapped-key model
The fix is a single consistent model across browsers:
- Generate a non-extractable AES-GCM wrapping key
- Export the X25519 private key as PKCS8
- Encrypt the PKCS8 with AES-GCM
- Store the encrypted blob in IndexedDB
- On use: decrypt and re-import as non-extractable
The wrapping key never leaves the browser. The private key exists in plaintext only in memory, briefly. No Safari-specific code paths. Same model everywhere.
An honest threat model
The private key is encrypted at rest. It could be protected further — PIN, biometrics. It isn’t. BeeBuzz is an operational notification system. Attachments are short-lived (6 hours). An attacker capable of extracting IndexedDB at runtime is already beyond what browser protections can contain. Adding a second factor would add friction without meaningfully changing that risk. So it’s not there — at least for now.
In summary
- A 4KB limit forces an attachment-based design
- That design requires client-side encryption across devices
- age provides multi-recipient encryption without extra decisions, and works consistently across Go and TypeScript
- Safari breaks direct key persistence
- The wrapped-key model fixes it without special cases
The system isn’t perfect. But it’s simple, consistent, and works across real browsers.