| 1 | # π€ The push relay β sharing APNs without sharing the signing key |
| 2 | |
| 3 | Status: **shipped** (`push/`, deployed as `push.elevenmessenger.com`). This is |
| 4 | the design note; the code is the source of truth. |
| 5 | |
| 6 | ## The problem |
| 7 | |
| 8 | Native iOS push (`apns.go`) needs Apple's `.p8` signing key. Every lchat |
| 9 | instance pushes to the **same** Apple app (`com.elevenmessenger.messenger`) via |
| 10 | the **same** team's key, so `deploy/miren.sh` used to copy that `.p8` into |
| 11 | **every** instance's env. Two consequences: |
| 12 | |
| 13 | - **Wide blast radius.** The Apple signing key sat on every box. Leak one |
| 14 | instance's env and you can push to any device registered for the app. |
| 15 | - **A blocker for self-hosting.** You can't let a third party run an instance |
| 16 | that sends native push without handing them your Apple identity. |
| 17 | |
| 18 | ## The shape |
| 19 | |
| 20 | A small central service β `push/`, one static Go binary like `home/` β that is |
| 21 | the **only place the `.p8` lives**. An instance authenticates with a |
| 22 | per-instance bearer token and POSTs the same `{device_token, sandbox, thread, |
| 23 | blob}` it would otherwise send to Apple; the relay signs the provider JWT and |
| 24 | forwards it. Instances never hold signing capability. |
| 25 | |
| 26 | ``` |
| 27 | instance ββ(Bearer instance-token)βββΆ push.elevenmessenger.com ββ(signed JWT)βββΆ Apple βββΆ device |
| 28 | {device_token, sandbox, (holds the .p8; (relays |
| 29 | thread, blob=ciphertext} signs per request) ciphertext) |
| 30 | ``` |
| 31 | |
| 32 | The messenger picks its transport from env (`newAPNsClient`, `apns.go`), relay |
| 33 | first: `LCHAT_PUSH_RELAY_URL` + `LCHAT_PUSH_RELAY_TOKEN` β relay mode; |
| 34 | `LCHAT_APNS_*` β direct mode (legacy, `.p8` on the box); neither β disabled. |
| 35 | Relay-wins lets an instance migrate in two steps β add the relay vars, then drop |
| 36 | the `.p8` later. |
| 37 | |
| 38 | ## Why E2E encryption survives untouched |
| 39 | |
| 40 | The `.p8` is Apple **auth**, not a message-decryption key β the same category as |
| 41 | the admin token in the CLAUDE.md rule. The relay sees a device token, an opaque |
| 42 | `thread` id, a `sandbox` flag, and the **ciphertext** `blob`. That is a *strict |
| 43 | subset of what Apple already sees per notification*. It never sees plaintext, a |
| 44 | thread key, the instance key, or a user's private key. The iOS Notification |
| 45 | Service Extension decrypts the blob on-device with keys from the Keychain β the |
| 46 | relay (like Apple) only ever relays sealed bytes. The one authoritative copy of |
| 47 | the envelope + signer lives in `internal/apns/` so the messenger and the relay |
| 48 | can't drift (a `BuildPayload` byte-stability test pins the NSE contract). |
| 49 | |
| 50 | ## What you're trading away (the honest costs) |
| 51 | |
| 52 | 1. **Metadata concentration.** A central relay is one place where |
| 53 | `{device token β thread β instance β time}` can be correlated across |
| 54 | instances β the same class of concern as `docs/cross-instance-relay.md`. It's |
| 55 | *metadata, not content*, and no more than Apple already sees. Mitigation: the |
| 56 | relay is content-blind and logs only `{instance label, outcome, latency}` β |
| 57 | never the bearer token, device token, `thread`, `blob`, JWT, or `.p8`, and |
| 58 | never its outbound Apple URL (the device token rides in that URL path). It |
| 59 | persists **no push traffic** β the only state is the minted-token table. |
| 60 | |
| 61 | 2. **A single point of failure for iOS push.** The relay is stateful (SQLite + |
| 62 | disk β single instance, like `home/`), so an outage pauses iOS notifications |
| 63 | across the fleet. Accepted because push is **best-effort and off the |
| 64 | message-post path** (`pushNewMessage` fans out in a goroutine β posting never |
| 65 | blocks on push), **Web Push is unaffected** (each instance self-generates its |
| 66 | own VAPID keypair and posts directly to browser push services β no shared |
| 67 | secret, so it never needed a relay), and redeploys are quick. Revisit with an |
| 68 | HA / stateless-auth design only if this bites. |
| 69 | |
| 70 | 3. **A leaked instance token = notification spam** to device tokens the holder |
| 71 | already knows, carrying attacker bytes β but the NSE AES-GCM-decrypts with |
| 72 | keys the attacker lacks, so it injects **no readable content** (E2E paying |
| 73 | off). The ceiling is spam, bounded by per-instance + global rate limits, and |
| 74 | the token is revocable at runtime. The topic is fixed by the relay's env, so a |
| 75 | token can't target a different app. |
| 76 | |
| 77 | ## The `POST /api/push` contract |
| 78 | |
| 79 | - Auth: `Authorization: Bearer <instance-token>` (looked up by `sha256`; miss β |
| 80 | 401). |
| 81 | - Validation order: auth β per-instance rate limit (keyed by instance label, not |
| 82 | IP β instances share platform IPs) β global backstop β body β€ 8 KB (413) β |
| 83 | `device_token` matches `^[0-9a-fA-F]{1,200}$` (400 β it's interpolated into the |
| 84 | Apple URL path, so non-hex could inject). |
| 85 | - Body: `{"device_token": "<hex>", "sandbox": bool, "thread": "<id>", "blob": |
| 86 | "<base64url ciphertext>", "id": <message id>, "at": <created_at seconds>}`. |
| 87 | The relay builds the aps envelope and sets `apns-topic` from its **own** env. |
| 88 | `id`/`at` are optional routing metadata (older instances omit them), forwarded |
| 89 | verbatim so the device's NSE can file the decrypted message into its local |
| 90 | store at push time β still no content, and nothing Apple couldn't already |
| 91 | infer from delivery order and timing. |
| 92 | - Response is a **gone-normalised passthrough**: Apple 410 / `BadDeviceToken` β |
| 93 | HTTP **410** (the instance prunes the token); Apple 2xx β 200; anything else β |
| 94 | 502. Relay-internal errors (401/429/400/413/502) are **never** 410 and never |
| 95 | echo `BadDeviceToken`, so a transient failure can't trigger a false prune. |
| 96 | |
| 97 | ## Admin surface (mint / list / revoke tokens) |
| 98 | |
| 99 | Gated by the relay's `LCHAT_ADMIN_TOKEN` (constant-time hash compare). Tokens are |
| 100 | minted at runtime β rotation needs no redeploy: |
| 101 | |
| 102 | ``` |
| 103 | POST /api/admin/tokens {label} β {id, label, token} (plaintext once) |
| 104 | GET /api/admin/tokens β [{id,label,created_at,last_used_at}] |
| 105 | DELETE /api/admin/tokens/<id> β {ok} |
| 106 | GET /healthz β 200 (no auth, no secrets) |
| 107 | ``` |
| 108 | |
| 109 | Startup fails loud (`log.Fatal`) if the `.p8`/APNs env or admin token is missing |
| 110 | or unparseable β a misconfigured relay is a fleet-wide outage, so it must not |
| 111 | boot silently disabled. An empty token table is fine (mint after first deploy). |
| 112 | |
| 113 | ## Deploy |
| 114 | |
| 115 | The relay runs as the `lchat-push` systemd service on the router box (unix |
| 116 | socket, fronted by the router): build the `push` binary, copy it up, |
| 117 | `systemctl restart lchat-push` β data-safe; the DB and the `.p8` + admin token |
| 118 | env (`/data/push.env`) are never touched by a redeploy. DNS needs an explicit |
| 119 | `push.elevenmessenger.com` A record (no wildcard on that zone, like `home`). |
| 120 | Operator specifics live in `CLAUDE.local.md`. |
| 121 | |