Skip to content

feat(ios): end-to-end encrypted push, signing setup, and On-call loading state - #242

Draft
cl8dep wants to merge 2 commits into
mainfrom
feat/ios-push-e2e
Draft

feat(ios): end-to-end encrypted push, signing setup, and On-call loading state#242
cl8dep wants to merge 2 commits into
mainfrom
feat/ios-push-e2e

Conversation

@cl8dep

@cl8dep cl8dep commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

Completes RFC 0017 on iOS. The app had none of it: the device registered without a push key, so the
backend fell back to sending cleartext, and Apple could read the contents of every page. Android has
had the encrypted path since the RFC shipped.

The compiler found it first. Kotlin's default argument for pushPublicKey does not survive into
Swift, so registerDevice did not build — that failure is the evidence the client was never updated.

Three pieces: PushKeyStore generates a P-256 keypair and keeps the private half in the Keychain,
PushPayloadUnsealer opens the envelope, and a notification service extension runs it before iOS
displays anything. The extension is required, not a nicety: the app process is not running when a push
lands, so nothing else gets the chance to decrypt.

Also adds the signing and release setup the project was missing, and a few UI fixes found while
testing on a device.

Related

Client half of RFC 0017. The server half is #241the two should land together: once the backend
sends sealed payloads, a client without the extension shows only the placeholder text.

Changes

Push encryption

  • PushKeyStore — P-256 keypair, private half in the Keychain under AfterFirstUnlockThisDeviceOnly
    so the extension can decrypt on a locked phone, ThisDeviceOnly so a restored backup re-keys rather
    than inheriting.
  • PushPayloadUnsealer — ECDH P-256, HKDF-SHA256 salted with the ephemeral key, AES-GCM with the
    version bound into the AAD. Byte-for-byte with the server and the Android client.
  • PiroNotificationService — rewrites the notification before display, restores the userInfo the
    deep-link router needs, and falls back to a readable placeholder rather than swallowing a page it
    cannot decrypt.
  • PushManager now publishes the public key when registering.

Signing and release

  • DEVELOPMENT_TEAM pinned, ExportOptions.plist for App Store export, TestFlight documented in the
    README.
  • aps-environment split per configuration: it is a fixed string in a plist, so one file cannot serve
    both APNs environments.
  • Release and extension schemes, so push is testable at all — a Debug build gets the APNs sandbox and
    never sees a production push.
  • The Gradle pre-build phase probes for a JDK. Xcode inherits neither JAVA_HOME nor the shell PATH,
    so a toolchain that works in a terminal was invisible and the build failed with a bare nonzero exit.

UI

  • The On-call banner shows a skeleton while readiness is unknown instead of rendering a verdict the app
    does not have yet. It already had the right states; .registering just looked identical to an answer.
  • The email field uses .emailAddress rather than .username, so iOS offers addresses from Contacts
    and not only a saved credential.
  • The server URL placeholder drops its example, which the help text below already covers.
  • Previews for every readiness state — the project had none, and the skeleton is otherwise impossible
    to see, since registration completes in well under a second.

Testing

  • Manually verified the change end-to-end
  • Not applicable (docs/config only)

No dotnet test: this touches no C#. No iOS test target exists yet, which is the gap worth closing
next.

The crypto was verified against the real server sealer, not a Swift-side round trip — a
self-consistent bug would pass the latter. A PushPayloadSealer envelope decrypted with every field
intact; the vector is recorded in PushPayloadUnsealer.swift so the check is reproducible.

Verified on a physical iPhone: the app registers an 87-character public key (base64url of a
65-byte P-256 point), and a push sent through the relay was accepted and delivered.

Two things this took several attempts to get right, both now recorded in comments:

  • The Keychain access group must be the app's default one. A custom group has to be registered on
    the App ID and present in the provisioning profile; declaring it in the entitlements file is not
    enough. The Keychain then denies every call, which surfaced as a device registering with no push key
    and silently receiving cleartext. SecItemAdd's status is now logged rather than collapsed to a
    bool, because that failure was invisible.
  • Building the Release configuration does not produce a production-APNs build. The provisioning
    profile decides, and installing from Xcode uses the development profile, which forces
    aps-environment: development whatever the entitlements say. Only a distribution-signed export gets
    production. I got this wrong twice before checking the signed binary.

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

