push notification service for ios, android and pwa
  • Go 76.7%
  • Swift 8.1%
  • templ 5.4%
  • Dart 4.3%
  • JavaScript 3.4%
  • Other 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-10 23:28:25 +02:00
client astra sec fixes 2026-10-10 23:28:25 +02:00
cmd/lbspush astra sec fixes 2026-10-10 23:28:25 +02:00
components Move to lbsauth 2026-10-10 22:39:15 +02:00
deploy Initial 2026-10-10 22:14:46 +02:00
flutter/lbs_push astra sec fixes 2026-10-10 23:28:25 +02:00
internal astra sec fixes 2026-10-10 23:28:25 +02:00
views Move to lbsauth 2026-10-10 22:39:15 +02:00
web astra sec fixes 2026-10-10 23:28:25 +02:00
.dockerignore astra sec fixes 2026-10-10 23:28:25 +02:00
.env.example astra sec fixes 2026-10-10 23:28:25 +02:00
.gitignore Initial 2026-10-10 22:14:46 +02:00
CLAUDE.md astra sec fixes 2026-10-10 23:28:25 +02:00
docker-compose.yml Initial 2026-10-10 22:14:46 +02:00
Dockerfile Move to lbsauth 2026-10-10 22:39:15 +02:00
go.mod astra sec fixes 2026-10-10 23:28:25 +02:00
go.sum astra sec fixes 2026-10-10 23:28:25 +02:00
mage.go Initial 2026-10-10 22:14:46 +02:00
magefile.go astra sec fixes 2026-10-10 23:28:25 +02:00
README.md astra sec fixes 2026-10-10 23:28:25 +02:00
SECURITY_REVIEW.md astra sec fixes 2026-10-10 23:28:25 +02:00
sqlc.yaml Initial 2026-10-10 22:14:46 +02:00

lbspush

A small, self-hosted push notification service shared by many apps. It holds the push credentials (one APNs team key, one VAPID key pair per app), keeps a registry of devices per user, and delivers through an outbox with retries. Payloads are end-to-end encrypted: app servers encrypt per device with the Go client, lbspush relays ciphertext it cannot read.

 device ──registration──▶ app server ──/v1/devices──▶ lbspush        (user never talks to lbspush)
 app server: encrypt per device ──/v1/send (ciphertext)──▶ lbspush ──▶ APNs / Web Push ──▶ device
                                                                         device decrypts (NSE / browser)
Client Channel Credential (only lbspush has it) Decryption
Flutter app (iOS) APNs, token auth one team .p8 key for all apps Notification Service Extension (HPKE)
PWA (iOS 16.4+ Home Screen, desktop browsers) Web Push (VAPID) per-app VAPID key pair, generated by lbspush the browser (RFC 8291)

Pieces in this repo:

Path What
cmd/lbspush, internal/ the service: API, outbox worker, admin UI, backups, CLI
client/ Go client for app servers (register, send with encryption, VAPID key)
client/pushcrypto/ the encryption (HPKE for APNs, RFC 8291 for Web Push)
flutter/lbs_push/ Flutter plugin: APNs token, keys, taps; NSE template in ios_extension_template/
web/ lbs-push.js (subscribe, iOS Home Screen hint) and sw-template.js

Status: built and tested locally (see Development). Not yet deployed or tried on a real device. Admin login: Pocket ID via lbsauth, with a break-glass password.


Encryption design

Native iOS (APNs): HPKE

