- Go 76.7%
- Swift 8.1%
- templ 5.4%
- Dart 4.3%
- JavaScript 3.4%
- Other 2.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| client | ||
| cmd/lbspush | ||
| components | ||
| deploy | ||
| flutter/lbs_push | ||
| internal | ||
| views | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| mage.go | ||
| magefile.go | ||
| README.md | ||
| SECURITY_REVIEW.md | ||
| sqlc.yaml | ||
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 envelope0x01 || enc (32 B) || ciphertexttravels base64 in the APNs payload under"lbs", next to a generic alert (the app's fallback text, e.g. "Demo / New notification") andmutable-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: truewith the deep-linkdata. Nothing else inuserInfois trusted: it is the APNs payload, which whoever can send to APNs for the app controls (and the extension does not run for payloads withoutmutable-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 underLBSPUSH_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_KEYordata/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: trueonly 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 thepush_devicestable 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(®); 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:toregister()(a new account gets a new key, so old notifications stop decrypting) and callresetKeys()on logout. - PWA: pass
accountto the snippet, and calldisablePush()on logout. - App server:
Notifier.Unregister/UnregisterUseron 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. Expect429fromRegisterwhen device limits are hit (log it, as any push error). - Flutter plugin (iOS): copy
ios_extension_template/LbsPushNSE/LbsPushCrypto.swiftinto 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 reportdecrypted: false). Callregister(account: user.id)andresetKeys()on logout; usetap.localPath()for URL deep links.lbs_data/lbs_decryptedinuserInfoare no longer written or read. - PWA: update the vendored
lbs-push.js(newaccountoption, calldisablePush()on logout) and the service worker'snotificationclickfromweb/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).