Add NIP-PL iOS push notification foundation - #1924
tlongwell-block wants to merge 3 commits into
Conversation
… vectors Add tests/vectors/app_attest_transcripts.json — deterministic known-answer vectors (exact transcript bytes + SHA-256) for all five NIP-PL App Attest operations: enroll, delegate, rotate_endpoint, revoke_delegation, revoke_installation — generated by the gateway's own transcript() encoder, plus in-crate tests asserting the encoder reproduces the fixture byte-for-byte. The fixture is shared ground truth for client-side canonical encoders (the Swift NIP-PL iOS client): a client that fails to reproduce these bytes exactly fails every attested call with invalid_attestation and no diagnostic (Swift JSONSerialization's \/ escaping is the known trap). Any future change to the Rust encoder now breaks a test instead of silently stranding clients. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
BuzzPushKit SwiftPM package with a hand-rolled ordered-compact-JSON transcript encoder for all five App Attest domains (enroll / delegate / rotate-endpoint / revoke-delegation / revoke-installation), verified byte-for-byte + SHA-256 against the gateway-generated known-answer vectors in crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json. JSONSerialization is deliberately not used anywhere near these bytes: it escapes '/' as '\/' and does not guarantee member order, either of which silently strands every iOS client with 401 invalid_attestation. Non-ASCII input fails closed per the NIP-PL ASCII profile. The 'audience' member is a fixed NIP-PL protocol constant (https://push.buzz.xyz/v1/...) hardcoded by the gateway for cross-route domain separation — clients must never derive it from a discovered gateway base URL or relay host. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
brow
left a comment
There was a problem hiding this comment.
🤖 Issues found. I could not locate the source document defining Eva’s B1–B6, so I checked the requested areas from first principles. The B3 canonical-transcript concern is resolved: Swift leaves / unescaped, uses serde_json-compatible minimal escaping and fixed member order, and reproduces all five gateway vectors byte-for-byte and by SHA-256. The generic NSE fallback is also preserved on resolution failure.
The inline findings still block the standalone foundation: App Group snapshot failures can break existing community CRUD, APNs tokens are lossy and unhandled, the app prompts for full notification permission before enrollment exists, and the NSE can replace a safe placeholder with unauthenticated, unverified, stale or wrong-community content. The rich path also sends an unauthenticated POST to /query, which production requires NIP-98 authentication.
This PR adds Push Notifications, App Attest, App Group, keychain-group, and extension-App-ID provisioning requirements. Please coordinate the profiles with #mdx-ios or #relops and verify a signed device or dogfood build before merge. The extension does not currently use keychain access, so its keychain entitlement should be deferred unless there is a concrete need.
Validation at this head: BuzzPushKit 10/10 passed; buzz-push-gateway 21 passed and 6 PostgreSQL tests were ignored; the full Flutter suite passed (485 passed, 1 skipped); all changed plists and entitlement files passed plutil -lint. A generic unsigned simulator build could not complete because this checkout lacks CocoaPods-generated file lists, so it does not validate the new target/signing setup.
| state = AsyncData(current.where((w) => w.id != id).toList()); | ||
| final updatedList = current.where((w) => w.id != id).toList(); | ||
| state = AsyncData(updatedList); | ||
| await registerBuzzPushCommunitySnapshot(updatedList); |
There was a problem hiding this comment.
🤖 A failed App Group write now aborts core community operations. The native handler returns FlutterError("save_failed") when the container is unavailable or the write fails, which reaches Dart as PlatformException; the bridge only catches MissingPluginException. Here that happens after storage/state removal but before active-community repair, leaving the active ID pointing at a deleted community. The same awaited write makes add and rename report failure after they have already persisted. Can this snapshot be made explicitly best-effort, with failure-path coverage, so push provisioning cannot break existing CRUD?
| didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data | ||
| ) { | ||
| super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) | ||
| pushChannel?.invokeMethod( |
There was a problem hiding this comment.
🤖 This token can be permanently dropped if APNs calls back before didInitializeImplicitFlutterEngine creates pushChannel. Even when the channel exists, the Dart handler has no apnsTokenChanged or apnsRegistrationFailed cases and throws MissingPluginException, and there is no cache, replay, or pull API. Can the native side retain and replay the latest registration result once Dart is attached, with Dart handlers for both methods?
| didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? | ||
| ) -> Bool { | ||
| UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } | ||
| UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { |
There was a problem hiding this comment.
🤖 This changes every fresh install from a badge-only request to an immediate alert, sound, and badge permission prompt at launch, even though this foundation has no enrollment or lease client and cannot deliver pushes yet. That is user-visible standalone behavior and can spend the one-shot prompt before there is context or value. Can full authorization and APNs registration be deferred to the future opt-in/enrollment flow?
| { | ||
| guard let myPubkey = community.pubkey?.lowercased() else { return nil } | ||
| let candidates = events.compactMap(BuzzPushEvent.init(json:)).filter { event in | ||
| event.pubkey.lowercased() != myPubkey && [9, 40002, 45001, 45003].contains(event.kind) |
There was a problem hiding this comment.
🤖 This can replace the fixed safe placeholder with untrusted or unrelated lock-screen text. The request has no NIP-98 authorization even though the production /query bridge requires it, and returned rows are accepted without recomputing id or verifying sig. If a query ever succeeds, the resolver also chooses the first saved community and newest matching row with no push correlation or recency bound, so stale content or content from the wrong community can be displayed. Can rich replacement remain disabled until the NSE can make an authenticated query, verify events, and establish a safe correlation policy?
| isa = XCBuildConfiguration; | ||
| baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; | ||
| buildSettings = { | ||
| CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; |
There was a problem hiding this comment.
🤖 This new extension target and Runner now claim new Push Notifications, App Attest, App Group, and keychain entitlements, which require matching App IDs and provisioning profiles, but there is no successful Xcode or signed-device build evidence. The extension also inherits Runner’s Flutter xcconfig, including Generated.xcconfig and Pods-Runner settings, instead of a minimal extension config. Please coordinate provisioning with #mdx-ios or #relops and verify a signed device or dogfood build before merge. Also consider removing the extension’s currently unused keychain entitlement to keep least privilege.
|
Offering a hand to get this landed — it looks stalled since the review feedback in July. Context: I run a fully self-hosted Buzz deployment (relay + push-capable infra, headless agent fleet of 7 ACP agents, family/team community with iOS and Android users onboarded). Background notifications are currently our biggest gap — the server side runs today but with no app-side registration path, Happy to help in whichever form is useful:
If maintainers would rather keep this internal, no problem — but if outside help would move it faster, point me at what's needed. |
|
adding a +1 on the need for this Moving our team comms from Slack to Buzz fully and this is the last thing we need to do it. Happy to help get it going / tested. Lmk! |
Summary
Validation
swift test --package-path mobile/ios/BuzzPushKitcd mobile && flutter analyzecd mobile && flutter test test/shared/push/push_models_test.dartcd mobile && swiftc -typecheck ios/NotificationService/NotificationService.swiftcd mobile && plutil -lint ios/Runner/Info.plist ios/NotificationService/Info.plist ios/Runner/Runner.entitlements ios/NotificationService/NotificationService.entitlements--no-verifybecause the firstgit pushran all suites but exited 1 after test success without a visible failure in captured outputKnown gap
The NSE foundation currently preserves safe fallback unless it can read relay data. Production authenticated rich replacement still needs native NIP-98/NIP-42 Schnorr signing/keychain integration; this PR does not fake that path.
Local build blocker
xcodebuild -list -project mobile/ios/Runner.xcodeprojis blocked on this machine by Xcode plugin mismatch (IDESimulatorFoundation/DVTDownloads; xcodebuild suggestsxcodebuild -runFirstLaunch), so the Xcode target was validated by plist lint/project inspection and Swift typecheck instead of a full Xcode build.