feat(mobile-push): end-to-end encrypted push and a Heva relay transport - #232
Merged
Conversation
Piro's mobile apps are published to the stores by Heva, so a store build is signed against Heva's Firebase project and Apple bundle id. A self-hosted operator whose team installed that app has no credentials that can reach it, and handing out Heva's service account would grant every operator send rights over every installation. Routing through Heva's push relay closes that gap, but every push Piro sent carried the alert in cleartext, so a relay would have read its customers' incident data. Both problems are fixed together, because neither is safe alone. Encryption. Each device generates a P-256 keypair at registration and publishes only the public half (DeviceToken.PushPublicKey, nullable, so a device registered earlier keeps working and re-publishes on next launch). The dispatcher seals per device — ECDH, HKDF-SHA256, AES-256-GCM with the envelope version bound as additional authenticated data, so a downgrade fails to decrypt rather than being reinterpreted. The curve is P-256 rather than the X25519 in the RFC draft: .NET throws PlatformNotSupportedException for curve25519, and hand-rolling curve arithmetic in a security path is not worth it. P-256 needs no new dependency on either side. Relay transport. RelayPushTransport posts the sealed blob to the relay and maps its response onto the existing PushSendResult. Only 410 prunes a token: the relay collapses every fault of its own into 503 precisely so callers do not delete healthy tokens over someone else's misconfiguration, and 401/403/ 429 must not be mistaken for token death. That discipline also fixes two pre-existing bugs in the direct transports, where FCM pruned on InvalidArgument and APNs on DeviceTokenNotForTopic — both misconfigurations that would wipe every token they touched. Transport selection is now by (platform, mode), because platform alone no longer identifies a transport and the relay would otherwise shadow FCM by DI registration order, silently. Onboarding. Heva mints a single-use invite; the admin pastes it and Piro redeems it for a scoped key, storing it encrypted. Redemption updates the integration in place: notification preferences and subscriptions cascade- delete from Integration, and MobilePush is one platform-wide instance, so recreating it would wipe the whole team's preferences with the invite already spent. Android decrypts the envelope and gains a Server URL field on login, which iOS already had. A release build previously pointed at the literal https://your-piro-host, making the published app unusable for any self-hoster. Changing host clears the session, since a token from one server is meaningless to another. Also fixed along the way: - A CLR enum in a config class rendered as an empty text box: the schema builder only emitted Enum for an explicit [ConfigFieldOptions], so enums now supply their own option names. - Reading a saved config with a named enum threw: IntegrationHost used Web defaults, which accept only the numeric form. - The integrations config form ignored [VisibleWhen], so mode-irrelevant credentials were all shown at once. It now honours it, like the checks form. - Clearing a subscription's tag filter did not persist: FilterJson was missing from the repository's update assignments. iOS decryption needs a Notification Service Extension, which does not exist yet, and the relay's dev deployment has no APNs key — so iOS is designed in the RFC and deferred to its own phase. Includes the removal of Service.HistoryDaysDesktop/Mobile and its migration.
Contributor
|
RFC guard: could not resolve a target RFC. This PR is labeled |
cl8dep
marked this pull request as ready for review
July 27, 2026 19:33
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Piro's mobile apps are published to the App Store and Play Store by heva, so a store build is signed against heva's Firebase project and Apple bundle id. A self-hosted operator whose team installed that app has no credentials that can reach it, and handing out Heva's FCM service account would grant every operator send rights over every installation. Routing through Heva's push relay is the only way to close that gap.
But every push Piro sent carried the alert in cleartext (
title,body,eventKey,alertIdas plain FCM data strings;title/bodydirectly in the APNsaps.alert), and alert titles routinely name hosts and failure modes. Adding a relay on top of that would mean Heva reads its customers' incident data. So both problems are fixed together, because neither is safe alone.The design principle is to encrypt for the device, not for the transport: the payload is sealed against a key the device generated before any transport is chosen, which is what makes adding a third-party relay safe at all.
Related
Implements RFC 0017, added in this PR. The relay's own contract is documented in the
heva-notifications-backendwiki; this PR only makes Piro a client of it.Changes
Encryption:
PushPayloadSealer: ECDH over P-256, HKDF-SHA256, AES-256-GCM, with a versioned envelope and the version bound into the additional authenticated data so a downgrade fails to decrypt rather than being silently reinterpreted. A fresh ephemeral keypair per push gives forward secrecy.DeviceToken.PushPublicKey(nullable) plus its migration, threaded throughRegisterDeviceRequest, the service, the repository andDeviceTokenInfo. Nullable is deliberate: a device registered before this ships keeps working and publishes a key on its next launch.ECDiffieHellman.Create(ECCurve.CreateFromFriendlyName("curve25519"))throwsPlatformNotSupportedExceptionon .NET, so X25519 would mean hand-writing curve arithmetic in a security path. P-256 needs no new dependency on either side: it is in the BCL, in Android's JCA well below ourminSdkof 26, and in CryptoKit. The RFC was corrected to match.Relay transport:
RelayPushTransport(one per platform) posts the sealed blob to the relay and maps its response onto the existingPushSendResult. Only 410 prunes a token. The relay collapses every fault of its own — expired APNs key, missing FCM credential, unknown appId, its own database down — into 503 precisely so callers do not delete healthy tokens over someone else's misconfiguration, and 401/403/429 must not be mistaken for token death.FcmPushTransportpruned onMessagingErrorCode.InvalidArgument(which FCM also returns for a malformed message, so a wrong service account wiped every Android token) andApnsPushTransportpruned onDeviceTokenNotForTopic(a bundle-id misconfiguration, so a wrongApnsBundleIdwiped every iOS token).(platform, mode). Platform alone no longer identifies a transport, and the relay would otherwise shadow FCM by DI registration order, with no error and no log.PushTransportModedefaults toDirect, so an existing deployment with FCM/APNs credentials behaves exactly as before after an upgrade. Relay is an explicit opt-in, never inferred from which fields are filled.Onboarding and admin UI:
POST /api/v1/integrations/{id}/relay/redeem-inviteexchanges a single-useinv_code for a scoped key and stores it encrypted, deriving the register endpoint from the configured push URL so the operator supplies one address. An already-issuedhvr_key is stored as-is.UserNotificationPreferenceandNotificationSubscriptionboth cascade-delete fromIntegration, and MobilePush is a single platform-wide instance shared by every user, so delete-and-recreate would wipe the whole team's notification preferences and subscriptions with the invite already spent.Android:
Bugs found and fixed along the way:
ConfigSchemaBuilderonly emittedEnumfor an explicit[ConfigFieldOptions], so enums now supply their own option names. Any integration with an enum benefits.IntegrationHostdeserialized with Web defaults, which accept only the numeric form.[VisibleWhen], so mode-irrelevant credentials were all shown at once. It now honours it, reusing the rule the checks form already applied instead of adding a second mechanism.FilterJsonwas missing fromNotificationSubscriptionRepository.UpdateAsync's assignment list, so"filter": nullwas silently ignored.Also included: removal of
Service.HistoryDaysDesktop/HistoryDaysMobileand its migration. This is bundled rather than split because the EF model snapshot carries both changes in one file, so separating them would leave the model inconsistent in whichever PR went first.Testing
dotnet testpasses (unit + integration)Database
Down()is reversible — or, if not, that's called out below and a backup is required before deployTwo migrations.
DeviceTokenPushPublicKeyadds a nullable column and is fully reversible.DropServiceHistoryDaysdrops two columns, so it is destructive by design: the per-service history-day values are discarded.Down()recreates the columns with the original entity defaults (30 and 15) rather than EF's generated0, so a rollback yields usable values, but the previously configured per-service numbers are not recoverable. No other row data is affected.Screenshots
Checklist
.env/appsettings.*.jsonvalues committedDocs: RFC 0017 is added and its §4.1 corrected to P-256 to match what shipped.
google-services.jsonis gitignored and stayed out of the commit; the relay API key is a[SecretField], so it is encrypted at rest and masked on the way out through the existing machinery.