feat(ios): end-to-end encrypted push, signing setup, and On-call loading state - #242
Draft
cl8dep wants to merge 2 commits into
Draft
feat(ios): end-to-end encrypted push, signing setup, and On-call loading state#242cl8dep wants to merge 2 commits into
cl8dep wants to merge 2 commits into
Conversation
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.
Contributor
|
RFC guard: could not resolve a target RFC. This PR is labeled |
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
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
pushPublicKeydoes not survive intoSwift, so
registerDevicedid not build — that failure is the evidence the client was never updated.Three pieces:
PushKeyStoregenerates a P-256 keypair and keeps the private half in the Keychain,PushPayloadUnsealeropens the envelope, and a notification service extension runs it before iOSdisplays 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 #241 — the 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 underAfterFirstUnlockThisDeviceOnlyso the extension can decrypt on a locked phone,
ThisDeviceOnlyso a restored backup re-keys ratherthan inheriting.
PushPayloadUnsealer— ECDH P-256, HKDF-SHA256 salted with the ephemeral key, AES-GCM with theversion bound into the AAD. Byte-for-byte with the server and the Android client.
PiroNotificationService— rewrites the notification before display, restores theuserInfothedeep-link router needs, and falls back to a readable placeholder rather than swallowing a page it
cannot decrypt.
PushManagernow publishes the public key when registering.Signing and release
DEVELOPMENT_TEAMpinned,ExportOptions.plistfor App Store export, TestFlight documented in theREADME.
aps-environmentsplit per configuration: it is a fixed string in a plist, so one file cannot serveboth APNs environments.
never sees a production push.
JAVA_HOMEnor the shell PATH,so a toolchain that works in a terminal was invisible and the build failed with a bare nonzero exit.
UI
does not have yet. It already had the right states;
.registeringjust looked identical to an answer..emailAddressrather than.username, so iOS offers addresses from Contactsand not only a saved credential.
to see, since registration completes in well under a second.
Testing
No
dotnet test: this touches no C#. No iOS test target exists yet, which is the gap worth closingnext.
The crypto was verified against the real server sealer, not a Swift-side round trip — a
self-consistent bug would pass the latter. A
PushPayloadSealerenvelope decrypted with every fieldintact; the vector is recorded in
PushPayloadUnsealer.swiftso 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 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 abool, because that failure was invisible.
profile decides, and installing from Xcode uses the development profile, which forces
aps-environment: developmentwhatever the entitlements say. Only a distribution-signed export getsproduction. I got this wrong twice before checking the signed binary.
Screenshots
Checklist
.env/appsettings.*.jsonvalues committedStill 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.