Still open

The extension has never actually decrypted a push on a device, because the server does not send sealed
payloads until #241 lands. The unsealer is verified against a real envelope in isolation; the
end-to-end path is not yet exercised.

cl8dep added 2 commits July 28, 2026 16:43
Completes RFC 0017 on iOS. The app had none of it: the compiler caught
that first, because Kotlin's default argument for pushPublicKey does not
survive into Swift, so registerDevice did not build. That failure is the
evidence the client was never updated when push encryption shipped.

PushKeyStore generates a P-256 keypair and keeps the private half in the
Keychain; only the public half is published, which is what makes a payload
unreadable by Piro's operator, by the relay, and by APNs.
PushPayloadUnsealer opens the envelope, and a new notification service
extension runs it before iOS displays anything — the app process is not
running when a push lands, so nothing else gets the chance.

The crypto was verified against a real PushPayloadSealer envelope rather
than a Swift-side round trip, which a self-consistent bug would pass. The
vector is recorded in the file; there is no iOS test target yet to keep
that check running.

Two things this took several attempts to get right, both recorded in
comments so the next person does not repeat them:

The Keychain access group must be the app's default one. A custom group
has to be registered on the App ID and present in the provisioning
profile — declaring it in the entitlements file is not enough, and the
Keychain then denies every call, which surfaces as a device that registers
with no push key and silently receives cleartext. SecItemAdd's status is
now logged instead of collapsed to a bool, because that failure was
invisible.

aps-environment needs one file per configuration. It is a fixed string in
a plist, so a Release build shipping `development` registers a sandbox
token that production APNs will not deliver to.

Also here, smaller:

- The On-call banner shows a skeleton while readiness is unknown, instead
  of rendering a verdict the app does not have yet. It already had the
  right states; .registering just looked identical to an answer.
- The email field uses .emailAddress rather than .username, so iOS offers
  addresses from Contacts and not only a saved credential.
- The server URL placeholder drops its example, which the help text below
  already covers.
- Release and extension schemes, so push is testable: a Debug build gets
  the APNs sandbox and never sees a production push.
- The Gradle pre-build phase probes for a JDK. Xcode inherits neither
  JAVA_HOME nor the shell PATH, so a toolchain that works in a terminal
  was invisible and the build failed with a bare nonzero exit.
Pins DEVELOPMENT_TEAM in project.yml so an archive signs against the team
that owns the co.heva.piro App ID regardless of which Mac builds it. A
contributor building for the simulator is unaffected: ad-hoc signing
ignores it.

Adds ExportOptions.plist configured for app-store-connect, so the export
is one command rather than a dialog. teamID is deliberately absent from
it — it comes from the build setting, so the two cannot disagree.

Documents the release path in the iOS README, including the three things
that actually catch people out: the build number must increase on every
upload or App Store Connect rejects it; the App ID needs the Push
Notifications capability or signing fails, because the app declares it in
its entitlements; and a TestFlight build registers against production
APNs, not the sandbox that debug builds use.

One correction worth recording: building the Release *configuration* does
not by itself produce a production-APNs build. The provisioning profile
decides, and installing straight from Xcode uses the development profile,
which forces aps-environment to development whatever the entitlements file
says. Only an export signed for distribution (ad-hoc or App Store) gets
production.
@cl8dep cl8dep added enhancement New feature or request frontend Frontend / UI work notifications Notification channels & triggers infrastructure Infrastructure & deployment implements-rfc Implements a previously approved RFC proposal labels Jul 28, 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 removed the implements-rfc Implements a previously approved RFC proposal label Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request frontend Frontend / UI work infrastructure Infrastructure & deployment notifications Notification channels & triggers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant