End-to-end encryption
Encrypt the text of your notifications with a key only your devices know. We store and deliver ciphertext, and the PushPiper app decrypts it on each device.
How it works
You pick a 256-bit key and add it to the PushPiper app on each of your devices. Your sender encrypts message, title, url and url_title with that key and sends encrypted=1. We store and deliver the ciphertext, and your devices decrypt it. The key never reaches our servers.
Each field is encrypted on its own:
- Compress the UTF-8 text with gzip.
- Pick a random 16-byte IV.
- Encrypt with AES-256-CBC and PKCS#7 padding.
- Compute HMAC-SHA256 over the IV and ciphertext, with the same key.
- Send the Base64 of IV, ciphertext and HMAC, in that order.
Generate a key
A key is 64 hexadecimal characters. Generate one with any of these:
openssl rand -hex 32node -p 'require("node:crypto").randomBytes(32).toString("hex")'python3 -c 'import secrets; print(secrets.token_hex(32))'Then paste the same key into the encryption setting of the PushPiper app on every device that should read these messages. It's stored only on that device.
Encrypt and send
Each recipe reads the key from PUSHPIPER_E2EE_KEY, encrypts a title and a message, and sends them. Replace APP_TOKEN and USER_KEY with your own.
#!/bin/sh
# Requires openssl, gzip, xxd and curl 7.76+
set -eu
die() { echo "$*" >&2; exit 1; }
KEY=${PUSHPIPER_E2EE_KEY:-}
case $KEY in *[!0-9a-fA-F]*) die "PUSHPIPER_E2EE_KEY must be hex" ;; esac
[ "${#KEY}" -eq 64 ] || die "PUSHPIPER_E2EE_KEY must be 64 hex characters"
encrypt() {
iv=$(openssl rand -hex 16)
ct=$(printf '%s' "$1" | gzip -9 |
openssl enc -aes-256-cbc -K "$KEY" -iv "$iv" | xxd -p | tr -d '\n')
mac=$(printf '%s%s' "$iv" "$ct" | xxd -r -p |
openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" | awk '{print $NF}')
[ -n "$ct" ] || die "openssl enc failed"
[ "${#mac}" -eq 64 ] || die "openssl dgst failed"
printf '%s%s%s' "$iv" "$ct" "$mac" | xxd -r -p | openssl base64 -A
}
title=$(encrypt "Backup finished")
message=$(encrypt "nightly-db: 4.2 GB in 3m12s, all checks passed")
curl -sS --fail-with-body --max-time 10 https://api.pushpiper.com/1/messages.json \
-F "token=APP_TOKEN" \
-F "user=USER_KEY" \
-F "title=$title" \
-F "message=$message" \
-F "encrypted=1"// Node.js 18+, no dependencies. Save as send.mjs
import { createCipheriv, createHmac, randomBytes } from "node:crypto";
import { gzipSync } from "node:zlib";
const hexKey = process.env.PUSHPIPER_E2EE_KEY ?? "";
if (!/^[0-9a-f]{64}$/i.test(hexKey)) {
throw new Error("PUSHPIPER_E2EE_KEY must be 64 hex characters");
}
const key = Buffer.from(hexKey, "hex");
function encrypt(plaintext) {
const iv = randomBytes(16);
const compressed = gzipSync(plaintext, { level: 9 });
const cipher = createCipheriv("aes-256-cbc", key, iv);
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
const mac = createHmac("sha256", key).update(iv).update(ciphertext).digest();
return Buffer.concat([iv, ciphertext, mac]).toString("base64");
}
const res = await fetch("https://api.pushpiper.com/1/messages.json", {
method: "POST",
body: new URLSearchParams({
token: "APP_TOKEN",
user: "USER_KEY",
title: encrypt("Backup finished"),
message: encrypt("nightly-db: 4.2 GB in 3m12s, all checks passed"),
encrypted: "1",
}),
signal: AbortSignal.timeout(10_000),
});
const body = await res.text();
if (!res.ok) throw new Error(`PushPiper ${res.status}: ${body}`);
console.log(body);# pip install cryptography (everything else is stdlib)
import base64
import gzip
import hmac
import os
import re
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
HEX_KEY = os.environ.get("PUSHPIPER_E2EE_KEY", "")
if not re.fullmatch(r"[0-9a-fA-F]{64}", HEX_KEY):
raise SystemExit("PUSHPIPER_E2EE_KEY must be 64 hex characters")
KEY = bytes.fromhex(HEX_KEY)
def encrypt(plaintext: str) -> str:
iv = os.urandom(16)
padder = padding.PKCS7(128).padder()
padded = padder.update(gzip.compress(plaintext.encode())) + padder.finalize()
encryptor = Cipher(algorithms.AES(KEY), modes.CBC(iv)).encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
mac = hmac.digest(KEY, iv + ciphertext, "sha256")
return base64.b64encode(iv + ciphertext + mac).decode()
fields = {
"token": "APP_TOKEN",
"user": "USER_KEY",
"title": encrypt("Backup finished"),
"message": encrypt("nightly-db: 4.2 GB in 3m12s, all checks passed"),
"encrypted": "1",
}
body = urlencode(fields).encode()
request = Request("https://api.pushpiper.com/1/messages.json", body)
try:
with urlopen(request, timeout=10) as res:
print(res.read().decode())
except HTTPError as err:
raise SystemExit(f"PushPiper {err.code}: {err.read().decode()}") from Noneapt install xxd.Limits apply to your plaintext, before encryption: 1024 characters for message, 250 for title, 512 for url and 100 for url_title.
With encrypted=1, each of those four fields you send must be encrypted. Leave out the ones you don't need. Without a title, the notification shows your app's name.
How devices decrypt
The PushPiper app decrypts in its service worker, before the notification appears, and again in the inbox. It checks the HMAC first, then decrypts and decompresses. html and monospace formatting is applied to the decrypted text.
If a device has no key or the wrong one, the notification says "(Encrypted message)" and the inbox shows a locked message. Add the key later and the history unlocks, because we keep the ciphertext.
Writing your own client? This reference decrypts one field with WebCrypto:
// Browsers, service workers and Node.js 20+
async function decrypt(value, hexKey) {
if (!/^[0-9a-f]{64}$/i.test(hexKey)) {
throw new Error("Key must be 64 hex characters");
}
const raw = Uint8Array.from(hexKey.match(/../g), (h) => parseInt(h, 16));
const data = Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
if (data.length < 64 || (data.length - 48) % 16 !== 0) {
throw new Error("Malformed ciphertext");
}
const iv = data.subarray(0, 16);
const ciphertext = data.subarray(16, -32);
const mac = data.subarray(-32);
const { subtle } = crypto;
// Authenticate IV + ciphertext before decrypting anything
const hmac = { name: "HMAC", hash: "SHA-256" };
const macKey = await subtle.importKey("raw", raw, hmac, false, ["verify"]);
if (!(await subtle.verify(hmac, macKey, mac, data.subarray(0, -32)))) {
throw new Error("HMAC mismatch: wrong key or tampered message");
}
const aes = { name: "AES-CBC", iv };
const aesKey = await subtle.importKey("raw", raw, aes, false, ["decrypt"]);
const compressed = await subtle.decrypt(aes, aesKey, ciphertext);
const gunzip = new DecompressionStream("gzip");
const stream = new Blob([compressed]).stream().pipeThrough(gunzip);
return new Response(stream).text();
}What isn't encrypted
Encryption covers those four text fields. We still see what we need to deliver a message:
- The app that sent it, its recipients and the time.
- Delivery settings:
priority,sound,device,ttl,timestampand thehtmlormonospaceflag. - Emergency receipts, callbacks and acknowledgements.
- Attachments. They're never encrypted, the same as on Pushover.
- Messages from the email gateway, which are always plain text.
Questions and answers
Can PushPiper read encrypted messages?
No. We only receive and store ciphertext, and the key stays on your devices. We do see delivery details like the app, the recipients, the priority and the time.
Is it compatible with Pushover's encryption?
Yes. PushPiper uses Pushover's format byte for byte, so anything that encrypts for Pushover and lets you change the API host works with PushPiper.
Are attachments encrypted?
No. Attachments are stored and delivered as they are, as on Pushover. Put sensitive details in the encrypted message instead.
What happens if I lose my key?
Messages encrypted with it stay locked. Generate a new key, add it to your devices and your sender, and new messages decrypt again.