Choice: HPKE (RFC 9180), base mode, suite DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / ChaCha20-Poly1305, info string lbspush/v1/apns.

  • Each install generates an X25519 key pair (the plugin, on register()). The public key goes to the app server with the registration; lbspush never receives it.
  • The Go client seals {title, subtitle, body, data, thread, badge} (JSON, padded with spaces to a 128-byte boundary) to that key. The envelope 0x01 || enc (32 B) || ciphertext travels base64 in the APNs payload under "lbs", next to a generic alert (the app's fallback text, e.g. "Demo / New notification") and mutable-content: 1.
  • The Notification Service Extension decrypts and replaces title, subtitle, body, thread and badge for display; the envelope stays in userInfo. If decryption fails, the fallback text stays.
  • On a tap the plugin opens the envelope again with the device key and only then reports decrypted: true with the deep-link data. Nothing else in userInfo is trusted: it is the APNs payload, which whoever can send to APNs for the app controls (and the extension does not run for payloads without mutable-content).

Why HPKE: it is the standardised form of exactly the "ephemeral X25519 + HKDF + AEAD" construction we would otherwise hand-roll, and both ends have it built in: Go's standard library (crypto/hpke, Go 1.26+) and Apple's CryptoKit (HPKE.Ciphersuite.Curve25519_SHA256_ChachaPoly, iOS 17+). No third-party crypto on either side, unlike libsodium sealed boxes (would need swift-sodium in the app and the extension). The interop is tested: mage swift:interop compiles the plugin's real LbsPushCrypto.swift with swiftc and decrypts Go-sealed envelopes. Cost: the plugin needs iOS 17+ for push (on older iOS register() returns null; the app keeps working without push).

Key storage on the device: shared keychain access group

The private key lives in the keychain, in an access group shared by the app and its extension (keychain-access-groups entitlement on both targets), with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and not synchronizable.

Why not an App Group container: a file or UserDefaults in an App Group is covered by iCloud/device backups and only by file data protection; a keychain item with ...ThisDeviceOnly never leaves the device (a restored or new phone registers a new key, which is correct) and is the place Apple intends for key material. AfterFirstUnlock is required because the extension must decrypt while the phone is locked; until the first unlock after a reboot the fallback text is shown.

Web Push (PWA): RFC 8291

The app server encrypts with the subscription's p256dh/auth keys (RFC 8291 aes128gcm, padded to 128-byte steps) using the same JSON shape; lbspush adds the RFC 8292 VAPID JWT (per-app key, sub from VAPID_SUBJECT) and delivers. The service worker gets the decrypted JSON from event.data. lbspush stores only the endpoint URL, not the keys. Verified against the RFC 8291 Appendix A test vector, byte for byte.

What lbspush can and cannot see

  • Never: titles, bodies, deep-link data, badge, thread; the device keys.
  • Sees: app, the app's user_id, device tokens / endpoints, time and padded size of each notification, urgency and TTL, an opaque collapse ID (HMAC of the collapse key and user under LBSPUSH_COLLAPSE_SECRET, a secret only the app server has), the app's generic fallback text.
  • VAPID private keys are stored sealed (AES-256-GCM) with a key outside the database (SECRET_KEY or data/secret.key), so DB snapshots and admin backup downloads do not contain usable keys.
  • A compromised lbspush (or anyone with the APNs team key or a VAPID key) can drop, delay or replay notifications and show arbitrary cleartext alerts (the fallback text is not protected). It cannot read notification content, and it cannot produce content the app accepts as genuine (taps report decrypted: true only for a valid envelope), because encrypting needs the device public keys, which only the app server holds. HPKE base mode does not authenticate the sender beyond that: anyone who obtains a device public key from the app server could encrypt to it. Keep the push_devices table as private as other user data; if origin authentication against a compromised app-server database is ever needed, add an app-server signature.

Setting up an app

1. Register the app

Admin UI ("Register an app") or CLI on the host:

docker compose exec lbspush /lbspush app create myapp --display-name "My App" --bundle-id at.example.myapp
# prints LBSPUSH_TOKEN=lbspush_... once, and the VAPID public key

Put into the app server's env:

LBSPUSH_URL=https://push.example.com
LBSPUSH_TOKEN=lbspush_...        # shown once; issue more in the admin UI
LBSPUSH_COLLAPSE_SECRET=...      # 32+ random bytes (openssl rand -hex 32); never the token
# LBSPUSH_APP=myapp              # optional

LBSPUSH_COLLAPSE_SECRET keys the collapse IDs (see below); without it a random per-process secret is used and collapsing works only until restart.

Missing values disable push (logged once); the app keeps working.

2. App server (Go)

import pushclient "git.lbsfilm.at/lbsadmin/lbspush/client"

push := &pushclient.Notifier{Client: pushclient.FromEnv(), Store: pushDevices} // pushDevices implements DeviceStore

// POST /api/v1/push/devices (authenticated as the app user). Body: the JSON
// from the Flutter plugin (reg.toJson()) or from lbs-push.js.
func registerPush(w http.ResponseWriter, r *http.Request) {
	var reg pushclient.Registration
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&reg); err != nil { ... }
	if _, err := push.Register(r.Context(), currentUser(r).ID, reg); err != nil { ... }
}

