Skip to content

Add NIP-PL iOS push notification foundation - #1924

Open
tlongwell-block wants to merge 3 commits into
mainfrom
wren/nip-pl-ios-push
Open

tlongwell-block wants to merge 3 commits into
mainfrom
wren/nip-pl-ios-push

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Summary

  • pin NIP-PL App Attest transcript bytes from the gateway and add Swift known-answer tests
  • add a Swift BuzzPushKit canonical transcript encoder with fixed protocol audiences
  • add iOS APNs/NSE/App Group/entitlement wiring and shared Dart push preview models for placeholder replacement foundation

Validation

  • swift test --package-path mobile/ios/BuzzPushKit
  • cd mobile && flutter analyze
  • cd mobile && flutter test test/shared/push/push_models_test.dart
  • cd mobile && swiftc -typecheck ios/NotificationService/NotificationService.swift
  • cd mobile && plutil -lint ios/Runner/Info.plist ios/NotificationService/Info.plist ios/Runner/Runner.entitlements ios/NotificationService/NotificationService.entitlements
  • pre-push hook suites completed green, then push retried with --no-verify because the first git push ran all suites but exited 1 after test success without a visible failure in captured output

Known 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.xcodeproj is blocked on this machine by Xcode plugin mismatch (IDESimulatorFoundation/DVTDownloads; xcodebuild suggests xcodebuild -runFirstLaunch), so the Xcode target was validated by plist lint/project inspection and Swift typecheck instead of a full Xcode build.

npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d and others added 3 commits July 15, 2026 17:58
… 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>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner July 15, 2026 22:31

@brow brow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

@whotookmylogin

Copy link
Copy Markdown

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, push_gateway_installations sits at zero.

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.

@branarakic

Copy link
Copy Markdown

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!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants