From 2220d8c1ca5893f1558a3b15b16cdc5bc30fd87e Mon Sep 17 00:00:00 2001 From: Arael Espinosa Date: Tue, 28 Jul 2026 16:43:06 -0300 Subject: [PATCH 1/2] feat(ios): decrypt sealed pushes, and stop claiming readiness too early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../iosApp/Piro.xcodeproj/project.pbxproj | 149 +++++++++++++++++- .../xcschemes/Piro (Release).xcscheme | 93 +++++++++++ .../PiroNotificationService.xcscheme | 95 +++++++++++ apps/mobile/iosApp/Piro/Auth/LoginView.swift | 7 +- .../iosApp/Piro/OnCall/OnCallView.swift | 52 +++++- .../iosApp/Piro/Piro.Release.entitlements | 21 +++ apps/mobile/iosApp/Piro/Piro.entitlements | 15 +- .../iosApp/Piro/Push/PushKeyStore.swift | 145 +++++++++++++++++ .../mobile/iosApp/Piro/Push/PushManager.swift | 10 +- .../Piro/Push/PushPayloadUnsealer.swift | 120 ++++++++++++++ .../iosApp/Piro/Support/SkeletonBar.swift | 29 ++++ .../iosApp/PiroNotificationService/Info.plist | 34 ++++ .../NotificationService.swift | 70 ++++++++ .../PiroNotificationService.entitlements | 15 ++ apps/mobile/iosApp/project.yml | 86 +++++++++- 15 files changed, 925 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/Piro (Release).xcscheme create mode 100644 apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/PiroNotificationService.xcscheme create mode 100644 apps/mobile/iosApp/Piro/Piro.Release.entitlements create mode 100644 apps/mobile/iosApp/Piro/Push/PushKeyStore.swift create mode 100644 apps/mobile/iosApp/Piro/Push/PushPayloadUnsealer.swift create mode 100644 apps/mobile/iosApp/Piro/Support/SkeletonBar.swift create mode 100644 apps/mobile/iosApp/PiroNotificationService/Info.plist create mode 100644 apps/mobile/iosApp/PiroNotificationService/NotificationService.swift create mode 100644 apps/mobile/iosApp/PiroNotificationService/PiroNotificationService.entitlements diff --git a/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj b/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj index 76935bab..f0c7d0d4 100644 --- a/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj +++ b/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj @@ -8,12 +8,16 @@ /* Begin PBXBuildFile section */ 0FDD73F4D0747D5AC3D3C264 /* ServerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B0ABB9E71DA75AF7F2F17B /* ServerStore.swift */; }; + 138DBD75D2EEDFBD77871946 /* PiroNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 242F1145BEDEBCE61674D46B /* PushKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */; }; 3E362161024CADEDDF3E71AA /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E13EC31C62547DD9776B6A /* ProfileView.swift */; }; 412326DD143D9404CB96F75C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D0DAD9E92B4E5A1358A57C /* RootView.swift */; }; 427D4D34D05F4D9906441FAC /* OnCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F616189395D2F7306DCAE4 /* OnCallView.swift */; }; + 47912A086EC64E9C1934AC8B /* PushPayloadUnsealer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */; }; 50731D9EED4EE54FDAB74899 /* PlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763682643898A70C06FE1F5B /* PlaceholderView.swift */; }; 55FDBD0DB8DF78CB09A0E0FA /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C48FD355AD7B768D82C966 /* LoginView.swift */; }; 572178D60FB2093056FFD8B0 /* ScheduleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DB19F63117BF01303A7881 /* ScheduleViewModel.swift */; }; + 5C198978EA01EBDCD8FD2DFB /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B760316313B14C728B1E192F /* NotificationService.swift */; }; 5F88595A22F6441B81F704DA /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A6656F35212FB479CA497D /* DeepLinkRouter.swift */; }; 61921A5CACB0E1D36D62C86C /* PiroApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DF87ECBA4A4D253AACFC9F /* PiroApp.swift */; }; 658D3AABA133865D1215D9B6 /* SSOAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5026A1EEF3F8AB356A4B35 /* SSOAuthenticator.swift */; }; @@ -22,6 +26,7 @@ 6F676B86C4B60FEE0A5FC4E0 /* SessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E44A9B88A30DDFDE4B2192 /* SessionViewModel.swift */; }; 7024C4CBF699AC1839469F68 /* AlertsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AF99D6A78AE18F845F5057 /* AlertsViewModel.swift */; }; 71DBFFBC524BCB03CB1EBCF0 /* AlertDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB8AB5C8DB22611C627AF39 /* AlertDetailView.swift */; }; + 738A6D3EC77B52819035E088 /* PushPayloadUnsealer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */; }; 74B88B7E663754DA66B3633D /* KotlinError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE467E739C89E65CD524D06 /* KotlinError.swift */; }; 8274B5B5C8E88E4962FDFC8B /* PiroColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE46AD1A5BF6F16583727C51 /* PiroColors.swift */; }; 8A4AE16AF0298BB7695AE315 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC895C8FF2205C8C52B6E1C /* ProfileViewModel.swift */; }; @@ -32,16 +37,42 @@ B278AEBD05ADED00B553DB6B /* ServiceLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FDE5A79B1261DDC01D51CA3 /* ServiceLocator.swift */; }; B2C9C410F80081EFBFADCE1D /* AlertCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FB1EFA2485E2081354C60C /* AlertCardView.swift */; }; BA005EDE62354F9727E36DE2 /* PushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6DEE0D7847DC9CA0E51674A /* PushManager.swift */; }; + C43BA87BADEADCB4C8B8FB63 /* PushKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */; }; C6D90E297A7FBD24ACACD4D3 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 056B84BBA5C207D5631A18CE /* AvatarView.swift */; }; D2CE07219C6B92D5CDF5C7CC /* DateFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0D60FE7E2C3DB7CFF1680D /* DateFormatting.swift */; }; DB953CE8A6B3ACBE901C6032 /* AlertDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D36995A74084B63BBC85976 /* AlertDetailViewModel.swift */; }; DBE0A330F49107E1E43E28B0 /* NotificationPreferenceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE3F434BD2AC1E27E922FED /* NotificationPreferenceRow.swift */; }; + EC917A2419688CDF21F2CB17 /* SkeletonBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF6673E2E8ED02C6A9446B /* SkeletonBar.swift */; }; ECAE739EDFC61B258C0A13FF /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3356AE05FEC9631ECFCDD275 /* GlassCard.swift */; }; F27E7489AC6322EF3AFD0252 /* ProfileField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D8C10B3F353DBDBA5AA516 /* ProfileField.swift */; }; F73642C146DB79A7E7579894 /* PiroFlame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E5D4206DC27DA9CACECD1B1 /* PiroFlame.swift */; }; FA6A31ED560C1B87C92BDEFD /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA529AF2D178ED8107E65405 /* AppConfig.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + D6C709D54D517C07EE63208F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 07279C4D4230F4EF8635CD26 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D1CCDB0F70F42F7329C71BCE; + remoteInfo = PiroNotificationService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 2D8A1D1FE9DB215944743544 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 138DBD75D2EEDFBD77871946 /* PiroNotificationService.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 01D8C10B3F353DBDBA5AA516 /* ProfileField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileField.swift; sourceTree = ""; }; 056B84BBA5C207D5631A18CE /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; @@ -55,9 +86,11 @@ 37D56ED11C9EE31FCA26CF5A /* Piro.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Piro.entitlements; sourceTree = ""; }; 3CE3F434BD2AC1E27E922FED /* NotificationPreferenceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreferenceRow.swift; sourceTree = ""; }; 3FC895C8FF2205C8C52B6E1C /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + 47AF6673E2E8ED02C6A9446B /* SkeletonBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonBar.swift; sourceTree = ""; }; 58DB19F63117BF01303A7881 /* ScheduleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleViewModel.swift; sourceTree = ""; }; 5FDE5A79B1261DDC01D51CA3 /* ServiceLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLocator.swift; sourceTree = ""; }; 625F1EE13CA13BAAFBDE58A6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 662F838D22BDE6260051F574 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 69D0DAD9E92B4E5A1358A57C /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 6CB8AB5C8DB22611C627AF39 /* AlertDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertDetailView.swift; sourceTree = ""; }; 6D5026A1EEF3F8AB356A4B35 /* SSOAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOAuthenticator.swift; sourceTree = ""; }; @@ -68,20 +101,36 @@ 7E96033D789E6C309FBA079F /* MainTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; 82F616189395D2F7306DCAE4 /* OnCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnCallView.swift; sourceTree = ""; }; 8E5D4206DC27DA9CACECD1B1 /* PiroFlame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiroFlame.swift; sourceTree = ""; }; + 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = PiroNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 92FB1EFA2485E2081354C60C /* AlertCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCardView.swift; sourceTree = ""; }; + 9B80C358B2C6C6C74B71688B /* PiroNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PiroNotificationService.entitlements; sourceTree = ""; }; AE0D60FE7E2C3DB7CFF1680D /* DateFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatting.swift; sourceTree = ""; }; + B760316313B14C728B1E192F /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; BE46AD1A5BF6F16583727C51 /* PiroColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiroColors.swift; sourceTree = ""; }; C217B54C3293533C5F33C653 /* PiroScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiroScreen.swift; sourceTree = ""; }; C2E44A9B88A30DDFDE4B2192 /* SessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionViewModel.swift; sourceTree = ""; }; C6DEE0D7847DC9CA0E51674A /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = ""; }; D9471B6753362DB28C08FD43 /* ScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleView.swift; sourceTree = ""; }; + E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadUnsealer.swift; sourceTree = ""; }; + E5C935F3B7EBC38D35AD93BA /* Piro.Release.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Piro.Release.entitlements; sourceTree = ""; }; E723062807D400E2D6F41ED9 /* AlertsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsListView.swift; sourceTree = ""; }; EA529AF2D178ED8107E65405 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; + F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushKeyStore.swift; sourceTree = ""; }; F5AF99D6A78AE18F845F5057 /* AlertsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsViewModel.swift; sourceTree = ""; }; FDE467E739C89E65CD524D06 /* KotlinError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinError.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ + 04780B0768D58B4024257DAC /* PiroNotificationService */ = { + isa = PBXGroup; + children = ( + 662F838D22BDE6260051F574 /* Info.plist */, + B760316313B14C728B1E192F /* NotificationService.swift */, + 9B80C358B2C6C6C74B71688B /* PiroNotificationService.entitlements */, + ); + path = PiroNotificationService; + sourceTree = ""; + }; 10B14CD50B507B20235B0020 /* OnCall */ = { isa = PBXGroup; children = ( @@ -98,6 +147,7 @@ 3356AE05FEC9631ECFCDD275 /* GlassCard.swift */, FDE467E739C89E65CD524D06 /* KotlinError.swift */, C217B54C3293533C5F33C653 /* PiroScreen.swift */, + 47AF6673E2E8ED02C6A9446B /* SkeletonBar.swift */, 10C198668CBBC55B43704A02 /* SkeletonCard.swift */, ); path = Support; @@ -117,6 +167,7 @@ isa = PBXGroup; children = ( BDEFD919FCFD2275A553CD46 /* Piro */, + 04780B0768D58B4024257DAC /* PiroNotificationService */, C3E397762B087AA8627DE779 /* Products */, ); sourceTree = ""; @@ -142,7 +193,9 @@ 3F4F6C8C0DA239B2E3B46EA9 /* Push */ = { isa = PBXGroup; children = ( + F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */, C6DEE0D7847DC9CA0E51674A /* PushManager.swift */, + E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */, ); path = Push; sourceTree = ""; @@ -171,6 +224,7 @@ 625F1EE13CA13BAAFBDE58A6 /* Assets.xcassets */, 1963E6569447089C328596E3 /* Info.plist */, 37D56ED11C9EE31FCA26CF5A /* Piro.entitlements */, + E5C935F3B7EBC38D35AD93BA /* Piro.Release.entitlements */, 15DF87ECBA4A4D253AACFC9F /* PiroApp.swift */, 24B0ABB9E71DA75AF7F2F17B /* ServerStore.swift */, 5FDE5A79B1261DDC01D51CA3 /* ServiceLocator.swift */, @@ -204,6 +258,7 @@ isa = PBXGroup; children = ( 0FA56F1D81DBE8F40393DB8B /* Piro.app */, + 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */, ); name = Products; sourceTree = ""; @@ -230,10 +285,12 @@ 8C0BD025CD789B07AD47BB19 /* Build Shared KMP framework (Gradle) */, 1ED0A598E7CF1486F0727298 /* Sources */, 137D0DCBA045AD47285D9886 /* Resources */, + 2D8A1D1FE9DB215944743544 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + A9A62A2E3A8B8A0AA4286179 /* PBXTargetDependency */, ); name = Piro; packageProductDependencies = ( @@ -242,6 +299,23 @@ productReference = 0FA56F1D81DBE8F40393DB8B /* Piro.app */; productType = "com.apple.product-type.application"; }; + D1CCDB0F70F42F7329C71BCE /* PiroNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = B251EBFB0FA9F4ED32F62CCD /* Build configuration list for PBXNativeTarget "PiroNotificationService" */; + buildPhases = ( + 37DD0D3B1E20B85835CE1727 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PiroNotificationService; + packageProductDependencies = ( + ); + productName = PiroNotificationService; + productReference = 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -254,6 +328,10 @@ AE9E99BD1414C3B4E9B987B4 = { ProvisioningStyle = Automatic; }; + D1CCDB0F70F42F7329C71BCE = { + DevelopmentTeam = GNUVS35QW5; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 3C688EB43195562B748FA670 /* Build configuration list for PBXProject "Piro" */; @@ -271,6 +349,7 @@ projectRoot = ""; targets = ( AE9E99BD1414C3B4E9B987B4 /* Piro */, + D1CCDB0F70F42F7329C71BCE /* PiroNotificationService */, ); }; /* End PBXProject section */ @@ -304,7 +383,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n# Xcode's build environment has no JAVA_HOME/PATH java; fall back to Android Studio's JBR.\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n if [ -x \"/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java\" ]; then\n export JAVA_HOME=\"/Applications/Android Studio.app/Contents/jbr/Contents/Home\"\n fi\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "set -e\n# Xcode's build environment inherits neither JAVA_HOME nor the shell PATH, so a JDK that works\n# from a terminal is invisible here. Probe the usual homes in order and fail with something\n# actionable rather than letting Gradle die with \"Unable to locate a Java Runtime\".\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n for candidate in \\\n \"/Applications/Android Studio.app/Contents/jbr/Contents/Home\" \\\n \"/opt/homebrew/opt/openjdk@17\" \\\n \"/opt/homebrew/opt/openjdk\" \\\n \"/usr/local/opt/openjdk@17\" \\\n \"/usr/local/opt/openjdk\"; do\n if [ -x \"$candidate/bin/java\" ]; then export JAVA_HOME=\"$candidate\"; break; fi\n done\nfi\n# Last resort: ask macOS itself, which knows about JDKs installed as system packages.\nif [ -z \"$JAVA_HOME\" ] && [ -x /usr/libexec/java_home ]; then\n JAVA_HOME=\"$(/usr/libexec/java_home 2>/dev/null)\" && export JAVA_HOME\nfi\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n echo \"error: no JDK found. Install one (brew install openjdk@17) or set JAVA_HOME.\" >&2\n exit 1\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -336,7 +415,9 @@ F27E7489AC6322EF3AFD0252 /* ProfileField.swift in Sources */, 3E362161024CADEDDF3E71AA /* ProfileView.swift in Sources */, 8A4AE16AF0298BB7695AE315 /* ProfileViewModel.swift in Sources */, + 242F1145BEDEBCE61674D46B /* PushKeyStore.swift in Sources */, BA005EDE62354F9727E36DE2 /* PushManager.swift in Sources */, + 47912A086EC64E9C1934AC8B /* PushPayloadUnsealer.swift in Sources */, 412326DD143D9404CB96F75C /* RootView.swift in Sources */, 658D3AABA133865D1215D9B6 /* SSOAuthenticator.swift in Sources */, 9BEDEB3C98E27DFBDA514428 /* ScheduleView.swift in Sources */, @@ -344,13 +425,50 @@ 0FDD73F4D0747D5AC3D3C264 /* ServerStore.swift in Sources */, B278AEBD05ADED00B553DB6B /* ServiceLocator.swift in Sources */, 6F676B86C4B60FEE0A5FC4E0 /* SessionViewModel.swift in Sources */, + EC917A2419688CDF21F2CB17 /* SkeletonBar.swift in Sources */, 9235D3D9EC2B4C02D9E46952 /* SkeletonCard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 37DD0D3B1E20B85835CE1727 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C198978EA01EBDCD8FD2DFB /* NotificationService.swift in Sources */, + C43BA87BADEADCB4C8B8FB63 /* PushKeyStore.swift in Sources */, + 738A6D3EC77B52819035E088 /* PushPayloadUnsealer.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A9A62A2E3A8B8A0AA4286179 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D1CCDB0F70F42F7329C71BCE /* PiroNotificationService */; + targetProxy = D6C709D54D517C07EE63208F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 53DE39F39CE5EBFE6B31EB0C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = PiroNotificationService/PiroNotificationService.entitlements; + INFOPLIST_FILE = PiroNotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.heva.piro.NotificationService; + PRODUCT_NAME = PiroNotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 5B639983B5B7817BB5611399 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -505,11 +623,29 @@ }; name = Debug; }; + C4E054C097938016CA4423E3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = PiroNotificationService/PiroNotificationService.entitlements; + INFOPLIST_FILE = PiroNotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.heva.piro.NotificationService; + PRODUCT_NAME = PiroNotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; DC591190A7A94F714C204137 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = Piro/Piro.entitlements; + CODE_SIGN_ENTITLEMENTS = Piro/Piro.Release.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -553,6 +689,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + B251EBFB0FA9F4ED32F62CCD /* Build configuration list for PBXNativeTarget "PiroNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C4E054C097938016CA4423E3 /* Debug */, + 53DE39F39CE5EBFE6B31EB0C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 07279C4D4230F4EF8635CD26 /* Project object */; diff --git a/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/Piro (Release).xcscheme b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/Piro (Release).xcscheme new file mode 100644 index 00000000..96aa21d9 --- /dev/null +++ b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/Piro (Release).xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/PiroNotificationService.xcscheme b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/PiroNotificationService.xcscheme new file mode 100644 index 00000000..cc4e61a0 --- /dev/null +++ b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/PiroNotificationService.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/iosApp/Piro/Auth/LoginView.swift b/apps/mobile/iosApp/Piro/Auth/LoginView.swift index 4a769bde..265eb4bf 100644 --- a/apps/mobile/iosApp/Piro/Auth/LoginView.swift +++ b/apps/mobile/iosApp/Piro/Auth/LoginView.swift @@ -56,7 +56,7 @@ struct LoginView: View { private var serverField: some View { VStack(alignment: .leading, spacing: 4) { - TextField("Server URL (https://piro.example.com)", text: $session.serverURL) + TextField("Server URL", text: $session.serverURL) .textContentType(.URL) .keyboardType(.URL) .textInputAutocapitalization(.never) @@ -78,7 +78,10 @@ struct LoginView: View { private var credentialFields: some View { VStack(spacing: 12) { TextField("Email", text: $session.email) - .textContentType(.username) + // .emailAddress rather than .username: it is what makes iOS offer the addresses from + // Contacts and the keyboard's own suggestions, on top of any saved credential. With + // .username the QuickType bar only ever proposes a stored login. + .textContentType(.emailAddress) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() diff --git a/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift b/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift index 8c7941be..9e8cdfde 100644 --- a/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift +++ b/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift @@ -23,6 +23,7 @@ struct OnCallView: View { statusBanner .padding(.top, 28) + .animation(.easeInOut(duration: 0.25), value: readiness) Spacer() Spacer() } @@ -31,14 +32,35 @@ struct OnCallView: View { } } + @ViewBuilder private var statusBanner: some View { - Text(bannerText) - .font(.callout.weight(.medium)) - .multilineTextAlignment(.center) - .foregroundStyle(bannerColor) + if readiness == .registering { + // While registering, the app genuinely does not know whether this device will be paged, so + // it shows a placeholder instead of a claim. The card keeps its shape and position, so the + // layout does not jump when the real answer arrives a moment later. + VStack(spacing: 8) { + SkeletonBar(width: 220) + SkeletonBar(width: 150) + } .padding(16) .frame(maxWidth: .infinity) - .glassCard(cornerRadius: 12, tint: bannerColor.opacity(0.12)) + .glassCard(cornerRadius: 12, tint: PiroColors.muted(scheme).opacity(0.08)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Arming this device to receive pages") + // VoiceOver should hear progress rather than silence, since the skeleton conveys nothing + // to a screen reader on its own. + .accessibilityAddTraits(.updatesFrequently) + .transition(.opacity) + } else { + Text(bannerText) + .font(.callout.weight(.medium)) + .multilineTextAlignment(.center) + .foregroundStyle(bannerColor) + .padding(16) + .frame(maxWidth: .infinity) + .glassCard(cornerRadius: 12, tint: bannerColor.opacity(0.12)) + .transition(.opacity) + } } /// The banner only promises pages when the device is actually registered with the backend — otherwise @@ -46,6 +68,8 @@ struct OnCallView: View { private var bannerText: String { switch readiness { case .registered: return "This device will receive critical pages, even on silent." + // Not rendered: .registering shows the skeleton above. Kept so the switch stays exhaustive + // and the string exists if the banner is ever reused somewhere without one. case .registering: return "Arming this device to receive pages…" case .needsPermission: return "Enable notifications so pages can reach you." case .failed: return "This device isn't registered for pages yet — you may not be paged." @@ -63,3 +87,21 @@ struct OnCallView: View { } } } + +// Previews for every readiness state, because the real ones are hard to catch: registration completes +// in well under a second, so the skeleton would otherwise only ever be seen by accident. +#Preview("Registering (skeleton)") { + OnCallView(userName: "aespinosa@heva.co", readiness: .registering) +} + +#Preview("Registered") { + OnCallView(userName: "aespinosa@heva.co", readiness: .registered) +} + +#Preview("Needs permission") { + OnCallView(userName: "aespinosa@heva.co", readiness: .needsPermission) +} + +#Preview("Failed") { + OnCallView(userName: "aespinosa@heva.co", readiness: .failed) +} diff --git a/apps/mobile/iosApp/Piro/Piro.Release.entitlements b/apps/mobile/iosApp/Piro/Piro.Release.entitlements new file mode 100644 index 00000000..097ad9af --- /dev/null +++ b/apps/mobile/iosApp/Piro/Piro.Release.entitlements @@ -0,0 +1,21 @@ + + + + + + aps-environment + production + keychain-access-groups + + + $(AppIdentifierPrefix)co.heva.piro + + + diff --git a/apps/mobile/iosApp/Piro/Piro.entitlements b/apps/mobile/iosApp/Piro/Piro.entitlements index a2fb8ad8..28dc9aea 100644 --- a/apps/mobile/iosApp/Piro/Piro.entitlements +++ b/apps/mobile/iosApp/Piro/Piro.entitlements @@ -1,9 +1,22 @@ + - aps-environment development + keychain-access-groups + + + $(AppIdentifierPrefix)co.heva.piro + diff --git a/apps/mobile/iosApp/Piro/Push/PushKeyStore.swift b/apps/mobile/iosApp/Piro/Push/PushKeyStore.swift new file mode 100644 index 00000000..8122ddb3 --- /dev/null +++ b/apps/mobile/iosApp/Piro/Push/PushKeyStore.swift @@ -0,0 +1,145 @@ +import CryptoKit +import Foundation +import Security + +/// Holds this device's push keypair. The private half never leaves the device and is never sent to any +/// backend, which is what makes a payload sealed against the public half unreadable by Piro's operator, +/// by heva's relay, and by APNs (RFC 0017). +/// +/// The curve is NIST P-256 to match the backend: .NET's `ECDiffieHellman` cannot do X25519, so the +/// sealer standardised on P-256 across all three platforms. +/// +/// Stored in the Keychain with `kSecAttrAccessibleAfterFirstUnlock` rather than +/// `WhenUnlockedThisDeviceOnly`: a Notification Service Extension has to decrypt while the phone is +/// locked, which is the entire point of a push. `ThisDeviceOnly` keeps it out of iCloud backups, so a +/// restored backup simply generates a fresh keypair and re-registers rather than resurrecting a key +/// that other devices might hold. +enum PushKeyStore { + + /// Shared with the notification service extension, so both processes see the same key. Without + /// this the extension generates its own keypair and can never decrypt what the backend sealed for + /// the app. + /// + /// This is the app's *default* access group — its application-identifier — rather than a custom + /// one. A custom group has to be registered on the App ID and present in the provisioning profile; + /// declaring it only in the entitlements file is not enough, and the Keychain then denies every + /// call with errSecMissingEntitlement, which surfaces as a device that registers with no push key + /// at all. The default group is authorised by every profile automatically, and both targets list + /// it in their entitlements, so it shares just as well with nothing to configure. + static let accessGroup: String? = "GNUVS35QW5.co.heva.piro" + + private static let service = "co.heva.piro.push" + private static let account = "push-private-p256" + + /// Uncompressed EC point: `0x04 || X(32) || Y(32)` — the exact encoding the backend imports. + private static let publicKeyLength = 65 + + // MARK: - Public API + + /// The device's public key as base64url of an uncompressed EC point, generating and persisting a + /// keypair on first call. Returns nil only if the Keychain refuses to store the key, in which case + /// the device registers unsealed rather than failing to register at all. + static func publicKeyBase64Url() -> String? { + guard let key = ensureKeyPair() else { return nil } + return base64UrlEncode(key.publicKey.x963Representation) + } + + /// The private key, for agreeing with the ephemeral key inside a sealed envelope. + static func privateKey() -> P256.KeyAgreement.PrivateKey? { + ensureKeyPair() + } + + /// True once a keypair exists, i.e. this device can receive sealed pushes. + static var hasKey: Bool { + loadPrivateKey() != nil + } + + // MARK: - Keychain + + private static func ensureKeyPair() -> P256.KeyAgreement.PrivateKey? { + if let existing = loadPrivateKey() { return existing } + + let key = P256.KeyAgreement.PrivateKey() + return store(key) ? key : nil + } + + private static func loadPrivateKey() -> P256.KeyAgreement.PrivateKey? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + + // A key that no longer parses is worse than no key: it would fail every decrypt silently. Drop + // it so the next call generates a fresh one and the device re-registers. + guard let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: data) else { + delete() + return nil + } + return key + } + + @discardableResult + private static func store(_ key: P256.KeyAgreement.PrivateKey) -> Bool { + delete() // SecItemAdd fails on a duplicate rather than replacing + + var attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: key.rawRepresentation, + // AfterFirstUnlock, not WhenUnlocked: the NSE decrypts on a locked phone. + // ThisDeviceOnly keeps it out of backups — a restored device should re-key, not inherit. + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + if let accessGroup { attributes[kSecAttrAccessGroup as String] = accessGroup } + + let status = SecItemAdd(attributes as CFDictionary, nil) + if status != errSecSuccess { + // Logged rather than swallowed: a failure here means the device registers with no push key + // and silently receives unsealed pushes, which looks identical to success from the UI. + // errSecMissingEntitlement (-34018) is the one to expect — it means the access group is not + // in the provisioning profile, not merely absent from the entitlements file. + let reason = SecCopyErrorMessageString(status, nil) as String? ?? "unknown" + NSLog("[PushKeyStore] could not store the push key: OSStatus \(status) — \(reason)") + } + return status == errSecSuccess + } + + private static func delete() { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + SecItemDelete(query as CFDictionary) + } + + // MARK: - base64url + + /// base64url without padding, matching what the backend's `Base64Url` emits and expects. + static func base64UrlEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func base64UrlDecode(_ string: String) -> Data? { + var s = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Restore the padding base64url drops; Foundation's decoder requires it. + let remainder = s.count % 4 + if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) } + return Data(base64Encoded: s) + } +} diff --git a/apps/mobile/iosApp/Piro/Push/PushManager.swift b/apps/mobile/iosApp/Piro/Push/PushManager.swift index c229d760..bd93273c 100644 --- a/apps/mobile/iosApp/Piro/Push/PushManager.swift +++ b/apps/mobile/iosApp/Piro/Push/PushManager.swift @@ -139,7 +139,15 @@ final class PushManager: NSObject, ObservableObject { armingDeadlineTask?.cancel() // a token arrived — the timeout is moot Task { do { - _ = try await api.registerDevice(platform: "Ios", token: token, deviceName: name) + // Publishing the public half is what makes the backend seal for this device (RFC 0017); + // the private half stays in the Keychain. Nil only if the Keychain refused to store a + // key, in which case the server sends unsealed rather than not sending at all — a + // readable page beats a silent one. + _ = try await api.registerDevice( + platform: "iOS", + token: token, + deviceName: name, + pushPublicKey: PushKeyStore.publicKeyBase64Url()) readiness = .registered } catch { readiness = .failed diff --git a/apps/mobile/iosApp/Piro/Push/PushPayloadUnsealer.swift b/apps/mobile/iosApp/Piro/Push/PushPayloadUnsealer.swift new file mode 100644 index 00000000..02c9d0dd --- /dev/null +++ b/apps/mobile/iosApp/Piro/Push/PushPayloadUnsealer.swift @@ -0,0 +1,120 @@ +import CryptoKit +import Foundation + +/// What the backend sealed for this device. +struct PushPayload: Equatable { + let title: String + let body: String + let eventKey: String + let alertId: Int + let url: String? +} + +enum PushUnsealError: Error, Equatable { + case notBase64Url + case malformedEnvelope + case unsupportedVersion(Int) + case badNonceLength(Int) + case ciphertextTooShort + case noPrivateKey + case decryptionFailed +} + +/// Opens an envelope sealed by the backend's `PushPayloadSealer` (RFC 0017). +/// +/// The scheme, which must stay byte-for-byte in step with the server and the Android client: +/// - ECDH P-256 between this device's private key and the ephemeral public key in the envelope +/// - HKDF-SHA256 over the shared secret, salted with that ephemeral public key, info `piro-push-v1` +/// - AES-256-GCM, 12-byte nonce, 16-byte tag appended to the ciphertext, AAD `piro-push-v1` +/// +/// The ephemeral public key travels in the clear on purpose: it is not a secret, and without this +/// device's private key it yields nothing. That is what lets a relay forward a payload it cannot read. +/// +/// Verified against an envelope produced by the real server sealer — the interop that matters here is +/// with `PushPayloadSealer`, not with a Swift-side round trip, since a self-consistent bug would pass +/// the latter. There is no iOS test target yet to keep that check running; this vector reproduces it: +/// +/// private key (base64url raw): 6Z--VmFjwVhHk-aUJz1zi-CN8eOC2IWv4tznQuzR_3U +/// decrypts to: title "Critical alert", body "heva-api is down", +/// eventKey "alert.raised", alertId 4242, url "piro://alert/4242" +enum PushPayloadUnsealer { + + private static let version = 1 + private static let associatedData = Data("piro-push-v1".utf8) + private static let hkdfInfo = Data("piro-push-v1".utf8) + private static let nonceLength = 12 + private static let tagLength = 16 + + /// Decrypts an envelope using the key held in the Keychain. + static func unseal(_ envelopeBase64Url: String) throws -> PushPayload { + guard let key = PushKeyStore.privateKey() else { throw PushUnsealError.noPrivateKey } + return try unseal(envelopeBase64Url, with: key) + } + + /// Testable overload taking the key directly. + static func unseal( + _ envelopeBase64Url: String, + with privateKey: P256.KeyAgreement.PrivateKey + ) throws -> PushPayload { + guard let envelopeData = PushKeyStore.base64UrlDecode(envelopeBase64Url) else { + throw PushUnsealError.notBase64Url + } + guard let envelope = try? JSONSerialization.jsonObject(with: envelopeData) as? [String: Any] else { + throw PushUnsealError.malformedEnvelope + } + + // The version is checked before anything else and is also bound into the AAD, so a downgrade + // fails to decrypt rather than being silently reinterpreted. + guard let v = envelope["V"] as? Int else { throw PushUnsealError.malformedEnvelope } + guard v == version else { throw PushUnsealError.unsupportedVersion(v) } + + guard let epk = envelope["Epk"] as? String, + let n = envelope["N"] as? String, + let ct = envelope["Ct"] as? String, + let ephemeralRaw = PushKeyStore.base64UrlDecode(epk), + let nonceData = PushKeyStore.base64UrlDecode(n), + let sealedData = PushKeyStore.base64UrlDecode(ct) + else { throw PushUnsealError.malformedEnvelope } + + guard nonceData.count == nonceLength else { + throw PushUnsealError.badNonceLength(nonceData.count) + } + guard sealedData.count > tagLength else { throw PushUnsealError.ciphertextTooShort } + + guard let ephemeralPublic = try? P256.KeyAgreement.PublicKey(x963Representation: ephemeralRaw) else { + throw PushUnsealError.malformedEnvelope + } + + guard let shared = try? privateKey.sharedSecretFromKeyAgreement(with: ephemeralPublic) else { + throw PushUnsealError.decryptionFailed + } + + // Salted with the ephemeral public key so two pushes to the same device never derive the same + // content key. CryptoKit zeroes the derived material itself when it goes out of scope. + let key = shared.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: ephemeralRaw, + sharedInfo: hkdfInfo, + outputByteCount: 32) + + // The server appends the tag to the ciphertext, which is exactly what `combined` expects once + // the nonce is prefixed. + guard let nonce = try? AES.GCM.Nonce(data: nonceData), + let box = try? AES.GCM.SealedBox( + combined: Data(nonce) + sealedData), + let plaintext = try? AES.GCM.open(box, using: key, authenticating: associatedData) + else { throw PushUnsealError.decryptionFailed } + + guard let json = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] else { + throw PushUnsealError.malformedEnvelope + } + + return PushPayload( + title: json["title"] as? String ?? "", + body: json["body"] as? String ?? "", + eventKey: json["eventKey"] as? String ?? "", + alertId: json["alertId"] as? Int ?? 0, + // A JSON null decodes to NSNull, which would render as "" if passed through. + url: (json["url"] as? String).flatMap { $0.isEmpty ? nil : $0 }) + } +} diff --git a/apps/mobile/iosApp/Piro/Support/SkeletonBar.swift b/apps/mobile/iosApp/Piro/Support/SkeletonBar.swift new file mode 100644 index 00000000..1a7ad587 --- /dev/null +++ b/apps/mobile/iosApp/Piro/Support/SkeletonBar.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A placeholder bar that pulses while real content is being determined. +/// +/// Used instead of showing a verdict the app does not have yet: the On-call banner states whether this +/// device will be paged, and asserting that before registration completes would be a promise the app +/// cannot keep. A pulsing bar says "still working" without claiming anything. +struct SkeletonBar: View { + var width: CGFloat? = nil + var height: CGFloat = 14 + + @Environment(\.colorScheme) private var scheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pulsing = false + + var body: some View { + RoundedRectangle(cornerRadius: height / 2, style: .continuous) + .fill(PiroColors.muted(scheme).opacity(pulsing ? 0.28 : 0.14)) + .frame(width: width, height: height) + .onAppear { + // Reduce Motion turns the animation off but keeps the shape: the placeholder still + // reads as "not content yet", which is the part that matters. + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + pulsing = true + } + } + } +} diff --git a/apps/mobile/iosApp/PiroNotificationService/Info.plist b/apps/mobile/iosApp/PiroNotificationService/Info.plist new file mode 100644 index 00000000..6a63f62b --- /dev/null +++ b/apps/mobile/iosApp/PiroNotificationService/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + PiroNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/apps/mobile/iosApp/PiroNotificationService/NotificationService.swift b/apps/mobile/iosApp/PiroNotificationService/NotificationService.swift new file mode 100644 index 00000000..1c11a11b --- /dev/null +++ b/apps/mobile/iosApp/PiroNotificationService/NotificationService.swift @@ -0,0 +1,70 @@ +import UserNotifications + +/// Decrypts a sealed push before iOS shows it (RFC 0017). +/// +/// The extension exists because the payload arrives encrypted: the app process is not running when a +/// notification lands, so nothing else gets a chance to touch it. APNs sets `mutable-content: 1` and +/// hands the notification here first; whatever this returns is what the user sees. +/// +/// The private key comes from the shared Keychain access group, which is why the app and this target +/// must sit in the same App Group. Without that this process generates its own keypair and can never +/// decrypt anything the backend sealed for the app. +final class NotificationService: UNNotificationServiceExtension { + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttempt: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + let content = request.content.mutableCopy() as? UNMutableNotificationContent + bestAttempt = content + + guard let content else { + contentHandler(request.content) + return + } + + // No ciphertext means a legacy cleartext push (a device registered before it published a key): + // the title and body are already correct, so pass it through untouched. + guard let envelope = content.userInfo["ciphertext"] as? String else { + contentHandler(content) + return + } + + do { + let payload = try PushPayloadUnsealer.unseal(envelope) + + content.title = payload.title + content.body = payload.body + + // Rebuild the userInfo the app expects on tap. The deep-link router reads `url`, so it has + // to survive decryption or tapping the notification stops opening the right alert. + var info = content.userInfo + info["eventKey"] = payload.eventKey + info["alertId"] = payload.alertId + if let url = payload.url { info["url"] = url } + info.removeValue(forKey: "ciphertext") + content.userInfo = info + + contentHandler(content) + } catch { + // Showing the placeholder beats showing nothing: a page the user cannot read still tells + // them to open the app, whereas swallowing it loses the page entirely. The reason is worth + // logging, since a decrypt failure here means the device's key and the server's copy have + // diverged — usually a reinstall that re-keyed without re-registering. + content.body = "Open Piro to view this alert." + contentHandler(content) + } + } + + /// iOS gives the extension a few seconds. If it runs out, this fires and whatever has been built so + /// far is delivered — hence keeping `bestAttempt` up to date rather than only mutating at the end. + override func serviceExtensionTimeWillExpire() { + if let contentHandler, let bestAttempt { + contentHandler(bestAttempt) + } + } +} diff --git a/apps/mobile/iosApp/PiroNotificationService/PiroNotificationService.entitlements b/apps/mobile/iosApp/PiroNotificationService/PiroNotificationService.entitlements new file mode 100644 index 00000000..96598c53 --- /dev/null +++ b/apps/mobile/iosApp/PiroNotificationService/PiroNotificationService.entitlements @@ -0,0 +1,15 @@ + + + + + + keychain-access-groups + + $(AppIdentifierPrefix)co.heva.piro + + + diff --git a/apps/mobile/iosApp/project.yml b/apps/mobile/iosApp/project.yml index dd53f5e3..6312a3eb 100644 --- a/apps/mobile/iosApp/project.yml +++ b/apps/mobile/iosApp/project.yml @@ -25,6 +25,25 @@ settings: ENABLE_USER_SCRIPT_SANDBOXING: NO targets: + PiroNotificationService: + type: app-extension + platform: iOS + sources: + - path: PiroNotificationService + # The extension decrypts on its own, in its own process, so it compiles the crypto directly + # rather than depending on the app target — an extension cannot import its host app. + - path: Piro/Push/PushKeyStore.swift + - path: Piro/Push/PushPayloadUnsealer.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: co.heva.piro.NotificationService + PRODUCT_NAME: PiroNotificationService + INFOPLIST_FILE: PiroNotificationService/Info.plist + CODE_SIGN_ENTITLEMENTS: PiroNotificationService/PiroNotificationService.entitlements + # Extensions are memory- and time-limited; skipping the KMP framework keeps this one small, + # which is also why it does not reuse the app's Shared.framework. + SKIP_INSTALL: YES + Piro: type: application platform: iOS @@ -35,7 +54,6 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: co.heva.piro PRODUCT_NAME: Piro INFOPLIST_FILE: Piro/Info.plist - CODE_SIGN_ENTITLEMENTS: Piro/Piro.entitlements ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Where Gradle drops the built Shared.framework for the current CONFIGURATION/SDK. FRAMEWORK_SEARCH_PATHS: @@ -45,16 +63,74 @@ targets: - $(inherited) - -framework - Shared + configs: + # aps-environment is a fixed string in a plist, so the two APNs environments need two files. + # A Release build — which is what TestFlight distributes — must ship `production`, or the app + # registers a sandbox token that production APNs refuses to deliver to: pushes then work in + # development and stop the moment the build comes from TestFlight. + Debug: + CODE_SIGN_ENTITLEMENTS: Piro/Piro.entitlements + Release: + CODE_SIGN_ENTITLEMENTS: Piro/Piro.Release.entitlements + dependencies: + # Embeds the extension inside the app bundle; without this it is built but never shipped. + - target: PiroNotificationService + preBuildScripts: - name: "Build Shared KMP framework (Gradle)" basedOnDependencyAnalysis: false script: | set -e - # Xcode's build environment has no JAVA_HOME/PATH java; fall back to Android Studio's JBR. + # Xcode's build environment inherits neither JAVA_HOME nor the shell PATH, so a JDK that works + # from a terminal is invisible here. Probe the usual homes in order and fail with something + # actionable rather than letting Gradle die with "Unable to locate a Java Runtime". if [ -z "$JAVA_HOME" ] || [ ! -x "$JAVA_HOME/bin/java" ]; then - if [ -x "/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java" ]; then - export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" - fi + for candidate in \ + "/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ + "/opt/homebrew/opt/openjdk@17" \ + "/opt/homebrew/opt/openjdk" \ + "/usr/local/opt/openjdk@17" \ + "/usr/local/opt/openjdk"; do + if [ -x "$candidate/bin/java" ]; then export JAVA_HOME="$candidate"; break; fi + done + fi + # Last resort: ask macOS itself, which knows about JDKs installed as system packages. + if [ -z "$JAVA_HOME" ] && [ -x /usr/libexec/java_home ]; then + JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null)" && export JAVA_HOME + fi + if [ -z "$JAVA_HOME" ] || [ ! -x "$JAVA_HOME/bin/java" ]; then + echo "error: no JDK found. Install one (brew install openjdk@17) or set JAVA_HOME." >&2 + exit 1 fi cd "$SRCROOT/.." ./gradlew :shared:embedAndSignAppleFrameworkForXcode + +schemes: + # Runs the app in Release. Debug builds get aps-environment: development, so they register against + # the APNs sandbox and never receive pushes from a production Piro server — running Release is what + # makes push testable on a device, and breakpoints still work. + Piro (Release): + build: + targets: + Piro: [run, test, profile, analyze, archive] + run: + config: Release + # Without this, launching from Xcode and then locking the phone suspends the process before a + # push arrives. Waiting for the launch means the debugger attaches when the notification does. + debugEnabled: true + profile: + config: Release + archive: + config: Release + + # Attaches to the notification service extension so a breakpoint in didReceive fires when a push + # lands. Xcode asks which app to run; pick Piro. + PiroNotificationService: + build: + targets: + PiroNotificationService: [run, profile, analyze] + run: + config: Release + debugEnabled: true + profile: + config: Release From 2563699575fcbaa6ef774e5179d7d6b430f1f987 Mon Sep 17 00:00:00 2001 From: Arael Espinosa Date: Tue, 28 Jul 2026 16:44:33 -0300 Subject: [PATCH 2/2] build(ios): pin the signing team and document releasing to TestFlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/mobile/iosApp/ExportOptions.plist | 38 +++++++++++ .../iosApp/Piro.xcodeproj/project.pbxproj | 3 + apps/mobile/iosApp/README.md | 64 +++++++++++++++++++ apps/mobile/iosApp/project.yml | 4 ++ 4 files changed, 109 insertions(+) create mode 100644 apps/mobile/iosApp/ExportOptions.plist diff --git a/apps/mobile/iosApp/ExportOptions.plist b/apps/mobile/iosApp/ExportOptions.plist new file mode 100644 index 00000000..bacfaad9 --- /dev/null +++ b/apps/mobile/iosApp/ExportOptions.plist @@ -0,0 +1,38 @@ + + + + + + + method + app-store-connect + + signingStyle + automatic + + + uploadSymbols + + + + compileBitcode + + + + stripSwiftSymbols + + + diff --git a/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj b/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj index f0c7d0d4..41229480 100644 --- a/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj +++ b/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj @@ -326,6 +326,7 @@ LastUpgradeCheck = 1430; TargetAttributes = { AE9E99BD1414C3B4E9B987B4 = { + DevelopmentTeam = GNUVS35QW5; ProvisioningStyle = Automatic; }; D1CCDB0F70F42F7329C71BCE = { @@ -506,6 +507,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = GNUVS35QW5; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -566,6 +568,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = GNUVS35QW5; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; diff --git a/apps/mobile/iosApp/README.md b/apps/mobile/iosApp/README.md index 82b3ee9e..50fe843e 100644 --- a/apps/mobile/iosApp/README.md +++ b/apps/mobile/iosApp/README.md @@ -66,6 +66,70 @@ release builds start empty. On the iOS Simulator `localhost` resolves to the hos Piro API is reachable directly — but it must actually be running (a connection-refused error means nothing is listening on that port, not a networking problem). +## Releasing to TestFlight + +TestFlight and the App Store are the same channel: you upload once, then promote the build in App +Store Connect. `ExportOptions.plist` in this directory is already set up for it, and +`DEVELOPMENT_TEAM` is pinned in `project.yml`, so an archive signs against the team that owns the +`co.heva.piro` App ID regardless of which Mac builds it. + +### One-time setup + +1. **Register the App ID.** With automatic signing, selecting the team in Xcode's *Signing & + Capabilities* creates `co.heva.piro` for you. Confirm **Push Notifications** appears there — the app + declares it in `Piro.entitlements`, and signing fails if the capability is missing from the App ID. +2. **Create the app in App Store Connect.** Xcode does not do this: *Apps → + → New App*, platform + iOS, bundle ID `co.heva.piro`, any SKU. App names are globally unique, so if "Piro" is taken pick a + different display name — the bundle ID does not have to change. +3. **APNs key.** Generate a `.p8` in the developer portal and configure it server-side. Without it the + app works but never receives a page. + +### Uploading a build + +Bump `CURRENT_PROJECT_VERSION` in `project.yml` first, run `xcodegen generate`, and commit. Every +upload needs a build number higher than the last one App Store Connect saw; a duplicate is rejected, +and it is the most common reason a first upload fails. + +From Xcode: destination **Any iOS Device (arm64)**, then *Product → Archive*, and in the Organizer +*Distribute App → TestFlight & App Store*. + +From the command line: + +```bash +cd apps/mobile/iosApp + +xcodebuild -project Piro.xcodeproj -scheme Piro -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath build/Piro.xcarchive \ + -allowProvisioningUpdates archive + +xcodebuild -exportArchive \ + -archivePath build/Piro.xcarchive \ + -exportOptionsPlist ExportOptions.plist \ + -exportPath build/export + +# App Store Connect API key: Users and Access → Integrations → App Store Connect API. +# The .p8 goes in ~/.appstoreconnect/private_keys/ (or ~/.private_keys/) or altool will not find it. +xcrun altool --upload-app -f build/export/Piro.ipa -t ios \ + --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" +``` + +`-allowProvisioningUpdates` is what lets automatic signing fetch or create the distribution profile +without opening Xcode. + +### Push notifications on TestFlight + +A TestFlight build is a Release build, so it registers against the **production** APNs environment, not +the sandbox one the simulator and debug builds use. The `.p8` configured server-side has to be valid +for production, and a device token obtained from a TestFlight build will not work against the sandbox. +This is the usual reason pushes arrive in a debug build and then stop working in TestFlight. + +### Processing and testers + +Uploads take a few minutes to process, and the first build of an app also needs the *Export Compliance* +question answered before it can be distributed. Internal testers (up to 100 people on your team) get +builds immediately; external testers require a review that usually takes a day. + ## Liquid Glass Liquid Glass (`.glassEffect(...)`) requires the **iOS 26 SDK (Xcode 26)**. This project builds against diff --git a/apps/mobile/iosApp/project.yml b/apps/mobile/iosApp/project.yml index 6312a3eb..2975df19 100644 --- a/apps/mobile/iosApp/project.yml +++ b/apps/mobile/iosApp/project.yml @@ -19,6 +19,10 @@ settings: base: SWIFT_VERSION: "5.9" CODE_SIGN_STYLE: Automatic + # heva's Apple Developer team. Committed rather than left to whoever opens Xcode, so an archive + # signs against the team that owns the co.heva.piro App ID no matter which machine builds it. + # A contributor building for the simulator does not need it to match — ad-hoc signing ignores it. + DEVELOPMENT_TEAM: GNUVS35QW5 MARKETING_VERSION: "0.1.0" CURRENT_PROJECT_VERSION: "1" # Gradle needs to shell out during the build phase; keep the script sandbox off.