// Anywhere: best-effort, log errors.
if _, err := push.Notify(ctx, userID, pushclient.Message{
	Title: "New message", Body: preview, Thread: "chat-42",
	Data: map[string]string{"chat": "42"},   // IDs for deep links, not content
	CollapseKey: "chat-42",                   // optional: newer replaces older
}); err != nil {
	log.Warn("push failed", "error", err)
}

// Logout on a device / account deletion:
push.Unregister(ctx, deviceID)
push.UnregisterUser(ctx, userID)

// PWA page: render the key into the page for pushManager.subscribe.
key, err := push.Client.VAPIDPublicKey(ctx) // cached; ErrDisabled when not configured

DeviceStore is the app's own table (lbspush does not keep the keys):

CREATE TABLE push_devices (
    id         TEXT PRIMARY KEY,           -- lbspush device ID
    user_id    TEXT NOT NULL,
    channel    TEXT NOT NULL,              -- apns | webpush
    public_key TEXT NOT NULL,              -- APNs X25519 key or Web Push p256dh
    auth       TEXT NOT NULL DEFAULT '',   -- Web Push auth secret
    created_at TEXT NOT NULL
);
CREATE INDEX push_devices_user ON push_devices (user_id);

Notify deletes rows lbspush reports as gone (dead token, unregistered, or re-registered by another user). client.MemoryStore exists for tests.

3. Flutter app (iOS)

See flutter/lbs_push/README.md: add the plugin, enable Push Notifications + Keychain Sharing, add the Notification Service Extension from the template. No AppDelegate changes.

// From a user action; account = the signed-in user's ID: a different
// account on the same phone gets a fresh key (see Account switches).
final reg = await LbsPush.instance.register(account: user.id);
if (reg != null) await api.post('/api/v1/push/devices', reg.toJson());
LbsPush.instance.onTap.listen((tap) {
  final path = tap.localPath();      // validated in-app path from data['url'], or null
  if (path != null) router.go(path);
});

4. PWA

Copy web/lbs-push.js into your static files and web/sw-template.js to /sw.js (adjust REGISTER_URL and deepLink). The page needs a manifest (display: standalone) so iOS users can add it to the Home Screen.

<button id="push-btn">Enable notifications</button>
<p id="push-hint" hidden></p>
<script type="module">
  import { mountPushButton } from '/static/lbs-push.js';
  mountPushButton(document.getElementById('push-btn'), document.getElementById('push-hint'), {
    vapidPublicKey: '{{ the key from VAPIDPublicKey }}',
    registerUrl: '/api/push/subscription',   // your endpoint -> Notifier.Register
    serviceWorkerUrl: '/sw.js',
    account: '{{ the signed-in user ID }}',   // a different user gets a new subscription
  });
</script>

Permission is requested only from the button tap. In an iOS Safari tab the button is replaced by an "Add to Home Screen" hint. Every push shows a notification (no silent pushes). After a VAPID key rotation or an account switch the snippet resubscribes on the next page load. The service worker opens only URLs on the app's own origin (checked on the parsed URL).

Account switches and logout

The server cancels everything still queued for a device the moment it is registered to a different user, and a send never reaches a registration that changed owner. Notifications already handed to APNs or the browser's push service cannot be recalled, though, so the clients rotate their keys:

  • Flutter: pass account: to register() (a new account gets a new key, so old notifications stop decrypting) and call resetKeys() on logout.
  • PWA: pass account to the snippet, and call disablePush() on logout.
  • App server: Notifier.Unregister / UnregisterUser on logout.

API

All /v1 routes require Authorization: Bearer lbspush_... (never a query parameter). JSON in and out; errors are {"error": "..."}.

