eleven / messenger Public

push: central APNs relay (push.elevenmessenger.com) (#171)

* server: route native push through internal/apns + add relay mode

Extract the APNs provider client (key parse, JWT signing, aps envelope, gone
classification) into internal/apns so the messenger and the new relay share
exactly one copy — the envelope is the E2E contract the iOS NSE mirrors, so a
byte-stability test pins it.

newAPNsClient now picks its transport from env, relay-first:
LCHAT_PUSH_RELAY_URL + _TOKEN -> relaySender (POSTs to push.elevenmessenger.com,
holds no key; the relay's normalised 410 maps back to apns.ErrGone so
prune-on-gone is unchanged); LCHAT_APNS_* -> direct mode (today); neither ->
disabled. apnsFanOut is untouched beyond the sentinel rename.

Also tighten APNs device-token validation to hex (shared ValidDeviceToken,
enforced in Send too): the token is interpolated into the Apple URL path, so
non-hex could inject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* push: add the central APNs relay service + deploy scaffolding

push/ is a small, content-blind APNs signer (sibling of home/): the Apple .p8
lives only here. Instances authenticate with a per-instance bearer token and
POST {device_token, sandbox, thread, blob}; the relay signs and forwards to
Apple. It sees a strict subset of what Apple already sees — never plaintext,
never a decryption key.

- POST /api/push: bearer auth -> per-instance + global rate limit -> 8KB cap ->
  hex device-token check; gone-normalised passthrough (Apple 410/BadDeviceToken
  -> bare 410 so the instance prunes; internal errors never 410).
- DB-backed admin surface (LCHAT_ADMIN_TOKEN) to mint/list/revoke tokens at
  runtime; tokens stored hashed. Fails loud at startup on bad .p8/admin config.
- Content-blind logging (label + outcome + latency only). Port 1749.
- deploy-push.sh mirrors deploy-home.sh (data-safe, disk-preserving);
  deploy-instance.sh now forwards LCHAT_PUSH_RELAY_URL/_TOKEN for migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document the push relay (design + trade-offs)

docs/push-relay.md explains the shape (relay holds the .p8, instances hold a
bearer token), why E2E survives (Apple auth != decryption key), and the three
honest costs: metadata concentration, a single point of failure for iOS push,
and leaked-token spam capped by the E2E blob. A short CLAUDE.md note anchors it
under the load-bearing-encryption rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 6c73c3b5560686534498d0ba41422f8b2169c09d parent 7f86f7a
14 files changed, +1347 −116
  • CLAUDE.md +10 −0
  • apns.go +87 −111
  • apns_relay_test.go +42 −0
  • deploy-instance.sh +8 −3
  • deploy-push.sh +63 −0
  • docs/push-relay.md +115 −0
  • internal/apns/apns.go +199 −0
  • internal/apns/apns_test.go +119 −0
  • main.go +4 −2
  • push/Dockerfile +21 −0
  • push/app.toml +34 −0
  • push/db.go +144 −0
  • push/main.go +312 −0
  • push/push_test.go +189 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 212e9bf..968cff3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,6 +43,16 @@ Save-passkey card — so they can't be E2E-encrypted like profile/group photos.
Keep branding to this narrow set; anything that is *user* content still gets
encrypted client-side.
+Native iOS push goes through a central APNs relay (`push.elevenmessenger.com`,
+`push/`) so the Apple `.p8` signing key lives in exactly one place instead of on
+every instance. Like the admin token, the `.p8` is *auth*, not a decryption key:
+the relay forwards the same ciphertext blob Apple already sees (device token +
+opaque thread id + sealed bytes), never plaintext and never a usable key —
+instances authenticate to it with a per-instance bearer token. The one copy of
+the signer + aps envelope lives in `internal/apns/` (shared by the messenger and
+the relay so they can't drift). Web Push is unaffected (per-instance VAPID, no
+shared secret). See `docs/push-relay.md`.
+
## Architecture
Single static Go binary; the frontend and a pure-Go SQLite engine are compiled
diff --git a/apns.go b/apns.go
index 1f7141e..af2b9ea 100644
--- a/apns.go
+++ b/apns.go
@@ -2,13 +2,7 @@ package main
import (
"bytes"
- "crypto/ecdsa"
- "crypto/rand"
- "crypto/sha256"
- "crypto/x509"
- "encoding/base64"
"encoding/json"
- "encoding/pem"
"errors"
"fmt"
"io"
@@ -18,6 +12,8 @@ import (
"strings"
"sync"
"time"
+
+ "lchat/internal/apns"
)
// APNs support for the native iOS app ("Eleven"). Mirrors the Web Push path:
@@ -26,13 +22,21 @@ import (
// Keychain, so the E2EE rule holds end to end (Apple relays ciphertext).
//
// Configured entirely by env; with no key configured the whole transport is a
-// no-op, so instances without an APNs key behave exactly as before.
+// no-op, so instances without an APNs key behave exactly as before. Two
+// transports (see newAPNsClient): direct mode holds the .p8 and calls Apple
+// itself; relay mode holds no key and POSTs to push.elevenmessenger.com, which
+// signs on its behalf — the p8 then lives only on the relay.
//
+// Direct mode:
// LCHAT_APNS_KEY — the .p8 auth key (PEM), or base64 of it
// LCHAT_APNS_KEY_ID — the 10-char key id
// LCHAT_APNS_TEAM_ID — the Apple team id
// LCHAT_APNS_TOPIC — the app's bundle id
//
+// Relay mode (takes precedence when both are set):
+// LCHAT_PUSH_RELAY_URL — the relay base, e.g. https://push.elevenmessenger.com
+// LCHAT_PUSH_RELAY_TOKEN — this instance's per-instance bearer token
+//
// Tokens are stored per device-session token (like Web Push subscriptions), so
// the same sender/active-user suppression rules apply. A token registered from
// a debug build sets sandbox=1 and is delivered via Apple's sandbox gateway.
@@ -91,126 +95,98 @@ func (db *DB) deleteAPNsTokensForToken(token string) error {
return err
}
-// apnsClient signs and sends. nil when unconfigured (transport disabled).
-type apnsClient struct {
- key *ecdsa.PrivateKey
- keyID string
- teamID string
- topic string
- http *http.Client
-
- mu sync.Mutex
- jwt string
- jwtAge time.Time
+// apnsSender abstracts "deliver this ciphertext blob to this device". Two
+// implementations, chosen from env by newAPNsClient: directSender signs with a
+// local .p8 and calls Apple; relaySender holds no key and POSTs to the central
+// push relay. nil means APNs is disabled (Web Push only), exactly as before.
+type apnsSender interface {
+ send(deviceToken string, sandbox bool, threadID, blob string) error
}
-// newAPNsClient reads the env config; returns nil (and logs why) when absent.
-func newAPNsClient() *apnsClient {
- raw := os.Getenv("LCHAT_APNS_KEY")
- keyID := os.Getenv("LCHAT_APNS_KEY_ID")
- teamID := os.Getenv("LCHAT_APNS_TEAM_ID")
- topic := os.Getenv("LCHAT_APNS_TOPIC")
- if raw == "" || keyID == "" || teamID == "" || topic == "" {
- return nil // not configured — Web Push only
- }
- if !strings.Contains(raw, "BEGIN") { // base64-packed PEM (easier to pass as env)
- if dec, err := base64.StdEncoding.DecodeString(raw); err == nil {
- raw = string(dec)
- }
- }
- block, _ := pem.Decode([]byte(raw))
- if block == nil {
- log.Println("apns: LCHAT_APNS_KEY is not PEM; APNs disabled")
- return nil
- }
- parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
- if err != nil {
- log.Println("apns: bad key:", err, "— APNs disabled")
- return nil
- }
- ec, ok := parsed.(*ecdsa.PrivateKey)
- if !ok {
- log.Println("apns: key is not ECDSA; APNs disabled")
- return nil
- }
- log.Printf("apns: configured (team %s, key %s, topic %s)", teamID, keyID, topic)
- return &apnsClient{
- key: ec, keyID: keyID, teamID: teamID, topic: topic,
- http: &http.Client{Timeout: 15 * time.Second},
- }
-}
+// directSender is today's transport: the .p8 lives in this instance's env and
+// the shared client (internal/apns) signs + calls Apple directly.
+type directSender struct{ c *apns.Client }
-// bearer returns a cached provider JWT, re-signed when older than ~50 minutes
-// (Apple accepts 20–60; a stale token gets ExpiredProviderToken).
-func (c *apnsClient) bearer() (string, error) {
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.jwt != "" && time.Since(c.jwtAge) < 50*time.Minute {
- return c.jwt, nil
- }
- b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
- header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": c.keyID})
- claims, _ := json.Marshal(map[string]any{"iss": c.teamID, "iat": time.Now().Unix()})
- signing := b64(header) + "." + b64(claims)
- digest := sha256.Sum256([]byte(signing))
- r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:])
- if err != nil {
- return "", err
- }
- // JOSE wants the raw fixed-width r||s, not ASN.1.
- sig := make([]byte, 64)
- r.FillBytes(sig[:32])
- s.FillBytes(sig[32:])
- c.jwt = signing + "." + b64(sig)
- c.jwtAge = time.Now()
- return c.jwt, nil
+func (d directSender) send(deviceToken string, sandbox bool, threadID, blob string) error {
+ return d.c.Send(deviceToken, sandbox, threadID, blob)
}
-var errAPNsGone = errors.New("apns: token gone")
+// relaySender forwards to push.elevenmessenger.com. It posts the same
+// {device_token, sandbox, thread, blob} the relay needs — all opaque, no key —
+// authenticated with this instance's per-instance bearer token. The relay
+// normalises Apple's "token gone" to HTTP 410, which we map back to
+// apns.ErrGone so apnsFanOut's existing prune-on-gone path is untouched. Any
+// other non-2xx (relay auth/rate-limit/error) is a plain error and NEVER a
+// prune signal.
+type relaySender struct {
+ url string // full endpoint, e.g. https://push.elevenmessenger.com/api/push
+ token string
+ http *http.Client
+}
-// send posts one alert to one device. mutable-content lets the app's
-// notification extension decrypt and rewrite the placeholder text.
-func (c *apnsClient) send(deviceToken string, sandbox bool, threadID, blob string) error {
- payload, _ := json.Marshal(map[string]any{
- "aps": map[string]any{
- "alert": map[string]string{"title": "New message"},
- "mutable-content": 1,
- "sound": "default",
- "thread-id": threadID,
- },
- // Same keys as the Web Push payload (sw.js) — the NSE mirrors it.
- "thread": threadID,
- "blob": blob,
+func (r relaySender) send(deviceToken string, sandbox bool, threadID, blob string) error {
+ body, _ := json.Marshal(map[string]any{
+ "device_token": deviceToken, "sandbox": sandbox, "thread": threadID, "blob": blob,
})
- host := "https://api.push.apple.com"
- if sandbox {
- host = "https://api.sandbox.push.apple.com"
- }
- req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(payload))
+ req, err := http.NewRequest("POST", r.url, bytes.NewReader(body))
if err != nil {
return err
}
- token, err := c.bearer()
- if err != nil {
- return err
- }
- req.Header.Set("authorization", "bearer "+token)
- req.Header.Set("apns-topic", c.topic)
- req.Header.Set("apns-push-type", "alert")
- req.Header.Set("apns-priority", "10")
- resp, err := c.http.Do(req)
+ req.Header.Set("authorization", "Bearer "+r.token)
+ req.Header.Set("content-type", "application/json")
+ resp, err := r.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
- if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512)) // drain for reuse
+ switch {
+ case resp.StatusCode >= 200 && resp.StatusCode < 300:
return nil
+ case resp.StatusCode == http.StatusGone:
+ return apns.ErrGone
+ default:
+ return fmt.Errorf("apns relay: status %d", resp.StatusCode)
}
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
- if resp.StatusCode == http.StatusGone || bytes.Contains(body, []byte("BadDeviceToken")) {
- return errAPNsGone
+}
+
+// newAPNsClient picks the APNs transport from env, relay first:
+//
+// LCHAT_PUSH_RELAY_URL + LCHAT_PUSH_RELAY_TOKEN → relay mode (no p8 here)
+// LCHAT_APNS_KEY + _KEY_ID + _TEAM_ID + _TOPIC → direct mode (p8 in this env)
+// neither → disabled (Web Push only)
+//
+// Relay-wins lets an instance migrate in two safe steps: set the relay vars (it
+// flips to relay while any leftover p8 sits unused), then drop the p8 later.
+func newAPNsClient() apnsSender {
+ if url := os.Getenv("LCHAT_PUSH_RELAY_URL"); url != "" {
+ if tok := os.Getenv("LCHAT_PUSH_RELAY_TOKEN"); tok != "" {
+ log.Printf("apns: relay mode via %s", url)
+ return relaySender{
+ url: strings.TrimRight(url, "/") + "/api/push",
+ token: tok,
+ http: &http.Client{Timeout: 20 * time.Second},
+ }
+ }
+ log.Println("apns: LCHAT_PUSH_RELAY_URL set without LCHAT_PUSH_RELAY_TOKEN — ignoring")
+ }
+ c, err := apns.New(
+ os.Getenv("LCHAT_APNS_KEY"),
+ os.Getenv("LCHAT_APNS_KEY_ID"),
+ os.Getenv("LCHAT_APNS_TEAM_ID"),
+ os.Getenv("LCHAT_APNS_TOPIC"),
+ )
+ if err != nil {
+ // Incomplete config = APNs off (Web Push only), as before. Only shout
+ // when SOME key material was present but wrong, to aid debugging.
+ if os.Getenv("LCHAT_APNS_KEY") != "" {
+ log.Println("apns:", err, "— APNs disabled")
+ }
+ return nil
}
- return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body)
+ log.Printf("apns: configured (team %s, key %s, topic %s)",
+ os.Getenv("LCHAT_APNS_TEAM_ID"), os.Getenv("LCHAT_APNS_KEY_ID"), c.Topic())
+ return directSender{c: c}
}
// apnsFanOut applies the selectRecipients rules to APNs tokens (keep the filter
@@ -262,7 +238,7 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool,
mu.Lock()
sent++
mu.Unlock()
- case errors.Is(err, errAPNsGone):
+ case errors.Is(err, apns.ErrGone):
_ = s.db.deleteAPNsToken(t.APNsToken)
default:
log.Println("apns:", err)
diff --git a/apns_relay_test.go b/apns_relay_test.go
new file mode 100644
index 0000000..a37b8b7
--- /dev/null
+++ b/apns_relay_test.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "lchat/internal/apns"
+)
+
+// relaySender must map the relay's normalised 410 back to apns.ErrGone so
+// apnsFanOut prunes the dead token — and must NOT treat any other non-2xx as
+// gone (that would prune a live token on a transient relay error).
+func TestRelaySenderClassifies(t *testing.T) {
+ var status int
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer tok" {
+ t.Errorf("missing bearer: %q", got)
+ }
+ w.WriteHeader(status)
+ }))
+ defer stub.Close()
+ rs := relaySender{url: stub.URL + "/api/push", token: "tok", http: http.DefaultClient}
+
+ status = http.StatusOK
+ if err := rs.send("aabbccdd", false, "th", "blob"); err != nil {
+ t.Errorf("200: want nil, got %v", err)
+ }
+ status = http.StatusGone
+ if err := rs.send("aabbccdd", false, "th", "blob"); !errors.Is(err, apns.ErrGone) {
+ t.Errorf("410: want ErrGone, got %v", err)
+ }
+ status = http.StatusTooManyRequests
+ if err := rs.send("aabbccdd", false, "th", "blob"); err == nil || errors.Is(err, apns.ErrGone) {
+ t.Errorf("429: want a non-gone error, got %v", err)
+ }
+ status = http.StatusBadGateway
+ if err := rs.send("aabbccdd", false, "th", "blob"); err == nil || errors.Is(err, apns.ErrGone) {
+ t.Errorf("502: want a non-gone error, got %v", err)
+ }
+}
diff --git a/deploy-instance.sh b/deploy-instance.sh
index 549381b..bb6b273 100755
--- a/deploy-instance.sh
+++ b/deploy-instance.sh
@@ -35,10 +35,15 @@ sed -e "s/^name = \"lchat\"\$/name = \"$INSTANCE\"/" \
"$REPO/.miren/app.toml" > "$TMP/.miren/app.toml"
echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)"
-# APNs for the native app: forward the LCHAT_APNS_* vars when the caller has
-# them set (the transport is a no-op on instances deployed without them).
+# APNs for the native app: forward whichever transport the caller configured.
+# Relay mode (LCHAT_PUSH_RELAY_URL + _TOKEN) points the instance at
+# push.elevenmessenger.com and needs no .p8 here; direct mode forwards the raw
+# LCHAT_APNS_* signing key (legacy — the p8 lives on the box). The app prefers
+# relay when both are set, so an instance can migrate in two steps. All are
+# no-ops when unset.
EXTRA=()
-for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC; do
+for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC \
+ LCHAT_PUSH_RELAY_URL LCHAT_PUSH_RELAY_TOKEN; do
[ -n "${!v:-}" ] && EXTRA+=(-e "$v=${!v}")
done
miren deploy -d "$TMP" -C "$CLUSTER" -f \
diff --git a/deploy-push.sh b/deploy-push.sh
new file mode 100755
index 0000000..23abec5
--- /dev/null
+++ b/deploy-push.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Deploy the push relay (push/ — the central APNs signer) as its own Miren app.
+# Mirrors deploy-home.sh: templates a throwaway app.toml in a temp dir so git
+# stays clean; a plain redeploy PRESERVES the disk (which holds the minted
+# per-instance tokens — never delete the app to update it).
+#
+# The Apple .p8 signing key lives ONLY on this app. It is read from the caller's
+# env (LCHAT_APNS_KEY + _KEY_ID / _TEAM_ID / _TOPIC — same names deploy-instance.sh
+# uses) and passed with `-e` (Miren doesn't inject `-s` sensitive vars into the
+# container). Pass LCHAT_APNS_KEY base64-packed; the relay decodes it.
+#
+# Usage: ./deploy-push.sh <instance> <hostname> [admin-token]
+# ./deploy-push.sh push push.elevenmessenger.com
+#
+# Reuse the admin token on redeploy (omitting it mints a new one, which would
+# invalidate the saved token). Recover it with:
+# miren env get LCHAT_ADMIN_TOKEN -u -a push -C <cluster>
+set -euo pipefail
+
+INSTANCE="${1:?usage: deploy-push.sh <instance> <hostname> [admin-token]}"
+HOST="${2:?usage: deploy-push.sh <instance> <hostname> [admin-token]}"
+TOKEN="${3:-$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')}"
+CLUSTER="${MIREN_CLUSTER:-luanchat}"
+
+for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC; do
+ [ -n "${!v:-}" ] || { echo "error: $v must be set (the .p8 signing config lives only on the relay)" >&2; exit 1; }
+done
+
+REPO="$(cd "$(dirname "$0")" && pwd)"
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+# Build context = the source, with instance-specific app + disk name. Trim the
+# upload context (same reasoning as deploy-home.sh): drop the native ios/ build
+# output (~700MB), node_modules, VCS/editor/agent dirs, the local binary, and
+# *.db — the local SQLite holding data, which must never leave the machine.
+cp -R "$REPO"/. "$TMP"/
+rm -rf "$TMP/.git" "$TMP/ios" "$TMP/.claude" "$TMP/lchat" \
+ "$TMP"/*.db "$TMP"/*.db-wal "$TMP"/*.db-shm
+find "$TMP" -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
+cp "$REPO/push/Dockerfile" "$TMP/Dockerfile.push"
+sed -e "s/^name = \"push\"\$/name = \"$INSTANCE\"/" \
+ -e "s/^name = \"push-data\"\$/name = \"${INSTANCE}-data\"/" \
+ "$REPO/push/app.toml" > "$TMP/.miren/app.toml"
+
+echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)"
+miren deploy -d "$TMP" -C "$CLUSTER" -f \
+ -e LCHAT_PUSH_DB=/data/push.db \
+ -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
+ -e "LCHAT_APNS_KEY=$LCHAT_APNS_KEY" \
+ -e "LCHAT_APNS_KEY_ID=$LCHAT_APNS_KEY_ID" \
+ -e "LCHAT_APNS_TEAM_ID=$LCHAT_APNS_TEAM_ID" \
+ -e "LCHAT_APNS_TOPIC=$LCHAT_APNS_TOPIC"
+miren route set "$HOST" "$INSTANCE" -C "$CLUSTER"
+
+echo
+echo "✅ '$INSTANCE' deployed and routed to $HOST"
+echo " Recover the admin token: miren env get LCHAT_ADMIN_TOKEN -u -a $INSTANCE -C $CLUSTER"
+echo " Health check: https://$HOST/healthz"
+echo " Mint a per-instance token (store it in the instance's 1Password item):"
+echo " TOK=\$(miren env get LCHAT_ADMIN_TOKEN -u -a $INSTANCE -C $CLUSTER)"
+echo " curl -sX POST https://$HOST/api/admin/tokens -H \"Authorization: Bearer \$TOK\" -d '{\"label\":\"<host>\"}'"
+echo " Last step (if needed): add DNS $HOST A -> <server IP> (TLS auto-provisions after)."
diff --git a/docs/push-relay.md b/docs/push-relay.md
new file mode 100644
index 0000000..37c0f6b
--- /dev/null
+++ b/docs/push-relay.md
@@ -0,0 +1,115 @@
+# 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-instance.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>"}`. The relay builds the aps envelope and sets
+ `apns-topic` from its **own** env.
+- 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
+
+`./deploy-push.sh push push.elevenmessenger.com` (mirrors `deploy-home.sh`;
+data-safe redeploys preserve the disk — never delete the app to update it). The
+`.p8` + admin token are read from the caller's env and passed with `-e`. DNS
+needs an explicit `push.elevenmessenger.com` A record (no wildcard on that zone,
+like `home`). Operator specifics live in `CLAUDE.local.md`.
diff --git a/internal/apns/apns.go b/internal/apns/apns.go
new file mode 100644
index 0000000..eab833b
--- /dev/null
+++ b/internal/apns/apns.go
@@ -0,0 +1,199 @@
+// Package apns holds the one authoritative APNs provider client: it parses the
+// Apple .p8 auth key, signs the ES256 provider JWT, builds the alert envelope,
+// and posts to Apple. It exists as its own importable package (not package main)
+// so BOTH the messenger (direct mode, apns.go) and the push relay (push/) share
+// exactly one copy.
+//
+// The aps envelope BuildPayload produces is a load-bearing E2E contract: the
+// iOS Notification Service Extension mirrors it field-for-field to decrypt the
+// ciphertext blob on-device. Apple only ever relays that ciphertext — the p8 is
+// Apple *auth*, never a message-decryption key — so routing it through a relay
+// does not weaken end-to-end encryption.
+package apns
+
+import (
+ "bytes"
+ "crypto/ecdsa"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// ErrGone means Apple reported the device token is dead (HTTP 410 or a
+// BadDeviceToken reason). Callers should prune the token.
+var ErrGone = errors.New("apns: token gone")
+
+// ParseKey turns the .p8 auth key — PEM, or base64 of the PEM (easier to pass
+// as an env var) — into an ECDSA private key.
+func ParseKey(raw string) (*ecdsa.PrivateKey, error) {
+ if !strings.Contains(raw, "BEGIN") { // base64-packed PEM
+ if dec, err := base64.StdEncoding.DecodeString(raw); err == nil {
+ raw = string(dec)
+ }
+ }
+ block, _ := pem.Decode([]byte(raw))
+ if block == nil {
+ return nil, errors.New("apns: key is not PEM")
+ }
+ parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("apns: bad key: %w", err)
+ }
+ ec, ok := parsed.(*ecdsa.PrivateKey)
+ if !ok {
+ return nil, errors.New("apns: key is not ECDSA")
+ }
+ return ec, nil
+}
+
+// ValidDeviceToken reports whether t looks like an APNs device token: hex,
+// 1..200 chars. Rejecting non-hex is load-bearing, not cosmetic: the token is
+// interpolated into the request URL path to Apple (/3/device/<token>), so a
+// '/', '.', '?', or CR/LF could rewrite that path or smuggle a header. APNs
+// tokens are hex, so this is lossless. Send enforces it; callers should too, at
+// the point they accept a token, for a clearer error.
+func ValidDeviceToken(t string) bool {
+ if t == "" || len(t) > 200 {
+ return false
+ }
+ for i := 0; i < len(t); i++ {
+ c := t[i]
+ if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
+ return false
+ }
+ }
+ return true
+}
+
+// Client signs and sends. Safe for concurrent use.
+type Client struct {
+ key *ecdsa.PrivateKey
+ keyID string
+ teamID string
+ topic string
+ http *http.Client
+
+ // APNs gateways, overridable in tests so a stub can stand in for Apple.
+ prodHost string
+ sandboxHost string
+
+ mu sync.Mutex
+ jwt string
+ jwtAge time.Time
+}
+
+// New builds a Client from the raw key material + identifiers. It returns an
+// error (never a silent nil) so each caller decides whether APNs is optional
+// (the messenger falls back to Web Push) or fatal (the relay exits).
+func New(rawKey, keyID, teamID, topic string) (*Client, error) {
+ if rawKey == "" || keyID == "" || teamID == "" || topic == "" {
+ return nil, errors.New("apns: incomplete config (need key, key id, team id, topic)")
+ }
+ key, err := ParseKey(rawKey)
+ if err != nil {
+ return nil, err
+ }
+ return &Client{
+ key: key, keyID: keyID, teamID: teamID, topic: topic,
+ http: &http.Client{Timeout: 15 * time.Second},
+ prodHost: "https://api.push.apple.com",
+ sandboxHost: "https://api.sandbox.push.apple.com",
+ }, nil
+}
+
+// Topic is the app bundle id this client signs for.
+func (c *Client) Topic() string { return c.topic }
+
+// SetHosts overrides the APNs gateways. Tests only — it lets an httptest server
+// stand in for Apple.
+func (c *Client) SetHosts(prod, sandbox string) { c.prodHost, c.sandboxHost = prod, sandbox }
+
+// bearer returns a cached provider JWT, re-signed when older than ~50 minutes
+// (Apple accepts 20–60; a stale token gets ExpiredProviderToken).
+func (c *Client) bearer() (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.jwt != "" && time.Since(c.jwtAge) < 50*time.Minute {
+ return c.jwt, nil
+ }
+ b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
+ header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": c.keyID})
+ claims, _ := json.Marshal(map[string]any{"iss": c.teamID, "iat": time.Now().Unix()})
+ signing := b64(header) + "." + b64(claims)
+ digest := sha256.Sum256([]byte(signing))
+ r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:])
+ if err != nil {
+ return "", err
+ }
+ // JOSE wants the raw fixed-width r||s, not ASN.1.
+ sig := make([]byte, 64)
+ r.FillBytes(sig[:32])
+ s.FillBytes(sig[32:])
+ c.jwt = signing + "." + b64(sig)
+ c.jwtAge = time.Now()
+ return c.jwt, nil
+}
+
+// BuildPayload builds the aps envelope. Exported so it can be pinned by a test
+// (the NSE mirrors these exact keys — mutable-content lets the app decrypt and
+// rewrite the placeholder title; thread/blob mirror the Web Push payload).
+func BuildPayload(threadID, blob string) []byte {
+ payload, _ := json.Marshal(map[string]any{
+ "aps": map[string]any{
+ "alert": map[string]string{"title": "New message"},
+ "mutable-content": 1,
+ "sound": "default",
+ "thread-id": threadID,
+ },
+ "thread": threadID,
+ "blob": blob,
+ })
+ return payload
+}
+
+// Send posts one alert to one device, returning ErrGone when Apple says the
+// token is dead so the caller can prune it.
+func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string) error {
+ if !ValidDeviceToken(deviceToken) {
+ return errors.New("apns: invalid device token") // never ErrGone — not a prune signal
+ }
+ host := c.prodHost
+ if sandbox {
+ host = c.sandboxHost
+ }
+ req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(BuildPayload(threadID, blob)))
+ if err != nil {
+ return err
+ }
+ token, err := c.bearer()
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "bearer "+token)
+ req.Header.Set("apns-topic", c.topic)
+ req.Header.Set("apns-push-type", "alert")
+ req.Header.Set("apns-priority", "10")
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
+ if resp.StatusCode == http.StatusGone || bytes.Contains(body, []byte("BadDeviceToken")) {
+ return ErrGone
+ }
+ return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body)
+}
diff --git a/internal/apns/apns_test.go b/internal/apns/apns_test.go
new file mode 100644
index 0000000..9c53fd2
--- /dev/null
+++ b/internal/apns/apns_test.go
@@ -0,0 +1,119 @@
+package apns
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// testKeyPEM returns a throwaway P-256 key as PKCS8 PEM, the shape apns.New
+// expects for a real .p8.
+func testKeyPEM(t *testing.T) string {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ der, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
+}
+
+func newTestClient(t *testing.T, stubURL string) *Client {
+ t.Helper()
+ c, err := New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetHosts(stubURL, stubURL)
+ return c
+}
+
+func TestValidDeviceToken(t *testing.T) {
+ ok := []string{"a", "AABBCCDD", strings.Repeat("f", 200)}
+ for _, s := range ok {
+ if !ValidDeviceToken(s) {
+ t.Errorf("expected %q valid", s)
+ }
+ }
+ bad := []string{"", "xyz", "aa/bb", "aa..bb", "aa\r\nHost:", strings.Repeat("a", 201), "zz"}
+ for _, s := range bad {
+ if ValidDeviceToken(s) {
+ t.Errorf("expected %q invalid", s)
+ }
+ }
+}
+
+// TestBuildPayloadStable pins the aps envelope byte-for-byte. The iOS
+// Notification Service Extension mirrors these exact keys; if this changes,
+// on-device decryption breaks — so a drift must be a deliberate, reviewed edit.
+func TestBuildPayloadStable(t *testing.T) {
+ got := string(BuildPayload("t1", "ciphertext-blob"))
+ want := `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"blob":"ciphertext-blob","thread":"t1"}`
+ if got != want {
+ t.Errorf("envelope drift:\n got: %s\nwant: %s", got, want)
+ }
+}
+
+func TestSendClassifiesResponses(t *testing.T) {
+ const (
+ live = "aaaaaaaa"
+ dead = "deadbeef"
+ bad = "badd0000"
+ err5 = "cccccccc"
+ )
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.Contains(r.URL.Path, dead):
+ w.WriteHeader(http.StatusGone)
+ case strings.Contains(r.URL.Path, bad):
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"reason":"BadDeviceToken"}`))
+ case strings.Contains(r.URL.Path, err5):
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte(`{"reason":"InternalServerError"}`))
+ default:
+ w.WriteHeader(http.StatusOK)
+ }
+ }))
+ defer stub.Close()
+ c := newTestClient(t, stub.URL)
+
+ if err := c.Send(live, false, "th", "blob"); err != nil {
+ t.Errorf("live token: want nil, got %v", err)
+ }
+ if err := c.Send(dead, false, "th", "blob"); !errors.Is(err, ErrGone) {
+ t.Errorf("410: want ErrGone, got %v", err)
+ }
+ if err := c.Send(bad, false, "th", "blob"); !errors.Is(err, ErrGone) {
+ t.Errorf("BadDeviceToken: want ErrGone, got %v", err)
+ }
+ if err := c.Send(err5, false, "th", "blob"); err == nil || errors.Is(err, ErrGone) {
+ t.Errorf("500: want a non-gone error, got %v", err)
+ }
+}
+
+// A non-hex token must be rejected before it can reach the URL path, and must
+// never be classified as gone.
+func TestSendRejectsBadToken(t *testing.T) {
+ reached := false
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
+ defer stub.Close()
+ c := newTestClient(t, stub.URL)
+ err := c.Send("aa/../bb", false, "th", "blob")
+ if err == nil || errors.Is(err, ErrGone) {
+ t.Errorf("bad token: want a non-gone error, got %v", err)
+ }
+ if reached {
+ t.Error("bad token reached the upstream server — validation is too late")
+ }
+}
diff --git a/main.go b/main.go
index ca9cc1a..ca9c0af 100644
--- a/main.go
+++ b/main.go
@@ -20,6 +20,8 @@ import (
"strings"
"sync"
"time"
+
+ "lchat/internal/apns"
)
// maxUploadBytes caps an encrypted attachment upload: ~25 MB of plaintext (the
@@ -59,7 +61,7 @@ type Server struct {
homeOrigin string
vapidPub string
vapidPriv string
- apns *apnsClient // nil unless LCHAT_APNS_* env is configured
+ apns apnsSender // nil unless APNs (direct p8 or relay) is configured
// inflight de-duplicates concurrent unfurls of the same URL.
inflight sync.Map
// active tracks the last time each device token reported being open + focused
@@ -2049,7 +2051,7 @@ func (s *Server) apnsSubscribe(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "not a member")
return
}
- if body.APNsToken == "" || len(body.APNsToken) > 200 {
+ if !apns.ValidDeviceToken(body.APNsToken) {
httpError(w, http.StatusBadRequest, "bad request")
return
}
diff --git a/push/Dockerfile b/push/Dockerfile
new file mode 100644
index 0000000..e0cb103
--- /dev/null
+++ b/push/Dockerfile
@@ -0,0 +1,21 @@
+# The push relay: single static Go binary, built from the repo root so the
+# shared go.mod resolves. deploy-push.sh copies this file to the build context
+# root as "Dockerfile.push".
+
+# ---- build ----
+FROM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+# Pure-Go SQLite (modernc) means we can build a fully static binary with no cgo.
+RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /push-relay ./push
+
+# ---- run ----
+# distroless/static: ~2MB, no shell. The SQLite engine is embedded; the only
+# state is the minted-token table on the mounted volume.
+FROM gcr.io/distroless/static-debian12
+COPY --from=build /push-relay /push-relay
+# Runtime config comes from env: LCHAT_PUSH_DB, LCHAT_ADMIN_TOKEN, LCHAT_APNS_*, ADDR.
+EXPOSE 1749
+ENTRYPOINT ["/push-relay"]
diff --git a/push/app.toml b/push/app.toml
new file mode 100644
index 0000000..240389e
--- /dev/null
+++ b/push/app.toml
@@ -0,0 +1,34 @@
+# Miren deployment config template for the push relay (see deploy-push.sh, which
+# copies this to .miren/app.toml with the instance name substituted — mirrors
+# how deploy-home.sh / deploy-instance.sh template their app.toml).
+
+name = "push"
+
+[build]
+# deploy-push.sh places push/Dockerfile at the context root under this name.
+dockerfile = "Dockerfile.push"
+
+[services.web]
+# Miren terminates TLS and routes external HTTPS to this container port.
+# The relay listens on :1749 by default (or $ADDR), so these must match.
+port = 1749
+
+# A persistent disk requires fixed concurrency, and the disk suits SQLite's
+# single-writer model. NOTE: single instance makes the relay a single point of
+# failure for iOS push across the fleet — accepted because push is best-effort
+# and off the message-post path, and Web Push is unaffected (docs/push-relay.md).
+[services.web.concurrency]
+mode = "fixed"
+num_instances = 1
+
+[[services.web.disks]]
+name = "push-data"
+mount_path = "/data"
+size_gb = 1
+
+[[env]]
+key = "LCHAT_PUSH_DB"
+value = "/data/push.db"
+
+# LCHAT_ADMIN_TOKEN and the LCHAT_APNS_* signing key are secrets set at deploy
+# time by deploy-push.sh via `miren deploy -e` (the .p8 lives ONLY on this app).
diff --git a/push/db.go b/push/db.go
new file mode 100644
index 0000000..558c6b9
--- /dev/null
+++ b/push/db.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/hex"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// DB holds the relay's only state: the set of per-instance bearer tokens the
+// admin has minted. It stores NO push traffic — no device tokens, threads, or
+// blobs ever touch disk. Tokens are stored hashed (cf. the messenger's admin
+// token), so a stolen DB yields no live credential.
+type DB struct{ sql *sql.DB }
+
+func openDB(path string) (*DB, error) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ // One writer at a time keeps WAL happy and avoids "database is locked".
+ d.SetMaxOpenConns(1)
+ for _, p := range []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA busy_timeout=5000",
+ "PRAGMA synchronous=NORMAL",
+ } {
+ if _, err := d.Exec(p); err != nil {
+ return nil, err
+ }
+ }
+ db := &DB{sql: d}
+ if err := db.migrate(); err != nil {
+ return nil, err
+ }
+ return db, nil
+}
+
+func (db *DB) migrate() error {
+ _, err := db.sql.Exec(`
+CREATE TABLE IF NOT EXISTS relay_tokens (
+ id TEXT PRIMARY KEY, -- random public handle, used to revoke
+ label TEXT NOT NULL, -- e.g. "family.luan.chat" — for logs/listing, not auth
+ token_hash TEXT NOT NULL UNIQUE, -- sha256(bearer token); the secret itself is never stored
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+);`)
+ return err
+}
+
+func hashToken(t string) string {
+ h := sha256.Sum256([]byte(t))
+ return hex.EncodeToString(h[:])
+}
+
+func randToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// RelayToken is a token row for the admin listing. It deliberately never carries
+// the secret (only mintToken ever returns that, once).
+type RelayToken struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ CreatedAt int64 `json:"created_at"`
+ LastUsedAt *int64 `json:"last_used_at,omitempty"`
+}
+
+// mintToken creates a per-instance token, stores only its hash, and returns the
+// plaintext ONCE (the caller shows it to the operator; it's unrecoverable after).
+func (db *DB) mintToken(label string) (id, plaintext string, err error) {
+ if id, err = randToken(); err != nil {
+ return "", "", err
+ }
+ if plaintext, err = randToken(); err != nil {
+ return "", "", err
+ }
+ _, err = db.sql.Exec(
+ `INSERT INTO relay_tokens(id, label, token_hash, created_at) VALUES(?, ?, ?, ?)`,
+ id, label, hashToken(plaintext), time.Now().Unix(),
+ )
+ if err != nil {
+ return "", "", err
+ }
+ return id, plaintext, nil
+}
+
+// authInstance resolves a bearer token to its instance label, bumping
+// last_used_at. Returns ""/false for an empty or unknown token.
+func (db *DB) authInstance(token string) (label string, ok bool) {
+ if token == "" {
+ return "", false
+ }
+ var id string
+ err := db.sql.QueryRow(
+ `SELECT id, label FROM relay_tokens WHERE token_hash = ?`, hashToken(token),
+ ).Scan(&id, &label)
+ if err != nil {
+ return "", false
+ }
+ // Best-effort; a failed bump doesn't deny an otherwise-valid token.
+ _, _ = db.sql.Exec(`UPDATE relay_tokens SET last_used_at = ? WHERE id = ?`, time.Now().Unix(), id)
+ return label, true
+}
+
+func (db *DB) listTokens() ([]RelayToken, error) {
+ rows, err := db.sql.Query(
+ `SELECT id, label, created_at, last_used_at FROM relay_tokens ORDER BY created_at`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []RelayToken{}
+ for rows.Next() {
+ var t RelayToken
+ var last sql.NullInt64
+ if err := rows.Scan(&t.ID, &t.Label, &t.CreatedAt, &last); err != nil {
+ return nil, err
+ }
+ if last.Valid {
+ t.LastUsedAt = &last.Int64
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+func (db *DB) revokeToken(id string) (bool, error) {
+ res, err := db.sql.Exec(`DELETE FROM relay_tokens WHERE id = ?`, id)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
diff --git a/push/main.go b/push/main.go
new file mode 100644
index 0000000..afab3bd
--- /dev/null
+++ b/push/main.go
@@ -0,0 +1,312 @@
+// The Eleven push relay: a small, content-blind APNs signing service. Every
+// messenger instance pushes to the SAME Apple app via the SAME team's .p8, so
+// rather than copy that signing key onto every instance (a wide blast radius,
+// and a blocker for third-party self-hosting), the key lives ONLY here. An
+// instance authenticates with a per-instance bearer token and POSTs the same
+// {device_token, sandbox, thread, blob} it would send to Apple; the relay signs
+// the provider JWT and forwards it. Instances never hold signing capability.
+//
+// It preserves end-to-end encryption: the relay sees a device token, an opaque
+// thread id, and the ciphertext blob — a strict subset of what Apple already
+// sees per notification — never plaintext and never a message-decryption key.
+// The .p8 is Apple *auth*, not a decryption key. See docs/push-relay.md.
+package main
+
+import (
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "flag"
+ "log"
+ "net/http"
+ "os"
+ "sync"
+ "time"
+
+ "lchat/internal/apns"
+)
+
+// maxPushBytes caps a push request body. Apple rejects alert payloads over
+// ~4 KB; this leaves generous headroom for the ciphertext blob + envelope and
+// fails oversized requests fast at the relay instead of after a wasted Apple
+// round-trip.
+const maxPushBytes = 8 << 10
+
+type Server struct {
+ db *DB
+ apns *apns.Client
+
+ adminHash string // sha256 of LCHAT_ADMIN_TOKEN; compared in constant time
+
+ // Rate limits. perInstance keys by instance label so one instance can't
+ // exhaust another's budget (never by IP — instances egress from shared
+ // platform IPs; never by device token — unbounded bucket growth). global is
+ // a fleet-wide backstop against a single compromised token flooding Apple.
+ perInstance *rateLimiter
+ global *rateLimiter
+}
+
+func main() {
+ addr := flag.String("addr", envOr("ADDR", ":1749"), "listen address (env: ADDR)")
+ dbPath := flag.String("db", envOr("LCHAT_PUSH_DB", "push.db"), "SQLite path (env: LCHAT_PUSH_DB)")
+ flag.Parse()
+
+ // Fail loud: a misconfigured relay is a fleet-wide iOS-push outage, so a
+ // missing/bad key or admin token must stop the boot, not silently disable.
+ admin := os.Getenv("LCHAT_ADMIN_TOKEN")
+ if admin == "" {
+ log.Fatal("push: LCHAT_ADMIN_TOKEN is required")
+ }
+ client, err := apns.New(
+ os.Getenv("LCHAT_APNS_KEY"),
+ os.Getenv("LCHAT_APNS_KEY_ID"),
+ os.Getenv("LCHAT_APNS_TEAM_ID"),
+ os.Getenv("LCHAT_APNS_TOPIC"),
+ )
+ if err != nil {
+ log.Fatalf("push: APNs config: %v", err)
+ }
+
+ db, err := openDB(*dbPath)
+ if err != nil {
+ log.Fatalf("open db: %v", err)
+ }
+ s := &Server{
+ db: db,
+ apns: client,
+ adminHash: hashToken(admin),
+ // Generous: a legitimate large-group fan-out is a burst of hundreds. The
+ // limit is a runaway/quota backstop, not a tight boundary (a token can
+ // only reach device tokens its instance already holds).
+ perInstance: newRateLimiter(1000, 500),
+ global: newRateLimiter(4000, 2000),
+ }
+ log.Printf("push relay listening on %s (db: %s, topic: %s)", *addr, *dbPath, client.Topic())
+ if err := http.ListenAndServe(*addr, s.handler()); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// handler wires the routes. A method so tests exercise the real router via
+// httptest.
+func (s *Server) handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
+ mux.HandleFunc("GET /", s.root)
+
+ // The one data endpoint: an instance forwards a push. Bearer instance token.
+ mux.HandleFunc("POST /api/push", s.pushHandler)
+
+ // Admin surface: mint / list / revoke per-instance tokens at runtime
+ // (rotation needs no redeploy). Gated by LCHAT_ADMIN_TOKEN.
+ mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintHandler))
+ mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listHandler))
+ mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeHandler))
+
+ return securityHeaders(mux)
+}
+
+func (s *Server) root(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Write([]byte("Eleven push relay. Content-blind APNs signer. See docs/push-relay.md.\n"))
+}
+
+// ---- push ------------------------------------------------------------------
+
+func (s *Server) pushHandler(w http.ResponseWriter, r *http.Request) {
+ // Order matters: auth → per-instance limit → global limit → size → shape.
+ label, ok := s.db.authInstance(bearerToken(r))
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if !s.perInstance.allow(label) || !s.global.allow("*") {
+ http.Error(w, "rate limited", http.StatusTooManyRequests)
+ return
+ }
+ var body struct {
+ DeviceToken string `json:"device_token"`
+ Sandbox bool `json:"sandbox"`
+ Thread string `json:"thread"`
+ Blob string `json:"blob"`
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxPushBytes)
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if !apns.ValidDeviceToken(body.DeviceToken) {
+ http.Error(w, "bad device token", http.StatusBadRequest)
+ return
+ }
+
+ start := time.Now()
+ err := s.apns.Send(body.DeviceToken, body.Sandbox, body.Thread, body.Blob)
+ latency := time.Since(start).Milliseconds()
+ switch {
+ case err == nil:
+ // Content-blind logging: instance label + outcome + latency ONLY — never
+ // the device token, thread, blob, or any key material.
+ log.Printf("push[%s]: ok (%dms)", label, latency)
+ w.WriteHeader(http.StatusOK)
+ case errors.Is(err, apns.ErrGone):
+ // Normalise "token dead" to a bare 410 so the instance prunes it. The
+ // body must NOT contain "BadDeviceToken" — the instance also sniffs that
+ // string, so echoing it from a non-gone path would trigger a false prune.
+ log.Printf("push[%s]: gone (%dms)", label, latency)
+ http.Error(w, "gone", http.StatusGone)
+ default:
+ // Any other failure: log for us (the apns error carries Apple's status
+ // but no device token), return a generic 502 that is neither 410 nor
+ // contains "BadDeviceToken", so the instance does not prune a live token.
+ log.Printf("push[%s]: upstream error (%dms): %v", label, latency, err)
+ http.Error(w, "upstream error", http.StatusBadGateway)
+ }
+}
+
+// ---- admin -----------------------------------------------------------------
+
+// admin gates a handler behind the relay admin token (constant-time hash
+// compare, matching the messenger's checkAdmin).
+func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if subtle.ConstantTimeCompare([]byte(hashToken(bearerToken(r))), []byte(s.adminHash)) != 1 {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (s *Server) mintHandler(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Label string `json:"label"`
+ }
+ if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body) != nil || body.Label == "" {
+ http.Error(w, "label required", http.StatusBadRequest)
+ return
+ }
+ id, token, err := s.db.mintToken(body.Label)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ // The plaintext token is returned exactly once; it is unrecoverable after.
+ writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": body.Label, "token": token})
+}
+
+func (s *Server) listHandler(w http.ResponseWriter, r *http.Request) {
+ tokens, err := s.db.listTokens()
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
+}
+
+func (s *Server) revokeHandler(w http.ResponseWriter, r *http.Request) {
+ deleted, err := s.db.revokeToken(r.PathValue("id"))
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if !deleted {
+ http.Error(w, "no such token", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// ---- middleware & helpers --------------------------------------------------
+
+// securityHeaders mirrors the messenger's / home's defense-in-depth headers.
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("Strict-Transport-Security", "max-age=31536000")
+ next.ServeHTTP(w, r)
+ })
+}
+
+// bearerToken pulls the token out of "Authorization: Bearer <t>".
+func bearerToken(r *http.Request) string {
+ const p = "Bearer "
+ h := r.Header.Get("Authorization")
+ if len(h) > len(p) && h[:len(p)] == p {
+ return h[len(p):]
+ }
+ return ""
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ log.Printf("write json: %v", err)
+ }
+}
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+// ---- rate limiting (per-key token bucket, copied from home/main.go) --------
+
+type rateLimiter struct {
+ mu sync.Mutex
+ buckets map[string]*bucket
+ burst float64
+ perSec float64
+}
+
+type bucket struct {
+ tokens float64
+ last time.Time
+}
+
+func newRateLimiter(burst, perSec float64) *rateLimiter {
+ return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec}
+}
+
+func (rl *rateLimiter) allow(key string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ now := time.Now()
+ if len(rl.buckets) > 10000 {
+ for k, b := range rl.buckets {
+ if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour {
+ delete(rl.buckets, k)
+ }
+ }
+ }
+ b, ok := rl.buckets[key]
+ if !ok {
+ b = &bucket{tokens: rl.burst, last: now}
+ rl.buckets[key] = b
+ }
+ b.tokens += now.Sub(b.last).Seconds() * rl.perSec
+ if b.tokens > rl.burst {
+ b.tokens = rl.burst
+ }
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
diff --git a/push/push_test.go b/push/push_test.go
new file mode 100644
index 0000000..b04d4ce
--- /dev/null
+++ b/push/push_test.go
@@ -0,0 +1,189 @@
+package main
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "lchat/internal/apns"
+)
+
+const testAdmin = "test-admin-token"
+
+// device tokens the stub Apple server treats specially (all valid hex).
+const (
+ liveDevice = "aaaaaaaa"
+ deadDevice = "deadbeef"
+)
+
+func testKeyPEM(t *testing.T) string {
+ t.Helper()
+ key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ der, _ := x509.MarshalPKCS8PrivateKey(key)
+ return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
+}
+
+// newTestRelay wires the real router (s.handler) over a temp DB, with the APNs
+// client pointed at a stub standing in for Apple.
+func newTestRelay(t *testing.T) *httptest.Server {
+ t.Helper()
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, deadDevice) {
+ w.WriteHeader(http.StatusGone)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ c, err := apns.New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app")
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.SetHosts(stub.URL, stub.URL)
+
+ db, err := openDB(filepath.Join(t.TempDir(), "push.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := &Server{
+ db: db, apns: c, adminHash: hashToken(testAdmin),
+ perInstance: newRateLimiter(1000, 500),
+ global: newRateLimiter(4000, 2000),
+ }
+ srv := httptest.NewServer(s.handler())
+ t.Cleanup(func() { srv.Close(); stub.Close(); db.sql.Close() })
+ return srv
+}
+
+func do(t *testing.T, method, url, bearer, body string) *http.Response {
+ t.Helper()
+ var r io.Reader
+ if body != "" {
+ r = strings.NewReader(body)
+ }
+ rq, _ := http.NewRequest(method, url, r)
+ if bearer != "" {
+ rq.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ resp, err := http.DefaultClient.Do(rq)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp
+}
+
+// mint creates an instance token via the admin API and returns (id, token).
+func mint(t *testing.T, base, label string) (string, string) {
+ t.Helper()
+ resp := do(t, "POST", base+"/api/admin/tokens", testAdmin, `{"label":"`+label+`"}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("mint: status %d", resp.StatusCode)
+ }
+ var out struct{ ID, Token string }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Token == "" || out.ID == "" {
+ t.Fatal("mint returned empty id/token")
+ }
+ return out.ID, out.Token
+}
+
+func push(t *testing.T, base, token, deviceToken, extra string) int {
+ t.Helper()
+ body := `{"device_token":"` + deviceToken + `","thread":"th","blob":"x"` + extra + `}`
+ resp := do(t, "POST", base+"/api/push", token, body)
+ resp.Body.Close()
+ return resp.StatusCode
+}
+
+func TestPushAuth(t *testing.T) {
+ base := newTestRelay(t).URL
+ if got := push(t, base, "", liveDevice, ""); got != http.StatusUnauthorized {
+ t.Errorf("no token: want 401, got %d", got)
+ }
+ if got := push(t, base, "bogus-token", liveDevice, ""); got != http.StatusUnauthorized {
+ t.Errorf("bad token: want 401, got %d", got)
+ }
+ _, tok := mint(t, base, "family.luan.chat")
+ if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK {
+ t.Errorf("minted token: want 200, got %d", got)
+ }
+}
+
+func TestPushRevoke(t *testing.T) {
+ base := newTestRelay(t).URL
+ id, tok := mint(t, base, "test.luan.chat")
+ if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK {
+ t.Fatalf("pre-revoke: want 200, got %d", got)
+ }
+ resp := do(t, "DELETE", base+"/api/admin/tokens/"+id, testAdmin, "")
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("revoke: want 200, got %d", resp.StatusCode)
+ }
+ if got := push(t, base, tok, liveDevice, ""); got != http.StatusUnauthorized {
+ t.Errorf("post-revoke: want 401, got %d", got)
+ }
+}
+
+func TestPushGonePassthrough(t *testing.T) {
+ base := newTestRelay(t).URL
+ _, tok := mint(t, base, "test.luan.chat")
+ // Apple says the token is dead → relay must return a bare 410 so the
+ // instance prunes it.
+ if got := push(t, base, tok, deadDevice, ""); got != http.StatusGone {
+ t.Errorf("dead device: want 410, got %d", got)
+ }
+}
+
+func TestPushBadDeviceToken(t *testing.T) {
+ base := newTestRelay(t).URL
+ _, tok := mint(t, base, "test.luan.chat")
+ if got := push(t, base, tok, "zznothex", ""); got != http.StatusBadRequest {
+ t.Errorf("non-hex device token: want 400, got %d", got)
+ }
+}
+
+func TestPushOversized(t *testing.T) {
+ base := newTestRelay(t).URL
+ _, tok := mint(t, base, "test.luan.chat")
+ // A blob well over maxPushBytes must 413, never 410 (a false prune).
+ big := `,"pad":"` + strings.Repeat("a", 9000) + `"`
+ if got := push(t, base, tok, liveDevice, big); got != http.StatusRequestEntityTooLarge {
+ t.Errorf("oversized: want 413, got %d", got)
+ }
+}
+
+func TestAdminRequiresToken(t *testing.T) {
+ base := newTestRelay(t).URL
+ for _, tc := range []struct{ method, path string }{
+ {"POST", "/api/admin/tokens"},
+ {"GET", "/api/admin/tokens"},
+ {"DELETE", "/api/admin/tokens/x"},
+ } {
+ resp := do(t, tc.method, base+tc.path, "wrong-admin", `{"label":"x"}`)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("%s %s without admin: want 401, got %d", tc.method, tc.path, resp.StatusCode)
+ }
+ }
+}
+
+func TestHealthz(t *testing.T) {
+ base := newTestRelay(t).URL
+ resp := do(t, "GET", base+"/healthz", "", "")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("healthz: want 200, got %d", resp.StatusCode)
+ }
+}