fix: per-app notification routing (stop wallet/station cross-app leak) - #27
Conversation
Both notification bugs share one root cause: the APNs topic was hardcoded to the wallet bundle id (const appID = com.vultisig.wallet) for EVERY push. APNs only delivers a push when the topic matches the app that owns the device token, so an agent (Station, money.terra.station) sharing a vault_id with the wallet had its pushes delivered to the WALLET (topic match) and rejected for Station (topic mismatch). Result: the wallet got Station's notifications and Station got nothing. Fix (strictly additive / opt-in - zero change for the wallet): - Device gains app_id, defaulting to com.vultisig.wallet. AutoMigrate adds a NOT NULL column with that default, backfilling every existing row to today's behaviour. ResolvedAppID() guarantees a non-empty topic. - The APNs topic is now the device's OWN app_id (was the hardcoded wallet id), so a push reaches the app that registered the token. - NotificationRequest gains an optional app_id; when set, GetRegisteredDevices restricts the fan-out to that app's devices so a targeted (Station) notify doesn't also hit the wallet. When omitted (every existing caller, e.g. the wallet keysign flow) behaviour is unchanged: all vault devices, each on its own topic. No regression for regular Vultisig apps: they never send app_id, so they keep the wallet default on both register and notify. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…up (codex r1) Addresses the gaps that made per-app routing unsafe: - Per-app APNs certificate map (APNsAppCertificate, variadic so existing callers are unchanged). APNs validates the provider cert against the topic, so a single shared cert can only deliver to one app. The wallet cert is seeded under DefaultAppID; an app with NO configured cert is SKIPPED rather than pushed with the wrong cert (which APNs would 400 and the failure path could then mistake for a dead token). - Reason-aware unregister: only delete a token on 410 Unregistered or 400 BadDeviceToken. Other 400s (DeviceTokenNotForTopic, TopicDisallowed, BadCertificate) are topic/cert mismatches for a STILL-VALID token and must not drop the registration (also pre-existing over-aggressive behaviour). - Dedup key includes app_id so a wallet and a targeted (Station) notify for the same vault within the 30s window are distinct and neither is dropped. Scope note: the WebSocket/Redis-stream in-app path is intentionally NOT changed here - it uses a per-vault consumer group (load-balanced), so app filtering needs a per-app-group redesign to avoid dropping messages. Tracked as a follow-up; this PR is the APNs (lock-screen) routing + the reported wallet-leak symptom. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(codex r2) P1: plumb per-app certificates through config (AppCertificates, keyed by bundle id) and cmd/worker into the service, so non-wallet routing actually delivers once an app's cert is provisioned (until then the app is safely skipped, not misrouted). Wallet path unchanged. P2: the WS/Redis-stream realtime channel is consumed ONLY by the regular wallet (Station and the SDK do not open this socket - confirmed: Station's in-app banner comes from the agent-backend SSE stream). So only wallet-targeted or untargeted notifications are published to it; a notification targeted at another app no longer surfaces on the wallet's WebSocket. This closes the realtime-path cross-app leak without a consumer-group redesign and with zero change for the wallet.
…x r3) P2-WS (subscription side): the /ws realtime channel is wallet-only, so reject a non-wallet device from subscribing. Combined with the publish-side gate, the realtime path is now fully cross-app isolated on BOTH ends: a Station device can neither be published to nor subscribe to the wallet stream. Station does not use this socket at all (it consumes the agent-backend SSE stream), so this is zero-impact for Station and unchanged for the wallet. P2-env: AppCertificates is a slice of structs that viper's AutomaticEnv cannot populate from a single env var, so env-only deployments would silently get no per-app certs. Add an APP_CERTIFICATES_JSON env override (JSON array) parsed after Unmarshal; a config-file app-certificates: list still works and takes precedence.
…t (codex r4) Two cross-app correctness + safety fixes: - GetRegisteredDevices now ALWAYS scopes by app (empty -> wallet default). Previously an untargeted notify returned ALL apps' devices, which with the new per-device topics would have leaked the wallet's keysign notifications to other apps (e.g. Station) - a behaviour change from the old wallet-only delivery. Untargeted is wallet-only again; non-wallet apps are reached only by explicit app_id. - The tokenless /unregister is now app-scoped (defaults to wallet), so a Station client can no longer delete the wallet's rows for a shared vault+party. Crucially, the wallet bucket (whereAppID) matches app_id = wallet OR empty OR NULL, so neither path depends on the AutoMigrate column backfill having run: a pre-migration device row with no app_id is always still reachable (and unregisterable) as a wallet device. This is the hard no-regression guarantee for existing wallet registrations.
P2-1: add app_id to the device unique index + upsert conflict key, so the same (vault_id, party_name, token) registered by two apps becomes two rows rather than the second overwriting the first's app_id (matters for app-defined tokens like device_type web). For APNs/FCM the token already implies the app, so this only ever adds rows - existing wallet registrations keep their identity. P2-3: the dedup key now uses the RESOLVED app_id, so an omitted app_id and an explicit com.vultisig.wallet (which route identically everywhere else) share one bucket and two such wallet callers can't bypass the 30s deduper and double-send. P2-2 (tokenless unregister omitting a non-wallet app_id): deliberately keeping the app-scoped, wallet-default behaviour from the previous round - a client that registers with an app_id must unregister with it. Defaulting to the wallet is the safe choice; the alternative (delete across all apps) would reintroduce the cross-app deletion that scoping was added to prevent.
… (codex r6) Reverts the round-5 over-correction that put app_id in the unique index. That broke token-based identity (FindDeviceByToken / token unregister query token only, so a shared token could authenticate the wrong app) AND duplicated legacy wallet rows on re-registration (app_id '' vs com.vultisig.wallet upserted as a new row -> wallet notified twice). APNs/FCM tokens are already unique to one app, so (vault_id, party_name, token) remains the correct identity: a wallet re-register idempotently UPDATES its row (OnConflict refreshes app_id, backfilling legacy '' rows) and token paths stay unambiguous. The web/app-defined-token multi-app edge is not relevant here (Station uses APNs/FCM, not web push) and isn't worth reintroducing those regressions.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (2)
WalkthroughThis PR introduces multi-application support with app-scoped APNs certificate routing and device isolation. Devices now carry an optional app ID that defaults to the wallet bundle, notifications are deduplicated and routed per app, and WebSocket subscriptions are restricted to wallet-app clients only to prevent cross-app message leakage. ChangesMulti-app notification support with app-scoped APNs certificates
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@models/notification.go`:
- Around line 10-15: The comment for the AppID field in models/notification.go
is inaccurate: empty app_id no longer means "notify all vault devices" but is
resolved to the wallet-default scope; update the doc comment above the AppID
field (the AppID field/comment block) to state that when AppID is empty routing
resolves to the wallet-default app scope and only devices subscribed to that
default scope are notified, so integrators do not expect fan-out to every
app/topic for the vault.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: fea7409b-c44f-4171-aa74-4bccb6deb690
📒 Files selected for processing (9)
api/server.gocmd/worker/main.goconfig/config.gomodels/device.gomodels/device_test.gomodels/notification.goservice/notification.gostorage/database.gows/handler.go
…pe, not fan-out) CR caught the stale comment: empty `app_id` no longer notifies all of the vault's devices. Routing resolves empty to DefaultAppID (com.vultisig.wallet), and whereAppID() then matches devices where app_id = DefaultAppID OR empty OR NULL. So only wallet-bundle devices (plus pre-migration rows that never set app_id) are notified. Non-wallet apps must be targeted explicitly. Comment-only.
|
done @coderabbitai - 127c5e2 corrects the empty-app_id contract on
so empty app_id only fans out to wallet-bundle devices + pre-migration empty/NULL rows, never to Station etc. updated the doc comment to say exactly that + that non-wallet apps must target their bundle id explicitly. gucci 🚀 |
|
Tip For best results, initiate chat on the files or code changes.
(¯ ✏️ Learnings added
|
…-wallet WS rejection - Replace raw string literal "BadDeviceToken" with apns2.ReasonBadDeviceToken constant to prevent silent mismatch if the constant value is ever audited/changed. - Add TestNonWalletDeviceRejected to ws/handler_test.go: verifies that a device registered under a non-wallet app_id (e.g. money.terra.station) receives 403 when attempting to subscribe to the wallet-only WebSocket channel, closing the realtime-path cross-app leak on the subscription side as well as the push side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NeOMakinG
left a comment
There was a problem hiding this comment.
testing approach
Triple-lane review (3 adversarial passes) — Claude deep trace + GLM third-eye (Codex auth stale, skipped per [[feedback_codex_auth_brittleness]]). Two in-band fixes applied and pushed as 1e336e5.
PASSES: 3
Pass 1 — full code trace of every routing path.
Pass 2 — adversarial re-read: empty/null app_id injection, legacy-row migration safety, race conditions, allowlist gaps, WS subscription timing.
Pass 3 — blast radius: all four cross-app leak scenarios closed; GORM migration safety confirmed by reading migrator source.
blocking
None.
preferably-blocking
None.
should-fix
s1 — use apns2.ReasonBadDeviceToken constant (fixed in-band, 1e336e5)
service/notification.go:239 used the raw string literal "BadDeviceToken" rather than the exported apns2.ReasonBadDeviceToken constant from the sideshow/apns2 library. The library already exports all reason strings as constants (response.go:20). A future audit that changes the constant value would break the comparison silently; using the exported constant makes drift a compile-time catch. Fixed in 1e336e5.
s2 — missing test for WS non-wallet device rejection (fixed in-band, 1e336e5)
ws/handler_test.go's mockDeviceFinder always returns &models.DeviceDBModel{} (AppID = empty → ResolvedAppID = DefaultAppID = wallet). Every WS test therefore simulated a wallet device. The 403-rejection path at handler.go:67–70 — the guard that prevents a Station device from subscribing to the wallet's realtime stream — had zero test coverage. A regression that removed that guard would pass all pre-existing tests. Added TestNonWalletDeviceRejected (+ fixedDeviceFinder helper) which creates a device with AppID="money.terra.station" and asserts HTTP 403. Test passes and is non-vacuous: removing the guard causes it to fail with 101 Switching Protocols. Fixed in 1e336e5.
suggestion
Validation of app_id on /register and /notify. Currently any arbitrary string is accepted as app_id. An allowlist derived from configured AppCertificates + DefaultAppID would close the theoretical injection path (a client registering with app_id="com.vultisig.wallet" and a Station token). The practical risk is low — the attacker already needs a valid (vault_id, party_name, APNs-token) triple — but the defence is cheap: reject unknown app_id values with 400 at registration time. Not blocking; the DB filter and the cert-skip in processAppleNotification already limit the blast radius of a wrong app_id.
q
Android/FCM per-app routing: FCM tokens are already app-unique (a Station token can only be delivered by Firebase to the Station app), and GetRegisteredDevices already filters by app_id before FCM dispatch. So the routing is correct without per-FCM-project certs. Worth documenting explicitly so the next reviewer doesn't raise the same question.
pre-existing bugs i bumped into
None.
risk
Low post-fix. The original cross-app leak (Station→Wallet on both APNs and WebSocket) is fully closed. Wallet regression is prevented at three layers: (1) whereAppID covers empty/NULL/explicit-wallet rows, (2) GORM AutoMigrate backfills existing rows with DEFAULT 'com.vultisig.wallet' (confirmed by reading migrator.go:97–103 + schema/field.go:211), and (3) app_id is excluded from the unique index so wallet re-registrations idempotently UPDATE rather than duplicate.
verdict
APPROVE. Three adversarial passes, zero exploitable cross-app leak paths found. Two in-band fixes pushed: s1 (apns2 constant) and s2 (WS rejection test). CI: CodeRabbit ✅, build ✅. All 4 test packages green on HEAD 1e336e5.
🤖 vultisig-ops reviewer
QA Evidence — notification#27 r1Test suite (HEAD 1e336e5)GORM migration safety (verified from source)
Cross-app leak verification (3 adversarial passes)
In-band fixes (1e336e5)
CICodeRabbit ✅ | build (18s) ✅ — both passing on original HEAD 127c5e2; fixes in 1e336e5 are logic-only (no new dependencies, no API changes). APNs runtime deliveryNot exercised against live APNs — this is a BE-only service with no iOS sim attachment needed. Correct topic routing (device.ResolvedAppID() → notification.Topic) is verified by code trace + test coverage. End-to-end delivery (Station app receives push, wallet does not) requires the companion changes in Station SDK + agent-backend noted in the PR body. 🤖 vultisig-ops reviewer |
…routing Companion to vultisig/notification#27 + agent-backend notifier app_id. Apps that share a vault with the regular wallet (Station, money.terra.station) register their device under their own bundle id so the notification service delivers their pushes to the right app instead of the wallet that shares the vault_id. - RegisterDeviceOptions.appId -> sent as app_id on /register, persisted locally. - unregisterVault sends the persisted app_id so the tokenless DELETE scopes to the same app (server defaults missing app_id to the wallet bucket). - isVaultRegistered(vaultId, appId?) migration-aware: a local record under a different/missing appId counts as not-registered so the consumer re-registers existing opted-in devices onto their app_id. - notifyVaultMembers gains an OPT-IN appId (not inferred): the keysign path must reach all vault devices regardless of app; scheduled app-scoping is the agent-backend notifier's job, not this method. All optional + wallet-default-preserving: the wallet sends no app_id and the server keeps legacy routing. @vultisig/sdk minor changeset included. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…routing (#606) Companion to vultisig/notification#27 + agent-backend notifier app_id. Apps that share a vault with the regular wallet (Station, money.terra.station) register their device under their own bundle id so the notification service delivers their pushes to the right app instead of the wallet that shares the vault_id. - RegisterDeviceOptions.appId -> sent as app_id on /register, persisted locally. - unregisterVault sends the persisted app_id so the tokenless DELETE scopes to the same app (server defaults missing app_id to the wallet bucket). - isVaultRegistered(vaultId, appId?) migration-aware: a local record under a different/missing appId counts as not-registered so the consumer re-registers existing opted-in devices onto their app_id. - notifyVaultMembers gains an OPT-IN appId (not inferred): the keysign path must reach all vault devices regardless of app; scheduled app-scoping is the agent-backend notifier's job, not this method. All optional + wallet-default-preserving: the wallet sends no app_id and the server keeps legacy routing. @vultisig/sdk minor changeset included. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
what
Both Station notification bugs share ONE root cause: the APNs topic was hardcoded to the wallet bundle id (
const appID = "com.vultisig.wallet") for every push. APNs only delivers a push when the topic matches the app that owns the device token, so an agent app (Station,money.terra.station) that shares avault_idwith the wallet had its pushes delivered to the wallet (topic match) and rejected for Station (topic mismatch). Result: the wallet received Station's notifications, and Station received nothing.This makes notifications route per-app: a device is pushed on its own app's APNs topic, and a notification can target a single app so it doesn't fan out to other apps sharing the vault.
how
app_id(defaults tocom.vultisig.wallet). The APNs topic is now the device's ownapp_idinstead of the hardcoded wallet id./notifygains an optionalapp_id: when set, only that app's devices are notified (closes the wallet leak); when omitted, it resolves to the wallet so behaviour is unchanged.app-certificatesconfig /APP_CERTIFICATES_JSONenv): APNs validates the provider cert against the topic, so each app needs its own cert. An app with no configured cert is skipped (never pushed with the wrong cert, which would 400 and could then delete a valid token).410 Unregistered/400 BadDeviceToken, never on a topic/cert mismatch.no regression for the regular Vultisig wallet (the hard requirement)
app_id, so it always resolves to the wallet bucket on register, notify, and unregister - identical to today.whereAppIDmakes the wallet bucket matchapp_id = 'com.vultisig.wallet' OR '' OR NULL, so it does not depend on the AutoMigrate backfill running: a pre-migration row with noapp_idis always still reachable (and unregisterable) as a wallet device.(vault_id, party_name, token)(APNs/FCM tokens are app-unique), so a wallet re-register idempotently updates its row (backfilling legacyapp_id) rather than duplicating it.rollout
Existing Station installs are currently stored in the wallet bucket (they never sent
app_id) and were not correctly receiving Station notifications (the bug). They migrate by re-registering when the updated Station app launches (sendingapp_id = money.terra.station); until then they stay in the wallet bucket exactly as today. The wallet is unaffected throughout. Companion changes (Station/SDK sendingapp_id, agent-backend/notifysendingapp_id, and provisioning Station's APNs cert) ship alongside.review
7 rounds of
codex exec review, each with real iterations (per-app certs + safe-skip, reason-aware unregister, WS isolation both ends, env-loadable certs, app-scoped queries with a backfill-independent wallet bucket, dedup normalization, and reverting an over-aggressive unique-index change that regressed token identity). Converged to a rollout note.receipts
risk
Medium (shared notification service), mitigated by the no-regression guarantees above + 7 review rounds. Wallet delivery path is behaviourally unchanged; new behaviour is strictly opt-in via
app_id+ provisioned per-app certs.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Configuration