“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:
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 A | Mode B | |
|---|---|---|
| Who encrypts | Relay | Sender |
| Relay sees plaintext | Briefly, in memory | Never |
| Cloud support | Rejected | Required |
| Trust boundary | Relay is trusted | Relay 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.
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
ECDHoutput is not used directly as the encryption key.HKDF-SHA256derives theAEADkey 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-Poly1305authenticates both the ciphertext and the header. Tampering fails closed.- No custom cryptography is involved.
X25519,HKDF-SHA256, andChaCha20-Poly1305are 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:
- Parse the envelope and reject unknown protocol versions or algorithms.
- Check
message_idagainst the local replay cache. - Check
created_atagainst the freshness window. - Unseal the private key with the Android Keystore key.
- Derive the same shared secret and
HKDFkey. - Verify and decrypt with
ChaCha20-Poly1305, using the header as associated data. - Build the local Android notification.
Authentication failure means the notification is discarded. There is no “show it anyway” path for untrusted content.
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_atis outside the configured clock-skew window. The phone also checks the envelope TTL, which is authenticated. - Key versioning:
key_versionprevents a sender from encrypting to a stale device key after rotation. A mismatch returnsstale_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.
prelay send --encrypted --device 0193f8e2-... \ --title "Deployment failed" \ --message "Production is down." \ --priority highSelf-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.

