eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– The push relay β€” sharing APNs without sharing the signing key
2
3Status: **shipped** (`push/`, deployed as `push.elevenmessenger.com`). This is
4the design note; the code is the source of truth.
5
6## The problem
7
8Native iOS push (`apns.go`) needs Apple's `.p8` signing key. Every lchat
9instance pushes to the **same** Apple app (`com.elevenmessenger.messenger`) via
10the **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
20A small central service β€” `push/`, one static Go binary like `home/` β€” that is
21the **only place the `.p8` lives**. An instance authenticates with a
22per-instance bearer token and POSTs the same `{device_token, sandbox, thread,
23blob}` it would otherwise send to Apple; the relay signs the provider JWT and
24forwards it. Instances never hold signing capability.
25
26```
27instance ──(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
32The messenger picks its transport from env (`newAPNsClient`, `apns.go`), relay
33first: `LCHAT_PUSH_RELAY_URL` + `LCHAT_PUSH_RELAY_TOKEN` β†’ relay mode;
34`LCHAT_APNS_*` β†’ direct mode (legacy, `.p8` on the box); neither β†’ disabled.
35Relay-wins lets an instance migrate in two steps β€” add the relay vars, then drop
36the `.p8` later.
37
38## Why E2E encryption survives untouched
39
40The `.p8` is Apple **auth**, not a message-decryption key β€” the same category as
41the 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
43subset of what Apple already sees per notification*. It never sees plaintext, a
44thread key, the instance key, or a user's private key. The iOS Notification
45Service Extension decrypts the blob on-device with keys from the Keychain β€” the
46relay (like Apple) only ever relays sealed bytes. The one authoritative copy of
47the envelope + signer lives in `internal/apns/` so the messenger and the relay
48can't drift (a `BuildPayload` byte-stability test pins the NSE contract).
49
50## What you're trading away (the honest costs)
51
521. **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
612. **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
703. **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
99Gated by the relay's `LCHAT_ADMIN_TOKEN` (constant-time hash compare). Tokens are
100minted at runtime β€” rotation needs no redeploy:
101
102```
103POST /api/admin/tokens {label} β†’ {id, label, token} (plaintext once)
104GET /api/admin/tokens β†’ [{id,label,created_at,last_used_at}]
105DELETE /api/admin/tokens/<id> β†’ {ok}
106GET /healthz β†’ 200 (no auth, no secrets)
107```
108
109Startup fails loud (`log.Fatal`) if the `.p8`/APNs env or admin token is missing
110or unparseable β€” a misconfigured relay is a fleet-wide outage, so it must not
111boot silently disabled. An empty token table is fine (mint after first deploy).
112
113## Deploy
114
115The relay runs as the `lchat-push` systemd service on the router box (unix
116socket, 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
118env (`/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`).
120Operator specifics live in `CLAUDE.local.md`.
121