eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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.
  1. 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.
  1. 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.