Inside the envelope: how Prelay keeps a notification private

A practical look at Prelay's zero-knowledge notification path: the envelope, the keys, what the relay can see, and what end-to-end encryption does not cover.

1 min read

“End-to-end encrypted” gets used loosely. Sometimes it means the transport is encrypted. Sometimes it means the provider promises not to look. Sometimes it means the provider cannot look even if it wanted to.

Prelay is built around the third version. In Mode B, the sender encrypts the notification before the request leaves the machine. The relay receives an envelope, not a message. The Android device decrypts it. Prelay Cloud accepts Mode B only.

This post walks through that path at the protocol level: what is inside the envelope, who holds which key, what the relay can see, and where the limits are.

The trust boundary

In Mode B, a notification crosses five gates from the sender to the lock screen:

Mode B path
Layer by layer: Mode B
The sender seals the message before the relay sees it.
Layer by layer: Mode B A plaintext notification is encrypted on the sender, then passes through relay, FCM transport, and device decryption before becoming a local notification. The relay and FCM handle only the sealed envelope. Deploy failed: api production is down plaintext encrypt sender · Mode B relay header + ciphertext transport FCM envelope decrypt device private key notify local alert lock screen one notification · no history plaintext at the sender and phone · ciphertext in between
FIG C The sender encrypts first. The relay and FCM handle a sealed envelope; the phone decrypts and posts a local notification.

TLS protects each connection. It does not protect the message from the relay or from FCM. Those are different trust boundaries.

Prelay has two modes:

Mode AMode B
Who encryptsRelaySender
Relay sees plaintextBriefly, in memoryNever
Cloud supportRejectedRequired
Trust boundaryRelay is trustedRelay and FCM are outside

Mode A is the self-hosted default: you send a plaintext request to your own relay, and it encrypts for the target device. That is useful when the relay is part of your trusted infrastructure.

Mode B is stricter. The sender fetches the device public key, encrypts locally, and posts only the resulting envelope. The relay validates and routes ciphertext. It has no private key and no shared secret.

That is the model Cloud uses.

What the relay can see

Even in Mode B, the envelope is not opaque. It has a header that the relay must read to route and validate the message:

{
"version": 2,
"message_id": "0193f8e2-6b4a-7b9e-a2c1-000000000001",
"device_id": "0193f8e2-6b4a-7b9e-a2c1-000000000002",
"key_version": 1,
"algorithm": "X25519-ChaCha20-Poly1305",
"sender_key": "<ephemeral public key>",
"nonce": "<12 random bytes>",
"ciphertext": "<encrypted notification>",
"created_at": 1710000000,
"ttl": 3600,
"priority": "high"
}

The relay can see the device id, message id, key version, timestamps, TTL, priority, and the size of the ciphertext. It cannot see the title, message body, channel, or action buttons.

The encrypted part is the notification plaintext:

{
"title": "Deployment failed",
"message": "Production deployment failed.",
"priority": "high",
"channel": "deploy",
"version": 1
}

The header is not secret, but it is authenticated. In protocol v2, every header field except sender_key and nonce is passed to ChaCha20-Poly1305 as associated data. If a relay rewrites message_id, created_at, ttl, or priority, the authentication tag fails and the phone discards the envelope.

sender_key and nonce are excluded because they already feed the key derivation and the AEAD directly. Binding them again would add nothing.

Envelope anatomy
What the relay can see
A routing header on the outside; the notification sealed inside.
What the relay can see in a Prelay envelope The envelope header is visible to the relay and includes device id, message id, key version, created at, ttl, priority, and algorithm. The notification payload is ciphertext and contains title, message, channel, and actions. The header and ciphertext are authenticated together; a changed header fails the tag and the phone discards the envelope. one envelope · two zones HEADER visible to the relay device_id 0193f8e2…02 ttl 3600 message_id 0193f8e2…01 priority high key_version 1 created_at 1710000000 algorithm X25519-ChaCha20-Poly1305 SEALED PAYLOAD ciphertext · authenticated title message channel actions AUTHENTICATED AS ASSOCIATED DATA header + ciphertext ChaCha20 Poly1305 tag verified header changed → tag fails → discard the relay reads the header; the phone reads the payload
FIG D The relay reads the routing header and carries the sealed payload. The header is authenticated as associated data; if it changes, the phone rejects the envelope.

The key path

The Android app generates an X25519 keypair on first run. The private key never leaves the device. It is sealed with an Android Keystore AES-GCM key and can only be unsealed on that device.

The public key is registered with the relay. In Mode B, the sender fetches it along with the current key_version and performs the encryption locally:

shared_secret = X25519(ephemeral_sender_private, device_public)
key = HKDF-SHA256(
shared_secret,
salt = SHA-256(sender_public || device_public),
info = "prelay-envelope-v2"
)
ciphertext = ChaCha20-Poly1305(key, nonce, plaintext, aad = header)

A few details matter:

  • The ECDH output is not used directly as the encryption key. HKDF-SHA256 derives the AEAD key and provides domain separation.
  • The salt binds the key to the exact pair of public keys. A different sender/device pair produces a different key.
  • The sender key is ephemeral and fresh for every message. That gives forward secrecy for message keys and makes a random 12-byte nonce safe.
  • ChaCha20-Poly1305 authenticates both the ciphertext and the header. Tampering fails closed.
  • No custom cryptography is involved. X25519, HKDF-SHA256, and ChaCha20-Poly1305 are standard, audited primitives.

