Skip to content

feat(mobile-push): end-to-end encrypted push and a Heva relay transport - #232

Merged
cl8dep merged 1 commit into
mainfrom
feat/push-relay-transport
Jul 27, 2026
Merged

feat(mobile-push): end-to-end encrypted push and a Heva relay transport#232
cl8dep merged 1 commit into
mainfrom
feat/push-relay-transport

Conversation

@cl8dep

@cl8dep cl8dep commented Jul 27, 2026

Copy link
Copy Markdown
Member

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, alertId as plain FCM data strings; title/body directly in the APNs aps.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-backend wiki; 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 through RegisterDeviceRequest, the service, the repository and DeviceTokenInfo. Nullable is deliberate: a device registered before this ships keeps working and publishes a key on its next launch.
  • The curve is P-256, not the X25519 the RFC draft specified. ECDiffieHellman.Create(ECCurve.CreateFromFriendlyName("curve25519")) throws PlatformNotSupportedException on .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 our minSdk of 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 existing PushSendResult. 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.
  • That same discipline fixes two pre-existing bugs in the direct transports: FcmPushTransport pruned on MessagingErrorCode.InvalidArgument (which FCM also returns for a malformed message, so a wrong service account wiped every Android token) and ApnsPushTransport pruned on DeviceTokenNotForTopic (a bundle-id misconfiguration, so a wrong ApnsBundleId wiped every iOS token).
  • Transport selection is now by (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.
  • PushTransportMode defaults to Direct, 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-invite exchanges a single-use inv_ 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-issued hvr_ key is stored as-is.
  • Redemption updates the integration in place. UserNotificationPreference and NotificationSubscription both cascade-delete from Integration, 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.
  • The relay fields live inside the existing Configuration section, gated on the delivery mode, rather than in a separate section.

Android:

  • Generates and persists the P-256 keypair. The cleartext path is kept for devices with no published key.
  • Adds the Server URL field on login, which iOS already had.

Bugs found and fixed along the way:

  • A CLR enum in a config class rendered as an empty text box: ConfigSchemaBuilder only emitted Enum for an explicit [ConfigFieldOptions], so enums now supply their own option names. Any integration with an enum benefits.
  • Reading a saved config containing a named enum threw: IntegrationHost deserialized with 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, reusing the rule the checks form already applied instead of adding a second mechanism.
  • Clearing a subscription's tag filter did not persist: FilterJson was missing from NotificationSubscriptionRepository.UpdateAsync's assignment list, so "filter": null was silently ignored.

Also included: removal of Service.HistoryDaysDesktop/HistoryDaysMobile and 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 test passes (unit + integration)
  • Manually verified the change end-to-end

Database

  • Adds an EF Core migration
  • Migration is safe against a populated production DB (no data loss on existing rows)
  • Down() is reversible — or, if not, that's called out below and a backup is required before deploy

Two migrations. DeviceTokenPushPublicKey adds a nullable column and is fully reversible.

DropServiceHistoryDays drops 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 generated 0, so a rollback yields usable values, but the previously configured per-service numbers are not recoverable. No other row data is affected.

Screenshots

Checklist

  • Title follows conventional commits
  • Applied all relevant labels
  • Docs updated if behavior/config changed (wiki, README, or RFC status)
  • No secrets, credentials, or .env/appsettings.*.json values committed

Docs: RFC 0017 is added and its §4.1 corrected to P-256 to match what shipped. google-services.json is 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.

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.
@cl8dep cl8dep added bug Something isn't working enhancement New feature or request backend Backend / API work frontend Frontend / UI work notifications Notification channels & triggers implements-rfc Implements a previously approved RFC proposal labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

RFC guard: could not resolve a target RFC.

This PR is labeled implements-rfc, but no RFC could be resolved from the branch name
(implements-rfc/NNNN-...) or a tracking issue referenced in the description.
Point the branch or body at the RFC it implements, or remove the implements-rfc label.

@cl8dep cl8dep added rfc Introduces a technical proposal or design document open for community feedback and discussion and removed implements-rfc Implements a previously approved RFC proposal labels Jul 27, 2026
@cl8dep cl8dep self-assigned this Jul 27, 2026
@cl8dep
cl8dep marked this pull request as ready for review July 27, 2026 19:33
@cl8dep
cl8dep merged commit bc1fa4c into main Jul 27, 2026
8 of 16 checks passed
@cl8dep
cl8dep deleted the feat/push-relay-transport branch July 27, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend / API work bug Something isn't working enhancement New feature or request frontend Frontend / UI work notifications Notification channels & triggers rfc Introduces a technical proposal or design document open for community feedback and discussion

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant