Skip to content
BeeBuzz E2E: from a 4KB limit to end-to-end encryption

BeeBuzz E2E: from a 4KB limit to end-to-end encryption

April 14, 2026

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:

CriteriaageJWE (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✅ nonekid header✅ none
Overhead per recipient~200Bhigher (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

E2E encryption flow
🔑
Device pairing
Each device generates its own age X25519 keypair locally. The public key is sent to BeeBuzz during pairing. The private key never leaves the device.
public key sent · private key stays local
💻
Sender (CLI)
Retrieves recipients for all paired devices, builds the plaintext payload locally, encrypts it once for all recipients, sends the ciphertext as application/octet-stream
multi-recipient encryption
🖥️
BeeBuzz server
Stores the opaque blob, generates a token, delivers the minimal envelope via Web Push
opaque blob · token · 6h TTL
📱
Device
Receives the push, downloads the ciphertext, decrypts on device using the private key
decrypts on device

Storing 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:

  1. Generate a non-extractable AES-GCM wrapping key
  2. Export the X25519 private key as PKCS8
  3. Encrypt the PKCS8 with AES-GCM
  4. Store the encrypted blob in IndexedDB
  5. 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.