Method & path Body Answer
GET /v1/app {name, display_name, bundle_id, vapid_public_key, quota_per_hour, quota_used}
GET /v1/apps/{app}/vapid-public-key {vapid_public_key} (only the token's own app; others 404)
POST /v1/devices {user_id, channel:"apns", token, environment:"sandbox"|"production"} or {user_id, channel:"webpush", endpoint} 201/200 {device_id, created}; 429 over the device limits
DELETE /v1/devices/{device_id} 204, 404 if unknown
DELETE /v1/users/{user_id}/devices {deleted}
POST /v1/send {user_id, notifications:[{device_id, ciphertext}], collapse_id?, urgency?, ttl?} 202 {send_id, queued, unknown_devices}; 429 over quota
GET /v1/sends/{send_id} {send_id, user_id, created_at, deliveries:[{device_id, channel, status, attempts, last_status, last_error, device_removed}]}
GET /healthz (public) ok, or 503 if the DB or backups are unhealthy

ciphertext is standard base64 of what client.Encrypt produces. Limits: 50 notifications per send; Web Push ciphertext <= 4096 bytes; the APNs payload (fallback alert + ciphertext) <= 4096 bytes; ttl 60 s to 7 days (default 24 h); urgency high (default), normal, low; collapse_id <= 32 chars of [A-Za-z0-9_-]. A device registered again by a different user gets a new device ID, and its pending deliveries for the previous user are cancelled. Web Push endpoints must be https and on an allowed push service (WEBPUSH_ALLOWED_HOSTS); private and tailnet addresses are refused at connect time.

Device limits per app token: at most DEVICE_LIMIT_PER_USER devices per user (older registrations are dropped), DEVICE_LIMIT_PER_APP per app and DEVICE_REGISTRATIONS_PER_HOUR new ones per app per hour (both 429), and devices that neither re-registered nor received a push within DEVICE_EXPIRY are deleted. Clients re-register on every app start / page load, which keeps active devices alive.

Delivery: each notification is a row in the outbox (deliveries). The worker sends due rows, retries 429/5xx/network errors with exponential backoff (10 s doubling up to 1 h, jitter, Retry-After honoured, at most 10 attempts, never past the TTL), and on APNs 410 / BadDeviceToken / DeviceTokenNotForTopic or Web Push 404/410 deletes the device (matched by row, app and public device ID, never by row ID alone). Delivery is at-least-once. Quotas count notifications per rolling hour per app. Failure reasons are fixed texts (network: timeout, an HTTP reason, ...): tokens and endpoint URLs never end up in logs or the delivery log.


Upgrading clients to v0.1.1

v0.1.1 fixes the findings of the 2026-10-10 security review (SECURITY_REVIEW.md). The /v1 API is unchanged; v0.1.0 clients keep working. What to change when bumping:

  • Go client: set LBSPUSH_COLLAPSE_SECRET (32+ random bytes, kept only by the app server); collapse IDs are no longer derived from the API token. Expect 429 from Register when device limits are hit (log it, as any push error).
  • Flutter plugin (iOS): copy ios_extension_template/LbsPushNSE/LbsPushCrypto.swift into the app's Notification Service Extension again (required: the extension now keeps the envelope, which the plugin verifies on tap; with the old copy taps report decrypted: false). Call register(account: user.id) and resetKeys() on logout; use tap.localPath() for URL deep links. lbs_data / lbs_decrypted in userInfo are no longer written or read.
  • PWA: update the vendored lbs-push.js (new account option, call disablePush() on logout) and the service worker's notificationclick from web/sw-template.js (sameOrigin).

Running lbspush

Configuration

Environment variables, .env as fallback; full list with comments in .env.example.

Variable Default
LISTEN :8080 listen address
PUBLIC_BASE_URL public origin: OIDC callback base, CSRF trusted origin (http:// = no Secure cookies, dev only)
DATA_DIR / DB_PATH data / $DATA_DIR/lbspush.db
SECRET_KEY data/secret.key (generated) seals VAPID private keys
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (admin UI closed) Pocket ID client for the admin login
LBSPUSH_ADMIN_PASSWORD_HASH (break-glass off) argon2id from lbspush hash-password
TRUSTED_PROXY_CIDRS proxies allowed to set X-Forwarded-For
APNS_KEY_FILE, APNS_KEY_ID, APNS_TEAM_ID (APNs off) the team .p8 key
VAPID_SUBJECT (Web Push off) mailto: or https: contact
WEBPUSH_ALLOWED_HOSTS Apple, Google, Mozilla, Microsoft push service allowlist
DEFAULT_QUOTA_PER_HOUR 1000 for new apps
DEVICE_LIMIT_PER_USER, DEVICE_LIMIT_PER_APP 20, 10000 device limits per app token
DEVICE_REGISTRATIONS_PER_HOUR, DEVICE_EXPIRY 1000, 4320h (180 days) new-device rate; inactivity expiry
DELIVERY_RETENTION 336h send log retention
BACKUP_INTERVAL, BACKUP_KEEP, BACKUP_DIR 6h, 28, data/backups
LOG_LEVEL info

Deploy (lbsa, Traefik, push.lbs.sh)

On the host, in ~/lbspush: .env from .env.example (chmod 600) including the Pocket ID client (see Admin UI), the APNs key at secrets/AuthKey.p8 with APNS_KEY_FILE=/secrets/AuthKey.p8. Then from the repo:

mage deploy:up        # test + lint, build image on the host via DOCKER_HOST, copy compose, up -d
mage deploy:logs | deploy:ps | deploy:down

Host and directory default to lbsa / lbspush; override with deploy/local/deploy.env.

Admin UI

https://push.example.com/admin/: apps with device and delivery counts, register app (token shown once), per app settings, tokens (issue, revoke), VAPID key (rotate), devices (remove), recent deliveries and failures, backups (create, download), admins with "log out everywhere".

Login goes through lbsauth (STACK.md 7.1, personal profile): Pocket ID with authorization code + PKCE, access only for members of the group lbspush-admin (role admin, recomputed at every login). lbsauth keeps its users and sessions in lbsauth_* tables of lbspush's database (versioned in lbsauth_schema, so they are in every snapshot); session cookies are 32 random bytes, stored only as SHA-256, __Host-, HttpOnly, Secure, SameSite=Lax. One http.CrossOriginProtection guards the admin UI, logout and break-glass; the /v1 bearer API is exempt. Without OIDC_ISSUER/OIDC_CLIENT_ID the admin UI answers 503; the push API is unaffected.

Pocket ID client (once): in Pocket ID, Administration > User Groups: create lbspush-admin and add the admins. Administration > OIDC Clients > Add: name lbspush, confidential, callback URL https://push.lbs.sh/auth/callback; copy client ID and secret into OIDC_CLIENT_ID / OIDC_CLIENT_SECRET (and OIDC_ISSUER) in the host's .env; on the client's Access tab select the group lbspush-admin.

Break-glass (Pocket ID down): lbspush hash-password (or docker compose run --rm -T lbspush hash-password <<< 'the password'), put the hash single-quoted into LBSPUSH_ADMIN_PASSWORD_HASH, then use the "Break-glass login" on /admin/login. Rate-limited by lbsauth (10 tries per 15 min per IP or IPv6 /64, 60 per minute overall, 2 hash checks at a time); sessions last 12 hours.

Log out everywhere: the admins card lists everyone who signed in (Pocket ID admins and the break-glass user). After removing someone from lbspush-admin, press "Log out everywhere" for them; otherwise the change applies at their next login or when the session expires (7 days).

Backups and restore

Snapshots (VACUUM INTO, quick_check, atomic rename) land in data/backups/lbspush-<UTC time>-<reason>.db: at startup, before migrations, every BACKUP_INTERVAL, and on demand (admin UI, lbspush backup, mage deploy:backup downloads one into ./tmp/). BACKUP_KEEP are kept; pre-migration snapshots are never pruned. /healthz fails when the last success is older than twice the interval.

Restore (the server must be stopped; the CLI refuses otherwise):

mage deploy:restore lbspush-20260101T000000Z-manual.db
# or on the host:
docker compose stop lbspush
docker compose run --rm --no-deps lbspush restore lbspush-20260101T000000Z-manual.db
docker compose up -d

The snapshot is checked first; the current database is moved aside as lbspush.db.before-restore-<time>, never deleted. Keep data/secret.key (or SECRET_KEY) with the backups: without it the VAPID keys in a snapshot cannot be opened and every app needs a VAPID rotation (PWAs resubscribe).


Development

mage gen          # templ + sqlc (pinned in go.mod, run via go tool)
mage dev          # live reload on http://localhost:7331 (data in tmp/dev; admin login
                  # needs OIDC_* for a dev client with callback http://localhost:7331/auth/callback)
mage test         # go test -race ./... + flutter test (plugin)
mage lint         # go vet + staticcheck + flutter analyze
mage swift:interop    # Go -> Swift (CryptoKit) decryption + tap verification, macOS
mage vuln             # govulncheck (pinned)
mage flutter:example  # builds the plugin example for the simulator

Tests use real HTTP and real SQLite; APNs (TLS, HTTP/2) and Web Push services are httptest fakes that verify the APNs JWT and the VAPID signature and decrypt what they receive with the simulated device keys. The admin login is tested end to end against lbsauth's in-process OIDC provider (lbsauth/oidctest).