# 🤖 The push relay — sharing APNs without sharing the signing key

Status: **shipped** (`push/`, deployed as `push.elevenmessenger.com`). This is
the design note; the code is the source of truth.

## The problem

Native iOS push (`apns.go`) needs Apple's `.p8` signing key. Every lchat
instance pushes to the **same** Apple app (`com.elevenmessenger.messenger`) via
the **same** team's key, so `deploy/miren.sh` used to copy that `.p8` into
**every** instance's env. Two consequences:

- **Wide blast radius.** The Apple signing key sat on every box. Leak one
  instance's env and you can push to any device registered for the app.
- **A blocker for self-hosting.** You can't let a third party run an instance
  that sends native push without handing them your Apple identity.

## The shape

A small central service — `push/`, one static Go binary like `home/` — that is
the **only place the `.p8` lives**. An instance authenticates with a
per-instance bearer token and POSTs the same `{device_token, sandbox, thread,
blob}` it would otherwise send to Apple; the relay signs the provider JWT and
forwards it. Instances never hold signing capability.

```
instance ──(Bearer instance-token)──▶ push.elevenmessenger.com ──(signed JWT)──▶ Apple ──▶ device
              {device_token, sandbox,        (holds the .p8;                      (relays
               thread, blob=ciphertext}       signs per request)                  ciphertext)
```

The messenger picks its transport from env (`newAPNsClient`, `apns.go`), relay
first: `LCHAT_PUSH_RELAY_URL` + `LCHAT_PUSH_RELAY_TOKEN` → relay mode;
`LCHAT_APNS_*` → direct mode (legacy, `.p8` on the box); neither → disabled.
Relay-wins lets an instance migrate in two steps — add the relay vars, then drop
the `.p8` later.

## Why E2E encryption survives untouched

The `.p8` is Apple **auth**, not a message-decryption key — the same category as
the admin token in the CLAUDE.md rule. The relay sees a device token, an opaque
`thread` id, a `sandbox` flag, and the **ciphertext** `blob`. That is a *strict
subset of what Apple already sees per notification*. It never sees plaintext, a
thread key, the instance key, or a user's private key. The iOS Notification
Service Extension decrypts the blob on-device with keys from the Keychain — the
relay (like Apple) only ever relays sealed bytes. The one authoritative copy of
the envelope + signer lives in `internal/apns/` so the messenger and the relay
can't drift (a `BuildPayload` byte-stability test pins the NSE contract).

## What you're trading away (the honest costs)

1. **Metadata concentration.** A central relay is one place where
   `{device token ↔ thread ↔ instance ↔ time}` can be correlated across
   instances — the same class of concern as `docs/cross-instance-relay.md`. It's
   *metadata, not content*, and no more than Apple already sees. Mitigation: the
   relay is content-blind and logs only `{instance label, outcome, latency}` —
   never the bearer token, device token, `thread`, `blob`, JWT, or `.p8`, and
   never its outbound Apple URL (the device token rides in that URL path). It
   persists **no push traffic** — the only state is the minted-token table.

2. **A single point of failure for iOS push.** The relay is stateful (SQLite +
   disk → single instance, like `home/`), so an outage pauses iOS notifications
   across the fleet. Accepted because push is **best-effort and off the
   message-post path** (`pushNewMessage` fans out in a goroutine — posting never
   blocks on push), **Web Push is unaffected** (each instance self-generates its
   own VAPID keypair and posts directly to browser push services — no shared
   secret, so it never needed a relay), and redeploys are quick. Revisit with an
   HA / stateless-auth design only if this bites.

3. **A leaked instance token = notification spam** to device tokens the holder
   already knows, carrying attacker bytes — but the NSE AES-GCM-decrypts with
   keys the attacker lacks, so it injects **no readable content** (E2E paying
   off). The ceiling is spam, bounded by per-instance + global rate limits, and
   the token is revocable at runtime. The topic is fixed by the relay's env, so a
   token can't target a different app.

## The `POST /api/push` contract

- Auth: `Authorization: Bearer <instance-token>` (looked up by `sha256`; miss →
  401).
- Validation order: auth → per-instance rate limit (keyed by instance label, not
  IP — instances share platform IPs) → global backstop → body ≤ 8 KB (413) →
  `device_token` matches `^[0-9a-fA-F]{1,200}$` (400 — it's interpolated into the
  Apple URL path, so non-hex could inject).
- Body: `{"device_token": "<hex>", "sandbox": bool, "thread": "<id>", "blob":
  "<base64url ciphertext>", "id": <message id>, "at": <created_at seconds>}`.
  The relay builds the aps envelope and sets `apns-topic` from its **own** env.
  `id`/`at` are optional routing metadata (older instances omit them), forwarded
  verbatim so the device's NSE can file the decrypted message into its local
  store at push time — still no content, and nothing Apple couldn't already
  infer from delivery order and timing.
- Response is a **gone-normalised passthrough**: Apple 410 / `BadDeviceToken` →
  HTTP **410** (the instance prunes the token); Apple 2xx → 200; anything else →
  502. Relay-internal errors (401/429/400/413/502) are **never** 410 and never
  echo `BadDeviceToken`, so a transient failure can't trigger a false prune.

## Admin surface (mint / list / revoke tokens)

Gated by the relay's `LCHAT_ADMIN_TOKEN` (constant-time hash compare). Tokens are
minted at runtime — rotation needs no redeploy:

```
POST   /api/admin/tokens   {label}  → {id, label, token}   (plaintext once)
GET    /api/admin/tokens            → [{id,label,created_at,last_used_at}]
DELETE /api/admin/tokens/<id>       → {ok}
GET    /healthz                     → 200 (no auth, no secrets)
```

Startup fails loud (`log.Fatal`) if the `.p8`/APNs env or admin token is missing
or unparseable — a misconfigured relay is a fleet-wide outage, so it must not
boot silently disabled. An empty token table is fine (mint after first deploy).

## Deploy

The relay runs as the `lchat-push` systemd service on the router box (unix
socket, fronted by the router): build the `push` binary, copy it up,
`systemctl restart lchat-push` — data-safe; the DB and the `.p8` + admin token
env (`/data/push.env`) are never touched by a redeploy. DNS needs an explicit
`push.elevenmessenger.com` A record (no wildcard on that zone, like `home`).
Operator specifics live in `CLAUDE.local.md`.
