端到端加密
在您自己的机器上,用只有您的设备才持有的密钥加密通知的标题、副标题、正文和链接。PushWard 存储并投递它自己无法读取的文本。
本节介绍的功能来自一个正在等待 App Store 审核的应用更新。更新上线后,此处会自动解锁。
The tool that sends the notification seals its title, subtitle, body and url with your encryption key and
sends the result as a single field, encrypted. PushWard stores that envelope and pushes it
through Apple as it is. The PushWard app opens it with the same key on your devices. The key itself never
reaches PushWard, so the text cannot be read back from the server, its database backups or Apple's push
service.
pushward e2e import # paste the key from the app
pushward e2e key-id # should match the Key ID the app shows
pushward notify --title "Prod DB password rotated" --body "New one is in the vault under db/prod"What is encrypted
Four fields go inside the envelope: title, subtitle, body and url. The rest of the request stays readable to PushWard, and the part of it that ends up in the
push stays readable to Apple, because the server and iOS need it to deliver and show the notification:
levelandvolumethread_idandcollapse_idsourceandsource_display_name- the
mediaandicon_urlURLs (and whoever hosts them sees your devices download them) metadataandactivity_slug- an acknowledged alert's settings:
acknowledge, including the button'saction_title,tagsandcallback_url actions: ids, titles, URLs and webhook bodies. Which button someone tapped, and a reply typed into a text input, are recorded in plain text too.
Live Activities, widgets and email are not encrypted at all. Keep secrets out of the readable fields: a
password in metadata is as visible as it was before.
What it protects against
It covers the text of your notifications at rest and in transit, against anyone who can read PushWard's
database, logs or backups, and against Apple's push service. A leaked database dump shows each encrypted
notification as the placeholder title Encrypted notification and an opaque envelope.
It does not hide that you were sent something. PushWard and Apple still see:
- when a notification was sent, to which account, and how big the envelope was;
- every readable field listed above.
The server, or Apple, can still drop a notification, hold it back, or deliver the same envelope again. What they cannot do is change the text: AES-GCM authenticates the envelope, and one that was altered fails to open, so the device shows the placeholder rather than different words.
Only the Key ID is bound to the envelope as authenticated data, not the readable fields around it. So the server could also deliver a genuine envelope with different readable fields: other actions and button URLs, another level, other media. The text would be yours; what the buttons do would not be guaranteed.
The key is the weak point. Anyone holding it reads every notification sealed with it, and it lives in more places than your phone: in iCloud Keychain, and on every machine that sends with it (the CLI's config file, a GitHub secret, a Home Assistant config entry, the Grafana plugin's settings, the Unraid flash drive, an environment variable). Treat it like a password. On your devices the decrypted text is shown and kept in the app's history like any other notification.
Set up a key
- In PushWard 1.17.0 or later, open Settings > End-to-End Encryption and tap Create Key. The screen shows the key's Key ID, an 8-character fingerprint you can compare with every tool that holds the same key.
- Show Key asks for Face ID or your passcode, then shows the key as 64 hex characters and as a QR code. Copy it from there.
- The key reaches your other iPhones, iPads and Macs through iCloud Keychain, which Apple encrypts end to end. With iCloud Keychain off, use Add Existing Key on each device and paste the key, or scan the QR code with another device's camera and paste what it reads.
- Give the key to the tools that send for you, below.
- Send Encrypted Test Notification on the same screen checks the whole path on that device.
It works the other way round too: pushward e2e generate --save creates a key in the CLI, and Add Existing Key brings it into the app.
Sending encrypted notifications
CLI
Encryption needs CLI 1.4.0 or later. Once the CLI has a key, from PUSHWARD_E2E_KEY or from its
config file (e2e import or e2e generate --save put it there), every notify, notification send and schedule create is encrypted. --no-encrypt sends one in the clear, and --encrypt fails instead of sending readable
text when no key is set. pushward api never encrypts. pushward e2e encrypt prints
an envelope for a script that cannot seal one itself, and pushward e2e decrypt opens one.
pushward e2e encrypt --title "Disk full" --body "/var at 97%" --jq .encrypted
pushward e2e remove # forget the CLI's copy; your devices keep theirsGitHub Action
The e2e-key and encrypt inputs come with CLI 1.4.0, which @v1 follows.
Pass the key from a secret as the e2e-key input and set encrypt: true next to it. A
secret that does not exist, or that GitHub does not pass to a pull request from a fork, arrives empty, and
without encrypt the step would then send the text readable instead of failing. With encrypt: true, a command that cannot encrypt (anything but notify, notification send and schedule create) fails the step as well.
GitHub prints a step's inputs at the top of its log, so a title or body input is
readable there even though the request is encrypted. For text that must stay private, write the body to a
file in an earlier step and pass it with json: '@body.json'.
- run: |
jq -n --arg body "$(cat rotation.log)" '{title: "Credentials rotated", body: $body}' > body.json
- uses: mac-lucky/pushward-action@v1
with:
token: ${{ secrets.PUSHWARD_TOKEN }}
e2e-key: ${{ secrets.PUSHWARD_E2E_KEY }}
encrypt: true
json: '@body.json'MCP server
Set PUSHWARD_E2E_KEY for the MCP server (pushward-mcp 1.16.0
or later) when you run it yourself over stdio (single-user http mode reads it too). create_notification and create_scheduled_notification then seal the text before it leaves the process. The hosted server
at mcp.pushward.app holds no key and sends plain text.
Home Assistant
With the PushWard integration 0.51.0 or later, open Settings > Devices & Services > PushWard
> Configure and paste the key; the form shows its Key ID. pushward.send_notification and to-do reminders are sealed from then on, whether they go out now or later, and the integration checks the
text before sealing, so an action call fails instead of sending something your phone cannot show. The form
never shows the stored key: leaving the field empty keeps it, pasting another one replaces it, and Remove the encryption key goes back to plain text. Details on the Home Assistant page.
Grafana plugin
The Grafana app plugin 0.10.0 or later takes the key under End-to-end encryption key on its Configuration page and shows its Key ID there. It seals the title, subtitle and body of the alert pushes it sends, and its test notification. The timeline Live Activity stays readable. A key that does not work never sends the alert text readable: the push goes out without it, and the Configuration page shows the error. Details on the Grafana page.
Unraid plugin
The Unraid plugin 2026.10.08 or later takes the key under End-to-end encryption key in Settings → PushWard, and its status cards show the Key ID. It seals the title, subtitle, body and link of every Unraid notification on the Unraid server itself before sending it, and shortens a long message to fit, the body first. The parity, mover, backup and UPS Live Activities stay readable. Details on the Unraid page.
Webhooks sent through the Relay, the universal webhook included, cannot be encrypted. The relay builds the text from the webhook and never holds your key, so those notifications reach PushWard readable.
The API
From your own code, send encrypted to POST /notifications or POST /notifications/scheduled in place of title, subtitle, body and url; sending any of those four next to it is a 400. Every
other field works as usual.
curl -X POST https://api.pushward.app/notifications \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"encrypted": "pw1.767c0806.oKGio6Slpqeoqaqr-DhDOK4k53I0XIqFXerStl8L5wgFH2EUp_HSYIhOtPQU6R9X6oBtcwaIVw6kyen5OK3Vm_WWiP9w4ooFbGhar_XGOj1J",
"level": "time-sensitive",
"source": "monitoring"
}'That envelope is the ascii_minimal case from the test vectors, sealed with
their public test key, so a real device has no key for it and shows the placeholder. The response (and GET /notifications/scheduled for a schedule) carries the placeholders the server stored in place
of the text, plus the envelope:
{
"id": 4310,
"title": "Encrypted notification",
"body": "End-to-end encrypted. Open PushWard on a device with your encryption key to read it.",
"encrypted": "pw1.767c0806.oKGio6Slpqeoqaqr-DhDOK4k53I0XIqFXerStl8L5wgFH2EUp_HSYIhOtPQU6R9X6oBtcwaIVw6kyen5OK3Vm_WWiP9w4ooFbGhar_XGOj1J",
"level": "time-sensitive",
"source": "monitoring",
"pushed": true,
"created_at": "2026-10-20T07:00:00Z",
"delivery": "all"
}The envelope
The format is called pw1. It is small enough to write yourself in any language with HKDF and
AES-GCM. The four versions below build the envelope; put the checks that follow
the format in front of them.
- The key is 32 random bytes, written as 64 lowercase hex characters. Accept upper case and strip whitespace when reading one.
enc_key= HKDF-SHA256 of the key with an empty salt, infopushward/e2e/v1/enc, 32 bytes.kid= lowercase hex of HKDF-SHA256 of the key with an empty salt, infopushward/e2e/v1/kid, 4 bytes. This is the Key ID the app and the CLI show.- The plaintext is a UTF-8 JSON object with
titleandbody(both required, not empty) and optionalsubtitleandurl. Receivers ignore other keys. Pad it with trailing ASCII spaces to a multiple of 64 bytes, so the length gives less away, unless the padding would push the envelope past 3072 characters; then send it unpadded. No compression. - Seal it with AES-256-GCM under
enc_key, a random 12-byte nonce, and the ASCII stringpw1.<kid>as additional authenticated data. The 16-byte tag goes after the ciphertext. - The envelope is
pw1.<kid>.followed by base64url without padding ofnonce || ciphertext || tag. It must match^pw1\.[0-9a-f]{8}\.[A-Za-z0-9_-]{40,}$and be at most 3072 characters long. Decode it strictly: a length that leaves one character over, or a last character whose unused bits are not zero, is refused.
PushWard only checks that shape; it cannot look inside. So check the text yourself before sealing it, the way the server checks a plaintext send, and refuse to send with an error when a check fails:
titleandbodyare not empty.titleandsubtitleare at most 256 andbodyat most 4096 characters, counted in Unicode code points (not bytes, not UTF-16 units).urlis empty, or follows the action url rules: at most 2048 code points, a scheme, notjavascript:,data:,file:orvbscript:, and a host forhttp(s).
Skip these and nothing fails loudly: the app clips text that is too long and drops a url that breaks the rules, without telling anyone.
import base64, json, os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def _hkdf(key: bytes, info: bytes, length: int) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=length, salt=None, info=info).derive(key)
def seal(key_hex: str, title: str, body: str, subtitle: str = "", url: str = "") -> str:
key = bytes.fromhex(key_hex)
if len(key) != 32:
raise ValueError("the key must be 64 hex characters")
enc_key = _hkdf(key, b"pushward/e2e/v1/enc", 32)
kid = _hkdf(key, b"pushward/e2e/v1/kid", 4).hex()
msg = {"title": title, "body": body}
if subtitle:
msg["subtitle"] = subtitle
if url:
msg["url"] = url
plaintext = json.dumps(msg, ensure_ascii=False, separators=(",", ":")).encode()
# Pad with spaces to a multiple of 64 bytes, unless that no longer fits:
# 2266 bytes is the most a 3072-character envelope holds.
padded = plaintext + b" " * (-len(plaintext) % 64)
if len(padded) <= 2266:
plaintext = padded
nonce = os.urandom(12)
sealed = AESGCM(enc_key).encrypt(nonce, plaintext, f"pw1.{kid}".encode())
envelope = f"pw1.{kid}." + base64.urlsafe_b64encode(nonce + sealed).rstrip(b"=").decode()
if len(envelope) > 3072:
raise ValueError("too long to encrypt: shorten the title, subtitle, body or url")
return envelopeimport { createCipheriv, hkdfSync, randomBytes } from 'node:crypto';
const derive = (key, info, length) =>
Buffer.from(hkdfSync('sha256', key, Buffer.alloc(0), info, length));
// Returns the pw1 envelope to send as "encrypted".
export function seal(keyHex, { title, body, subtitle, url }) {
const key = Buffer.from(keyHex, 'hex');
if (key.length !== 32) throw new Error('the key must be 64 hex characters');
const encKey = derive(key, 'pushward/e2e/v1/enc', 32);
const kid = derive(key, 'pushward/e2e/v1/kid', 4).toString('hex');
// JSON.stringify leaves out undefined, so empty optional fields disappear.
const msg = { title, body, subtitle: subtitle || undefined, url: url || undefined };
let plaintext = Buffer.from(JSON.stringify(msg));
const pad = (64 - (plaintext.length % 64)) % 64;
if (plaintext.length + pad <= 2266) {
plaintext = Buffer.concat([plaintext, Buffer.alloc(pad, ' ')]);
}
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', encKey, nonce);
cipher.setAAD(Buffer.from(`pw1.${kid}`));
const ct = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
const envelope = `pw1.${kid}.${Buffer.concat([nonce, ct]).toString('base64url')}`;
if (envelope.length > 3072) throw new Error('too long to encrypt');
return envelope;
}crypto/hkdf is in the standard library from Go 1.24.
package e2e
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
)
type Message struct {
Title string `json:"title"`
Body string `json:"body"`
Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url,omitempty"`
}
// Seal encrypts msg for the devices holding keyHex and returns the pw1
// envelope to send as "encrypted".
func Seal(keyHex string, msg Message) (string, error) {
key, err := hex.DecodeString(keyHex)
if err != nil || len(key) != 32 {
return "", errors.New("the key must be 64 hex characters")
}
encKey, err := hkdf.Key(sha256.New, key, nil, "pushward/e2e/v1/enc", 32)
if err != nil {
return "", err
}
kidBytes, err := hkdf.Key(sha256.New, key, nil, "pushward/e2e/v1/kid", 4)
if err != nil {
return "", err
}
kid := hex.EncodeToString(kidBytes)
plaintext, err := json.Marshal(msg)
if err != nil {
return "", err
}
if pad := (64 - len(plaintext)%64) % 64; len(plaintext)+pad <= 2266 {
plaintext = append(plaintext, bytes.Repeat([]byte(" "), pad)...)
}
block, err := aes.NewCipher(encKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce)
sealed := gcm.Seal(nonce, nonce, plaintext, []byte("pw1."+kid))
envelope := "pw1." + kid + "." + base64.RawURLEncoding.EncodeToString(sealed)
if len(envelope) > 3072 {
return "", errors.New("too long to encrypt: shorten the title, subtitle, body or url")
}
return envelope, nil
}WebCrypto works in browsers, Deno, Bun and Node. Only seal in a page when the key stays on the machine of the person it belongs to, such as a browser extension or a local tool; a key shipped to a website's visitors is no secret.
const utf8 = new TextEncoder();
async function hkdf(key, info, length) {
const ikm = await crypto.subtle.importKey('raw', key, 'HKDF', false, ['deriveBits']);
const params = { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: utf8.encode(info) };
return new Uint8Array(await crypto.subtle.deriveBits(params, ikm, length * 8));
}
const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
const base64url = (bytes) =>
btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// Returns the pw1 envelope to send as "encrypted".
export async function seal(keyHex, { title, body, subtitle, url }) {
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) throw new Error('the key must be 64 hex characters');
const key = Uint8Array.from(keyHex.match(/../g), (h) => parseInt(h, 16));
const kid = hex(await hkdf(key, 'pushward/e2e/v1/kid', 4));
const rawKey = await hkdf(key, 'pushward/e2e/v1/enc', 32);
const encKey = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt']);
const msg = { title, body, subtitle: subtitle || undefined, url: url || undefined };
let plaintext = utf8.encode(JSON.stringify(msg));
const pad = (64 - (plaintext.length % 64)) % 64;
if (plaintext.length + pad <= 2266) {
const padded = new Uint8Array(plaintext.length + pad).fill(0x20);
padded.set(plaintext);
plaintext = padded;
}
const nonce = crypto.getRandomValues(new Uint8Array(12));
const params = { name: 'AES-GCM', iv: nonce, additionalData: utf8.encode(`pw1.${kid}`) };
const ct = new Uint8Array(await crypto.subtle.encrypt(params, encKey, plaintext));
const sealed = new Uint8Array(nonce.length + ct.length);
sealed.set(nonce);
sealed.set(ct, nonce.length);
const envelope = `pw1.${kid}.${base64url(sealed)}`;
if (envelope.length > 3072) throw new Error('too long to encrypt');
return envelope;
}Test vectors
/e2e/vectors-v1.json is the file the server,
the apps, the CLI, the MCP server and the Home Assistant integration all test against, byte for byte. Its seal cases give a key, a fixed nonce, the exact plaintext and the envelope sealing must produce,
plus what opening it must return. open_fail lists envelopes that must not open with the given key
(tampered tag, wrong key, swapped Key ID, plaintext that is not a JSON object), and parse_fail lists ones whose shape is already wrong (padding characters, an upper-case Key ID, pw2, over 3072
characters, a last character with unused bits set). To reproduce a seal case, seal its plaintext string exactly as given,
with no JSON building and no padding of your own, using its key and nonce_hex. Most cases are
unpadded, so building the JSON from title and body the way the samples above do matches only padded_to_64 and max_size; for the rest, seal, open and compare.
Limits and errors
An envelope holds at most 3072 characters, which leaves about 2266 bytes for the JSON plaintext: title, subtitle, body and url together, with their keys and quotes. That is well under the plain-text limit of 4096 characters for the body alone, so a long body that worked unencrypted may need trimming. The CLI, the MCP server and Home Assistant refuse such text before sending anything; the Grafana and Unraid plugins shorten it instead, the body first.
The whole push also has to fit Apple's 4 KB payload, with the actions, media and icon URLs and metadata next to the envelope. Plain-text notifications that are too big fail at send time; an encrypted one is refused up front.
| Status | Code | When |
|---|---|---|
400 | notification.invalid | title, subtitle, body or url sent next to encrypted, or an envelope with the wrong shape (the detail starts with encrypted:). |
422 | (schema) | encrypted longer than 3072 characters, or neither encrypted nor title and body. |
422 | notification.encrypted_too_large | With the other fields, the push would exceed 4 KB; for an acknowledged alert, its largest repeat counts.
Shorten the text, the actions or the URLs, or send it with push: false (stored in the inbox
only). |
422 | notification.encryption_unavailable | Sent with an organization key. See Organizations. |
What your devices show
- An iPhone or iPad on PushWard 1.17.0 or later that holds the key decrypts the notification before the banner appears, so the banner, the Lock Screen, the history and search show the real text.
- A device without the key shows the placeholder: Encrypted notification, "End-to-end encrypted. Open PushWard on a device with your encryption key to read it." The history marks it locked and opens it as soon as the key arrives, through iCloud Keychain or Add Existing Key.
- Versions of PushWard older than 1.17.0 show the same placeholder, on every device.
- On the Mac, banners show the placeholder. The notification history, the menu bar and search in the Mac app decrypt.
Organizations
Organization keys cannot send encrypted notifications yet: an organization's
members do not share one key, so nothing could open the envelope on their devices. The request fails with 422 notification.encryption_unavailable. Personal keys of the same people are not
affected. The Grafana plugin (0.10.1 and later) and the Unraid plugin (2026.10.08a and later) then send the
alert without its text.
Rotating or removing the key
Rotate Key in the app creates a new key and keeps the old one under Previous Keys, so notifications sealed with it still open. Then give the new key to every
sender (pushward e2e import --force, which replaces the stored key, the Home Assistant options,
the GitHub secret, the MCP environment, the Grafana and Unraid plugin settings).
A sender you missed keeps sealing with the old key, and your devices keep opening those, so nothing breaks
while you go through the list. Scheduled notifications were sealed when they were scheduled and open the same
way.
Remove Key deletes the keys from all your devices. Notifications sealed with them stay locked until you add the key again. PushWard never had a copy, so a key that is gone everywhere cannot be recovered, and neither can the text sealed with it.
CLI 1.3.x and earlier drop a stored encryption key when pushward auth login or auth logout rewrites the config file. If an older binary runs on the same machine, pushward e2e key-id tells you whether the key is still there.