The sender then posts the envelope with encrypted: true. Prelay validates the envelope structure, message id, timestamp, key version, and size, then hands it to FCM.

FCM receives a data message containing the envelope. It does not receive plaintext, and it is not trusted with the message.

The phone side

On the Android device, the flow is the reverse:

  1. Parse the envelope and reject unknown protocol versions or algorithms.
  2. Check message_id against the local replay cache.
  3. Check created_at against the freshness window.
  4. Unseal the private key with the Android Keystore key.
  5. Derive the same shared secret and HKDF key.
  6. Verify and decrypt with ChaCha20-Poly1305, using the header as associated data.
  7. Build the local Android notification.

Authentication failure means the notification is discarded. There is no “show it anyway” path for untrusted content.

Phone-side checks
What the phone verifies
The envelope must pass every gate before a notification appears.
What the phone verifies before showing a notification An envelope arrives from FCM and passes seven checks on the phone: protocol and algorithm, replay cache, freshness and TTL, unsealing the private key with Android Keystore, deriving the shared key, verifying and decrypting with ChaCha20-Poly1305, and building the local notification. Any failed check goes to discard with no notification. from FCM 1 parse / version 2 replay cache 3 freshness / TTL 4 unseal private key Android Keystore 5 derive shared key 6 verify + decrypt 7 build notification any failure fail closed no show-it-anyway Deployment failed Production is down. PRELAY · NOW discard no notification private key stays here · any failure is discarded
FIG E The phone parses, checks replay and freshness, unseals the Keystore-backed key, derives the key, and verifies the AEAD tag. Any failure ends in discard; there is no show-it-anyway path.

The relay never had the private key. In Mode B, it never had the plaintext either.

Replay, tampering, and rotation

Encryption alone is not enough. An attacker could capture and resend a valid envelope. Prelay handles that in layers:

  • Server-side replay protection: in Mode B, the relay keeps a bounded, TTL-scoped set of seen message_ids and rejects duplicates.
  • Device-side replay protection: the Android app keeps a persistent local seen-set and reserves an id before decrypting, committing it only after the notification is handled.
  • Freshness: the relay rejects envelopes whose created_at is outside the configured clock-skew window. The phone also checks the envelope TTL, which is authenticated.
  • Key versioning: key_version prevents a sender from encrypting to a stale device key after rotation. A mismatch returns stale_key, and the sender refreshes the public key.

Key rotation has its own guard. Rotating a device key requires proof of possession of the current private key: the device answers a short-lived X25519 challenge with an HKDF-derived proof. A stolen device:manage token alone cannot redirect future messages to an attacker key.

There is no admin force-rotation path. If the current private key is lost, recovery is revoke and re-onboard as a new device. On a successful rotation, the relay sends a high-priority security notification sealed to the old key, and the app compares key fingerprints on refresh.

What this does not cover

A security post is more useful when it is explicit about the edges.

Metadata is not encrypted. The relay sees routing and delivery metadata: device id, message id, timestamps, TTL, priority, and ciphertext size. FCM sees delivery metadata and timing. Mode B protects content, not the fact that a notification was sent.

A compromised phone sees plaintext. The private key is protected by the Android Keystore, but a fully compromised device can observe what the user can observe.

A compromised sender sees plaintext. Mode B moves the encryption to the sender; it does not make the sender trustworthy.

The public-key lookup is part of the trust boundary. The device exposes its key fingerprint, and high-assurance deployments should verify it out of band. Mode B guarantees that the relay only receives ciphertext after the sender has the correct device key.

There are no read receipts. Prelay deliberately does not report whether a notification was opened. Delivery records exist for routing, expire automatically, and are not a behavioral trail.

Prelay keeps no message history. The relay does not store notification contents, and there is no dashboard archive to browse later.

This is not anonymity. It is confidentiality and integrity for message contents. The relay and FCM still know that something was delivered.

Why the narrow design is the point

A notification relay does not need to become a messaging platform to be useful. It needs a small, trustworthy path from a system that has something to say to a phone that should show it.

Prelay’s protocol is deliberately narrow:

  • one envelope format;
  • standard primitives;
  • a private key that stays on the device;
  • a relay that can route without reading;
  • no message history, analytics, or read receipts;
  • known-answer tests across the Go server and Kotlin app.

That is what “zero-knowledge” means here. Not that metadata disappears. Not that the phone is magically secure. It means the hosted relay does not hold the key and does not receive the plaintext.

Using the zero-knowledge path

Prelay Cloud accepts Mode B only. The CLI performs the encryption locally: it fetches the device public key, builds the envelope, and sends only ciphertext.

Terminal window
prelay send --encrypted --device 0193f8e2-... \
--title "Deployment failed" \
--message "Production is down." \
--priority high

Self-hosted deployments can use the same path. Mode B is not a Cloud-only protocol feature; Cloud just makes it the only accepted mode.

Prelay Cloud is the hosted version of the same path: same Android app, same Mode B envelope, and no relay or Firebase project to run. You can create a Cloud account or read the Cloud setup guide. Self-hosted Prelay remains free and unlimited.

If you want the short version: the relay can carry the envelope, but it cannot open it. That is the boundary.