From d69b89c43ccdccfc2516603da5a8e7847eed3c1a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 18 Aug 2026 10:10:41 -0700 Subject: [PATCH 01/27] feat: add internal iOS push MVP Signed-off-by: Tom Brow --- .env.example | 6 + .github/workflows/ci.yml | 19 + .intersect/sadscan.yaml | 11 + Cargo.lock | 164 --- Justfile | 7 +- crates/buzz-db/src/migration.rs | 21 +- crates/buzz-db/src/push.rs | 25 +- crates/buzz-push-gateway/Cargo.toml | 7 +- .../migrations/0002_application_profiles.sql | 18 + crates/buzz-push-gateway/src/apns.rs | 351 ++++--- crates/buzz-push-gateway/src/app_attest.rs | 335 ++++++ .../src/app_attest_policy.rs | 123 +++ crates/buzz-push-gateway/src/authority.rs | 2 +- crates/buzz-push-gateway/src/config.rs | 456 +++++++- .../buzz-push-gateway/src/dev_app_attest.rs | 168 +++ crates/buzz-push-gateway/src/grant.rs | 2 +- crates/buzz-push-gateway/src/http.rs | 226 +++- crates/buzz-push-gateway/src/lib.rs | 3 + crates/buzz-push-gateway/src/main.rs | 51 +- crates/buzz-push-gateway/src/metrics.rs | 22 +- crates/buzz-push-gateway/src/model.rs | 8 +- crates/buzz-push-gateway/src/postgres.rs | 6 +- .../tests/fixtures/apns-test-cert-only.pem | 11 + .../fixtures/apns-test-encrypted-identity.pem | 19 + .../tests/fixtures/apns-test-identity.pem | 16 + .../tests/fixtures/apns-test-key-only.pem | 5 + .../apns-test-mismatched-identity.pem | 16 + .../fixtures/app-attest-generator/Cargo.lock | 341 ++++++ .../fixtures/app-attest-generator/Cargo.toml | 14 + .../fixtures/app-attest-generator/README.md | 18 + .../fixtures/app-attest-generator/src/main.rs | 404 +++++++ .../tests/fixtures/app-attest-good.json | 13 + .../fixtures/app-attest-wrong-aaguid.json | 13 + .../tests/fixtures/app-attest-wrong-root.json | 13 + .../fixtures/apple-app-attestation-root.pem | 14 + .../tests/vectors/app_attest_transcripts.json | 48 + crates/buzz-relay/src/config.rs | 61 +- crates/buzz-relay/src/handlers/push_lease.rs | 41 +- crates/buzz-relay/src/main.rs | 11 +- crates/buzz-relay/src/nip11.rs | 8 +- crates/buzz-relay/src/push_runtime.rs | 122 ++- .../templates/deployment.yaml | 27 +- .../templates/prometheusrule.yaml | 6 +- .../tests/release-contract.sh | 55 +- .../charts/buzz-push-gateway/tests/render.sh | 166 +-- .../buzz-push-gateway/values-production.yaml | 4 +- .../buzz-push-gateway/values.schema.json | 84 +- deploy/charts/buzz-push-gateway/values.yaml | 23 +- docs/nips/NIP-PL.md | 20 +- docs/push-gateway-deployment.md | 122 ++- migrations/0032_push_message_kinds.sql | 24 + mobile/.env.json.example | 1 + mobile/README.md | 47 +- mobile/ios/.gitignore | 1 + mobile/ios/BuzzPushKit/Package.swift | 24 + .../BuzzPushKit/APNsRegistrationBuffer.swift | 40 + .../BuzzDevPushEnrollmentDriver.swift | 873 +++++++++++++++ .../BuzzPushNavigationTarget.swift | 104 ++ .../BuzzPushNotificationResolver.swift | 213 ++++ .../BuzzPushKit/BuzzPushTranscript.swift | 227 ++++ .../Sources/BuzzPushKit/NostrHTTPAuth.swift | 143 +++ .../Sources/BuzzPushKit/PushLease.swift | 197 ++++ .../APNsRegistrationBufferTests.swift | 29 + .../BuzzDevPushEnrollmentDriverTests.swift | 992 ++++++++++++++++++ .../BuzzPushNavigationTargetTests.swift | 65 ++ .../BuzzPushNotificationResolverTests.swift | 298 ++++++ .../BuzzPushTranscriptTests.swift | 159 +++ .../Tests/BuzzPushKitTests/Fixtures | 1 + .../BuzzPushKitTests/NostrHTTPAuthTests.swift | 76 ++ .../BuzzPushKitTests/PushLeaseTests.swift | 109 ++ mobile/ios/Flutter/Debug.xcconfig | 12 +- mobile/ios/Flutter/PushEnabled.xcconfig | 15 + mobile/ios/Flutter/Release.xcconfig | 9 +- mobile/ios/NotificationService/Info.plist | 35 + .../NotificationService.entitlements | 14 + .../NotificationService.swift | 108 ++ mobile/ios/Runner.xcodeproj/project.pbxproj | 204 +++- .../xcshareddata/swiftpm/Package.resolved | 15 + mobile/ios/Runner/AppDelegate.swift | 306 +++++- mobile/ios/Runner/Info.plist | 4 + .../ios/Runner/PushEndpointGrantStore.swift | 108 ++ mobile/ios/Runner/PushNativeState.swift | 40 + mobile/ios/Runner/Runner.entitlements | 5 + mobile/ios/Runner/RunnerPush.entitlements | 18 + mobile/lib/app.dart | 5 + .../channels/deep_link_dispatcher.dart | 63 ++ mobile/lib/main.dart | 13 +- mobile/lib/shared/auth/auth_provider.dart | 17 +- mobile/lib/shared/community/community.dart | 13 + .../shared/community/community_provider.dart | 200 +++- mobile/lib/shared/deeplink/deep_link.dart | 11 +- .../deeplink/pending_deep_link_provider.dart | 30 +- mobile/lib/shared/push/dev_push_lease.dart | 756 +++++++++++++ mobile/lib/shared/push/push_bootstrap.dart | 171 +++ mobile/lib/shared/push/push_bridge.dart | 245 +++++ mobile/lib/shared/push/push_capability.dart | 6 + mobile/lib/shared/push/push_snapshot.dart | 38 + mobile/lib/shared/push/push_subscription.dart | 479 +++++++++ .../push/push_subscription_provider.dart | 57 + mobile/lib/shared/relay/relay_provider.dart | 7 +- .../lib/shared/relay/signed_event_relay.dart | 73 ++ .../channels/deep_link_dispatcher_test.dart | 62 ++ .../test/shared/auth/auth_provider_test.dart | 162 ++- .../community/community_provider_test.dart | 54 +- .../community/community_storage_test.dart | 31 + .../pending_deep_link_provider_test.dart | 49 + .../test/shared/push/dev_push_lease_test.dart | 436 ++++++++ mobile/test/shared/push/push_bridge_test.dart | 216 ++++ .../test/shared/push/push_snapshot_test.dart | 28 + .../push/push_subscription_provider_test.dart | 66 ++ .../shared/push/push_subscription_test.dart | 149 +++ scripts/mobile-worktree-clean.sh | 12 +- scripts/mobile-worktree-overrides.sh | 2 +- scripts/run-tests.sh | 3 + scripts/test-ios-pbxproj-semantics.py | 87 ++ scripts/test-mobile-worktree-overrides.sh | 377 ++++++- 116 files changed, 11491 insertions(+), 669 deletions(-) create mode 100644 crates/buzz-push-gateway/migrations/0002_application_profiles.sql create mode 100644 crates/buzz-push-gateway/src/app_attest_policy.rs create mode 100644 crates/buzz-push-gateway/src/dev_app_attest.rs create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-good.json create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json create mode 100644 crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem create mode 100644 crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json create mode 100644 migrations/0032_push_message_kinds.sql create mode 100644 mobile/ios/BuzzPushKit/Package.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift create mode 120000 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift create mode 100644 mobile/ios/Flutter/PushEnabled.xcconfig create mode 100644 mobile/ios/NotificationService/Info.plist create mode 100644 mobile/ios/NotificationService/NotificationService.entitlements create mode 100644 mobile/ios/NotificationService/NotificationService.swift create mode 100644 mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 mobile/ios/Runner/PushEndpointGrantStore.swift create mode 100644 mobile/ios/Runner/PushNativeState.swift create mode 100644 mobile/ios/Runner/Runner.entitlements create mode 100644 mobile/ios/Runner/RunnerPush.entitlements create mode 100644 mobile/lib/shared/push/dev_push_lease.dart create mode 100644 mobile/lib/shared/push/push_bootstrap.dart create mode 100644 mobile/lib/shared/push/push_bridge.dart create mode 100644 mobile/lib/shared/push/push_capability.dart create mode 100644 mobile/lib/shared/push/push_snapshot.dart create mode 100644 mobile/lib/shared/push/push_subscription.dart create mode 100644 mobile/lib/shared/push/push_subscription_provider.dart create mode 100644 mobile/test/shared/deeplink/pending_deep_link_provider_test.dart create mode 100644 mobile/test/shared/push/dev_push_lease_test.dart create mode 100644 mobile/test/shared/push/push_bridge_test.dart create mode 100644 mobile/test/shared/push/push_snapshot_test.dart create mode 100644 mobile/test/shared/push/push_subscription_provider_test.dart create mode 100644 mobile/test/shared/push/push_subscription_test.dart create mode 100755 scripts/test-ios-pbxproj-semantics.py diff --git a/.env.example b/.env.example index 0f7bbba6f13..67a33742225 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,12 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone +# never enables it. When enabled and the URL is absent, the canonical +# https://push.buzz.xyz/v1/deliveries/apns endpoint is used. +BUZZ_PUSH_ENABLED=false +# BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a832c0a0aff..eee313b2f24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: - 'pnpm-lock.yaml' mobile: - 'mobile/**' + - 'crates/buzz-push-gateway/tests/vectors/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' @@ -71,6 +72,7 @@ jobs: - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' - 'scripts/test-mobile-worktree-overrides.sh' + - 'scripts/test-ios-pbxproj-semantics.py' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract @@ -900,6 +902,23 @@ jobs: - name: Build Android debug APK run: just mobile-build-android + mobile-swift: + name: Mobile Swift + runs-on: macos-latest + timeout-minutes: 10 + needs: [changes] + if: needs.changes.outputs.mobile == 'true' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Build + run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit + - name: Test + run: swift test --package-path mobile/ios/BuzzPushKit + - name: Validate iOS project semantics + run: python3 scripts/test-ios-pbxproj-semantics.py + security: name: Security runs-on: ubuntu-latest diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml index a321714bcb5..77710ae0d98 100644 --- a/.intersect/sadscan.yaml +++ b/.intersect/sadscan.yaml @@ -2,3 +2,14 @@ exclude_rules_for_files: sq.pii.cc.visa: - Cargo.lock + # Self-signed test fixture generated solely to exercise reqwest identity parsing. + kingfisher.privkey.2: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" + np.pem.1: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" diff --git a/Cargo.lock b/Cargo.lock index 16d86d0206f..2d273c29c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,7 +1230,6 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr 0.44.7", - "p256", "proptest", "rand 0.10.1", "reqwest 0.13.4", @@ -1910,12 +1909,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -2068,22 +2061,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -2101,9 +2078,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", ] [[package]] @@ -2171,7 +2146,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -2617,21 +2591,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der", - "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature", - "spki", - "zeroize", -] - [[package]] name = "ed25519" version = "3.0.0" @@ -2668,27 +2627,6 @@ dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" -dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff", - "group", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core 0.10.1", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "embedded-io" version = "0.4.0" @@ -2891,16 +2829,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3325,17 +3253,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" version = "0.4.16" @@ -3678,9 +3595,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] @@ -6595,19 +6510,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.0", -] - [[package]] name = "palette" version = "0.7.6" @@ -7092,33 +6994,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint", - "crypto-common 0.2.2", - "ff", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -8022,16 +7897,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint", - "hmac 0.13.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -8391,20 +8256,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" version = "0.29.1" @@ -8828,10 +8679,6 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] [[package]] name = "simd-adler32" @@ -11508,17 +11355,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "wnaf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" -dependencies = [ - "ff", - "group", - "hybrid-array", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/Justfile b/Justfile index ce8647cf77c..20dc326dbbb 100644 --- a/Justfile +++ b/Justfile @@ -312,9 +312,10 @@ test-unit: cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). - # They guard the embedded-migrator invariant (exactly the consolidated - # 0001; cutover/backfill stays an operator script, not startup state) - # and the tenant-scoping lints. The Postgres-backed buzz-db tests are + # They guard the embedded-migrator invariant (the complete checked-in + # additive migration set; legacy cutover/backfill remains an operator + # script, not startup state) and the tenant-scoping lints. The + # Postgres-backed buzz-db tests are # #[ignore]d, so --lib runs only the infra-free set. Without this gate a # stray file in migrations/ or a broken lint ships green. cargo nextest run -p buzz-db --lib diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index be87faa1ac2..9126984f6a7 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -1,8 +1,9 @@ //! Embedded SQLx migrations for Buzz. //! -//! Fresh deployments apply the checked-in SQL files under `migrations/`. The -//! multi-tenant rewrite owns a clean consolidated `0001`; legacy single-tenant -//! cutover/backfill is a separate operator script, not startup migration state. +//! Fresh deployments apply the checked-in additive SQL files under +//! `migrations/`. The multi-tenant rewrite begins from a clean consolidated +//! `0001`; legacy single-tenant cutover/backfill is a separate operator script, +//! not startup migration state. use std::future::Future; @@ -625,7 +626,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 31); + assert_eq!(migrations.len(), 32); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1059,6 +1060,18 @@ mod tests { assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT")); } + #[test] + fn push_match_trigger_is_narrowed_to_message_kinds_additively() { + let mut migrations: Vec<_> = MIGRATOR.iter().collect(); + migrations.sort_by_key(|migration| migration.version); + + assert_eq!(migrations[31].version, 32); + let sql = migrations[31].sql.as_str(); + assert!(sql.contains("CREATE OR REPLACE FUNCTION enqueue_push_match_job")); + assert!(sql.contains("NEW.kind IN (9, 40002, 45001, 45003)")); + assert!(!sql.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); + } + #[test] fn migration_lint_detects_tables_missing_community_id_by_default() { let sql = r#" diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs index 0b3245ffcc2..263ef6ee64c 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -54,7 +54,7 @@ async fn backfill_push_match_jobs( "INSERT INTO push_match_queue (community_id, event_id) \ SELECT community_id, id FROM events \ WHERE community_id = $1 \ - AND kind IN (7, 9, 1059, 40007, 46010) \ + AND kind IN (9, 40002, 45001, 45003) \ AND deleted_at IS NULL \ AND received_at > now() - make_interval(secs => $2) \ ON CONFLICT DO NOTHING", @@ -157,6 +157,8 @@ pub struct ClaimedWake { pub class: String, /// Delivery deadline, in Unix seconds. pub expires_at: i64, + /// Time this durable wake entered the relay outbox. + pub queued_at: DateTime, /// Attempt number, starting at one for the first claim. pub attempt: i32, } @@ -225,11 +227,17 @@ pub async fn accept_lease_event( address_lock.extend_from_slice(community.as_uuid().as_bytes()); address_lock.extend_from_slice(author); address_lock.extend_from_slice(installation_id.as_bytes()); - let address_lock = i64::from_le_bytes(Sha256::digest(&address_lock)[..8].try_into().unwrap()); + let address_digest = Sha256::digest(&address_lock); + let mut address_lock_bytes = [0_u8; 8]; + address_lock_bytes.copy_from_slice(&address_digest[..8]); + let address_lock = i64::from_le_bytes(address_lock_bytes); let mut author_lock = Vec::with_capacity(16 + author.len()); author_lock.extend_from_slice(community.as_uuid().as_bytes()); author_lock.extend_from_slice(author); - let author_lock = i64::from_le_bytes(Sha256::digest(&author_lock)[..8].try_into().unwrap()); + let author_digest = Sha256::digest(&author_lock); + let mut author_lock_bytes = [0_u8; 8]; + author_lock_bytes.copy_from_slice(&author_digest[..8]); + let author_lock = i64::from_le_bytes(author_lock_bytes); sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(address_lock) .execute(&mut *tx) @@ -597,10 +605,10 @@ pub async fn enqueue_wake( }], ) .await?; - Ok(outcomes + outcomes .into_iter() .next() - .expect("one outcome per request")) + .ok_or_else(|| crate::DbError::InvalidData("missing wake enqueue outcome".into())) } /// Set-wise counterpart of [`enqueue_wake`]: one transaction and a constant @@ -1066,7 +1074,8 @@ pub async fn claim_due_wakes( AND l.endpoint_hash = o.endpoint_hash RETURNING o.community_id, o.id, o.claim_id, o.event_id, c.channel_id, o.author, o.installation_id, o.lease_generation, - l.endpoint_grant, o.class, o.expires_at, o.attempts + l.endpoint_grant, o.class, o.expires_at, o.created_at AS queued_at, + o.attempts "#, ) .bind(community.as_uuid()) @@ -1094,7 +1103,8 @@ pub async fn revalidate_wake_for_send( r#" SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id, o.author, o.installation_id, o.lease_generation, - l.endpoint_grant, o.class, o.expires_at, o.attempts + l.endpoint_grant, o.class, o.expires_at, o.created_at AS queued_at, + o.attempts FROM push_wake_outbox o JOIN push_leases l ON l.community_id = o.community_id @@ -1257,6 +1267,7 @@ fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result { endpoint_grant: row.try_get("endpoint_grant")?, class: row.try_get("class")?, expires_at: row.try_get("expires_at")?, + queued_at: row.try_get("queued_at")?, attempt: row.try_get("attempts")?, }) } diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index aec3c43b026..c5a5368f3c8 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -7,6 +7,10 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[features] +default = [] +dev-app-attest-bypass = [] + [lib] name = "buzz_push_gateway" path = "src/lib.rs" @@ -29,9 +33,8 @@ getrandom = "0.4" metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } nostr = { workspace = true } -p256 = { version = "0.14", features = ["ecdsa", "pem", "pkcs8"] } rand = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["http2"] } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } diff --git a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql new file mode 100644 index 00000000000..45be402dc07 --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql @@ -0,0 +1,18 @@ +-- The original profile names encoded APNs transport environment, not a +-- verified application identity. They therefore cannot be mapped safely to +-- either closed bundle profile. Retire the pre-profile demo authority and let +-- clients re-attest under the exact server-owned application profile. +DELETE FROM push_gateway_delegations +WHERE installation_id IN ( + SELECT id FROM push_gateway_installations + WHERE app_profile IN ('buzz-ios-production', 'buzz-ios-sandbox') +); + +DELETE FROM push_gateway_installations +WHERE app_profile IN ('buzz-ios-production', 'buzz-ios-sandbox'); + +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_check; +ALTER TABLE push_gateway_installations + ADD CONSTRAINT push_gateway_installations_app_profile_check + CHECK (app_profile IN ('buzz-ios-dogfood', 'buzz-ios-app-store')); diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs index 8f6f1820001..99b3532cf01 100644 --- a/crates/buzz-push-gateway/src/apns.rs +++ b/crates/buzz-push-gateway/src/apns.rs @@ -1,21 +1,13 @@ //! APNs envelope construction, endpoint encryption, and response classification. -use std::{sync::Mutex, time::Duration}; +use std::time::Duration; use async_trait::async_trait; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use p256::{ - ecdsa::{signature::Signer, Signature, SigningKey}, - pkcs8::DecodePrivateKey, -}; -use reqwest::{ - header::{AUTHORIZATION, CONTENT_TYPE}, - StatusCode, -}; +use reqwest::{header::CONTENT_TYPE, StatusCode}; use serde::Deserialize; use thiserror::Error; -use crate::model::{AppProfile, APNS_RECONNECT_PAYLOAD}; +use crate::{config::ApnsEnvironment, model::APNS_RECONNECT_PAYLOAD}; /// Sanitized delivery outcome. Raw provider bodies never cross this boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,8 +24,6 @@ pub enum DeliveryOutcome { /// Retry-After delay in seconds, clamped by the transport. retry_after_seconds: Option, }, - /// Refresh the cached provider JWT, then retry once within normal attempt bounds. - RefreshCredential, /// Provider credential/profile configuration is unhealthy; do not invalidate endpoints. ConfigurationFault, /// The locally-generated request is permanently invalid. @@ -52,7 +42,6 @@ pub fn classify(code: u16, reason: Option<&str>, timestamp: Option) -> Deli unregistered_at: None, } } - (403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential, (403, _) | (429, Some("TooManyProviderTokenUpdates")) => { DeliveryOutcome::ConfigurationFault } @@ -85,110 +74,84 @@ pub struct DeliveryAttempt { #[async_trait] pub trait PushTransport: Send + Sync { /// Send one durable job. - async fn send( - &self, - attempt: DeliveryAttempt, - profile: AppProfile, - endpoint: &str, - ) -> DeliveryOutcome; - /// Discard a cached credential after APNs reports expiry. - fn refresh_credential(&self) {} + async fn send(&self, attempt: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome; } -struct CachedJwt { - token: String, - issued_at: i64, -} - -/// Direct HTTP/2 APNs transport using a cached ES256 provider token. +/// Direct HTTP/2 APNs transport using a client certificate identity. pub struct ApnsTransport { client: reqwest::Client, - signing_key: SigningKey, - key_id: String, - team_id: String, topic: String, - production_base_url: String, - sandbox_base_url: String, - cached_jwt: Mutex>, + base_url: String, } impl ApnsTransport { - /// Build a reusable APNs client from an Apple `.p8` private key. - pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|_| ApnsError::Client)?; - Self::token_with_client( - p8, - key_id, - team_id, - topic, - client, - "https://api.push.apple.com".to_owned(), - "https://api.sandbox.push.apple.com".to_owned(), - ) + /// Build a reusable APNs client from a combined PEM private key and certificate. + pub fn certificate( + identity_pem: &[u8], + topic: String, + environment: ApnsEnvironment, + ) -> Result { + let base_url = match environment { + ApnsEnvironment::Production => "https://api.push.apple.com", + ApnsEnvironment::Sandbox => "https://api.sandbox.push.apple.com", + }; + Self::certificate_with_base_url(identity_pem, topic, base_url.to_owned()) } - fn token_with_client( - p8: &[u8], - key_id: &str, - team_id: &str, + fn certificate_with_base_url( + identity_pem: &[u8], topic: String, - client: reqwest::Client, - production_base_url: String, - sandbox_base_url: String, + base_url: String, ) -> Result { - let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?; - let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?; + let identity = + reqwest::Identity::from_pem(identity_pem).map_err(|_| ApnsError::Credential)?; + let client = reqwest::Client::builder() + // APNs requires HTTP/2. This no-op method reference is intentionally + // feature-gated so removing reqwest's `http2` feature fails the build. + .http2_keep_alive_while_idle(false) + .identity(identity) + .timeout(Duration::from_secs(15)) + // Identity validation completes while the TLS client is built, so a + // malformed or mismatched certificate/key pair is a credential error. + .build() + .map_err(|_| ApnsError::Credential)?; Ok(Self { client, - signing_key, - key_id: key_id.to_owned(), - team_id: team_id.to_owned(), topic, - production_base_url, - sandbox_base_url, - cached_jwt: Mutex::new(None), + base_url, }) } - fn jwt(&self, now: i64) -> Result { - let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?; - if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) { - return Ok(jwt.token.clone()); - } - let header = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id})) - .map_err(|_| ApnsError::Credential)?, - ); - let claims = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now})) - .map_err(|_| ApnsError::Credential)?, - ); - let signing_input = format!("{header}.{claims}"); - let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); - let token = format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature.to_bytes()) - ); - *cached = Some(CachedJwt { - token: token.clone(), - issued_at: now, - }); - Ok(token) + fn request(&self, attempt: DeliveryAttempt, endpoint: &str) -> reqwest::RequestBuilder { + self.client + .post(format!("{}/3/device/{endpoint}", self.base_url)) + .header(CONTENT_TYPE, "application/json") + .header("apns-id", attempt.request_id.to_string()) + .header("apns-topic", &self.topic) + .header("apns-push-type", "alert") + .header("apns-priority", "10") + .header("apns-expiration", attempt.expires_at.to_string()) + // This is the only APNs application body in the program. It is a + // byte constant, not a serialization of the relay request, grant, + // endpoint, headers, route, provider response, or any generic JSON map. + .body(APNS_RECONNECT_PAYLOAD) + } + + async fn send_response( + &self, + attempt: DeliveryAttempt, + endpoint: &str, + ) -> Result { + self.request(attempt, endpoint).send().await } } /// APNs transport setup failure. It intentionally carries no credential material. #[derive(Debug, Error)] pub enum ApnsError { - /// Invalid provider key material. + /// Invalid client certificate identity material. #[error("invalid APNs credential")] Credential, - /// HTTP client setup failed. - #[error("failed to construct APNs client")] - Client, } #[derive(Deserialize)] @@ -199,38 +162,9 @@ struct ApnsErrorBody { #[async_trait] impl PushTransport for ApnsTransport { - async fn send( - &self, - attempt: DeliveryAttempt, - profile: AppProfile, - endpoint: &str, - ) -> DeliveryOutcome { - // This is the only APNs application body in the program. It is a - // byte constant, not a serialization of the relay request, grant, - // endpoint, headers, route, provider response, or any generic JSON map. - let body = APNS_RECONNECT_PAYLOAD; - let now = chrono::Utc::now().timestamp(); - let token = match self.jwt(now) { - Ok(token) => token, - Err(_) => return DeliveryOutcome::ConfigurationFault, - }; - let base_url = match profile { - AppProfile::BuzzIosProduction => &self.production_base_url, - AppProfile::BuzzIosSandbox => &self.sandbox_base_url, - }; - let response = self - .client - .post(format!("{base_url}/3/device/{endpoint}")) - .header(AUTHORIZATION, format!("bearer {token}")) - .header(CONTENT_TYPE, "application/json") - .header("apns-id", attempt.request_id.to_string()) - .header("apns-topic", &self.topic) - .header("apns-push-type", "alert") - .header("apns-priority", "10") - .header("apns-expiration", attempt.expires_at.to_string()) - .body(body) - .send() - .await; + async fn send(&self, attempt: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome { + crate::metrics::record_apns_send_attempt(); + let response = self.send_response(attempt, endpoint).await; let response = match response { Ok(response) => response, Err(_) => { @@ -262,63 +196,66 @@ impl PushTransport for ApnsTransport { outcome => outcome, } } - - fn refresh_credential(&self) { - if let Ok(mut cached) = self.cached_jwt.lock() { - *cached = None; - } - } } #[cfg(test)] mod tests { use super::*; - use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router}; - use p256::pkcs8::{EncodePrivateKey, LineEnding}; - use std::sync::Arc; + use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + routing::post, + Router, + }; + use std::sync::{Arc, Mutex}; + + // Self-signed test-only identity material. None of these are Apple credentials. + const TEST_IDENTITY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-identity.pem"); + const TEST_CERT_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-cert-only.pem"); + const TEST_KEY_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-key-only.pem"); + const TEST_ENCRYPTED_IDENTITY_PEM: &[u8] = + include_bytes!("../tests/fixtures/apns-test-encrypted-identity.pem"); + const TEST_MISMATCHED_IDENTITY_PEM: &[u8] = + include_bytes!("../tests/fixtures/apns-test-mismatched-identity.pem"); - async fn capture_body( - State(bodies): State>>>>, + #[derive(Default)] + struct CapturedRequest { + headers: HeaderMap, + body: Vec, + } + + async fn capture_request( + State(requests): State>>>, + headers: HeaderMap, body: Bytes, ) -> StatusCode { - bodies.lock().unwrap().push(body.to_vec()); + requests.lock().unwrap().push(CapturedRequest { + headers, + body: body.to_vec(), + }); StatusCode::OK } + #[tokio::test] - async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() { - let bodies = Arc::new(Mutex::new(Vec::new())); + async fn certificate_transport_sends_no_bearer_and_exact_body_for_every_attempt() { + let requests = Arc::new(Mutex::new(Vec::new())); let app = Router::new() - .route("/3/device/{endpoint}", post(capture_body)) - .with_state(bodies.clone()); + .route("/3/device/{endpoint}", post(capture_request)) + .with_state(requests.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let signing_key = SigningKey::from_slice(&[7; 32]).unwrap(); - let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap(); - let transport = ApnsTransport::token_with_client( - pem.as_bytes(), - "kid", - "team", + let transport = ApnsTransport::certificate_with_base_url( + TEST_IDENTITY_PEM, "app.topic".to_owned(), - reqwest::Client::new(), - base_url.clone(), base_url, ) .unwrap(); - for (request_id, expires_at, profile, endpoint) in [ - ( - uuid::Uuid::nil(), - 1, - AppProfile::BuzzIosProduction, - "00".repeat(32), - ), - ( - uuid::Uuid::max(), - i64::MAX, - AppProfile::BuzzIosSandbox, - "ff".repeat(32), - ), + for (request_id, expires_at, endpoint) in [ + (uuid::Uuid::nil(), 1, "00".repeat(32)), + (uuid::Uuid::max(), i64::MAX, "ff".repeat(32)), ] { assert_eq!( transport @@ -327,18 +264,94 @@ mod tests { request_id, expires_at, }, - profile, &endpoint, ) .await, DeliveryOutcome::Accepted ); } - let captured = bodies.lock().unwrap(); + let captured = requests.lock().unwrap(); assert_eq!(captured.len(), 2); assert!(captured .iter() - .all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD)); + .all(|request| request.body.as_slice() == APNS_RECONNECT_PAYLOAD)); + assert!(captured + .iter() + .all(|request| !request.headers.contains_key(reqwest::header::AUTHORIZATION))); + assert!(captured.iter().all(|request| request + .headers + .get("apns-topic") + .is_some_and(|topic| topic == "app.topic"))); + } + + #[tokio::test] + #[ignore = "requires the exported dogfood Apple Push Services PEM"] + async fn live_sandbox_probe_reports_literal_status_and_body() { + let cert_path = std::env::var("BUZZ_PUSH_LIVE_APNS_CERT_PATH") + .expect("set BUZZ_PUSH_LIVE_APNS_CERT_PATH to the dogfood identity PEM"); + let topic = std::env::var("BUZZ_PUSH_LIVE_APNS_TOPIC") + .expect("set BUZZ_PUSH_LIVE_APNS_TOPIC to the dogfood bundle id"); + let identity = std::fs::read(cert_path).unwrap(); + let transport = + ApnsTransport::certificate(&identity, topic, ApnsEnvironment::Sandbox).unwrap(); + let response = transport + .send_response( + DeliveryAttempt { + request_id: uuid::Uuid::nil(), + expires_at: chrono::Utc::now().timestamp() + 60, + }, + &"00".repeat(32), + ) + .await + .unwrap(); + let status = response.status(); + let body = response.text().await.unwrap(); + eprintln!("live APNs response: status={status}, body={body}"); + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body, r#"{"reason":"BadDeviceToken"}"#); + } + + #[test] + fn empty_certificate_identity_fails_as_a_credential_error() { + assert_credential_error(b""); + } + + #[test] + fn malformed_certificate_identity_fails_as_a_credential_error() { + assert_credential_error(b"not a PEM identity"); + } + + #[test] + fn certificate_without_private_key_fails_as_a_credential_error() { + assert_credential_error(TEST_CERT_ONLY_PEM); + } + + #[test] + fn private_key_without_certificate_fails_as_a_credential_error() { + assert_credential_error(TEST_KEY_ONLY_PEM); + } + + #[test] + fn encrypted_private_key_fails_as_a_credential_error() { + assert_credential_error(TEST_ENCRYPTED_IDENTITY_PEM); + } + + #[test] + fn mismatched_private_key_fails_as_a_credential_error() { + // reqwest parses both PEM blocks, then rejects the mismatched pair while + // building the TLS client. This locks the ClientBuilder error mapping. + assert_credential_error(TEST_MISMATCHED_IDENTITY_PEM); + } + + fn assert_credential_error(identity_pem: &[u8]) { + assert!(matches!( + ApnsTransport::certificate( + identity_pem, + "app.topic".to_owned(), + ApnsEnvironment::Production, + ), + Err(ApnsError::Credential) + )); } #[test] @@ -349,10 +362,12 @@ mod tests { unregistered_at: Some(7) } ); - assert_eq!( - classify(403, Some("InvalidProviderToken"), None), - DeliveryOutcome::ConfigurationFault - ); + for reason in ["InvalidProviderToken", "ExpiredProviderToken"] { + assert_eq!( + classify(403, Some(reason), None), + DeliveryOutcome::ConfigurationFault + ); + } assert_eq!( classify(429, Some("TooManyRequests"), None), DeliveryOutcome::Retry { diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index ebb1fc56bc0..302e2dd5add 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -45,6 +45,14 @@ impl AppAttestVerifier { apple_root_cert_pem, }) } + #[cfg(all(test, feature = "dev-app-attest-bypass"))] + pub(crate) fn for_policy_test() -> Self { + Self { + app_id: "policy-test".to_owned(), + apple_root_cert_pem: Vec::new(), + } + } + /// `client_data` is the exact canonical enrollment transcript represented by /// the challenge string passed to `attestKey`; callers must include every /// authority-bearing enrollment field in it. @@ -138,3 +146,330 @@ fn assertion_counter(cbor: &[u8]) -> Result { .ok_or(AppAttestError::Invalid)?; Ok(BigEndian::read_u32(&auth[33..37])) } + +#[cfg(test)] +mod tests { + use super::*; + use appattest::error::AppAttestError as DependencyAppAttestError; + use chrono::{Duration, NaiveDateTime, Utc}; + use serde::Deserialize; + + const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json"); + const WRONG_AAGUID_FIXTURE_JSON: &str = + include_str!("../tests/fixtures/app-attest-wrong-aaguid.json"); + const WRONG_ROOT_FIXTURE_JSON: &str = + include_str!("../tests/fixtures/app-attest-wrong-root.json"); + const APPLE_ROOT_CERT_PEM: &[u8] = + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem"); + + #[derive(Deserialize)] + struct Fixture { + description: String, + generator: String, + generated_at: String, + regeneration_command: String, + app_id: String, + challenge: String, + aaguid: String, + leaf_not_after: String, + attestation_b64: String, + key_id_b64: String, + root_cert_pem: String, + } + + fn fixture(json: &str) -> Fixture { + let fixture: Fixture = serde_json::from_str(json).expect("valid App Attest fixture JSON"); + assert!(!fixture.description.is_empty()); + assert_eq!( + fixture.generator, + "crates/buzz-push-gateway/tests/fixtures/app-attest-generator" + ); + assert!(!fixture.generated_at.is_empty()); + fixture + } + + fn verifier(app_id: &str, root_cert_pem: &[u8]) -> AppAttestVerifier { + AppAttestVerifier { + app_id: app_id.to_owned(), + apple_root_cert_pem: root_cert_pem.to_vec(), + } + } + + fn verify_dependency( + fixture: &Fixture, + app_id: &str, + challenge: &str, + key_id_b64: &str, + root_cert_pem: &[u8], + ) -> Result<(), DependencyAppAttestError> { + let cbor = STANDARD + .decode(&fixture.attestation_b64) + .expect("fixture attestation is base64"); + let attestation = Attestation::from_cbor_bytes(&cbor)?; + let result = attestation + .verify(challenge, app_id, key_id_b64, root_cert_pem) + .map(|_| ()); + result + } + + #[test] + fn strict_verifier_accepts_good_fixture() { + let fixture = fixture(GOOD_FIXTURE_JSON); + assert_eq!(fixture.aaguid, "appattest"); + verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ) + .expect("strict dependency verifier accepts the generated encoding"); + + let verified = verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .expect("shipped gateway wrapper accepts the generated encoding"); + assert_eq!(verified.key_id.len(), 32); + assert_eq!(verified.public_key.len(), 65); + } + + #[test] + fn wrong_root_is_rejected() { + let good = fixture(GOOD_FIXTURE_JSON); + let wrong_root = fixture(WRONG_ROOT_FIXTURE_JSON); + assert!(verify_dependency( + &wrong_root, + &wrong_root.app_id, + &wrong_root.challenge, + &wrong_root.key_id_b64, + good.root_cert_pem.as_bytes(), + ) + .is_err()); + assert!(verifier(&wrong_root.app_id, good.root_cert_pem.as_bytes()) + .verify_attestation( + &wrong_root.attestation_b64, + &wrong_root.key_id_b64, + wrong_root.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_app_id_is_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let wrong_app_id = "TEAMID.xyz.buzz.wrong"; + assert_eq!( + verify_dependency( + &fixture, + wrong_app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidAppID) + ); + assert!(verifier(wrong_app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_challenge_is_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let wrong_challenge = "wrong-challenge"; + assert_eq!( + verify_dependency( + &fixture, + &fixture.app_id, + wrong_challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidNonce) + ); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + wrong_challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_aaguid_is_rejected_as_invalid_aaguid() { + let fixture = fixture(WRONG_AAGUID_FIXTURE_JSON); + assert_eq!(fixture.aaguid, "appattestdevelop"); + assert_eq!( + verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidAAGUID) + ); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn short_and_oversize_key_ids_are_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + for key_id_b64 in [STANDARD.encode([0x11; 31]), STANDARD.encode([0x22; 33])] { + assert!(verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &key_id_b64, + fixture.root_cert_pem.as_bytes(), + ) + .is_err()); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + } + + #[test] + #[allow(clippy::assertions_on_constants, unexpected_cfgs)] + fn gateway_test_build_does_not_define_testing_feature() { + assert!(!cfg!(feature = "testing")); + } + + #[test] + fn constructor_still_pins_the_apple_root() { + let fixture = fixture(GOOD_FIXTURE_JSON); + assert!( + AppAttestVerifier::new(fixture.app_id.clone(), APPLE_ROOT_CERT_PEM.to_vec()).is_ok() + ); + assert!( + AppAttestVerifier::new(fixture.app_id, fixture.root_cert_pem.as_bytes().to_vec(),) + .is_err() + ); + } + + #[test] + fn fixture_leaf_certificate_is_valid_for_at_least_thirty_days() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let leaf_certificate = fixture_leaf_certificate(&fixture); + let not_after = certificate_not_after(&leaf_certificate); + assert_eq!( + not_after.format("%b %e %H:%M:%S %Y GMT").to_string(), + fixture.leaf_not_after + ); + assert!( + not_after > Utc::now() + Duration::days(30), + "App Attest fixture expires within 30 days; regenerate with: {}", + fixture.regeneration_command + ); + } + + fn fixture_leaf_certificate(fixture: &Fixture) -> Vec { + let cbor = STANDARD + .decode(&fixture.attestation_b64) + .expect("fixture attestation is base64"); + let mut decoder = minicbor::Decoder::new(&cbor); + let root_entries = decoder + .map() + .expect("attestation root is a map") + .expect("attestation root map has a fixed length"); + for _ in 0..root_entries { + let key = decoder.str().expect("attestation root key is text"); + if key != "attStmt" { + decoder.skip().expect("skip non-attStmt value"); + continue; + } + let statement_entries = decoder + .map() + .expect("attStmt is a map") + .expect("attStmt map has a fixed length"); + for _ in 0..statement_entries { + let key = decoder.str().expect("attStmt key is text"); + if key != "x5c" { + decoder.skip().expect("skip non-x5c value"); + continue; + } + assert!( + decoder + .array() + .expect("x5c is an array") + .expect("x5c has a fixed length") + >= 2 + ); + return decoder + .bytes() + .expect("x5c leaf certificate is bytes") + .to_vec(); + } + } + panic!("fixture attestation has no x5c leaf certificate"); + } + + fn certificate_not_after(certificate: &[u8]) -> chrono::DateTime { + let (tag, certificate, _) = der_tlv(certificate); + assert_eq!(tag, 0x30, "certificate must be a DER sequence"); + let (tag, tbs_certificate, _) = der_tlv(certificate); + assert_eq!(tag, 0x30, "TBSCertificate must be a DER sequence"); + + let mut fields = tbs_certificate; + if fields.first() == Some(&0xa0) { + fields = der_tlv(fields).2; + } + for _ in 0..3 { + fields = der_tlv(fields).2; + } + let (tag, validity, _) = der_tlv(fields); + assert_eq!(tag, 0x30, "certificate validity must be a DER sequence"); + let (_, _, after_not_before) = der_tlv(validity); + let (time_tag, not_after, _) = der_tlv(after_not_before); + let not_after = std::str::from_utf8(not_after).expect("notAfter is ASCII"); + let format = match time_tag { + 0x17 => "%y%m%d%H%M%SZ", + 0x18 => "%Y%m%d%H%M%SZ", + _ => panic!("unexpected DER time tag {time_tag:#x}"), + }; + NaiveDateTime::parse_from_str(not_after, format) + .expect("valid DER notAfter timestamp") + .and_utc() + } + + fn der_tlv(input: &[u8]) -> (u8, &[u8], &[u8]) { + let tag = *input.first().expect("DER TLV has a tag"); + let first_length = *input.get(1).expect("DER TLV has a length"); + let (length, length_bytes) = if first_length & 0x80 == 0 { + (first_length as usize, 1) + } else { + let byte_count = (first_length & 0x7f) as usize; + assert!( + byte_count > 0 && byte_count <= std::mem::size_of::(), + "supported DER long-form length" + ); + let length = input[2..2 + byte_count] + .iter() + .fold(0_usize, |length, byte| (length << 8) | *byte as usize); + (length, 1 + byte_count) + }; + let value_start = 1 + length_bytes; + let value_end = value_start + length; + assert!(value_end <= input.len(), "DER TLV length is in bounds"); + (tag, &input[value_start..value_end], &input[value_end..]) + } +} diff --git a/crates/buzz-push-gateway/src/app_attest_policy.rs b/crates/buzz-push-gateway/src/app_attest_policy.rs new file mode 100644 index 00000000000..77c724d9e60 --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest_policy.rs @@ -0,0 +1,123 @@ +//! Selects the production Apple verifier or the feature-gated development stub. + +use crate::app_attest::{ + AppAttestError, AppAttestVerifier, VerifiedAssertion, VerifiedAttestation, +}; +#[cfg(feature = "dev-app-attest-bypass")] +#[derive(Clone)] +pub struct DevelopmentAppAttestPolicy { + _private: (), +} + +#[cfg(feature = "dev-app-attest-bypass")] +pub struct DevelopmentAppAttestBypass { + _private: (), +} + +#[cfg(feature = "dev-app-attest-bypass")] +impl DevelopmentAppAttestBypass { + pub(crate) fn enabled() -> Self { + Self { _private: () } + } +} + +#[cfg_attr( + not(feature = "dev-app-attest-bypass"), + doc = r#" +The development policy is structurally unavailable in default builds: + +```compile_fail +use buzz_push_gateway::app_attest_policy::AppAttestPolicy; + +let _ = AppAttestPolicy::Development; +``` +"# +)] +#[derive(Clone)] +pub enum AppAttestPolicy { + Apple(AppAttestVerifier), + #[cfg(feature = "dev-app-attest-bypass")] + Development(DevelopmentAppAttestPolicy), +} + +impl AppAttestPolicy { + pub fn apple(verifier: AppAttestVerifier) -> Self { + Self::Apple(verifier) + } + + #[cfg(feature = "dev-app-attest-bypass")] + pub fn from_config( + bypass: Option, + apple: AppAttestVerifier, + ) -> Self { + if bypass.is_some() { + tracing::warn!( + "DEVELOPMENT APP ATTEST BYPASS ACTIVE; Apple attestation and assertion verification are disabled" + ); + Self::development() + } else { + Self::apple(apple) + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + fn development() -> Self { + Self::Development(DevelopmentAppAttestPolicy { _private: () }) + } + + pub fn verify_attestation( + &self, + attestation_b64: &str, + key_id_b64: &str, + client_data: &[u8], + ) -> Result { + match self { + Self::Apple(verifier) => { + verifier.verify_attestation(attestation_b64, key_id_b64, client_data) + } + #[cfg(feature = "dev-app-attest-bypass")] + Self::Development(_) => { + crate::dev_app_attest::verify_attestation(attestation_b64, key_id_b64, client_data) + } + } + } + + pub fn verify_assertion( + &self, + assertion_b64: &str, + client_data: &[u8], + public_key: &[u8], + previous_counter: u32, + challenge: &str, + stored_challenge: &str, + ) -> Result { + match self { + Self::Apple(verifier) => verifier.verify_assertion( + assertion_b64, + client_data, + public_key, + previous_counter, + challenge, + stored_challenge, + ), + #[cfg(feature = "dev-app-attest-bypass")] + Self::Development(_) => crate::dev_app_attest::verify_assertion( + assertion_b64, + client_data, + public_key, + previous_counter, + challenge, + stored_challenge, + ), + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn development_bypass_stays_a_non_default_feature() { + assert!(include_str!("../Cargo.toml") + .contains("[features]\ndefault = []\ndev-app-attest-bypass = []")); + } +} diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 36c220885cd..ae70064afb9 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -519,7 +519,7 @@ mod tests { app_attest_key_id: vec![1], app_attest_public_key: vec![2; 33], assertion_counter: 0, - profile: AppProfile::BuzzIosProduction, + profile: AppProfile::BuzzIosDogfood, token_ciphertext: vec![3], token_fingerprint: [4; 32], endpoint_epoch: 1, diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index c6194edbcb4..8e4d5512436 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "dev-app-attest-bypass")] +use crate::app_attest_policy::DevelopmentAppAttestBypass; use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::{ collections::{HashMap, HashSet}, @@ -6,6 +8,21 @@ use std::{ }; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApnsEnvironment { + Production, + Sandbox, +} + +#[derive(Debug, Clone)] +pub struct AppProfileConfig { + pub enabled: bool, + pub app_attest_app_id: String, + pub apns_cert_path: Option, + pub apns_topic: String, + pub apns_environment: ApnsEnvironment, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyConfig { pub id: String, @@ -21,19 +38,19 @@ pub struct Config { pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - pub enabled_profiles: HashSet, + /// Closed server-owned registry. Both known application identities are + /// present even when one is dormant; only enabled entries carry a required + /// APNs identity and can enroll or deliver. + pub profiles: HashMap, pub database_url: String, - pub app_attest_app_id: String, pub app_attest_root_cert_path: PathBuf, + #[cfg(feature = "dev-app-attest-bypass")] + pub dev_app_attest_bypass: bool, /// Ordered current key first, followed by decrypt-only predecessors. pub grant_keys: Vec, /// Independent token-custody keyring. These keys MUST NOT be reused for /// externally presented delivery capabilities. pub token_keys: Vec, - pub apns_key_path: PathBuf, - pub apns_key_id: String, - pub apns_team_id: String, - pub apns_topic: String, } #[derive(Debug, Error)] pub enum ConfigError { @@ -75,7 +92,66 @@ fn parse_keyring( } Ok(keys) } + +fn parse_profile( + e: &HashMap, + prefix: &'static str, + enabled: bool, +) -> Result { + let app_id_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", + _ => unreachable!("closed profile prefix"), + }; + let cert_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH", + _ => unreachable!("closed profile prefix"), + }; + let topic_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_TOPIC", + _ => unreachable!("closed profile prefix"), + }; + let environment_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT", + _ => unreachable!("closed profile prefix"), + }; + let required = |key: &'static str| { + e.get(key) + .map(String::as_str) + .filter(|value| !value.is_empty()) + .ok_or(ConfigError::Missing(key)) + }; + let app_attest_app_id = required(app_id_key)?.to_owned(); + let apns_topic = required(topic_key)?.to_owned(); + let apns_cert_path = match e.get(cert_key).filter(|value| !value.is_empty()) { + Some(path) => Some(PathBuf::from(path)), + None if enabled => return Err(ConfigError::Missing(cert_key)), + None => None, + }; + let apns_environment = match e.get(environment_key).map(String::as_str) { + None | Some("production") => ApnsEnvironment::Production, + Some("sandbox") => ApnsEnvironment::Sandbox, + Some(_) => return Err(ConfigError::Invalid(environment_key)), + }; + Ok(AppProfileConfig { + enabled, + app_attest_app_id, + apns_cert_path, + apns_topic, + apns_environment, + }) +} + impl Config { + #[cfg(feature = "dev-app-attest-bypass")] + pub fn dev_app_attest_bypass(&self) -> Option { + self.dev_app_attest_bypass + .then(DevelopmentAppAttestBypass::enabled) + } + pub fn from_env() -> Result { Self::from_map(&std::env::vars().collect()) } @@ -144,42 +220,86 @@ impl Config { let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")? .split(',') .map(|profile| match profile { - "buzz-ios-production" => Ok(crate::model::AppProfile::BuzzIosProduction), - "buzz-ios-sandbox" => Ok(crate::model::AppProfile::BuzzIosSandbox), + "buzz-ios-dogfood" => Ok(crate::model::AppProfile::BuzzIosDogfood), + "buzz-ios-app-store" => Ok(crate::model::AppProfile::BuzzIosAppStore), _ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")), }) .collect::, _>>()?; if enabled_profiles.is_empty() { return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); } + let profiles = HashMap::from([ + ( + crate::model::AppProfile::BuzzIosDogfood, + parse_profile( + e, + "DOGFOOD", + enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood), + )?, + ), + ( + crate::model::AppProfile::BuzzIosAppStore, + parse_profile( + e, + "APP_STORE", + enabled_profiles.contains(&crate::model::AppProfile::BuzzIosAppStore), + )?, + ), + ]); + let bind_addr = e + .get("BUZZ_PUSH_BIND_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8080") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?; + let health_addr = e + .get("BUZZ_PUSH_HEALTH_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8081") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?; + let dev_app_attest_bypass_requested = + match e.get("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS").map(String::as_str) { + None | Some("0") => false, + Some("1") => true, + Some(_) => return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")), + }; + #[cfg(not(feature = "dev-app-attest-bypass"))] + if dev_app_attest_bypass_requested { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } + #[cfg(feature = "dev-app-attest-bypass")] + let dev_app_attest_bypass = dev_app_attest_bypass_requested; + #[cfg(feature = "dev-app-attest-bypass")] + if dev_app_attest_bypass + && (!bind_addr.ip().is_loopback() || !health_addr.ip().is_loopback()) + { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } + #[cfg(feature = "dev-app-attest-bypass")] + if dev_app_attest_bypass + && (enabled_profiles.len() != 1 + || !enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood) + || profiles[&crate::model::AppProfile::BuzzIosDogfood].apns_environment + != ApnsEnvironment::Sandbox) + { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } Ok(Self { - bind_addr: e - .get("BUZZ_PUSH_BIND_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8080") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?, - health_addr: e - .get("BUZZ_PUSH_HEALTH_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8081") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?, + bind_addr, + health_addr, public_delivery_url, max_grant_lifetime_seconds, max_installation_lifetime_seconds, endpoint_quota_window_seconds, endpoint_quota_max_deliveries, - enabled_profiles, + profiles, database_url: req(e, "DATABASE_URL")?.to_owned(), - app_attest_app_id: req(e, "BUZZ_PUSH_APP_ATTEST_APP_ID")?.to_owned(), app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), + #[cfg(feature = "dev-app-attest-bypass")] + dev_app_attest_bypass, grant_keys, token_keys, - apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(), - apns_key_id: req(e, "BUZZ_PUSH_APNS_KEY_ID")?.to_owned(), - apns_team_id: req(e, "BUZZ_PUSH_APNS_TEAM_ID")?.to_owned(), - apns_topic: req(e, "BUZZ_PUSH_APNS_TOPIC")?.to_owned(), }) } } @@ -187,6 +307,10 @@ impl Config { #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "dev-app-attest-bypass")] + use crate::app_attest_policy::AppAttestPolicy; + #[cfg(feature = "dev-app-attest-bypass")] + use sha2::Digest as _; fn base() -> HashMap { HashMap::from([ @@ -216,24 +340,75 @@ mod tests { ), ( "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-production".into(), + "buzz-ios-dogfood".into(), ), ( "DATABASE_URL".into(), - "postgres://buzz:test@localhost/buzz".into(), + "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 + ), + ( + "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID".into(), + "TEAM.xyz.block.buzz.dogfood.mobile".into(), + ), + ( + "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID".into(), + "TEAM.xyz.block.buzz.mobile".into(), ), - ("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), "/apple-root.pem".into(), ), - ("BUZZ_PUSH_APNS_KEY_PATH".into(), "/key.p8".into()), - ("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()), - ("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()), - ("BUZZ_PUSH_APNS_TOPIC".into(), "app".into()), + ( + "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH".into(), + "/dogfood-identity.pem".into(), + ), + ( + "BUZZ_PUSH_DOGFOOD_APNS_TOPIC".into(), + "xyz.block.buzz.dogfood.mobile".into(), + ), + ( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "production".into(), + ), + ( + "BUZZ_PUSH_APP_STORE_APNS_TOPIC".into(), + "xyz.block.buzz.mobile".into(), + ), + ("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()), + ("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()), ]) } + #[test] + fn enabled_profile_requires_its_certificate_and_all_profiles_have_server_owned_identity() { + let config = Config::from_map(&base()).unwrap(); + let dogfood = &config.profiles[&crate::model::AppProfile::BuzzIosDogfood]; + assert!(dogfood.enabled); + assert_eq!( + dogfood.apns_cert_path, + Some(PathBuf::from("/dogfood-identity.pem")) + ); + assert_eq!(dogfood.apns_topic, "xyz.block.buzz.dogfood.mobile"); + let app_store = &config.profiles[&crate::model::AppProfile::BuzzIosAppStore]; + assert!(!app_store.enabled); + assert_eq!(app_store.apns_cert_path, None); + assert_eq!(app_store.apns_topic, "xyz.block.buzz.mobile"); + + for variable in [ + "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", + "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", + "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", + "BUZZ_PUSH_APP_STORE_APNS_TOPIC", + "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", + ] { + let mut env = base(); + env.remove(variable); + assert!( + matches!(Config::from_map(&env), Err(ConfigError::Missing(key)) if key == variable) + ); + } + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); @@ -255,7 +430,8 @@ mod tests { "BUZZ_PUSH_PUBLIC_DELIVERY_URL", "https://push.example/v1/deliveries/apns", ), - ("BUZZ_PUSH_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), @@ -279,6 +455,218 @@ mod tests { } } + #[test] + fn listener_defaults_remain_public_when_addresses_are_absent() { + let mut env = base(); + env.remove("BUZZ_PUSH_BIND_ADDR"); + env.remove("BUZZ_PUSH_HEALTH_ADDR"); + + let config = Config::from_map(&env).unwrap(); + assert_eq!(config.bind_addr, "0.0.0.0:8080".parse().unwrap()); + assert_eq!(config.health_addr, "0.0.0.0:8081".parse().unwrap()); + } + + #[test] + fn dogfood_and_app_store_profiles_parse_together_without_bypass() { + let mut env = base(); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood,buzz-ios-app-store".into(), + ); + env.insert( + "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), + "/app-store-identity.pem".into(), + ); + + let config = Config::from_map(&env).unwrap(); + assert_eq!(config.profiles.len(), 2); + assert!(config.profiles[&crate::model::AppProfile::BuzzIosDogfood].enabled); + assert!(config.profiles[&crate::model::AppProfile::BuzzIosAppStore].enabled); + } + + #[test] + fn dev_app_attest_bypass_flag_is_strict_and_feature_gated() { + let absent = Config::from_map(&base()).unwrap(); + #[cfg(feature = "dev-app-attest-bypass")] + assert!(!absent.dev_app_attest_bypass); + #[cfg(not(feature = "dev-app-attest-bypass"))] + let _ = absent; + + let mut disabled = base(); + disabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "0".into()); + let disabled = Config::from_map(&disabled).unwrap(); + #[cfg(feature = "dev-app-attest-bypass")] + assert!(!disabled.dev_app_attest_bypass); + #[cfg(not(feature = "dev-app-attest-bypass"))] + let _ = disabled; + + for value in ["", "false", "true", "TRUE", "yes", " 1"] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), value.into()); + assert!( + matches!( + Config::from_map(&env), + Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) + ), + "accepted non-canonical value {value:?}" + ); + } + + #[cfg(not(feature = "dev-app-attest-bypass"))] + { + let mut enabled = base(); + enabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + assert!(matches!( + Config::from_map(&enabled), + Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) + )); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_requires_both_loopback_listeners() { + for (key, value) in [ + ("BUZZ_PUSH_BIND_ADDR", "0.0.0.0:8080"), + ("BUZZ_PUSH_HEALTH_ADDR", "[::]:8081"), + ] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + env.insert(key.into(), value.into()); + assert!(Config::from_map(&env).is_err(), "accepted {key}={value}"); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn non_loopback_bind_is_rejected_before_loopback_equivalent_is_accepted() { + let mut non_loopback = base(); + non_loopback.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + non_loopback.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + non_loopback.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "0.0.0.0:8080".into()); + assert!(Config::from_map(&non_loopback).is_err()); + + non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()); + assert!( + Config::from_map(&non_loopback) + .unwrap() + .dev_app_attest_bypass + ); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_requires_sandbox_dogfood_as_the_only_profile() { + for profiles in ["buzz-ios-app-store", "buzz-ios-dogfood,buzz-ios-app-store"] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert("BUZZ_PUSH_ENABLED_PROFILES".into(), profiles.into()); + assert!( + Config::from_map(&env).is_err(), + "accepted profiles {profiles}" + ); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_accepts_explicit_one_for_loopback_sandbox() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn second_profile_is_rejected_before_sandbox_only_is_accepted() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood,buzz-ios-app-store".into(), + ); + env.insert( + "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), + "/app-store-identity.pem".into(), + ); + assert!(Config::from_map(&env).is_err()); + + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_selects_development_policy_from_validated_config() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + let config = Config::from_map(&env).unwrap(); + let apple = crate::app_attest::AppAttestVerifier::for_policy_test(); + + let policy = AppAttestPolicy::from_config(config.dev_app_attest_bypass(), apple); + + assert!(matches!(policy, AppAttestPolicy::Development(_))); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn bypass_unset_keeps_sentinel_on_the_apple_verifier() { + let config = Config::from_map(&base()).unwrap(); + let policy = AppAttestPolicy::from_config( + config.dev_app_attest_bypass(), + crate::app_attest::AppAttestVerifier::for_policy_test(), + ); + let mut sentinel = b"buzz-dev-app-attest-v1:".to_vec(); + sentinel.extend_from_slice(&[1; 32]); + let key_id = sha2::Sha256::digest(&sentinel); + + assert!(policy + .verify_attestation( + &STANDARD.encode(sentinel), + &STANDARD.encode(key_id), + b"canonical enrollment transcript", + ) + .is_err()); + } + #[test] fn malformed_or_empty_keyrings_fail_startup() { for (variable, value) in [ diff --git a/crates/buzz-push-gateway/src/dev_app_attest.rs b/crates/buzz-push-gateway/src/dev_app_attest.rs new file mode 100644 index 00000000000..038d8b524d7 --- /dev/null +++ b/crates/buzz-push-gateway/src/dev_app_attest.rs @@ -0,0 +1,168 @@ +//! Explicit development-only App Attest sentinel verification. +//! +//! This module is absent unless the non-default `dev-app-attest-bypass` Cargo +//! feature is enabled. Runtime configuration adds a second, independent gate. + +use crate::app_attest::{AppAttestError, VerifiedAssertion, VerifiedAttestation}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use sha2::{Digest, Sha256}; + +const ATTESTATION_PREFIX: &[u8] = b"buzz-dev-app-attest-v1:"; +const ASSERTION_SENTINEL: &[u8] = b"buzz-dev-app-assertion-v1"; +const PUBLIC_KEY_PREFIX: &[u8] = b"buzz-dev-app-attest-public-key-v1:"; +const NONCE_BYTES: usize = 32; + +pub fn assertion_sentinel() -> String { + STANDARD.encode(ASSERTION_SENTINEL) +} + +pub fn verify_attestation( + attestation_b64: &str, + key_id_b64: &str, + client_data: &[u8], +) -> Result { + let attestation = STANDARD + .decode(attestation_b64) + .map_err(|_| AppAttestError::Invalid)?; + let supplied_key_id = STANDARD + .decode(key_id_b64) + .map_err(|_| AppAttestError::Invalid)?; + let expected_key_id = Sha256::digest(&attestation); + if !attestation.starts_with(ATTESTATION_PREFIX) + || attestation.len() != ATTESTATION_PREFIX.len() + NONCE_BYTES + || supplied_key_id.as_slice() != expected_key_id.as_slice() + || client_data.is_empty() + { + return Err(AppAttestError::Invalid); + } + let mut public_key = PUBLIC_KEY_PREFIX.to_vec(); + public_key.extend_from_slice(&expected_key_id); + Ok(VerifiedAttestation { + key_id: expected_key_id.to_vec(), + public_key, + }) +} + +pub fn verify_assertion( + assertion_b64: &str, + client_data: &[u8], + public_key: &[u8], + previous_counter: u32, + challenge: &str, + stored_challenge: &str, +) -> Result { + let assertion = STANDARD + .decode(assertion_b64) + .map_err(|_| AppAttestError::Invalid)?; + if assertion != ASSERTION_SENTINEL + || client_data.is_empty() + || !public_key.starts_with(PUBLIC_KEY_PREFIX) + || public_key.len() != PUBLIC_KEY_PREFIX.len() + 32 + || challenge.is_empty() + || challenge != stored_challenge + { + return Err(AppAttestError::Invalid); + } + Ok(VerifiedAssertion { + counter: previous_counter + .checked_add(1) + .ok_or(AppAttestError::Invalid)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attestation(nonce: u8) -> (String, String) { + let mut sentinel = ATTESTATION_PREFIX.to_vec(); + sentinel.extend_from_slice(&[nonce; NONCE_BYTES]); + let key_id = Sha256::digest(&sentinel); + (STANDARD.encode(sentinel), STANDARD.encode(key_id)) + } + + #[test] + fn versioned_attestation_derives_a_unique_bound_key_id() { + let (attestation_a, key_id_a) = attestation(1); + let verified_a = verify_attestation( + &attestation_a, + &key_id_a, + b"canonical enrollment transcript", + ) + .unwrap(); + assert_eq!(verified_a.key_id.len(), 32); + assert!(verified_a.public_key.starts_with(PUBLIC_KEY_PREFIX)); + + let (attestation_b, key_id_b) = attestation(2); + let verified_b = verify_attestation( + &attestation_b, + &key_id_b, + b"canonical enrollment transcript", + ) + .unwrap(); + assert_ne!(verified_a.key_id, verified_b.key_id); + + for (attestation, key, transcript) in [ + ("bad".to_owned(), key_id_a.clone(), b"transcript".as_slice()), + (attestation_a.clone(), key_id_b, b"transcript"), + (attestation_a, key_id_a, b""), + ] { + assert!(verify_attestation(&attestation, &key, transcript).is_err()); + } + } + + #[test] + fn bad_attestation_sentinel_is_rejected_before_good_sentinel_is_accepted() { + let (good_attestation, key_id) = attestation(1); + let bad_attestation = STANDARD.encode(b"buzz-dev-app-attest-v1:bad"); + + assert!(verify_attestation(&bad_attestation, &key_id, b"transcript").is_err()); + assert!(verify_attestation(&good_attestation, &key_id, b"transcript").is_ok()); + } + + #[test] + fn exact_assertion_sentinel_and_stored_dev_marker_advance_counter() { + let (attestation, key_id) = attestation(1); + let public_key = verify_attestation(&attestation, &key_id, b"transcript") + .unwrap() + .public_key; + let verified = verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + &public_key, + 7, + "challenge", + "challenge", + ) + .unwrap(); + assert_eq!(verified.counter, 8); + + assert!(verify_assertion( + "bad", + b"canonical assertion transcript", + &public_key, + 7, + "challenge", + "challenge", + ) + .is_err()); + assert!(verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + b"not-the-development-marker", + 7, + "challenge", + "challenge", + ) + .is_err()); + assert!(verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + &public_key, + u32::MAX, + "challenge", + "challenge", + ) + .is_err()); + } +} diff --git a/crates/buzz-push-gateway/src/grant.rs b/crates/buzz-push-gateway/src/grant.rs index 54a29bac3d1..8eda1d7ce81 100644 --- a/crates/buzz-push-gateway/src/grant.rs +++ b/crates/buzz-push-gateway/src/grant.rs @@ -159,7 +159,7 @@ mod tests { v: 1, delegation_id: uuid::Uuid::nil(), relay_pubkey: "11".repeat(32), - app_profile: AppProfile::BuzzIosProduction, + app_profile: AppProfile::BuzzIosDogfood, endpoint_epoch: 1, generation: 2, expires_at: 99, diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 0564972c078..4033825d369 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -1,7 +1,7 @@ //! Stateful installation, delegation, delivery, and health APIs. use crate::{ apns::{DeliveryAttempt, DeliveryOutcome, PushTransport}, - app_attest::AppAttestVerifier, + app_attest_policy::AppAttestPolicy, authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, @@ -23,7 +23,7 @@ use nostr::{ Event, JsonUtil, Timestamp, }; use std::{ - collections::HashSet, + collections::HashMap, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -33,19 +33,33 @@ use std::{ use tower::limit::ConcurrencyLimitLayer; use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; +#[derive(Clone)] +pub struct ProfileRuntime { + /// Both values are absent for a registered but dormant profile. + pub app_attest: Option>, + pub transport: Option>, +} + +impl ProfileRuntime { + fn enabled(&self) -> bool { + self.app_attest.is_some() && self.transport.is_some() + } +} + #[derive(Clone)] pub struct AppState { pub grant_keyring: Arc, - pub app_attest: Arc, pub authority: Arc, pub token_keyring: Arc, - pub transport: Arc, + /// Closed server-owned app identity registry. Client profile selectors + /// choose only a candidate; App Attest verifies the configured application + /// ID before the profile is persisted as installation authority. + pub profiles: Arc>, pub delivery_url: url::Url, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - pub enabled_profiles: HashSet, pub now: fn() -> i64, pub accepting: Arc, } @@ -163,11 +177,14 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; + let profile = match s.profiles.get(&r.app_profile) { + Some(profile) if profile.enabled() => profile, + _ => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; if r.v != WIRE_VERSION || r.endpoint_epoch != 1 || r.expires_at <= now || r.expires_at > now.saturating_add(s.max_installation_lifetime_seconds) - || !s.enabled_profiles.contains(&r.app_profile) { return error(StatusCode::BAD_REQUEST, "invalid_request"); } @@ -190,14 +207,14 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; - let verified = - match s - .app_attest + let verified = match profile.app_attest.as_ref().and_then(|policy| { + policy .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) - { - Ok(v) => v, - Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), - }; + .ok() + }) { + Some(value) => value, + None => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), + }; if let Err(e) = s .authority .consume_challenge(r.challenge_id, challenge, now) @@ -252,10 +269,14 @@ async fn verify_installation_assertion( .installation(installation_id, now) .await .map_err(authority_error)?; + let app_attest = s + .profiles + .get(&installation.profile) + .and_then(|profile| profile.app_attest.as_ref()) + .ok_or_else(|| error(StatusCode::NOT_FOUND, "not_authorized"))?; let transcript = transcript(domain, signed) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; - let verified = s - .app_attest + let verified = app_attest .verify_assertion( assertion, transcript.as_bytes(), @@ -635,6 +656,22 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> return error(StatusCode::NOT_FOUND, "invalid_grant"); } let profile = permit.authority.profile; + let transport = match s + .profiles + .get(&profile) + .and_then(|runtime| runtime.transport.as_ref()) + .cloned() + { + Some(transport) => transport, + None => { + crate::metrics::record_delivery_error("profile_disabled"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await; + return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); + } + }; let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { Ok(token) => hex::encode(token), Err(_) => { @@ -650,23 +687,17 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> request_id: r.request_id, expires_at: r.expires_at, }; - let transport = Arc::clone(&s.transport); let authority_store = Arc::clone(&s.authority); // Admission already committed, so cancellation cannot undo either replay // fence. The detached task completes disposition bookkeeping. let delivery = tokio::spawn(async move { let started = std::time::Instant::now(); - let mut outcome = transport.send(attempt, profile, &endpoint).await; - if outcome == DeliveryOutcome::RefreshCredential { - crate::metrics::record_credential_refresh(); - transport.refresh_credential(); - outcome = transport.send(attempt, profile, &endpoint).await; - } + let outcome = transport.send(attempt, &endpoint).await; crate::metrics::record_apns_delivery(outcome, started.elapsed().as_secs_f64()); let disposition = match outcome { - DeliveryOutcome::Retry { .. } - | DeliveryOutcome::ConfigurationFault - | DeliveryOutcome::RefreshCredential => DeliveryDisposition::Retryable, + DeliveryOutcome::Retry { .. } | DeliveryOutcome::ConfigurationFault => { + DeliveryDisposition::Retryable + } DeliveryOutcome::Accepted | DeliveryOutcome::InvalidEndpoint { .. } | DeliveryOutcome::PermanentRequestFault => DeliveryDisposition::Terminal, @@ -704,7 +735,7 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> }), ) .into_response(), - DeliveryOutcome::ConfigurationFault | DeliveryOutcome::RefreshCredential => { + DeliveryOutcome::ConfigurationFault => { error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault") } DeliveryOutcome::PermanentRequestFault => error(StatusCode::BAD_REQUEST, "invalid_request"), @@ -774,3 +805,146 @@ pub fn router_with_metrics( } (public, health) } + +/// Known-answer vectors for the exact App Attest transcript bytes defined by +/// NIP-PL ("Exact App Attest transcript construction"). The fixture file is +/// shared ground truth with client-side canonical encoders (the Swift NIP-PL +/// iOS client): a client encoder that fails to reproduce these bytes exactly +/// fails every enroll/delegate/rotate/revoke call with `invalid_attestation`. +#[cfg(test)] +mod transcript_vector_tests { + use super::*; + use sha2::{Digest, Sha256}; + + const VECTORS_JSON: &str = include_str!("../tests/vectors/app_attest_transcripts.json"); + + // Deterministic fixture inputs mirrored in the vector file's `inputs`. + const CHALLENGE_ID: uuid::Uuid = + uuid::Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111); + const INSTALLATION: uuid::Uuid = + uuid::Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222); + // base64url-no-pad of bytes 0x00..=0x1f. + const CHALLENGE: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + // Standard base64 (padded) of 32 bytes of 0xAA. + const KEY_ID: &str = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="; + // 32-byte APNs token, lowercase hex. + const ENDPOINT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + const RELAY_PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn assert_vector(name: &str, actual: &str) { + let file: serde_json::Value = serde_json::from_str(VECTORS_JSON).unwrap(); + let vector = file["vectors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["name"] == name) + .unwrap_or_else(|| panic!("vector {name} missing from fixture")); + assert_eq!( + actual, + vector["transcript"].as_str().unwrap(), + "{name} bytes" + ); + assert_eq!( + hex::encode(Sha256::digest(actual.as_bytes())), + vector["sha256"].as_str().unwrap(), + "{name} sha256" + ); + } + + #[test] + fn fixture_encodings_match_their_raw_bytes() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let challenge_bytes: Vec = (0u8..32).collect(); + assert_eq!(URL_SAFE_NO_PAD.encode(&challenge_bytes), CHALLENGE); + assert_eq!(STANDARD.encode([0xAAu8; 32]), KEY_ID); + assert_eq!(hex::decode(ENDPOINT).unwrap().len(), 32); + } + + #[test] + fn enroll_transcript_vector() { + let t = EnrollTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + key_id: KEY_ID, + app_profile: AppProfile::BuzzIosDogfood, + endpoint: ENDPOINT, + endpoint_epoch: 1, + expires_at: 1_752_624_000, + }; + assert_vector("enroll", &transcript("buzz.push.enroll.v1", &t).unwrap()); + } + + #[test] + fn delegate_transcript_vector() { + let t = DelegateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + generation: 1, + relay_pubkey: RELAY_PUBKEY, + not_before: 1_752_620_000, + expires_at: 1_752_624_000, + }; + assert_vector( + "delegate", + &transcript("buzz.push.delegate.v1", &t).unwrap(), + ); + } + + #[test] + fn rotate_endpoint_transcript_vector() { + let t = RotateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/endpoint", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + endpoint: ENDPOINT, + }; + assert_vector( + "rotate_endpoint", + &transcript("buzz.push.rotate-endpoint.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_delegation_transcript_vector() { + let t = RevokeDelegationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + relay_pubkey: RELAY_PUBKEY, + generation: 2, + }; + assert_vector( + "revoke_delegation", + &transcript("buzz.push.revoke-delegation.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_installation_transcript_vector() { + let t = RevokeInstallationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + }; + assert_vector( + "revoke_installation", + &transcript("buzz.push.revoke-installation.v1", &t).unwrap(), + ); + } +} diff --git a/crates/buzz-push-gateway/src/lib.rs b/crates/buzz-push-gateway/src/lib.rs index 563d725db99..59fa2ca09bd 100644 --- a/crates/buzz-push-gateway/src/lib.rs +++ b/crates/buzz-push-gateway/src/lib.rs @@ -1,8 +1,11 @@ //! Stateful, capability-gated APNs last hop for NIP-PL. pub mod apns; pub mod app_attest; +pub mod app_attest_policy; pub mod authority; pub mod config; +#[cfg(feature = "dev-app-attest-bypass")] +pub mod dev_app_attest; pub mod grant; pub mod http; pub mod metrics; diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index 55e1853d3bf..a2613dfb374 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -1,6 +1,7 @@ use buzz_push_gateway::{ apns::ApnsTransport, app_attest::AppAttestVerifier, + app_attest_policy::AppAttestPolicy, authority::AuthorityStore, config::Config, grant::{GrantKey, GrantKeyring}, @@ -10,6 +11,7 @@ use buzz_push_gateway::{ AppState, }; use std::{ + collections::HashMap, fs, sync::{ atomic::{AtomicBool, Ordering}, @@ -35,12 +37,41 @@ async fn main() -> Result<(), Box> { } let c = Config::from_env()?; let metrics_handle = buzz_push_gateway::metrics::install()?; - let transport = Arc::new(ApnsTransport::token( - &fs::read(&c.apns_key_path)?, - &c.apns_key_id, - &c.apns_team_id, - c.apns_topic, - )?); + let app_attest_root = fs::read(&c.app_attest_root_cert_path)?; + let mut profiles = HashMap::new(); + for (profile, configured) in &c.profiles { + let runtime = if configured.enabled { + let cert_path = configured.apns_cert_path.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "enabled push profile has no APNs identity path", + ) + })?; + let transport = Arc::new(ApnsTransport::certificate( + &fs::read(cert_path)?, + configured.apns_topic.clone(), + configured.apns_environment, + )?); + let apple = AppAttestVerifier::new( + configured.app_attest_app_id.clone(), + app_attest_root.clone(), + )?; + #[cfg(feature = "dev-app-attest-bypass")] + let policy = AppAttestPolicy::from_config(c.dev_app_attest_bypass(), apple); + #[cfg(not(feature = "dev-app-attest-bypass"))] + let policy = AppAttestPolicy::apple(apple); + buzz_push_gateway::http::ProfileRuntime { + app_attest: Some(Arc::new(policy)), + transport: Some(transport), + } + } else { + buzz_push_gateway::http::ProfileRuntime { + app_attest: None, + transport: None, + } + }; + profiles.insert(*profile, runtime); + } let grant_keyring = GrantKeyring::new( c.grant_keys .iter() @@ -77,24 +108,18 @@ async fn main() -> Result<(), Box> { } } }); - let app_attest = Arc::new(AppAttestVerifier::new( - c.app_attest_app_id, - fs::read(&c.app_attest_root_cert_path)?, - )?); let accepting = Arc::new(AtomicBool::new(true)); let (public, health) = router_with_metrics( AppState { grant_keyring: Arc::new(grant_keyring), - app_attest, authority, token_keyring: Arc::new(token_keyring), - transport, + profiles: Arc::new(profiles), delivery_url: c.public_delivery_url, max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, endpoint_quota_window_seconds: c.endpoint_quota_window_seconds, endpoint_quota_max_deliveries: c.endpoint_quota_max_deliveries, - enabled_profiles: c.enabled_profiles, now: || chrono::Utc::now().timestamp(), accepting: accepting.clone(), }, diff --git a/crates/buzz-push-gateway/src/metrics.rs b/crates/buzz-push-gateway/src/metrics.rs index f40c126c79a..dfc45f467c0 100644 --- a/crates/buzz-push-gateway/src/metrics.rs +++ b/crates/buzz-push-gateway/src/metrics.rs @@ -41,18 +41,24 @@ pub fn install() -> Result { /// Stable metric label for each sanitized delivery outcome. The mapping is total /// over the closed [`DeliveryOutcome`] enum, so the `outcome` label can only take -/// these six values. +/// these five values. fn outcome_label(outcome: DeliveryOutcome) -> &'static str { match outcome { DeliveryOutcome::Accepted => "accepted", DeliveryOutcome::InvalidEndpoint { .. } => "invalid_endpoint", DeliveryOutcome::Retry { .. } => "retry", - DeliveryOutcome::RefreshCredential => "refresh_credential", DeliveryOutcome::ConfigurationFault => "configuration_fault", DeliveryOutcome::PermanentRequestFault => "permanent_request_fault", } } +/// Record entry into the concrete APNs HTTP send seam. This counter is kept +/// separate from terminal outcomes so a control scrape can distinguish +/// "transport never reached" from "APNs send returned an error". +pub fn record_apns_send_attempt() { + metrics::counter!("push_gateway_apns_send_attempts_total").increment(1); +} + /// Record the terminal APNs outcome and its send round-trip latency. pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { metrics::counter!("push_gateway_apns_deliveries_total", "outcome" => outcome_label(outcome)) @@ -60,11 +66,6 @@ pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { metrics::histogram!("push_gateway_apns_delivery_seconds").record(seconds); } -/// Record that a cached provider credential was refreshed after APNs reported expiry. -pub fn record_credential_refresh() { - metrics::counter!("push_gateway_apns_credential_refreshes_total").increment(1); -} - /// Delivery-admission result at the `authorize_delivery` seam. #[derive(Debug, Clone, Copy)] pub enum Admission { @@ -126,7 +127,7 @@ mod tests { #[test] fn outcome_label_covers_every_variant_with_static_strings() { // Exhaustive over the closed enum; each arm is a compile-time constant, - // so the `outcome` label is structurally bounded to these six values. + // so the `outcome` label is structurally bounded to these five values. for (outcome, expected) in [ (DeliveryOutcome::Accepted, "accepted"), ( @@ -141,7 +142,6 @@ mod tests { }, "retry", ), - (DeliveryOutcome::RefreshCredential, "refresh_credential"), (DeliveryOutcome::ConfigurationFault, "configuration_fault"), ( DeliveryOutcome::PermanentRequestFault, @@ -159,6 +159,7 @@ mod tests { fn recorder_renders_sanitized_bounded_series() { let handle = install().expect("recorder installs exactly once per test process"); + record_apns_send_attempt(); record_apns_delivery(DeliveryOutcome::Accepted, 0.012); record_apns_delivery( DeliveryOutcome::InvalidEndpoint { @@ -166,7 +167,6 @@ mod tests { }, 0.030, ); - record_credential_refresh(); record_admission(Admission::Admitted); record_admission(Admission::Rejected); record_admission(Admission::Unavailable); @@ -180,9 +180,9 @@ mod tests { // All expected series are present. for needle in [ + "push_gateway_apns_send_attempts_total", "push_gateway_apns_deliveries_total", "push_gateway_apns_delivery_seconds", - "push_gateway_apns_credential_refreshes_total", "push_gateway_admissions_total", "push_gateway_delivery_errors_total", "push_gateway_reaper_failures_total", diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 23f8015fe00..574d1d6b9c2 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -12,14 +12,14 @@ pub const WIRE_VERSION: u8 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AppProfile { - BuzzIosProduction, - BuzzIosSandbox, + BuzzIosDogfood, + BuzzIosAppStore, } impl AppProfile { pub const fn as_str(self) -> &'static str { match self { - Self::BuzzIosProduction => "buzz-ios-production", - Self::BuzzIosSandbox => "buzz-ios-sandbox", + Self::BuzzIosDogfood => "buzz-ios-dogfood", + Self::BuzzIosAppStore => "buzz-ios-app-store", } } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index bd69ec25646..a3ea82c8230 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -63,8 +63,8 @@ fn ts(v: DateTime) -> i64 { } fn profile(v: &str) -> Result { match v { - "buzz-ios-production" => Ok(AppProfile::BuzzIosProduction), - "buzz-ios-sandbox" => Ok(AppProfile::BuzzIosSandbox), + "buzz-ios-dogfood" => Ok(AppProfile::BuzzIosDogfood), + "buzz-ios-app-store" => Ok(AppProfile::BuzzIosAppStore), _ => Err(AuthorityError::Unavailable), } } @@ -683,7 +683,7 @@ mod tests { let now = Utc::now(); sqlx::query( "INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) - VALUES ($1,$2,$3,0,'buzz-ios-production',$4,$5,1,$6)", + VALUES ($1,$2,$3,0,'buzz-ios-dogfood',$4,$5,1,$6)", ) .bind(Uuid::from_u128(1)) .bind(vec![1u8]) diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem new file mode 100644 index 00000000000..dc7e9923a54 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem new file mode 100644 index 00000000000..7461fbe111f --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem @@ -0,0 +1,19 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCrxiLXIJU5iHcD0IMS +sRI0AgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQmlIQbhuOv5VUfS6I +MVPLEwSBkNqbXztd0jeDg0nA1RCDPerWJUZqN5i6TtZtLwxLhpfcrDPT0aVEoFLv +dyRLcdzRmYNmHAoEaO0o0nLahGOlu4PlYqEoTahIq/ursix7JV5NhUJUWMFJFTz9 +qgYSTxsvecejzM4SvMMVx5zVhgn/ojMDbocNOA8DfMW/U6gP9AxBV5RqyMGsMcuK +OPn9XCFJsg== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem new file mode 100644 index 00000000000..f174811712b --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0 +AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu +RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem new file mode 100644 index 00000000000..7c82d17d611 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0 +AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu +RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +-----END PRIVATE KEY----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem new file mode 100644 index 00000000000..bed75b120f2 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg18TP8zUw6UBPuIc2 +4zZIQ7TMe4Iu9VtXGxVXMV3PRPqhRANCAASN9Thxojkwcn1d2XN3KswViaVM+tpK +v69Qne0M1q8A6finFJ7chBwu8/G+nFPyYszJZnm6vGwxzxIBEpd9KJT1 +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock new file mode 100644 index 00000000000..d29012deecf --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock @@ -0,0 +1,341 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "buzz-app-attest-fixture-generator" +version = "0.1.0" +dependencies = [ + "base64", + "byteorder", + "ciborium", + "openssl", + "sha2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml new file mode 100644 index 00000000000..2ffc6d243f7 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "buzz-app-attest-fixture-generator" +version = "0.1.0" +edition = "2021" +publish = false + +[workspace] + +[dependencies] +base64 = "0.22" +byteorder = "1.5" +ciborium = "0.2" +openssl = "0.10" +sha2 = "0.10" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md new file mode 100644 index 00000000000..cf498bf1d68 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md @@ -0,0 +1,18 @@ +# App Attest verifier fixtures + +This standalone crate generates synthetic App Attest fixtures for the gateway's strict verifier tests. It has its own `[workspace]`, does not belong to the repository workspace dependency graph, and must never depend on `appattest`. That separation prevents the dependency's `testing` feature from being unified into the gateway test build, where it would permit the development AAGUID. + +The generator owns the full root, intermediate, and credential certificate chain. It writes Apple's nonce extension OID `1.2.840.113635.100.8.2` and App Attest EKU `1.2.840.113635.100.4.24` directly. Generator correctness is therefore load-bearing. The strict verifier's good-fixture acceptance test is the encoding oracle. Any generator change must pass the full App Attest test floor, and a fixture rejected by the shipped verifier must never be made green by loosening a test. + +`apple-app-attestation-root.pem` is the production pin-control fixture downloaded from [Apple's certificate authority](https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem). Its exact PEM-file SHA-256 must remain `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. + +Regenerate from the repository root: + +```bash +cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- \ + --output-dir crates/buzz-push-gateway/tests/fixtures \ + --good-aaguid appattest \ + --wrong-aaguid appattestdevelop +``` + +The command rewrites `app-attest-good.json`, `app-attest-wrong-aaguid.json`, and `app-attest-wrong-root.json`. Review all fixture changes and run the complete gateway package test suite afterward. diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs new file mode 100644 index 00000000000..fe367fbdaaf --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs @@ -0,0 +1,404 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use byteorder::{BigEndian, ByteOrder}; +use ciborium::{cbor, Value}; +use openssl::{ + asn1::{Asn1Integer, Asn1Object, Asn1OctetString, Asn1Time}, + bn::{BigNum, MsbOption}, + ec::{EcGroup, EcKey, PointConversionForm}, + hash::MessageDigest, + nid::Nid, + pkey::{PKey, Private}, + x509::{ + extension::{BasicConstraints, ExtendedKeyUsage, KeyUsage}, + X509Builder, X509Extension, X509Name, X509NameBuilder, X509, + }, +}; +use sha2::{Digest, Sha256}; + +const APP_ID: &str = "TEAMID.xyz.buzz.mobile"; +const CHALLENGE: &str = "buzz-app-attest-strict-verifier-fixture"; +const CERT_VALIDITY_DAYS: u32 = 7_305; +const REGENERATION_COMMAND: &str = "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop"; + +struct Args { + output_dir: PathBuf, + good_aaguid: String, + wrong_aaguid: String, +} + +struct CertificateAuthority { + root_cert: X509, + intermediate_cert: X509, + intermediate_key: PKey, +} + +struct Fixture { + attestation_b64: String, + key_id_b64: String, + root_cert_pem: String, + leaf_not_after: String, +} + +fn main() { + let args = parse_args(); + validate_aaguid(&args.good_aaguid); + validate_aaguid(&args.wrong_aaguid); + fs::create_dir_all(&args.output_dir).expect("create fixture output directory"); + + let primary_ca = CertificateAuthority::generate("Buzz App Attest Fixture Root"); + let good = build_fixture(&primary_ca, &args.good_aaguid); + let wrong_aaguid = build_fixture(&primary_ca, &args.wrong_aaguid); + + let unrelated_ca = CertificateAuthority::generate("Unrelated App Attest Fixture Root"); + let wrong_root = build_fixture(&unrelated_ca, &args.good_aaguid); + + write_fixture( + &args.output_dir, + "app-attest-good.json", + "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", + &args.good_aaguid, + &good, + ); + write_fixture( + &args.output_dir, + "app-attest-wrong-aaguid.json", + "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", + &args.wrong_aaguid, + &wrong_aaguid, + ); + write_fixture( + &args.output_dir, + "app-attest-wrong-root.json", + "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", + &args.good_aaguid, + &wrong_root, + ); +} + +fn parse_args() -> Args { + let mut output_dir = None; + let mut good_aaguid = None; + let mut wrong_aaguid = None; + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + let value = args + .next() + .unwrap_or_else(|| panic!("missing value for {arg}")); + match arg.as_str() { + "--output-dir" => output_dir = Some(PathBuf::from(value)), + "--good-aaguid" => good_aaguid = Some(value), + "--wrong-aaguid" => wrong_aaguid = Some(value), + _ => panic!("unknown argument {arg}"), + } + } + Args { + output_dir: output_dir.expect("--output-dir is required"), + good_aaguid: good_aaguid.expect("--good-aaguid is required"), + wrong_aaguid: wrong_aaguid.expect("--wrong-aaguid is required"), + } +} + +fn validate_aaguid(aaguid: &str) { + assert!( + !aaguid.is_empty() && aaguid.len() <= 16 && aaguid.is_ascii(), + "AAGUID must be 1 to 16 ASCII bytes" + ); +} + +impl CertificateAuthority { + fn generate(root_common_name: &str) -> Self { + let root_key = p384_key(); + let root_name = name(root_common_name); + let root_cert = certificate( + &root_name, + &root_name, + &root_key, + &root_key, + MessageDigest::sha384(), + true, + None, + ); + + let intermediate_key = p256_key(); + let intermediate_name = name("Buzz App Attest Fixture Intermediate"); + let intermediate_cert = certificate( + &intermediate_name, + root_cert.subject_name(), + &intermediate_key, + &root_key, + MessageDigest::sha384(), + true, + None, + ); + + Self { + root_cert, + intermediate_cert, + intermediate_key, + } + } +} + +fn build_fixture(ca: &CertificateAuthority, aaguid_value: &str) -> Fixture { + let device_key = p256_key(); + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("P-256 group"); + let ec_key = device_key.ec_key().expect("device EC key"); + let mut context = openssl::bn::BigNumContext::new().expect("bignum context"); + let public_key = ec_key + .public_key() + .to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut context) + .expect("serialize device public key"); + let key_id = Sha256::digest(&public_key); + let key_id_b64 = STANDARD.encode(key_id); + + let mut aaguid = [0_u8; 16]; + aaguid[..aaguid_value.len()].copy_from_slice(aaguid_value.as_bytes()); + let mut auth_data = Vec::with_capacity(87); + auth_data.extend_from_slice(&Sha256::digest(APP_ID.as_bytes())); + auth_data.push(0x41); + auth_data.extend_from_slice(&[0_u8; 4]); + auth_data.extend_from_slice(&aaguid); + let mut credential_length = [0_u8; 2]; + BigEndian::write_u16(&mut credential_length, key_id.len() as u16); + auth_data.extend_from_slice(&credential_length); + auth_data.extend_from_slice(&key_id); + + let mut nonce_input = auth_data.clone(); + nonce_input.extend_from_slice(&Sha256::digest(CHALLENGE.as_bytes())); + let nonce = Sha256::digest(nonce_input); + + let leaf_name = name("Buzz App Attest Fixture Credential"); + let leaf_cert = certificate( + &leaf_name, + ca.intermediate_cert.subject_name(), + &device_key, + &ca.intermediate_key, + MessageDigest::sha256(), + false, + Some(&nonce), + ); + let leaf_der = leaf_cert.to_der().expect("encode credential certificate"); + let intermediate_der = ca + .intermediate_cert + .to_der() + .expect("encode intermediate certificate"); + let value = cbor!({ + "fmt" => "apple-appattest", + "attStmt" => { + "x5c" => [ + Value::Bytes(leaf_der), + Value::Bytes(intermediate_der) + ], + "receipt" => Value::Bytes(Vec::new()) + }, + "authData" => Value::Bytes(auth_data) + }) + .expect("build attestation CBOR value"); + let mut cbor = Vec::new(); + ciborium::into_writer(&value, &mut cbor).expect("encode attestation CBOR"); + + Fixture { + attestation_b64: STANDARD.encode(cbor), + key_id_b64, + root_cert_pem: String::from_utf8( + ca.root_cert.to_pem().expect("encode root certificate PEM"), + ) + .expect("root PEM is UTF-8"), + leaf_not_after: leaf_cert.not_after().to_string(), + } +} + +fn certificate( + subject: &X509Name, + issuer: &openssl::x509::X509NameRef, + subject_key: &PKey, + issuer_key: &PKey, + signature_digest: MessageDigest, + is_ca: bool, + nonce: Option<&[u8]>, +) -> X509 { + let mut builder = X509Builder::new().expect("create certificate builder"); + builder.set_version(2).expect("set X.509 version"); + builder + .set_serial_number(&serial_number()) + .expect("set certificate serial"); + builder + .set_subject_name(subject) + .expect("set certificate subject"); + builder + .set_issuer_name(issuer) + .expect("set certificate issuer"); + builder + .set_pubkey(subject_key) + .expect("set certificate public key"); + builder + .set_not_before(&Asn1Time::days_from_now(0).expect("build notBefore")) + .expect("set notBefore"); + builder + .set_not_after(&Asn1Time::days_from_now(CERT_VALIDITY_DAYS).expect("build long notAfter")) + .expect("set notAfter"); + + if is_ca { + builder + .append_extension( + BasicConstraints::new() + .critical() + .ca() + .build() + .expect("build CA constraints"), + ) + .expect("append CA constraints"); + builder + .append_extension( + KeyUsage::new() + .critical() + .key_cert_sign() + .crl_sign() + .build() + .expect("build CA key usage"), + ) + .expect("append CA key usage"); + } else { + builder + .append_extension( + BasicConstraints::new() + .critical() + .build() + .expect("build leaf constraints"), + ) + .expect("append leaf constraints"); + builder + .append_extension( + ExtendedKeyUsage::new() + .other("1.2.840.113635.100.4.24") + .build() + .expect("build App Attest EKU"), + ) + .expect("append App Attest EKU"); + + let nonce = nonce.expect("credential certificate nonce"); + assert_eq!(nonce.len(), 32, "App Attest nonce must be 32 bytes"); + let mut extension_value = Vec::with_capacity(38); + extension_value.extend_from_slice(&[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]); + extension_value.extend_from_slice(nonce); + let oid = Asn1Object::from_str("1.2.840.113635.100.8.2") + .expect("parse App Attest nonce extension OID"); + let octets = Asn1OctetString::new_from_bytes(&extension_value) + .expect("encode App Attest nonce extension"); + builder + .append_extension( + X509Extension::new_from_der(&oid, false, &octets) + .expect("build App Attest nonce extension"), + ) + .expect("append App Attest nonce extension"); + } + + builder + .sign(issuer_key, signature_digest) + .expect("sign certificate"); + builder.build() +} + +fn p256_key() -> PKey { + ec_key(Nid::X9_62_PRIME256V1) +} + +fn p384_key() -> PKey { + ec_key(Nid::SECP384R1) +} + +fn ec_key(curve: Nid) -> PKey { + let group = EcGroup::from_curve_name(curve).expect("EC group"); + PKey::from_ec_key(EcKey::generate(&group).expect("generate EC key")).expect("wrap EC key") +} + +fn name(common_name: &str) -> X509Name { + let mut builder = X509NameBuilder::new().expect("create X.509 name builder"); + builder + .append_entry_by_text("CN", common_name) + .expect("set common name"); + builder + .append_entry_by_text("O", "Buzz") + .expect("set organization"); + builder.build() +} + +fn serial_number() -> Asn1Integer { + let mut number = BigNum::new().expect("create certificate serial"); + number + .rand(128, MsbOption::MAYBE_ZERO, false) + .expect("generate certificate serial"); + Asn1Integer::from_bn(&number).expect("convert certificate serial") +} + +fn write_fixture( + output_dir: &Path, + file_name: &str, + description: &str, + aaguid: &str, + fixture: &Fixture, +) { + let generated_at = generation_date(); + let json = format!( + concat!( + "{{\n", + " \"description\": \"{}\",\n", + " \"generator\": \"crates/buzz-push-gateway/tests/fixtures/app-attest-generator\",\n", + " \"generated_at\": \"{}\",\n", + " \"regeneration_command\": \"{}\",\n", + " \"app_id\": \"{}\",\n", + " \"challenge\": \"{}\",\n", + " \"aaguid\": \"{}\",\n", + " \"leaf_not_after\": \"{}\",\n", + " \"attestation_b64\": \"{}\",\n", + " \"key_id_b64\": \"{}\",\n", + " \"root_cert_pem\": \"{}\"\n", + "}}\n" + ), + json_escape(description), + generated_at, + json_escape(REGENERATION_COMMAND), + APP_ID, + CHALLENGE, + aaguid, + json_escape(&fixture.leaf_not_after), + fixture.attestation_b64, + fixture.key_id_b64, + json_escape(&fixture.root_cert_pem), + ); + fs::write(output_dir.join(file_name), json).expect("write fixture JSON"); +} + +fn generation_date() -> String { + let output = std::process::Command::new("date") + .args(["-u", "+%Y-%m-%d"]) + .output() + .expect("run date for fixture metadata"); + assert!(output.status.success(), "date command failed"); + String::from_utf8(output.stdout) + .expect("date output is UTF-8") + .trim() + .to_owned() +} + +fn json_escape(value: &str) -> String { + value + .chars() + .flat_map(|character| match character { + '\\' => "\\\\".chars().collect::>(), + '"' => "\\\"".chars().collect(), + '\n' => "\\n".chars().collect(), + '\r' => "\\r".chars().collect(), + '\t' => "\\t".chars().collect(), + value if value.is_control() => { + panic!("unsupported control character in fixture metadata") + } + value => vec![value], + }) + .collect() +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json new file mode 100644 index 00000000000..5c076ba951d --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json @@ -0,0 +1,13 @@ +{ + "description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", + "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", + "generated_at": "2026-08-02", + "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "leaf_not_after": "Aug 2 05:27:02 2046 GMT", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdgwggHUMIIBeqADAgECAhEA1hkzMVx4LIlx2Z04+dq+DjAKBggqhkjOPQQDAjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA8MSswKQYDVQQDDCJCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBDcmVkZW50aWFsMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnUoIjO//gCI6cvjfmAw62OnnngGVoId2q7MQYG//94tXIX2tIZChUe1y/spRzJqxLo0JNm7d9QKdoVuLNBpfaNbMFkwDAYDVR0TAQH/BAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCDi01h8mHF6AJkdlwJoO7ieXb9TDEttdsV48n1Jd57tIDAKBggqhkjOPQQDAgNIADBFAiEAmyNVz7oG03YWXBP55xcqJ1xrwv7INxQmSKjr/lrrXKwCIEGS9+8qhYxQfZa1q/jcegDlNxphatVVqx5j8cQbjNU2WQG6MIIBtjCCATugAwIBAgIQJj6YcsuecIX6zF/ZFQ6wzDAKBggqhkjOPQQDAzA2MSUwIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowPjEtMCsGA1UEAwwkQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgSW50ZXJtZWRpYXRlMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfGKED5L/Nh0lvKRJAllDU01J6pZhqYBV/a7HRTphUIkIhW0Jc/Q2BplGB+vrMgUG+QX9eG8k7VvRZjov/m7gbaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaQAwZgIxAONcQ0m5yYfK4ILWwnRWAZjhQg/ZrwiRY3VEBzkAc082FXwp0mqMjXwicSt/ibULFgIxAKFayHKDgusCMjLMPkoIYbOI2jnR+TY8Vftq89b33qLQ2EebRB1PGDld2mvVY01OU2dyZWNlaXB0QGhhdXRoRGF0YVhXH5nFfKMZs8qsLEqZv4n7atEJxvG0oHWjDbycL/O5tJlBAAAAAGFwcGF0dGVzdAAAAAAAAAAAIOtFw/nPMzM0gQAeS/gQ1R2aF7oMMjXIx08QJN8q0cuk", + "key_id_b64": "60XD+c8zMzSBAB5L+BDVHZoXugwyNcjHTxAk3yrRy6Q=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json new file mode 100644 index 00000000000..4b8a5737b7c --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json @@ -0,0 +1,13 @@ +{ + "description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", + "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", + "generated_at": "2026-08-02", + "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattestdevelop", + "leaf_not_after": "Aug 2 05:27:02 2046 GMT", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhAXdDyYByLYxE4WftXjOFC1MAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ8ZGvDc7xMJINZw6mLHRU6xr1kFY+vn+PRZYIMypdlYb99U/l8VCK9zWQt+xXSEAyNvzdcZiom5N/fKuAI5xh/o1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEILEfYIC8xsY+hZnqOrQF1PpWR3VioqnjjQwo5/YmtAwRMAoGCCqGSM49BAMCA0gAMEUCIQCpOzhfo94xcJ0ojQki6wxpOdORPsNwXtZz+eByIhtwlwIgPr71d/DiOaQ3Jd9jDaiCFrzozcR5owB0kaKRzvFuBv1ZAbowggG2MIIBO6ADAgECAhAmPphyy55whfrMX9kVDrDMMAoGCCqGSM49BAMDMDYxJTAjBgNVBAMMHEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8YoQPkv82HSW8pEkCWUNTTUnqlmGpgFX9rsdFOmFQiQiFbQlz9DYGmUYH6+syBQb5Bf14byTtW9FmOi/+buBtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA41xDSbnJh8rggtbCdFYBmOFCD9mvCJFjdUQHOQBzTzYVfCnSaoyNfCJxK3+JtQsWAjEAoVrIcoOC6wIyMsw+Sghhs4jaOdH5NjxV+2rz1vfeotDYR5tEHU8YOV3aa9VjTU5TZ3JlY2VpcHRAaGF1dGhEYXRhWFcfmcV8oxmzyqwsSpm/iftq0QnG8bSgdaMNvJwv87m0mUEAAAAAYXBwYXR0ZXN0ZGV2ZWxvcAAg6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", + "key_id_b64": "6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json new file mode 100644 index 00000000000..c709c118e44 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json @@ -0,0 +1,13 @@ +{ + "description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", + "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", + "generated_at": "2026-08-02", + "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "leaf_not_after": "Aug 2 05:27:02 2046 GMT", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhBGe4kbr8X3vBBmRW24fEPWMAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3NUd9f8Ma88b5fiKPmvgL0akkZfv3Q5v2jJMGVQ+pDY2ZFkZTQnzTfAPydFBFtVQE9HpPLlx22e/8eixSUFdLo1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEIF7PDSiNaYyhbJlVGsubqOBUPUSS4sT5PJ0Ri8mGDRjSMAoGCCqGSM49BAMCA0gAMEUCIQCSjdrbcQurd+avRl+OcRIZPusoJBNVGLun3Rda9tJ5NwIgOFEcGxdOZi3atz7Nwzwe409oVcu4GdXOVo9N86pOu8dZAb8wggG7MIIBQaADAgECAhEAx1cRnQUJhJKCUll92sLeGDAKBggqhkjOPQQDAzA7MSowKAYDVQQDDCFVbnJlbGF0ZWQgQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASz7MX/nc9MaCmjSQ3f+L8SCsgNdFEcDyZ7FxREEPu4bGUujA+P5exSwDuA8L64WrznNITC1J8sZ98VZ/tTNWFtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA8ABjGCavBGyl6FgO9u58hV/xzRnhdlFiTUPiN/XCvmfxDkOyYwzLk06/k4JmdqfCAjBADKJsa+9138UAMZgU8iYWTOY+FO96DHsdC+8H9vBoLBE/DxzQHsX2Wd/DEbggUGJncmVjZWlwdEBoYXV0aERhdGFYVx+ZxXyjGbPKrCxKmb+J+2rRCcbxtKB1ow28nC/zubSZQQAAAABhcHBhdHRlc3QAAAAAAAAAACCvcDv+nttQP9RSSwBycpsL+NiE13xuEsfU7iKqeRaTsQ==", + "key_id_b64": "r3A7/p7bUD/UUksAcnKbC/jYhNd8bhLH1O4iqnkWk7E=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB1DCCAVugAwIBAgIRALE3l3fzQ4wPjIL/IjBs02IwCgYIKoZIzj0EAwMwOzEq\nMCgGA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYD\nVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowOzEqMCgG\nA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQK\nDARCdXp6MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEPa8SWuDIcjNVDwTXlTQnWbKj\n5Vt8TCiGGH0CiSJajPOlevvjHBEYuVHf7bFYa5N/7OzXQ3qkZomCyizJ6nc5tBEN\nGL3rkz7vZjb9J3QPfixkBwyUHFHmx1WJ84fgAYcDoyMwITAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAMO0cvuHHJSqWj\n4DxJorq8LH7VH9ILTGjcZmz91rLlO7w4oDqiewFQE+GVFl9boekCMBhaa0a/WiW2\nyf2j5d04SOkXREM1NkbHsd1yH1jqSOCuj6PU3Z6zDSSXy1z3HjQIBg==\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem b/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem new file mode 100644 index 00000000000..4cff2277b51 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw +JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK +QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa +Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv +biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y +bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh +NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au +Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw +CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn +53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV +oyFraWVIyd/dganmrduC1bmTBGwD +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json new file mode 100644 index 00000000000..27b84035d6c --- /dev/null +++ b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json @@ -0,0 +1,48 @@ +{ + "description": "Known-answer vectors for the exact App Attest transcript bytes defined by NIP-PL ('Exact App Attest transcript construction'). Generated by the gateway's own transcript encoder (crates/buzz-push-gateway/src/http.rs transcript()). Client canonical encoders (Swift NIP-PL iOS client) MUST reproduce `transcript` byte-for-byte; `sha256` is the hex digest of those UTF-8 bytes (the App Attest clientDataHash input for assertion routes, and the exact clientData for enrollment).", + "inputs": { + "challenge_id": "11111111-1111-4111-8111-111111111111", + "installation_handle": "22222222-2222-4222-8222-222222222222", + "challenge": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "challenge_note": "base64url-no-pad of bytes 0x00..0x1f", + "key_id": "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=", + "key_id_note": "standard base64 (padded) of 32 bytes of 0xAA", + "app_profile": "buzz-ios-dogfood", + "endpoint": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "relay_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "not_before": 1752620000, + "expires_at": 1752624000 + }, + "vectors": [ + { + "name": "enroll", + "domain": "buzz.push.enroll.v1", + "transcript": "buzz.push.enroll.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"key_id\":\"qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=\",\"app_profile\":\"buzz-ios-dogfood\",\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\",\"endpoint_epoch\":1,\"expires_at\":1752624000}", + "sha256": "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270" + }, + { + "name": "delegate", + "domain": "buzz.push.delegate.v1", + "transcript": "buzz.push.delegate.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"generation\":1,\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"not_before\":1752620000,\"expires_at\":1752624000}", + "sha256": "7466177cc2dc2a4f9a075fdbb461531692fc858778a171a5862b855cccfaa059" + }, + { + "name": "rotate_endpoint", + "domain": "buzz.push.rotate-endpoint.v1", + "transcript": "buzz.push.rotate-endpoint.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/endpoint\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2,\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\"}", + "sha256": "601aba0c8d4021ddf97ce1e434b9c7ad1e051bf02a44929aaebd8c6bd724e7b3" + }, + { + "name": "revoke_delegation", + "domain": "buzz.push.revoke-delegation.v1", + "transcript": "buzz.push.revoke-delegation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"generation\":2}", + "sha256": "d6bcd4b25235adcb519ef189820b08dd0386fc752fd4e4c77bc3ffb7a519a84a" + }, + { + "name": "revoke_installation", + "domain": "buzz.push.revoke-installation.v1", + "transcript": "buzz.push.revoke-installation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2}", + "sha256": "0ba51827af6586a5e1230e9b770b99544fb342efb55db3ab1ce499cf24a893c8" + } + ] +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3d..c8034e87c10 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -269,10 +269,14 @@ pub struct Config { /// Used to authenticate internal policy endpoint requests. pub git_hook_hmac_secret: String, + /// Whether NIP-PL push discovery, lease acceptance, matching, and delivery + /// are enabled for this deployment. Defaults to false. + pub push_enabled: bool, /// Descriptor key identifier accepted in kind:30350 `exec` tags. pub push_executor_key_id: String, /// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities. - /// Push lease support is disabled when unset. + /// An absent setting selects the canonical Buzz gateway. An explicitly + /// empty setting is allowed only while push is disabled. pub push_gateway_delivery_url: Option, /// Hard timeout for one gateway delivery request. pub push_gateway_timeout: Duration, @@ -859,6 +863,7 @@ impl Config { let secret: [u8; 32] = rand::random(); hex::encode(secret) }); + let push_enabled = parse_bool("BUZZ_PUSH_ENABLED", false)?; let push_executor_key_id = std::env::var("BUZZ_PUSH_EXECUTOR_KEY_ID").unwrap_or_else(|_| "relay-v1".to_string()); if push_executor_key_id.is_empty() || push_executor_key_id.len() > 64 { @@ -867,6 +872,12 @@ impl Config { )); } let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") { + Ok(raw) if raw.trim().is_empty() && push_enabled => { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must not be empty when BUZZ_PUSH_ENABLED=true" + .to_string(), + )); + } Ok(raw) if raw.trim().is_empty() => None, Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?), Err(_) => Some(parse_push_gateway_delivery_url( @@ -1034,6 +1045,7 @@ impl Config { git_max_repos_per_pubkey, git_max_concurrent_ops, git_hook_hmac_secret, + push_enabled, push_executor_key_id, push_gateway_delivery_url, push_gateway_timeout, @@ -1577,11 +1589,25 @@ mod tests { } #[test] - fn push_gateway_defaults_to_buzz_and_can_be_disabled() { + fn push_is_opt_in_and_gateway_defaults_to_buzz() { let _guard = ENV_MUTEX.lock().unwrap(); + let previous_enabled = std::env::var_os("BUZZ_PUSH_ENABLED"); let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); + std::env::remove_var("BUZZ_PUSH_ENABLED"); std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); let config = Config::from_env().expect("default config"); + assert!(!config.push_enabled); + assert_eq!( + config + .push_gateway_delivery_url + .as_ref() + .map(url::Url::as_str), + Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) + ); + + std::env::set_var("BUZZ_PUSH_ENABLED", "true"); + let config = Config::from_env().expect("enabled push config"); + assert!(config.push_enabled); assert_eq!( config .push_gateway_delivery_url @@ -1591,9 +1617,22 @@ mod tests { ); std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", ""); + let result = Config::from_env(); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must not be empty") + )); + + std::env::set_var("BUZZ_PUSH_ENABLED", "false"); let config = Config::from_env().expect("disabled push config"); assert!(config.push_gateway_delivery_url.is_none()); + if let Some(value) = previous_enabled { + std::env::set_var("BUZZ_PUSH_ENABLED", value); + } else { + std::env::remove_var("BUZZ_PUSH_ENABLED"); + } if let Some(value) = previous { std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value); } else { @@ -1601,6 +1640,24 @@ mod tests { } } + #[test] + fn invalid_push_enabled_value_is_rejected() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_PUSH_ENABLED"); + std::env::set_var("BUZZ_PUSH_ENABLED", "sometimes"); + let result = Config::from_env(); + if let Some(value) = previous { + std::env::set_var("BUZZ_PUSH_ENABLED", value); + } else { + std::env::remove_var("BUZZ_PUSH_ENABLED"); + } + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_PUSH_ENABLED") + )); + } + #[test] fn push_gateway_url_is_exact_and_fail_closed() { assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index ec56a096fdc..f858611dff0 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -12,7 +12,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value}; use sha2::Digest as _; -pub(crate) const PUSH_KINDS: &[u64] = &[7, 9, 1059, 40007, 46010]; +/// Message kinds that can produce a mobile Activity-inbox notification. +/// Generic Nostr notes and non-message workflow/agent events are deliberately +/// excluded from the dogfood MVP. +pub(crate) const PUSH_KINDS: &[u64] = &[9, 40_002, 45_001, 45_003]; pub(crate) const URGENT_KINDS: &[u64] = &[]; /// NIP-PL addressable push-lease event kind. @@ -477,7 +480,7 @@ pub async fn accept( const MAX_CONTENT: usize = 65_536; const MAX_PLAINTEXT: usize = 32_768; const MAX_ACTIVE_LEASES: i64 = 16; - if state.config.push_gateway_delivery_url.is_none() { + if !state.config.push_enabled { return Err(AcceptError::Validation("push not supported".to_string())); } let envelope = validate_envelope(event, now, ALLOWED_SKEW, MAX_LEASE_TTL, MAX_CONTENT)?; @@ -498,11 +501,11 @@ pub async fn accept( author_hex: &author_hex, app_profiles: &[ AppProfile { - id: "buzz-ios-production", + id: "buzz-ios-dogfood", transport: "apns", }, AppProfile { - id: "buzz-ios-sandbox", + id: "buzz-ios-app-store", transport: "apns", }, ], @@ -531,25 +534,29 @@ pub async fn accept( let subscriptions; let capability; let active = if body.active { - let endpoint = body.endpoint.as_deref().expect("validated active endpoint"); - endpoint_hash = sha2::Sha256::digest(endpoint.as_bytes()).to_vec(); - let max_class = body + let endpoint = body + .endpoint + .as_deref() + .ok_or_else(|| "active lease is missing endpoint".to_string())?; + let body_subscriptions = body .subscriptions .as_ref() - .expect("validated subscriptions") + .ok_or_else(|| "active lease is missing subscriptions".to_string())?; + let app_profile = body + .app_profile + .as_deref() + .ok_or_else(|| "active lease is missing app profile".to_string())?; + endpoint_hash = sha2::Sha256::digest(endpoint.as_bytes()).to_vec(); + let max_class = body_subscriptions .iter() .map(|sub| sub.class.as_str()) .max_by_key(|class| class_rank(class)) - .expect("non-empty subscriptions"); + .ok_or_else(|| "active lease has no subscriptions".to_string())?; capability = endpoint.to_owned(); - subscriptions = serde_json::to_value( - body.subscriptions - .as_ref() - .expect("validated subscriptions"), - ) - .map_err(|_| "invalid subscriptions".to_string())?; + subscriptions = serde_json::to_value(body_subscriptions) + .map_err(|_| "invalid subscriptions".to_string())?; Some(buzz_db::push::ActiveLease { - app_profile: body.app_profile.as_deref().expect("validated profile"), + app_profile, endpoint_hash: &endpoint_hash, endpoint_grant: &capability, max_class, @@ -702,7 +709,7 @@ mod tests { .collect::>() .join(", "); let predicate = format!("NEW.kind IN ({kinds})"); - let migration = include_str!("../../../../migrations/0018_push_match_queue.sql"); + let migration = include_str!("../../../../migrations/0032_push_message_kinds.sql"); assert!( migration.contains(&predicate), "migration trigger must use PUSH_KINDS exactly: {predicate}" diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 3584e1849d1..7ca4f021961 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -150,6 +150,7 @@ async fn main() -> anyhow::Result<()> { metrics_port = config.metrics_port, max_frame_bytes = config.max_frame_bytes, audit_enabled = config.audit_enabled, + push_enabled = config.push_enabled, "Config loaded" ); @@ -157,6 +158,7 @@ async fn main() -> anyhow::Result<()> { let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); + metrics::gauge!("buzz_push_enabled").set(if config.push_enabled { 1.0 } else { 0.0 }); info!( port = config.metrics_port, idle_timeout_secs = usage_idle_timeout_secs, @@ -714,15 +716,16 @@ async fn main() -> anyhow::Result<()> { }); } - // NIP-PL matcher and worker are enabled as one unit. Lease acceptance is - // already disabled without the exact gateway URL, so discovery and runtime - // cannot advertise or accumulate work for an undeliverable configuration. - if state.config.push_gateway_delivery_url.is_some() { + // NIP-PL matcher and worker are enabled as one unit behind the explicit + // deployment opt-in. The gateway URL alone never enables push. + if state.config.push_enabled { tokio::spawn(buzz_relay::push_runtime::run_matcher(Arc::clone(&state))); tokio::spawn(buzz_relay::push_runtime::run_delivery_worker(Arc::clone( &state, ))); info!("NIP-PL push matcher and delivery worker started"); + } else { + info!("NIP-PL push disabled by BUZZ_PUSH_ENABLED"); } // NIP-ER reminder scheduler — polls for due reminders and publishes them diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7baa..3a780db787d 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -207,8 +207,8 @@ fn push_descriptor( "current": true }], "app_profiles": [ - {"id": "buzz-ios-production", "transport": "apns"}, - {"id": "buzz-ios-sandbox", "transport": "apns"} + {"id": "buzz-ios-dogfood", "transport": "apns"}, + {"id": "buzz-ios-app-store", "transport": "apns"} ], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, "urgent_kinds": crate::handlers::push_lease::URGENT_KINDS, @@ -247,7 +247,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), ); - let tenant_host = if state.config.push_gateway_delivery_url.is_some() { + let tenant_host = if state.config.push_enabled { crate::tenant::bind_community(&state.db, raw_host) .await .ok() @@ -256,7 +256,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st None }; if let Some(push) = push_descriptor( - state.config.push_gateway_delivery_url.is_some(), + state.config.push_enabled, &state.config.relay_url, &state.config.push_executor_key_id, &state.relay_keypair, diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 4946b248c65..2b16e05b34b 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -1,6 +1,9 @@ //! Durable NIP-PL event matcher and gateway delivery worker. -use std::{sync::Arc, time::Duration}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use base64::Engine as _; use buzz_core::filter::{filters_match, reader_authorized_for_event}; @@ -131,6 +134,8 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc // the whole batch for retry. Jobs that keep failing are reaped by // the periodic sweep once their attempts are exhausted. warn!(%community, "push match context load failed: {e}"); + metrics::counter!("buzz_push_match_jobs_total", "result" => "context_error") + .increment(batch.jobs.len() as u64); let ids: Vec> = batch .jobs .iter() @@ -158,14 +163,26 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc let mut pending = Vec::new(); let mut wakes: Vec = Vec::new(); for job in &batch.jobs { + let match_queue_seconds = Utc::now() + .signed_duration_since(job.event.received_at) + .num_milliseconds() + .max(0) as f64 + / 1_000.0; + metrics::histogram!("buzz_push_match_queue_seconds").record(match_queue_seconds); let event_id = job.event.event.id.as_bytes().to_vec(); match match_job(job, &context) { - Ok(job_wakes) if job_wakes.is_empty() => completed.push(event_id), + Ok(job_wakes) if job_wakes.is_empty() => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "unmatched") + .increment(1); + completed.push(event_id); + } Ok(job_wakes) => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "matched").increment(1); pending.push((event_id, job.attempt)); wakes.extend(job_wakes); } Err(e) => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "error").increment(1); warn!(event_id=%job.event.event.id, attempt=job.attempt, "push match failed: {e}"); if job.attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS { // A poison event/lease must not retry forever or pin @@ -182,8 +199,19 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc // transaction sends the contributing jobs back for an idempotent rematch // (the outbox dedup key absorbs any wakes that did commit elsewhere). match state.db.enqueue_push_wakes(community, &wakes).await { - Ok(_) => completed.extend(pending.into_iter().map(|(event_id, _)| event_id)), + Ok(outcomes) => { + for outcome in outcomes { + let result = match outcome { + buzz_db::push::EnqueueWakeOutcome::Enqueued(_) => "enqueued", + buzz_db::push::EnqueueWakeOutcome::Duplicate(_) => "duplicate", + buzz_db::push::EnqueueWakeOutcome::InactiveLease => "inactive_lease", + }; + metrics::counter!("buzz_push_wakes_total", "result" => result).increment(1); + } + completed.extend(pending.into_iter().map(|(event_id, _)| event_id)); + } Err(e) => { + metrics::counter!("buzz_push_wake_enqueue_errors_total").increment(1); warn!(%community, "push wake batch enqueue failed: {e}"); for (event_id, attempt) in pending { if attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS { @@ -310,10 +338,17 @@ fn push_filter_authorized_for_event( /// Continuously claim due wakes and deliver them through the push gateway. pub async fn run_delivery_worker(state: Arc) { - let http = reqwest::Client::builder() + let http = match reqwest::Client::builder() .timeout(state.config.push_gateway_timeout) .build() - .expect("push HTTP client"); + { + Ok(http) => http, + Err(error) => { + error!(%error, "push HTTP client initialization failed"); + record_delivery("configuration_error"); + return; + } + }; let mut idle_delay = Duration::from_millis(500); loop { let mut found = false; @@ -362,13 +397,23 @@ async fn deliver_one( .db .fail_push_wake(claimed.community, claimed.id, claimed.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { warn!(wake=%claimed.id, "push revalidation failed: {e}"); + record_delivery("worker_error"); return; } }; + if outcome.attempt == 1 { + let wake_queue_seconds = Utc::now() + .signed_duration_since(outcome.queued_at) + .num_milliseconds() + .max(0) as f64 + / 1_000.0; + metrics::histogram!("buzz_push_wake_queue_seconds").record(wake_queue_seconds); + } if let Some(channel) = outcome.channel_id { match state .db @@ -381,6 +426,7 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { @@ -394,6 +440,7 @@ async fn deliver_one( Utc::now() + TimeDelta::seconds(2), ) .await; + record_delivery("retry"); return; } } @@ -411,10 +458,12 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { warn!(wake=%outcome.id, "final push revalidation failed: {e}"); + record_delivery("worker_error"); return; } }; @@ -432,31 +481,47 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } }; let Some(url) = state.config.push_gateway_delivery_url.as_ref() else { + record_delivery("configuration_error"); return; }; - let body = delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at); + let body = match delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at) { + Ok(body) => body, + Err(error) => { + warn!(wake=%outcome.id, %error, "push delivery body encoding failed"); + record_delivery("worker_error"); + return; + } + }; let auth = match nip98_header(&state.relay_keypair, url.as_str(), &body) { Ok(auth) => auth, Err(e) => { warn!(wake=%outcome.id, "push auth failed: {e}"); + record_delivery("worker_error"); return; } }; if let Err(error) = serving_write.verify().await { warn!(wake=%outcome.id, %error, "push serving lease lost before delivery"); + record_delivery("suppressed"); return; } - let response = match serving_write + metrics::counter!("buzz_push_gateway_requests_total").increment(1); + let gateway_started = Instant::now(); + let protected = serving_write .protect(send_gateway_request(http, url, body, auth)) - .await - { + .await; + metrics::histogram!("buzz_push_gateway_request_seconds") + .record(gateway_started.elapsed().as_secs_f64()); + let response = match protected { Ok(response) => response, Err(error) => { warn!(wake=%outcome.id, %error, "push serving lease lost during delivery"); + record_delivery("suppressed"); return; } }; @@ -467,12 +532,14 @@ async fn deliver_one( .db .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("accepted"); } _ => { let _ = state .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("failed"); } }, Ok(r) if r.status() == reqwest::StatusCode::GONE => { @@ -500,6 +567,7 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("invalid_endpoint"); } Ok(r) if r.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => { let delay = match r.json::().await { @@ -510,10 +578,10 @@ async fn deliver_one( .unwrap_or(2), _ => 2, }; - retry_or_fail(state, &outcome, delay).await; + record_delivery(retry_or_fail(state, &outcome, delay).await); } Ok(r) if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS => { - retry_or_fail(state, &outcome, 2).await + record_delivery(retry_or_fail(state, &outcome, 2).await); } // A timed-out terminal attempt burns the stable request id. Its replay // is indistinguishable from another invalid-grant 404, but sending a @@ -523,13 +591,17 @@ async fn deliver_one( .db .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("replay_terminal"); + } + Err(e) if e.is_timeout() || e.is_connect() => { + record_delivery(retry_or_fail(state, &outcome, 2).await); } - Err(e) if e.is_timeout() || e.is_connect() => retry_or_fail(state, &outcome, 2).await, _ => { let _ = state .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("failed"); } } if let Err(error) = serving_write.finish().await { @@ -537,14 +609,17 @@ async fn deliver_one( } } -fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec { - serde_json::to_vec(&DeliveryRequest { +fn delivery_body( + endpoint_grant: &str, + request_id: uuid::Uuid, + expires_at: i64, +) -> anyhow::Result> { + Ok(serde_json::to_vec(&DeliveryRequest { v: 1, endpoint_grant, request_id, expires_at, - }) - .expect("closed delivery body") + })?) } async fn send_gateway_request( @@ -561,12 +636,21 @@ async fn send_gateway_request( .await } -async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, delay: i64) { +fn record_delivery(outcome: &'static str) { + metrics::counter!("buzz_push_deliveries_total", "outcome" => outcome).increment(1); +} + +async fn retry_or_fail( + state: &AppState, + wake: &buzz_db::push::ClaimedWake, + delay: i64, +) -> &'static str { if wake.attempt >= MAX_ATTEMPTS { let _ = state .db .fail_push_wake(wake.community, wake.id, wake.claim_id) .await; + "exhausted" } else { let secs = delay * (1_i64 << (wake.attempt - 1).clamp(0, 6)); let _ = state @@ -578,6 +662,7 @@ async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, dela Utc::now() + TimeDelta::seconds(secs), ) .await; + "retry" } } @@ -675,7 +760,8 @@ mod tests { let keys = nostr::Keys::generate(); let request_id = uuid::Uuid::new_v4(); for _ in 0..2 { - let body = delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60); + let body = + delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60).unwrap(); let auth = nip98_header(&keys, url.as_str(), &body).unwrap(); let response = send_gateway_request(&http, &url, body, auth).await.unwrap(); assert!(response.status().is_success()); diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 38f69dee6dc..588a394b051 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -32,16 +32,27 @@ spec: - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } - - { name: BUZZ_PUSH_APP_ATTEST_APP_ID, value: {{ .Values.appAttestAppId | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } - - { name: BUZZ_PUSH_APNS_KEY_PATH, value: /run/buzz/apns/provider.p8 } - {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_KEY_ID" "BUZZ_PUSH_APNS_TEAM_ID" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} + - { name: BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID, value: {{ .Values.profiles.dogfood.appAttestAppId | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_TOPIC, value: {{ .Values.profiles.dogfood.apnsTopic | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT, value: {{ .Values.profiles.dogfood.apnsEnvironment | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH, value: /run/buzz/apns-dogfood/identity.pem } + - { name: BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID, value: {{ .Values.profiles.appStore.appAttestAppId | quote }} } + - { name: BUZZ_PUSH_APP_STORE_APNS_TOPIC, value: {{ .Values.profiles.appStore.apnsTopic | quote }} } + - { name: BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT, value: {{ .Values.profiles.appStore.apnsEnvironment | quote }} } + {{- with .Values.profiles.appStore.apnsCert }} + - { name: BUZZ_PUSH_APP_STORE_APNS_CERT_PATH, value: /run/buzz/apns-app-store/identity.pem } + {{- end }} + {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} - name: {{ $name }} valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } {{- end }} volumeMounts: - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } - - { name: apns-key, mountPath: /run/buzz/apns, readOnly: true } + - { name: apns-dogfood, mountPath: /run/buzz/apns-dogfood, readOnly: true } + {{- with .Values.profiles.appStore.apnsCert }} + - { name: apns-app-store, mountPath: /run/buzz/apns-app-store, readOnly: true } + {{- end }} livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 } startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 } @@ -49,8 +60,12 @@ spec: volumes: - name: app-attest-root secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] } - - name: apns-key - secret: { secretName: {{ .Values.apnsKey.secretName }}, items: [{ key: {{ .Values.apnsKey.secretKey }}, path: provider.p8 }] } + - name: apns-dogfood + secret: { secretName: {{ .Values.profiles.dogfood.apnsCert.secretName }}, defaultMode: 0400, items: [{ key: {{ .Values.profiles.dogfood.apnsCert.secretKey }}, path: identity.pem }] } + {{- with .Values.profiles.appStore.apnsCert }} + - name: apns-app-store + secret: { secretName: {{ .secretName }}, defaultMode: 0400, items: [{ key: {{ .secretKey }}, path: identity.pem }] } + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml index 20b9894280a..7a718bda718 100644 --- a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml +++ b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml @@ -21,9 +21,9 @@ spec: annotations: summary: Push gateway APNs configuration faults description: >- - APNs is returning configuration faults (bad/expired provider token - or topic). Deliveries are failing without invalidating endpoints. - See runbook: check the APNs .p8 key, key id, team id, and topic. + APNs is returning certificate or topic configuration faults. + Deliveries are failing without invalidating endpoints. See the + runbook and check the APNs certificate identity and topic. # Authority store unavailable at admission = durable dependency is down. - alert: PushGatewayAdmissionUnavailable expr: | diff --git a/deploy/charts/buzz-push-gateway/tests/release-contract.sh b/deploy/charts/buzz-push-gateway/tests/release-contract.sh index 7ae85ce34e3..993c4c05369 100755 --- a/deploy/charts/buzz-push-gateway/tests/release-contract.sh +++ b/deploy/charts/buzz-push-gateway/tests/release-contract.sh @@ -1,30 +1,29 @@ #!/usr/bin/env bash set -euo pipefail -python3 - <<'PY' -from pathlib import Path -import yaml - -auto_path = Path('.github/workflows/auto-tag-on-release-pr-merge.yml') -publish_path = Path('.github/workflows/push-gateway-helm-chart.yml') -auto_text = auto_path.read_text() -publish_text = publish_path.read_text() -# Parse first, then pin the cross-workflow strings whose agreement makes this a -# reachable lane rather than an orphan publisher. -yaml.safe_load(auto_text) -yaml.safe_load(publish_text) -for needle in ( - 'push-chart-release/*)', - 'VERSION="${BRANCH#push-chart-release/}"', - 'TAG_PREFIX="push-chart-v"', - 'DISPATCH="push-gateway-helm-chart"', - 'push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml"', -): - assert needle in auto_text, f'missing auto-tag gateway chart contract: {needle}' -for needle in ( - 'tags: ["push-chart-v[0-9]*"]', - 'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"', - 'refs/tags/push-chart-v${version}^{commit}', - 'deploy/charts/buzz-push-gateway', -): - assert needle in publish_text, f'missing gateway chart publisher contract: {needle}' -PY +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml <<'RUBY' +auto_text = File.read('.github/workflows/auto-tag-on-release-pr-merge.yml') +publish_text = File.read('.github/workflows/push-gateway-helm-chart.yml') +# Parse first, then pin the tag producer and consumer strings whose agreement +# makes this a reachable lane rather than an orphan publisher. +YAML.load(auto_text) +YAML.load(publish_text) +[ + 'push-chart-release/*)', + 'VERSION="${BRANCH#push-chart-release/}"', + 'TAG_PREFIX="push-chart-v"', + '- name: Create and push tag', + 'TAG: ${{ steps.release.outputs.tag }}', + 'refs/tags/$TAG', + '-f sha="$TARGET_SHA"', +].each do |needle| + raise "missing auto-tag gateway chart contract: #{needle}" unless auto_text.include?(needle) +end +[ + 'tags: ["push-chart-v[0-9]*"]', + 'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"', + 'refs/tags/push-chart-v${version}^{commit}', + 'deploy/charts/buzz-push-gateway', +].each do |needle| + raise "missing gateway chart publisher contract: #{needle}" unless publish_text.include?(needle) +end +RUBY diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 137f8d0add7..43a8b360cef 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -10,7 +10,7 @@ helm template push deploy/charts/buzz-push-gateway >"$out" production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - --set 'appAttestAppId=REALTEAM.xyz.buzz' + --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' --set 'httpRoute.parentRefs[0].name=production-gateway' --set 'httpRoute.parentRefs[0].namespace=gateway-system' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' @@ -18,60 +18,88 @@ production_args=( helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out" -python3 - "$out" "$production_out" <<'PY' -import sys,yaml -xs=list(yaml.safe_load_all(open(sys.argv[1]))) -svc=next(x for x in xs if x and x.get('kind')=='Service') -assert [p['targetPort'] for p in svc['spec']['ports']]==['public'] -d=next(x for x in xs if x and x.get('kind')=='Deployment') -j=next(x for x in xs if x and x.get('kind')=='Job') -runtime={'app.kubernetes.io/name':'buzz-push-gateway','app.kubernetes.io/instance':'push','app.kubernetes.io/component':'runtime'} -migration={**runtime,'app.kubernetes.io/component':'migration'} -assert svc['spec']['selector']==runtime -assert d['spec']['selector']['matchLabels']==runtime -assert d['spec']['template']['metadata']['labels']==runtime -assert j['spec']['template']['metadata']['labels']==migration -assert svc['spec']['selector'] != j['spec']['template']['metadata']['labels'] -jenv={e['name']:e for e in j['spec']['template']['spec']['containers'][0]['env']} -assert jenv['BUZZ_PUSH_RUNTIME_DATABASE_ROLE']['value']=='buzz_push_gateway_runtime' -assert 'valueFrom' in jenv['DATABASE_URL'] -assert j['spec']['template']['spec']['containers'][0]['args']==['--migrate-only'] -assert j['metadata']['annotations']=={ - 'helm.sh/hook':'pre-install,pre-upgrade', - 'helm.sh/hook-weight':'-5', - 'helm.sh/hook-delete-policy':'before-hook-creation,hook-succeeded', +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ + - "$out" "$production_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +svc = xs.find { |x| x["kind"] == "Service" } +assert!(svc.dig("spec", "ports").map { |port| port["targetPort"] } == ["public"]) +d = xs.find { |x| x["kind"] == "Deployment" } +j = xs.find { |x| x["kind"] == "Job" } +runtime = { + "app.kubernetes.io/name" => "buzz-push-gateway", + "app.kubernetes.io/instance" => "push", + "app.kubernetes.io/component" => "runtime", } -env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']} -required={'DATABASE_URL','BUZZ_PUSH_APNS_KEY_ID','BUZZ_PUSH_APNS_TEAM_ID','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'} -assert required <= env -assert d['spec']['replicas'] >= 2 -assert not any(x and x.get('kind')=='HTTPRoute' for x in xs) +migration = runtime.merge("app.kubernetes.io/component" => "migration") +assert!(svc.dig("spec", "selector") == runtime) +assert!(d.dig("spec", "selector", "matchLabels") == runtime) +assert!(d.dig("spec", "template", "metadata", "labels") == runtime) +assert!(j.dig("spec", "template", "metadata", "labels") == migration) +assert!(svc.dig("spec", "selector") != j.dig("spec", "template", "metadata", "labels")) +jenv = j.dig("spec", "template", "spec", "containers", 0, "env").to_h { |entry| [entry["name"], entry] } +assert!(jenv.dig("BUZZ_PUSH_RUNTIME_DATABASE_ROLE", "value") == "buzz_push_gateway_runtime") +assert!(jenv.fetch("DATABASE_URL").key?("valueFrom")) +assert!(j.dig("spec", "template", "spec", "containers", 0, "args") == ["--migrate-only"]) +assert!(j.dig("metadata", "annotations") == { + "helm.sh/hook" => "pre-install,pre-upgrade", + "helm.sh/hook-weight" => "-5", + "helm.sh/hook-delete-policy" => "before-hook-creation,hook-succeeded", +}) +env_names = d.dig("spec", "template", "spec", "containers", 0, "env") + .map { |entry| entry["name"] }.to_set +required = Set.new(%w[ + DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH + BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID + BUZZ_PUSH_APP_STORE_APNS_TOPIC BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID + BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS +]) +assert!(required.subset?(env_names)) +assert!(!env_names.include?("BUZZ_PUSH_APP_STORE_APNS_CERT_PATH")) +apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } +assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) +assert!(d.dig("spec", "replicas") >= 2) +assert!(!xs.any? { |x| x["kind"] == "HTTPRoute" }) # Observability is opt-in: default render exposes no scrape CRDs and 8081 stays # free of pod ingress (only 8080 is reachable). -assert not any(x and x.get('kind') in ('PodMonitor','PrometheusRule') for x in xs) -nps=[x for x in xs if x and x.get('kind')=='NetworkPolicy'] -np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway') -migration_np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway-migration') -assert np['spec']['podSelector']['matchLabels']==runtime -assert migration_np['spec']['podSelector']['matchLabels']==migration -assert migration_np['metadata']['annotations']=={ - 'helm.sh/hook':'pre-install,pre-upgrade', - 'helm.sh/hook-weight':'-10', - 'helm.sh/hook-delete-policy':'before-hook-creation', -} -assert int(migration_np['metadata']['annotations']['helm.sh/hook-weight']) < int(j['metadata']['annotations']['helm.sh/hook-weight']) -assert migration_np['spec']['ingress']==[] -assert migration_np['spec']['policyTypes']==['Ingress','Egress'] -migration_ports={p['port'] for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])} -assert migration_ports=={53,5432}, migration_ports -assert all(p['port'] != 443 for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])) -ingress_ports={p['port'] for rule in np['spec']['ingress'] for p in rule.get('ports',[])} -assert ingress_ports=={8080}, ingress_ports -production=list(yaml.safe_load_all(open(sys.argv[2]))) -route=next(x for x in production if x and x.get('kind')=='HTTPRoute') -assert route['spec']['parentRefs'] -assert 'push.buzz.xyz' in route['spec']['hostnames'] -PY +assert!(!xs.any? { |x| %w[PodMonitor PrometheusRule].include?(x["kind"]) }) +nps = xs.select { |x| x["kind"] == "NetworkPolicy" } +np = nps.find { |x| x.dig("metadata", "name") == "push-buzz-push-gateway" } +migration_np = nps.find { |x| x.dig("metadata", "name") == "push-buzz-push-gateway-migration" } +assert!(np.dig("spec", "podSelector", "matchLabels") == runtime) +assert!(migration_np.dig("spec", "podSelector", "matchLabels") == migration) +assert!(migration_np.dig("metadata", "annotations") == { + "helm.sh/hook" => "pre-install,pre-upgrade", + "helm.sh/hook-weight" => "-10", + "helm.sh/hook-delete-policy" => "before-hook-creation", +}) +assert!(migration_np.dig("metadata", "annotations", "helm.sh/hook-weight").to_i < j.dig("metadata", "annotations", "helm.sh/hook-weight").to_i) +assert!(migration_np.dig("spec", "ingress") == []) +assert!(migration_np.dig("spec", "policyTypes") == %w[Ingress Egress]) +migration_ports = migration_np.dig("spec", "egress") + .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set +assert!(migration_ports == Set[53, 5432], migration_ports.inspect) +assert!(!migration_np.dig("spec", "egress").flat_map { |rule| rule.fetch("ports", []) }.any? { |port| port["port"] == 443 }) +ingress_ports = np.dig("spec", "ingress") + .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set +assert!(ingress_ports == Set[8080], ingress_ports.inspect) +production = YAML.load_stream(File.read(ARGV[1])).compact +route = production.find { |x| x["kind"] == "HTTPRoute" } +assert!(!route.dig("spec", "parentRefs").empty?) +assert!(route.dig("spec", "hostnames").include?("push.buzz.xyz")) +RUBY + +# Legacy token-auth values must fail rather than silently selecting the default +# certificate Secret. +if helm template push deploy/charts/buzz-push-gateway \ + --set apnsKey.secretName=legacy-apns-secret \ + --set apnsKey.secretKey=legacy-provider.p8 >/dev/null 2>&1; then + echo 'expected legacy apnsKey values to fail schema validation' >&2 + exit 1 +fi # Enabling a route without a Gateway attachment must fail schema validation. if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=true >/dev/null 2>&1; then @@ -97,20 +125,28 @@ helm template push deploy/charts/buzz-push-gateway \ --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ >"$monitoring_out" -python3 - "$monitoring_out" <<'PY' -import sys,yaml -xs=list(yaml.safe_load_all(open(sys.argv[1]))) -pm=next(x for x in xs if x and x.get('kind')=='PodMonitor') -ep=pm['spec']['podMetricsEndpoints'][0] -assert ep['port']=='health' and ep['path']=='/metrics', ep -assert next(x for x in xs if x and x.get('kind')=='PrometheusRule')['spec']['groups'] -np=next(x for x in xs if x and x.get('kind')=='NetworkPolicy' and x['metadata']['name']=='push-buzz-push-gateway') -mon=[r for r in np['spec']['ingress'] if {p['port'] for p in r.get('ports',[])}=={8081}] -assert len(mon)==1, 'exactly one scoped 8081 ingress rule' -frm=mon[0]['from'][0] +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ + - "$monitoring_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +pm = xs.find { |x| x["kind"] == "PodMonitor" } +endpoint = pm.dig("spec", "podMetricsEndpoints", 0) +assert!(endpoint["port"] == "health" && endpoint["path"] == "/metrics", endpoint.inspect) +assert!(!xs.find { |x| x["kind"] == "PrometheusRule" }.dig("spec", "groups").empty?) +np = xs.find do |x| + x["kind"] == "NetworkPolicy" && x.dig("metadata", "name") == "push-buzz-push-gateway" +end +monitoring = np.dig("spec", "ingress").select do |rule| + rule.fetch("ports", []).map { |port| port["port"] }.to_set == Set[8081] +end +assert!(monitoring.length == 1, "exactly one scoped 8081 ingress rule") +from = monitoring[0].fetch("from")[0] # 8081 ingress must be scoped by both selectors, never empty/blanket. -assert frm['namespaceSelector']['matchLabels'] and frm['podSelector']['matchLabels'], frm -PY +assert!(!from.dig("namespaceSelector", "matchLabels").empty? && !from.dig("podSelector", "matchLabels").empty?, from.inspect) +RUBY # Negative: monitoring enabled with default empty selectors must fail (would # otherwise render a blanket 8081 rule matching all namespaces/pods). diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 7a0569616fa..8017f6bacdb 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -3,7 +3,9 @@ image: tag: "" digest: "" -appAttestAppId: "" +profiles: + dogfood: + appAttestAppId: "" httpRoute: enabled: true parentRefs: [] diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 631a04aea5a..bef5d17a338 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -19,10 +19,23 @@ "minimum": 1, "maximum": 31536000 }, - "appAttestAppId": { - "type": "string", - "minLength": 1 + "profiles": { + "type": "object", + "additionalProperties": false, + "required": [ + "dogfood", + "appStore" + ], + "properties": { + "dogfood": { + "$ref": "#/$defs/enabledProfile" + }, + "appStore": { + "$ref": "#/$defs/dormantProfile" + } + } }, + "apnsKey": false, "httpRoute": { "type": "object", "required": [ @@ -232,12 +245,75 @@ } } }, + "$defs": { + "profileBase": { + "type": "object", + "additionalProperties": false, + "required": [ + "appAttestAppId", + "apnsTopic", + "apnsEnvironment" + ], + "properties": { + "appAttestAppId": { + "type": "string", + "minLength": 1 + }, + "apnsTopic": { + "type": "string", + "minLength": 1 + }, + "apnsEnvironment": { + "enum": [ + "production", + "sandbox" + ] + }, + "apnsCert": { + "$ref": "#/$defs/apnsCert" + } + } + }, + "enabledProfile": { + "allOf": [ + { + "$ref": "#/$defs/profileBase" + }, + { + "required": [ + "apnsCert" + ] + } + ] + }, + "dormantProfile": { + "$ref": "#/$defs/profileBase" + }, + "apnsCert": { + "type": "object", + "additionalProperties": false, + "required": [ + "secretName", + "secretKey" + ], + "properties": { + "secretName": { + "type": "string", + "minLength": 1 + }, + "secretKey": { + "type": "string", + "minLength": 1 + } + } + } + }, "required": [ "replicaCount", "existingSecret", "publicDeliveryUrl", "maxGrantLifetimeSeconds", - "appAttestAppId", + "profiles", "httpRoute", "image", "migration" diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index ec46d9dbdd8..5e5c770623b 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -20,16 +20,25 @@ migration: limits: {cpu: 250m, memory: 128Mi} publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns maxGrantLifetimeSeconds: 2592000 -enabledProfiles: buzz-ios-production -# Example App Attest identifier. Production MUST override this with the exact -# Apple TEAMID.bundle-id value (see values-production.yaml). -appAttestAppId: TEAMID.xyz.buzz +enabledProfiles: buzz-ios-dogfood +profiles: + dogfood: + # Production MUST override this with the exact Apple TEAMID.bundle-id. + appAttestAppId: TEAMID.xyz.block.buzz.dogfood.mobile + apnsTopic: xyz.block.buzz.dogfood.mobile + apnsEnvironment: production + apnsCert: + secretName: buzz-push-gateway + secretKey: dogfood-apns-identity.pem + appStore: + # Registered server-side now, but dormant and uncredentialed until its + # profile is explicitly added to enabledProfiles in a later rollout. + appAttestAppId: TEAMID.xyz.block.buzz.mobile + apnsTopic: xyz.block.buzz.mobile + apnsEnvironment: production appAttestRoot: secretName: buzz-push-gateway secretKey: app-attest-root.pem -apnsKey: - secretName: buzz-push-gateway - secretKey: apns-provider.p8 service: port: 8080 httpRoute: diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 08fa6ca34d9..df6428e7cdc 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -264,7 +264,17 @@ This section registers the public last-hop profile served at `https://push.buzz. ### Registered values and lease mapping -The registered `app_profile` values are `buzz-ios-production` (Apple production APNs environment) and `buzz-ios-sandbox` (Apple sandbox APNs environment). A gateway deployment MUST enable only profiles for which its App Attest application identifier, APNs topic, credentials, and APNs environment are configured consistently. The APNs token registered with the gateway is called the **installation endpoint** and never leaves gateway custody after enrollment. +The registered `app_profile` values are `buzz-ios-dogfood` and +`buzz-ios-app-store`. They identify closed Buzz application identities, not APNs +transport environments. The canonical gateway owns the mapping from each +profile to one exact App Attest application identifier, APNs topic, +certificate-backed connection pool, and APNs environment. A client profile +selector chooses only a candidate entry; enrollment succeeds only when App +Attest cryptographically verifies the configured application identifier. A +gateway deployment MUST enable only profiles whose full mapping is configured +consistently, and MUST NOT accept an APNs topic from a client. The APNs token +registered with the gateway is called the **installation endpoint** and never +leaves gateway custody after enrollment. The opaque string returned as `endpoint_grant` by `POST /v1/delegations` is the **delivery capability**. For this profile, the active lease plaintext's `endpoint` member MUST contain that `endpoint_grant`, not the raw APNs token. `transport` MUST be `apns`, and `app_profile` MUST equal the profile sealed into the grant. Base-protocol endpoint uniqueness, rotation, hashing, and coalescing operate on this opaque lease `endpoint` within an origin. A capability is scoped to one installation, relay signing pubkey, endpoint epoch, generation, and expiry; grants independently issued to different relays are intentionally distinct. The gateway separately enforces global installation-endpoint uniqueness using `(app_profile, SHA-256(token))`. A public-profile relay MUST treat `endpoint` as opaque and MUST NOT parse or transform it. @@ -305,7 +315,7 @@ The challenge is single-use. Invalid input is `400 invalid_request`; storage/ran Request members, in any request order: ```json -{"v":1,"challenge_id":"","challenge":"","key_id":"","attestation":"","app_profile":"buzz-ios-production","endpoint":"","endpoint_epoch":1,"expires_at":} +{"v":1,"challenge_id":"","challenge":"","key_id":"","attestation":"","app_profile":"buzz-ios-dogfood","endpoint":"","endpoint_epoch":1,"expires_at":} ``` `expires_at` MUST satisfy `now < expires_at <= now + configured_max_installation_lifetime`; the selected profile MUST be enabled. The exact transcript is domain `buzz.push.enroll.v1` followed by this ordered object: @@ -407,7 +417,9 @@ Responses: - `404 {"error":"invalid_grant"}` — capability, signer, authority, replay, expiry, or quota rejection. - `503 {"error":"temporarily_unavailable"}` — durable authority/custody/disposition failure. -The gateway performs one APNs request, except that an APNs expired-provider-token response permits one credential refresh and one retry. The application body is always the exact constant registered in the APNs transport profile above; no request or grant field enters it. +The gateway performs one APNs request per admitted delivery attempt. The +application body is always the exact constant registered in the APNs transport +profile above; no request or grant field enters it. ## Implementation Notes (Buzz, non-normative) @@ -439,4 +451,4 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery - Classes: `silent`, `default`, `time_sensitive`, `urgent` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-production`, `buzz-ios-sandbox`; wire version `1` +- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-dogfood`, `buzz-ios-app-store`; wire version `1` diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 63c63355a11..08a09a289ba 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -16,21 +16,31 @@ | `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | -| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated `buzz-ios-production` and/or `buzz-ios-sandbox`. | -| `BUZZ_PUSH_APP_ATTEST_APP_ID` | Exact Apple App Attest application identifier (`TEAMID.bundle-id`). | +| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated closed application profiles: `buzz-ios-dogfood` and/or `buzz-ios-app-store`. Dogfood is the only enabled MVP profile. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | -| `BUZZ_PUSH_APNS_KEY_PATH` | Read-only mounted Apple APNs `.p8` provider key. | -| `BUZZ_PUSH_APNS_KEY_ID` | APNs provider key id. | -| `BUZZ_PUSH_APNS_TEAM_ID` | Apple developer team id. | -| `BUZZ_PUSH_APNS_TOPIC` | Buzz iOS bundle id. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`) for each closed profile. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_TOPIC` | Server-owned APNs topic for each profile. Never accepted from a client. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_ENVIRONMENT` | `production` or `sandbox`, selected per profile by deployment configuration. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_CERT_PATH` | Read-only certificate/private-key PEM for an enabled profile. A dormant profile may omit it. | | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | +The single canonical `push.buzz.xyz` deployment owns a closed profile registry +for both dogfood (`xyz.block.buzz.dogfood.mobile`) and App Store +(`xyz.block.buzz.mobile`) application identities. Enrollment's profile selector +only chooses a candidate entry: the corresponding App Attest verifier must +cryptographically validate that entry's configured application ID before the +profile is stored with the installation. Assertions and delivery subsequently +select App Attest policy, APNs topic, certificate-backed connection pool, and +environment from that stored profile. No client request or relay grant can +supply or override an APNs topic. The MVP enables and credentials dogfood only; +the App Store entry remains registered but dormant until a later rollout. + Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS` (default `10`, max `86400`) and `BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES` (default `10`, max `10000`). These are Buzz policy hypotheses, not Apple-published limits; tune under load while retaining a hard ceiling. ## Secret and key rotation rules -Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs key and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. +Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs certificate identity and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefore contain ciphertext plus authority metadata and must receive the same access controls and retention treatment as the service secrets. @@ -44,13 +54,12 @@ The service reaps expired challenges and replay rows, idle quota rows, expired/r ## Metrics and alerting -The gateway serves Prometheus metrics at `GET /metrics` on the **private health listener** (`BUZZ_PUSH_HEALTH_ADDR`, default `0.0.0.0:8081`) — the same port as the probes, never on the public `8080`. All series are sanitized and bounded-cardinality: label values are drawn only from closed sets (the six APNs outcome classes, the fixed admission results, the static error codes already returned to callers, and the readiness causes). No endpoint, device token, relay pubkey, request id, or any request-scoped identifier is ever used as a label. +The gateway serves Prometheus metrics at `GET /metrics` on the **private health listener** (`BUZZ_PUSH_HEALTH_ADDR`, default `0.0.0.0:8081`) — the same port as the probes, never on the public `8080`. All series are sanitized and bounded-cardinality: label values are drawn only from closed sets (the five APNs outcome classes, the fixed admission results, the static error codes already returned to callers, and the readiness causes). No endpoint, device token, relay pubkey, request id, or any request-scoped identifier is ever used as a label. | Metric | Type | Labels | Meaning | |---|---|---|---| -| `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `refresh_credential` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | +| `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | | `push_gateway_apns_delivery_seconds` | histogram | — | APNs send round-trip latency (seconds). | -| `push_gateway_apns_credential_refreshes_total` | counter | — | Provider JWT refreshed after APNs reported expiry. | | `push_gateway_admissions_total` | counter | `result` = `admitted` \| `rejected` \| `unavailable` | Outcome at the `authorize_delivery` replay/quota fence. | | `push_gateway_delivery_errors_total` | counter | `class` (static) | Selected delivery-handler exit classes only (see note). | | `push_gateway_reaper_failures_total` | counter | — | Retention reaper sweep failures. | @@ -64,7 +73,7 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe | Alert | Fires when | Severity | Action | |---|---|---|---| -| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | APNs provider token/topic is unhealthy. Check the `.p8` key, `BUZZ_PUSH_APNS_KEY_ID`, `..._TEAM_ID`, and `..._TOPIC`. No endpoints are being invalidated, but nothing is delivering. | +| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | The enabled profile's APNs certificate/topic/environment is unhealthy. Check the matching `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_*` configuration. No endpoints are being invalidated. | | `PushGatewayAdmissionUnavailable` | any admission `unavailable` for 5m | critical | PostgreSQL authority store is unreachable. Check DB connectivity and the pod's `postgresEgressCidrs` NetworkPolicy. | | `PushGatewayReadinessAuthorityFailing` | readiness `authority` failures for 5m | warning | Replicas are being pulled from the Service on DB check failure. Fix DB health before capacity drops below the PodDisruptionBudget. | | `PushGatewayReaperFailing` | reaper failed ≥2 times within 30m (runs every 5m) | warning | Expired reservations aren't being swept, growing the bounded-until-expiry window. Check DB write availability. | @@ -72,23 +81,96 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe ## Relay configuration -Relays default `BUZZ_PUSH_GATEWAY_DELIVERY_URL` to the exact public delivery URL -`https://push.buzz.xyz/v1/deliveries/apns`. Operators can override it with -another exact HTTPS `/v1/deliveries/apns` URL, or explicitly disable NIP-PL push -by setting the variable to an empty string. When enabled, the relay advertises -its host-scoped NIP-PL descriptor in NIP-11 and starts the matcher and delivery -worker. Relays retain lease matching, authorization, coalescing, durable +Relay push is an explicit deployment opt-in through `BUZZ_PUSH_ENABLED=true`; +the established strict boolean parser rejects unknown values and the default is +false. When enabled, an absent `BUZZ_PUSH_GATEWAY_DELIVERY_URL` selects the exact +canonical URL `https://push.buzz.xyz/v1/deliveries/apns`; operators can provide +another exact HTTPS `/v1/deliveries/apns` URL as an advanced override. An +explicitly empty URL while enabled is a startup error. Only an enabled relay +advertises its host-scoped NIP-PL descriptor, accepts leases, and starts the +matcher and delivery worker. Relays retain lease matching, authorization, durable jobs/retries, and generation checks; they receive only opaque capabilities and never APNs tokens or provider credentials. +An enabled relay exports the following bounded-cardinality series on its +existing Prometheus endpoint. None carries a community, account, relay key, +installation, event, or request identifier as a label. + +| Metric | Type | Labels | Meaning | +|---|---|---|---| +| `buzz_push_enabled` | gauge | — | `1` only when the deployment opt-in is active. | +| `buzz_push_match_jobs_total` | counter | `result` = `matched` \| `unmatched` \| `error` \| `context_error` | Accepted message events evaluated by the matcher. | +| `buzz_push_match_queue_seconds` | histogram | — | Relay receipt to matcher evaluation latency. | +| `buzz_push_wakes_total` | counter | `result` = `enqueued` \| `duplicate` \| `inactive_lease` | Durable wake-enqueue outcomes. | +| `buzz_push_wake_enqueue_errors_total` | counter | — | Set-wise outbox transactions that failed. | +| `buzz_push_wake_queue_seconds` | histogram | — | First-attempt outbox enqueue-to-worker latency. | +| `buzz_push_gateway_requests_total` | counter | — | Relay requests that reached the gateway transport seam. | +| `buzz_push_gateway_request_seconds` | histogram | — | Relay-observed gateway request latency. | +| `buzz_push_deliveries_total` | counter | `outcome` (static closed set) | Accepted, retried, suppressed, exhausted, invalid, or failed relay delivery outcomes. | + ## Relay integration status The operational relay integration is complete: per-origin event matching with read-authorization checks, durable enqueue, send-time revalidation, and NIP-98 -delivery run whenever the gateway URL is enabled. End-to-end use still requires +delivery run only when `BUZZ_PUSH_ENABLED=true`. End-to-end use still requires the client App Attest enrollment/delegation flow to place a gateway-issued opaque capability—not a raw APNs token—into the encrypted relay lease. +## Internal dogfood evaluation and rollback + +The MVP is ready to enable only when all of these are true: the internal iOS +artifact was built with `mobile/ios/Flutter/PushEnabled.xcconfig`; the canonical +gateway has the dogfood profile enabled with its server-owned App Attest app ID, +APNs topic, production certificate identity, and production APNs environment; +and only the selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`. +The App Store profile remains configured but dormant, and ordinary/App Store +iOS builds continue to omit the push capability and notification extension. + +Local physical-device development may instead use the normal +`xyz.block.buzz.mobile` development identity with the push overlay and sandbox +entitlements. Its local gateway must enable only the closed App Store profile, +configured with that profile's server-owned App Attest application ID, APNs +topic, sandbox certificate, and sandbox environment. This is a development +integration proof, not dogfood release validation, and does not authorize +enabling the App Store profile on the canonical production deployment. + +Dogfood end-to-end release validation starts after this feature reaches `main`: +publish the next immutable `mobile-vX.Y.Z-rc.N` candidate from the exact current +`origin/main` commit, build that tag through the normal Block release pipeline, +and wait for the signed `xyz.block.buzz.dogfood.mobile` artifact to appear in +Mobile Releases/Comp Portal before installing it on a physical device. Verify +APNs delivery, fetched and signature-verified notification content, and +exact-message tap routing against the canonical gateway and a push-enabled +internal relay before widening the internal evaluation. + +Before that first push-enabled candidate, the private dogfood builder must +include `PushEnabled.xcconfig` from its generated `AppOverrides.xcconfig`. Its +manual signing and export configuration must also map separate distribution +profiles for both `xyz.block.buzz.dogfood.mobile` and +`xyz.block.buzz.dogfood.mobile.NotificationService`; an app-only profile does +not provision the extension. The App Store builder must continue omitting the +overlay and extension profile until that rollout is separately approved. + +For each evaluation cohort, measure relay receipt-to-match, wake queue, relay-to- +gateway, and gateway-to-APNs latencies from the histograms above. Track the +ratio of accepted or replay-terminal relay outcomes to newly enqueued wakes, +gateway APNs accepted/retry/invalid/configuration outcomes, retry exhaustion, +and NSE resolution fallback. APNs acceptance cannot prove device presentation: +record a small manual physical-device sample with event-created, banner-visible, +and notification-tap timestamps, and verify that the visible title/body came +from fetched, signature-verified relay content and that the tap opened the exact +triggering message. Keep fallback-to-channel and placeholder/failure cases as +explicit counts in the manual sample until privacy-preserving client telemetry +is designed. + +Rollback does not require deleting credentials or mutating existing leases. +Set `BUZZ_PUSH_ENABLED=false` on the enabled relays to stop advertisement, lease +acceptance, matching, workers, and new gateway traffic; disable the dogfood +gateway profile if the gateway itself is unhealthy; and ship the next internal +build without the push overlay if client behavior must be removed. Existing +leases and gateway authorities then expire naturally. Do not enable the App +Store build capability or profile as part of this internal evaluation. + ## Helm production inputs The chart defaults to the `main` image tag because `.github/workflows/docker.yml` publishes it from the push-gateway lane. For a production rollout, open that workflow run's **Publish public push gateway image** job summary and copy its `sha256:...` digest. Verify the published subject and provenance before injecting it: @@ -99,11 +181,11 @@ gh attestation verify \ --owner block ``` -Only after that command succeeds, set the exact digest as `image.digest`; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` is an intentionally invalid production-input contract: deployment CI must inject this verified `image.digest`, the provisioned Apple application identifier, an environment-owned Gateway parent reference, and the actual PostgreSQL network. Schema validation rejects the artifact when any remains empty; the render guard proves both rejection and a fully injected render. +Only after that command succeeds, set the exact digest as `image.digest`; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` is an intentionally invalid production-input contract: deployment CI must inject this verified `image.digest`, the provisioned dogfood Apple application identifier, an environment-owned Gateway parent reference, and the actual PostgreSQL network. Schema validation rejects the artifact when any remains empty; the render guard proves both rejection and a fully injected render. Network policy keeps APNs HTTPS and PostgreSQL egress in separate CIDR lists. APNs currently requires broad TCP/443 reachability; `networkPolicy.postgresEgressCidrs` must be narrowed to the production database network, and the DNS namespace/pod selectors must match the cluster DNS deployment. The sample private CIDR is not a claim about the production topology. -Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs credential rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. +Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs certificate rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. ## Gateway chart release diff --git a/migrations/0032_push_message_kinds.sql b/migrations/0032_push_message_kinds.sql new file mode 100644 index 00000000000..a76481b1592 --- /dev/null +++ b/migrations/0032_push_message_kinds.sql @@ -0,0 +1,24 @@ +-- Dogfood push is deliberately message-only. Replace the trigger allowlist +-- additively so already-applied 0018/0023 migrations remain checksum-stable. +CREATE OR REPLACE FUNCTION enqueue_push_match_job() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + -- Keep this allowlist identical to the relay's validated NIP-PL descriptor. + IF NEW.kind IN (9, 40002, 45001, 45003) THEN + PERFORM pg_advisory_xact_lock_shared( + hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0)); + IF EXISTS ( + SELECT 1 FROM push_leases + WHERE community_id = NEW.community_id + AND active + AND endpoint_enabled + AND expires_at > EXTRACT(EPOCH FROM now())::bigint + ) THEN + INSERT INTO push_match_queue (community_id, event_id) + VALUES (NEW.community_id, NEW.id) + ON CONFLICT DO NOTHING; + END IF; + END IF; + RETURN NEW; +END +$$; diff --git a/mobile/.env.json.example b/mobile/.env.json.example index 7960d5bf127..248ccda1b60 100644 --- a/mobile/.env.json.example +++ b/mobile/.env.json.example @@ -1,4 +1,5 @@ { "BUZZ_RELAY_URL": "http://localhost:3000", + "BUZZ_PUSH_GATEWAY_URL": "http://localhost:8080", "BUZZ_DEV_PUBKEY": "" } diff --git a/mobile/README.md b/mobile/README.md index 1849e1b097d..5e393c6e05d 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -22,7 +22,8 @@ cd mobile && flutter run ### Worktree-aware debug identity Debug builds produced from a git worktree get a unique app identifier keyed -to the **worktree directory name** (`com.buzz.buzzMobile.` on iOS, +to the **worktree directory name** +(`xyz.block.buzz.dogfood.mobile.` on iOS, `xyz.block.buzz.mobile.` on Android) plus a display-only branch label in the app name (`Buzz (my-branch)`, or a short SHA when the worktree is detached). Because the identifier follows the directory rather than the @@ -67,6 +68,50 @@ connected Android emulators, run `just mobile-clean` (add `--dry-run` via `./scripts/mobile-worktree-clean.sh --dry-run` to preview). Production installs are never touched. +### Internal iOS push capability + +iOS push is a compile/build capability and defaults off. A normal Debug, +Profile, Release, or App Store build excludes the native push bridge sources, +uses push-free Runner entitlements, and neither builds nor embeds the +Notification Service Extension. Dart also compiles out permission requests, +APNs registration, gateway enrollment/delegation, and relay lease behavior. + +For an authorized internal dogfood build only, create the gitignored +`mobile/ios/Flutter/AppOverrides.xcconfig` with this single include: + +```xcconfig +#include "PushEnabled.xcconfig" +``` + +The tracked overlay selects `xyz.block.buzz.dogfood.mobile`, production App +Attest/APNs entitlements, the internal development team, the push-capable +Runner entitlements, the native bridge, and the extension. CI may equivalently +inject that same include into its ephemeral `AppOverrides.xcconfig`; it must not +edit a tracked base configuration. Relay rollout is independent and remains off +unless its deployment sets `BUZZ_PUSH_ENABLED=true`. See +`docs/push-gateway-deployment.md` for the canonical gateway profile contract, +manual physical-device proof, measurements, and rollback procedure. + +For local physical-device development, enable the same capability while +overriding the dogfood identity back to the normal mobile development identity +and sandbox environments: + +```xcconfig +#include "PushEnabled.xcconfig" +BUNDLE_IDENTIFIER = xyz.block.buzz.mobile +BUZZ_DEVELOPMENT_TEAM = EYF346PHUG +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development +``` + +This exercises the client, extension, relay, and gateway integration without +requiring a dogfood development signing identity. It uses the canonical +gateway's server-owned App Store profile configured for sandbox in the local +development gateway; it does not validate the internally distributed dogfood +artifact or enable the App Store profile in production. Validate dogfood APNs +end to end by cutting an internal release, waiting for it to reach Mobile +Releases/Comp Portal, and installing that signed artifact on a physical device. + ## Checks ```bash diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index 950d73854f7..0562776fd4e 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -9,6 +9,7 @@ .tags* **/.vagrant/ **/DerivedData/ +BuzzPushKit/Package.resolved Icon? **/Pods/ **/.symlinks/ diff --git a/mobile/ios/BuzzPushKit/Package.swift b/mobile/ios/BuzzPushKit/Package.swift new file mode 100644 index 00000000000..ebfc644318e --- /dev/null +++ b/mobile/ios/BuzzPushKit/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "BuzzPushKit", + platforms: [.iOS(.v15), .macOS(.v12)], + products: [ + .library(name: "BuzzPushKit", targets: ["BuzzPushKit"]) + ], + dependencies: [ + .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1") + ], + targets: [ + .target( + name: "BuzzPushKit", + dependencies: [.product(name: "P256K", package: "swift-secp256k1")] + ), + .testTarget( + name: "BuzzPushKitTests", + dependencies: ["BuzzPushKit"], + resources: [.copy("Fixtures/app_attest_transcripts.json")] + ), + ] +) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift new file mode 100644 index 00000000000..174fc90bb23 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct APNsRegistrationUpdate: Equatable, Sendable { + public let method: String + public let arguments: [String: String] + public init(method: String, arguments: [String: String]) { + self.method = method + self.arguments = arguments + } +} + +public final class APNsRegistrationBuffer { + public private(set) var pending: APNsRegistrationUpdate? + private var deliver: ((APNsRegistrationUpdate) -> Void)? + public init() {} + public func attach(_ deliver: @escaping (APNsRegistrationUpdate) -> Void) { + self.deliver = deliver + flush() + } + public func recordToken(_ token: Data) { + record(APNsRegistrationUpdate( + method: "apnsTokenChanged", + arguments: ["token": token.map { String(format: "%02x", $0) }.joined()] + )) + } + public func recordError(_ message: String) { + record(APNsRegistrationUpdate( + method: "apnsRegistrationFailed", arguments: ["message": message] + )) + } + private func record(_ update: APNsRegistrationUpdate) { + pending = update + flush() + } + private func flush() { + guard let pending, let deliver else { return } + self.pending = nil + deliver(pending) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift new file mode 100644 index 00000000000..775a94f05ec --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -0,0 +1,873 @@ +import CryptoKit +import DeviceCheck +import Foundation + +#if canImport(Security) + import Security +#endif + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// The opaque gateway capability and binding metadata needed by a later lease publisher. +public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { + public let relayOrigin: String + public let relayPubkey: String + /// Gateway installation authority. This is distinct from [installationId], + /// which is the unlinkable per-relay-origin NIP-PL lease address. + public let gatewayInstallationHandle: String? + public let installationId: String + public let endpointGrant: String + public let endpointHash: String + public let appProfile: String + public let endpointEpoch: Int64 + public let generation: Int64 + public let expiresAt: Int64 + + public init( + relayOrigin: String, + relayPubkey: String, + gatewayInstallationHandle: String? = nil, + installationId: String, + endpointGrant: String, + endpointHash: String, + appProfile: String, + endpointEpoch: Int64, + generation: Int64, + expiresAt: Int64 + ) { + precondition(generation > 0, "Endpoint grant generation must be positive") + self.relayOrigin = relayOrigin + self.relayPubkey = relayPubkey + self.gatewayInstallationHandle = gatewayInstallationHandle + self.installationId = installationId + self.endpointGrant = endpointGrant + self.endpointHash = endpointHash + self.appProfile = appProfile + self.endpointEpoch = endpointEpoch + self.generation = generation + self.expiresAt = expiresAt + } +} + +/// Persistence boundary for endpoint grants. The Runner implementation stores +/// records in its Keychain access group and exposes them over the Flutter bridge. +public protocol BuzzPushEndpointGrantStore { + func records() throws -> [BuzzPushEndpointGrantRecord] + func save(_ record: BuzzPushEndpointGrantRecord) throws +} + +public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { + case invalidGatewayURL + case invalidRelayURL + case invalidRelayDescriptor + case invalidResponse(route: String) + case unexpectedStatus(route: String, expected: Int, actual: Int, body: String) + case randomGenerationFailed(Int32) + case appAttestUnsupported + case invalidAppAttestKeyId + case generationExhausted + + public var errorDescription: String? { + switch self { + case .invalidGatewayURL: + return "The development push gateway URL must be an HTTP or HTTPS origin." + case .invalidRelayURL: + return "The relay URL must be a ws or wss origin." + case .invalidRelayDescriptor: + return "NIP-11 must contain exactly one valid current push key." + case .invalidResponse(let route): + return "The response from \(route) did not match the closed push protocol." + case .unexpectedStatus(let route, let expected, let actual, let body): + return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)" + case .randomGenerationFailed(let status): + return "Secure random generation failed with status \(status)." + case .appAttestUnsupported: + return "App Attest is unavailable on this device." + case .invalidAppAttestKeyId: + return "The App Attest key identifier is missing or invalid." + case .generationExhausted: + return "The development push grant generation cannot advance further." + } + } +} + +protocol BuzzDevAppAttesting { + func prepareAttestation() async throws -> BuzzDevAttestation + func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws + -> BuzzDevAttestation + func assertion(clientData: Data) async throws -> String +} + +struct BuzzDevAttestation: Equatable { + let keyId: String + let attestation: String +} + +private enum BuzzSecureRandom { + static func bytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw BuzzDevPushEnrollmentError.randomGenerationFailed(status) + } + return Data(bytes) + } +} + +#if DEBUG + struct BuzzDevAppAttestProvider: BuzzDevAppAttesting { + private static let attestationPrefix = Data("buzz-dev-app-attest-v1:".utf8) + private static let assertionBytes = Data("buzz-dev-app-assertion-v1".utf8) + + let randomBytes: () throws -> Data + + init( + randomBytes: @escaping () throws -> Data = { + try BuzzSecureRandom.bytes(count: 32) + } + ) { + self.randomBytes = randomBytes + } + + func prepareAttestation() async throws -> BuzzDevAttestation { + let entropy = try randomBytes() + precondition(entropy.count == 32, "Development attestation entropy must be exactly 32 bytes") + let bytes = Self.attestationPrefix + entropy + return BuzzDevAttestation( + keyId: Data(SHA256.hash(data: bytes)).base64EncodedString(), + attestation: bytes.base64EncodedString() + ) + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + precondition(!clientData.isEmpty, "Enrollment client data must not be empty") + return prepared + } + + func assertion(clientData: Data) async throws -> String { + precondition(!clientData.isEmpty, "Delegation client data must not be empty") + return Self.assertionBytes.base64EncodedString() + } + } +#endif + +private enum BuzzAppAttestKeyId { + static func isValid(_ keyId: String) -> Bool { + guard !keyId.isEmpty, + keyId.unicodeScalars.allSatisfy(\.isASCII), + let bytes = Data(base64Encoded: keyId) + else { return false } + return bytes.count == 32 && bytes.base64EncodedString() == keyId + } +} + +protocol BuzzAppAttestKeyIdStoring { + func keyId() throws -> String? + func saveKeyId(_ keyId: String) throws +} + +struct BuzzAppAttestKeyIdKeychainStore: BuzzAppAttestKeyIdStoring { + private static let service = "buzz.push.app-attest" + private static let account = "key-id-v1" + + private let accessGroup: String? + private let copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + private let update: (CFDictionary, CFDictionary) -> OSStatus + private let add: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + + init( + accessGroup: String?, + copyMatching: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = + SecItemCopyMatching, + update: @escaping (CFDictionary, CFDictionary) -> OSStatus = SecItemUpdate, + add: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemAdd + ) { + self.accessGroup = accessGroup + self.copyMatching = copyMatching + self.update = update + self.add = add + } + + func keyId() throws -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw keychainError(status, operation: "read") + } + guard let data = result as? Data, + let keyId = String(data: data, encoding: .utf8), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + return keyId + } + + func saveKeyId(_ keyId: String) throws { + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let data = Data(keyId.utf8) + let updateStatus = update( + baseQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") + } + + var item = baseQuery() + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = add(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") + } + } + + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "App Attest key identifier Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" + ] + ) + } +} + +protocol BuzzDCAppAttestServicing { + var isSupported: Bool { get } + func generateKey() async throws -> String + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data +} + +extension DCAppAttestService: BuzzDCAppAttestServicing {} + +struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { + private let service: BuzzDCAppAttestServicing + private let keyIdStore: BuzzAppAttestKeyIdStoring + + init( + service: BuzzDCAppAttestServicing = DCAppAttestService.shared, + keyIdStore: BuzzAppAttestKeyIdStoring + ) { + self.service = service + self.keyIdStore = keyIdStore + } + + func prepareAttestation() async throws -> BuzzDevAttestation { + try requireSupportedService() + let keyId = try await service.generateKey() + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + try keyIdStore.saveKeyId(keyId) + return BuzzDevAttestation(keyId: keyId, attestation: "") + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + precondition(!clientData.isEmpty, "Enrollment client data must not be empty") + try requireSupportedService() + guard BuzzAppAttestKeyId.isValid(prepared.keyId), + try keyIdStore.keyId() == prepared.keyId + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.attestKey( + prepared.keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return BuzzDevAttestation( + keyId: prepared.keyId, + attestation: object.base64EncodedString() + ) + } + + func assertion(clientData: Data) async throws -> String { + precondition(!clientData.isEmpty, "Delegation client data must not be empty") + try requireSupportedService() + guard let keyId = try keyIdStore.keyId(), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.generateAssertion( + keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return object.base64EncodedString() + } + + private func requireSupportedService() throws { + guard service.isSupported else { + throw BuzzDevPushEnrollmentError.appAttestUnsupported + } + } +} + +/// Enrollment and delegation driver for real App Attest and the gated debug bypass. +public final class BuzzDevPushEnrollmentDriver { + public static let appProfile = "buzz-ios-dogfood" + public static let endpointEpoch: Int64 = 1 + + private let gatewayBaseURL: URL + private let store: BuzzPushEndpointGrantStore + private let session: URLSession + private let appAttest: BuzzDevAppAttesting + private let now: () -> Date + private let lifetimeSeconds: Int64 + private let installationIdBytes: () throws -> Data + + /// Creates a driver backed by Apple's App Attest service and persists the + /// generated App Attest key identifier in the requested Keychain access group. + public convenience init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + appAttestKeychainAccessGroup: String?, + session: URLSession = .shared + ) throws { + try self.init( + gatewayBaseURL: gatewayBaseURL, + store: store, + session: session, + appAttest: BuzzDCAppAttestProvider( + keyIdStore: BuzzAppAttestKeyIdKeychainStore( + accessGroup: appAttestKeychainAccessGroup + ) + ), + now: Date.init, + lifetimeSeconds: 2_592_000, + installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } + ) + } + + #if DEBUG + public convenience init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + session: URLSession = .shared + ) throws { + try self.init( + gatewayBaseURL: gatewayBaseURL, + store: store, + session: session, + appAttest: BuzzDevAppAttestProvider(), + now: Date.init, + lifetimeSeconds: 2_592_000, + installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } + ) + } + #endif + + init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + session: URLSession, + appAttest: BuzzDevAppAttesting, + now: @escaping () -> Date, + lifetimeSeconds: Int64, + installationIdBytes: @escaping () throws -> Data = { + try BuzzSecureRandom.bytes(count: 16) + } + ) throws { + guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + self.gatewayBaseURL = gatewayBaseURL + self.store = store + self.session = session + self.appAttest = appAttest + self.now = now + self.lifetimeSeconds = lifetimeSeconds + self.installationIdBytes = installationIdBytes + } + + public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { + try store.records() + } + + /// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint, + /// delegates to that key, and durably saves the resulting opaque grant. + public func enroll( + deviceToken: Data, + relayURL: URL + ) async throws -> BuzzPushEndpointGrantRecord { + precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") + let relayOrigin = try Self.relayOrigin(relayURL) + let relayPubkey = try await fetchCurrentRelayPushPubkey(from: relayOrigin.url) + let endpoint = Self.lowercaseHex(deviceToken) + let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + let nowSeconds = Int64(now().timeIntervalSince1970) + + let storedRecords = try store.records() + let storedForOrigin = storedRecords.first { + $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile + } + if let current = storedForOrigin, + current.relayPubkey == relayPubkey, + current.endpointHash == endpointHash, + current.endpointEpoch == Self.endpointEpoch, + current.expiresAt > nowSeconds + 300 + { + return current + } + + // One gateway delegation is scoped to an installation and relay key, not + // to a Buzz community. A second origin served by the same relay therefore + // gets a fresh unlinkable NIP-PL address while reusing the opaque grant. + if storedForOrigin == nil, + let sharedGrant = storedRecords.first(where: { + $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile + && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch + && $0.expiresAt > nowSeconds + 300 + }) + { + let record = BuzzPushEndpointGrantRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, + installationId: try makeInstallationId(), + endpointGrant: sharedGrant.endpointGrant, + endpointHash: endpointHash, + appProfile: Self.appProfile, + endpointEpoch: sharedGrant.endpointEpoch, + generation: sharedGrant.generation, + expiresAt: sharedGrant.expiresAt + ) + try store.save(record) + return record + } + + // A previously attested installation can delegate independently to a new + // relay key, or issue a higher-generation grant for the same relay, + // without attempting duplicate APNs-token enrollment. + let reusableInstallation = storedRecords.first { record in + guard record.appProfile == Self.appProfile, + record.endpointHash == endpointHash, + record.endpointEpoch == Self.endpointEpoch, + record.expiresAt > nowSeconds + 300, + let handle = record.gatewayInstallationHandle, + let uuid = UUID(uuidString: handle) + else { return false } + return handle == uuid.uuidString.lowercased() + } + + let installation: UUID + let expiresAt: Int64 + if let reusableInstallation, + let handle = reusableInstallation.gatewayInstallationHandle, + let existing = UUID(uuidString: handle) + { + installation = existing + expiresAt = reusableInstallation.expiresAt + } else { + let (newExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) + guard !expiresOverflow else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + expiresAt = newExpiration + let enrollmentChallenge = try await challenge() + let preparedAttestation = try await appAttest.prepareAttestation() + let enrollmentClientData = try BuzzPushTranscript.enroll( + challengeId: enrollmentChallenge.id, + challenge: enrollmentChallenge.value, + keyId: preparedAttestation.keyId, + appProfile: Self.appProfile, + endpoint: endpoint, + endpointEpoch: Self.endpointEpoch, + expiresAt: expiresAt + ) + let attestation = try await appAttest.attestation( + preparedAttestation, + clientData: enrollmentClientData + ) + guard attestation.keyId == preparedAttestation.keyId else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") + } + installation = try await enrollInstallation( + challenge: enrollmentChallenge, + endpoint: endpoint, + expiresAt: expiresAt, + attestation: attestation + ) + } + + let installationHandle = installation.uuidString.lowercased() + let currentGeneration = + storedRecords + .filter { + $0.gatewayInstallationHandle == installationHandle + && $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile + } + .map(\.generation) + .max() + let generation: Int64 + if let currentGeneration { + let (next, overflow) = currentGeneration.addingReportingOverflow(1) + guard !overflow, next > 0 else { + throw BuzzDevPushEnrollmentError.generationExhausted + } + generation = next + } else { + generation = 1 + } + + let delegationChallenge = try await challenge() + let delegationClientData = try BuzzPushTranscript.delegate( + challengeId: delegationChallenge.id, + challenge: delegationChallenge.value, + installationHandle: installation, + endpointEpoch: Self.endpointEpoch, + generation: generation, + relayPubkey: relayPubkey, + notBefore: nowSeconds, + expiresAt: expiresAt + ) + let assertion = try await appAttest.assertion(clientData: delegationClientData) + let endpointGrant = try await delegate( + challenge: delegationChallenge, + installationHandle: installation, + relayPubkey: relayPubkey, + generation: generation, + notBefore: nowSeconds, + expiresAt: expiresAt, + assertion: assertion + ) + + let record = BuzzPushEndpointGrantRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + gatewayInstallationHandle: installationHandle, + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + endpointGrant: endpointGrant, + endpointHash: endpointHash, + appProfile: Self.appProfile, + endpointEpoch: Self.endpointEpoch, + generation: generation, + expiresAt: expiresAt + ) + try store.save(record) + return record + } + + private func makeInstallationId() throws -> String { + let bytes = try installationIdBytes() + precondition( + bytes.count == 16, + "NIP-PL installation identity entropy must be exactly 16 bytes" + ) + // This value is per relay origin and never leaves the relay-facing lease. + return Self.lowercaseHex(bytes) + } + + private func challenge() async throws -> Challenge { + let response: ChallengeResponse = try await post( + route: "v1/installations/challenges", + expectedStatus: 200, + body: VersionRequest(v: 1) + ) + guard let id = UUID(uuidString: response.challengeId), + response.challengeId == id.uuidString.lowercased(), + Self.isBase64URLChallenge(response.challenge), + response.expiresAt > Int64(now().timeIntervalSince1970) + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/challenges") + } + return Challenge(id: id, value: response.challenge) + } + + private func enrollInstallation( + challenge: Challenge, + endpoint: String, + expiresAt: Int64, + attestation: BuzzDevAttestation + ) async throws -> UUID { + let response: InstallationResponse = try await post( + route: "v1/installations", + expectedStatus: 201, + body: InstallationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + keyId: attestation.keyId, + attestation: attestation.attestation, + appProfile: Self.appProfile, + endpoint: endpoint, + endpointEpoch: Self.endpointEpoch, + expiresAt: expiresAt + ) + ) + guard let installation = UUID(uuidString: response.installationHandle), + response.installationHandle == installation.uuidString.lowercased(), + response.endpointEpoch == Self.endpointEpoch, + response.expiresAt == expiresAt + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations") + } + return installation + } + + private func delegate( + challenge: Challenge, + installationHandle: UUID, + relayPubkey: String, + generation: Int64, + notBefore: Int64, + expiresAt: Int64, + assertion: String + ) async throws -> String { + let response: DelegationResponse = try await post( + route: "v1/delegations", + expectedStatus: 201, + body: DelegationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + installationHandle: installationHandle.uuidString.lowercased(), + endpointEpoch: Self.endpointEpoch, + generation: generation, + relayPubkey: relayPubkey, + notBefore: notBefore, + expiresAt: expiresAt, + assertion: assertion + ) + ) + guard !response.endpointGrant.isEmpty, response.endpointGrant.utf8.count <= 4_096 else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/delegations") + } + return response.endpointGrant + } + + private func fetchCurrentRelayPushPubkey(from relayOrigin: URL) async throws -> String { + var request = URLRequest(url: relayOrigin) + request.httpMethod = "GET" + request.setValue("application/nostr+json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: "NIP-11", expected: 200) + let document: RelayInformation + do { + document = try JSONDecoder().decode(RelayInformation.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + let current = document.push.keys.filter(\.current) + guard current.count == 1, Self.isLowercaseHexPubkey(current[0].pubkey) else { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + return current[0].pubkey + } + + private func post( + route: String, + expectedStatus: Int, + body: Request + ) async throws -> Response { + let url = route.split(separator: "/").reduce(gatewayBaseURL) { + $0.appendingPathComponent(String($1)) + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: route, expected: expectedStatus) + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + } + + private static func expectStatus( + _ response: URLResponse, + data: Data, + route: String, + expected: Int + ) throws { + guard let http = response as? HTTPURLResponse else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + guard http.statusCode == expected else { + let body = String(decoding: data.prefix(512), as: UTF8.self) + throw BuzzDevPushEnrollmentError.unexpectedStatus( + route: route, expected: expected, actual: http.statusCode, body: body + ) + } + } + + private static func isHTTPOrigin(_ url: URL) -> Bool { + (url.scheme == "http" || url.scheme == "https") + && url.host != nil + && (url.path.isEmpty || url.path == "/") + && url.user == nil + && url.password == nil + && url.query == nil + && url.fragment == nil + } + + private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) { + guard url.scheme == "ws" || url.scheme == "wss", + url.host != nil, + url.path.isEmpty || url.path == "/", + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil + else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + var components = URLComponents() + components.scheme = url.scheme == "wss" ? "https" : "http" + components.host = url.host + components.port = url.port + components.path = "/" + guard let httpURL = components.url else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + var relayComponents = components + relayComponents.scheme = url.scheme + relayComponents.path = "" + guard let relayText = relayComponents.string else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + return (httpURL, relayText) + } + + private static func isLowercaseHexPubkey(_ value: String) -> Bool { + value.utf8.count == 64 + && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } + + private static func isBase64URLChallenge(_ value: String) -> Bool { + guard value.utf8.count == 43, + value.utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) + || (97...122).contains($0) || $0 == 45 || $0 == 95 + }) + else { return false } + var padded = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + padded += String(repeating: "=", count: (4 - padded.count % 4) % 4) + return Data(base64Encoded: padded)?.count == 32 + } + + private static func lowercaseHex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private struct VersionRequest: Encodable { let v: Int } +private struct Challenge { + let id: UUID + let value: String +} +private struct ChallengeResponse: Decodable { + let challengeId: String + let challenge: String + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case challengeId = "challenge_id" + case challenge + case expiresAt = "expires_at" + } +} +private struct InstallationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let keyId: String + let attestation: String + let appProfile: String + let endpoint: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case keyId = "key_id" + case attestation + case appProfile = "app_profile" + case endpoint + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct InstallationResponse: Decodable { + let installationHandle: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct DelegationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let installationHandle: String + let endpointEpoch: Int64 + let generation: Int64 + let relayPubkey: String + let notBefore: Int64 + let expiresAt: Int64 + let assertion: String + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case generation + case relayPubkey = "relay_pubkey" + case notBefore = "not_before" + case expiresAt = "expires_at" + case assertion + } +} +private struct DelegationResponse: Decodable { + let endpointGrant: String + enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" } +} +private struct RelayInformation: Decodable { + struct Push: Decodable { + struct Key: Decodable { + let pubkey: String + let current: Bool + } + let keys: [Key] + } + let push: Push +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift new file mode 100644 index 00000000000..bde4a4494ca --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -0,0 +1,104 @@ +import Foundation + +/// A stable destination attached by the notification service extension after +/// it resolves and verifies the event that produced a push wake. +public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { + public static let userInfoKey = "buzz_push_navigation" + + public let eventID: String + public let communityID: String + public let channelID: String + + public init(eventID: String, communityID: String, channelID: String) { + self.eventID = eventID.lowercased() + self.communityID = communityID + self.channelID = channelID.lowercased() + } + + public var userInfoValue: [String: String] { + [ + "event_id": eventID, + "community_id": communityID, + "channel_id": channelID, + ] + } + + /// Decodes a target without trusting other fields from the APNs payload. + public static func decodeIfPresent( + from userInfo: [AnyHashable: Any] + ) -> BuzzPushNavigationTarget? { + guard let raw = userInfo[userInfoKey] as? [String: Any], + raw.count == 3, + let eventID = raw["event_id"] as? String, + let communityID = raw["community_id"] as? String, + let channelID = raw["channel_id"] as? String, + !communityID.isEmpty, + isLowercaseHex64(eventID.lowercased()), + isChannelID(channelID.lowercased()) + else { + return nil + } + return BuzzPushNavigationTarget( + eventID: eventID, + communityID: communityID, + channelID: channelID + ) + } + + private static func isLowercaseHex64(_ value: String) -> Bool { + value.utf8.count == 64 + && value.utf8.allSatisfy { + ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) + } + } + + private static func isChannelID(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard bytes.count == 36, + bytes[8] == 45, bytes[13] == 45, bytes[18] == 45, bytes[23] == 45, + bytes[14] == 52, + [56, 57, 97, 98].contains(bytes[19]) + else { return false } + return bytes.enumerated().allSatisfy { index, byte in + if [8, 13, 18, 23].contains(index) { return byte == 45 } + return (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } +} + +/// Thread-safe one-item buffer spanning notification delivery and Flutter +/// engine startup during a cold notification launch. +public final class BuzzPushNavigationBuffer: @unchecked Sendable { + private let lock = NSLock() + private var target: BuzzPushNavigationTarget? + + public init() {} + + public func record(_ target: BuzzPushNavigationTarget) { + lock.lock() + self.target = target + lock.unlock() + } + + public func peek() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + return target + } + + public func take() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + let current = target + target = nil + return current + } + + public func remove(ifMatching expected: BuzzPushNavigationTarget) { + lock.lock() + defer { lock.unlock() } + if target == expected { + target = nil + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift new file mode 100644 index 00000000000..36eb0d0fc43 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -0,0 +1,213 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Content resolved from unread Buzz events for a mutable push notification. +public struct BuzzPushResolution: Decodable, Equatable, Sendable { + public let title: String + public let body: String + public let subtitle: String? + public let threadIdentifier: String? + public let navigationTarget: BuzzPushNavigationTarget? + + public init( + title: String, + body: String, + subtitle: String?, + threadIdentifier: String?, + navigationTarget: BuzzPushNavigationTarget? = nil + ) { + self.title = title + self.body = body + self.subtitle = subtitle + self.threadIdentifier = threadIdentifier + self.navigationTarget = navigationTarget + } +} + +/// Resolves the content used to mutate a generic Buzz push notification. +public protocol BuzzPushNotificationResolving { + func resolve(completion: @escaping (BuzzPushResolution?) -> Void) +} + +/// Reads configured Buzz communities and resolves their newest unread event. +public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + private let session: URLSession + private let loadCommunitiesData: () -> Data? + private let loadPrivateKey: (String) -> String? + + /// Creates a resolver around the notification extension's App Group and Keychain I/O. + public init( + session: URLSession, + loadCommunitiesData: @escaping () -> Data?, + loadPrivateKey: @escaping (String) -> String? + ) { + self.session = session + self.loadCommunitiesData = loadCommunitiesData + self.loadPrivateKey = loadPrivateKey + } + + public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { + let communities = loadCommunities().filter { + $0.pubkey?.isEmpty == false + && loadPrivateKey($0.id) != nil + && (try? $0.pushSubscriptionState.authoritativeSubscriptions().isEmpty == false) == true + } + guard !communities.isEmpty else { + completion(nil) + return + } + let group = DispatchGroup() + let lock = NSLock() + var candidates: [(BuzzPushResolution, VerifiedNostrEvent)] = [] + for community in communities { + group.enter() + query(community) { candidate in + if let candidate { + lock.lock() + candidates.append(candidate) + lock.unlock() + } + group.leave() + } + } + group.notify(queue: .global(qos: .userInitiated)) { + let newest = candidates.max { + $0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt + } + completion(newest?.0) + } + } + + private func query( + _ community: PushLeaseCommunity, + completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void + ) { + guard let privateKey = loadPrivateKey(community.id), community.pubkey?.isEmpty == false else { + completion(nil) + return + } + guard + let subscriptions = try? community.pushSubscriptionState.authoritativeSubscriptions(), + !subscriptions.isEmpty, + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL), + let body = try? JSONSerialization.data( + withJSONObject: subscriptions.map { $0.filter.queryFilter(since: nil, limit: 10) } + ) + else { + completion(nil) + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 8 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, method: "POST", body: body, privateKeyHex: privateKey + ) + else { + completion(nil) + return + } + request.setValue(auth, forHTTPHeaderField: "Authorization") + session.dataTask(with: request) { data, response, _ in + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(nil) + return + } + completion( + Self.decodeResolution( + events: events.filter { event in + event.hasValidIDAndSignature() + && subscriptions.contains { subscription in + PushLeaseMatcher.matches(event: event, subscription: subscription) + } + }, + community: community + )) + }.resume() + } + + static func decodeResolution( + events: [VerifiedNostrEvent], community: PushLeaseCommunity + ) -> (BuzzPushResolution, VerifiedNostrEvent)? { + guard let mine = community.pubkey?.lowercased() else { return nil } + let event = events.filter { + $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) + }.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.first + guard let event else { return nil } + let body = previewBody(event.content) + guard !body.isEmpty else { return nil } + let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] + return ( + BuzzPushResolution( + title: shortPubkey(event.pubkey), body: body, subtitle: community.name, + threadIdentifier: channel ?? community.id, + navigationTarget: channel.map { + BuzzPushNavigationTarget( + eventID: event.id, + communityID: community.id, + channelID: $0 + ) + } + ), event + ) + } + + static func previewBody(_ content: String) -> String { + var result = content.replacingOccurrences( + of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) + result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"https?://\S+"#, with: "[link]", options: .regularExpression) + result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + return result.count > 180 + ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result + } + + static func shortPubkey(_ pubkey: String) -> String { + pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey + } + + private func loadCommunities() -> [PushLeaseCommunity] { + guard let data = loadCommunitiesData(), + let decoded = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + else { return [] } + return decoded.communities + } +} + +extension PushLeaseCommunity { + var relayURL: URL? { + guard var components = URLComponents(string: relayUrl), + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + components.scheme = + switch components.scheme?.lowercased() { + case "wss": "https" + case "ws": "http" + case "https": "https" + case "http": "http" + default: nil + } + components.path = "" + return components.url + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift new file mode 100644 index 00000000000..fd0bc34d696 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Errors thrown by the canonical transcript encoder. +public enum BuzzPushTranscriptError: Error, Equatable { + /// A string field contained non-ASCII scalars. NIP-PL admits only ASCII + /// authority-bearing strings; rather than guess at UTF-8-vs-escaping + /// behavior we fail closed. + case nonASCIIInput(field: String) +} + +/// Canonical NIP-PL App Attest transcript encoder. +/// +/// NIP-PL ("Exact App Attest transcript construction") pins the exact bytes +/// every App Attest operation signs: +/// +/// + "\n" + +/// +/// The JSON object has no insignificant whitespace, members appear in a fixed +/// per-route order, integers use shortest decimal notation, and strings use +/// minimal JSON escaping (quotation mark, reverse solidus, U+0000..U+001F). +/// The gateway builds the same bytes with serde_json and compares hashes, so +/// any byte difference is a silent `401 invalid_attestation`. This encoder is +/// hand-rolled for that reason: `JSONSerialization` escapes `/` as `\/` and +/// does not guarantee member order, so it must never be used for transcripts. +/// +/// Ground truth: `crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`, +/// generated and asserted by the gateway's own encoder. The tests in this +/// package replay those vectors byte-for-byte. +/// +/// The `audience` member of each transcript is a **fixed protocol constant** +/// defined by NIP-PL (`https://push.buzz.xyz/v1/...`). It is a cross-route +/// domain-separation string, not a deployment URL: the gateway hardcodes it +/// regardless of where it is hosted, so clients must never derive it from a +/// discovered gateway base URL or relay host. +public enum BuzzPushTranscript { + // MARK: Domains + + public static let enrollDomain = "buzz.push.enroll.v1" + public static let delegateDomain = "buzz.push.delegate.v1" + public static let rotateEndpointDomain = "buzz.push.rotate-endpoint.v1" + public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1" + public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1" + + // MARK: Fixed audiences (protocol constants, see type docs) + + public static let enrollAudience = "https://push.buzz.xyz/v1/installations" + public static let delegateAudience = "https://push.buzz.xyz/v1/delegations" + public static let rotateEndpointAudience = "https://push.buzz.xyz/v1/installations/endpoint" + public static let revokeDelegationAudience = "https://push.buzz.xyz/v1/delegations/revoke" + public static let revokeInstallationAudience = "https://push.buzz.xyz/v1/installations/revoke" + + /// Wire version pinned by NIP-PL. Every transcript carries `"v":1`. + public static let wireVersion: Int64 = 1 + + // MARK: Transcripts + + /// `buzz.push.enroll.v1` — these exact bytes are the App Attest + /// `clientData` supplied to attestation verification. + public static func enroll( + challengeId: UUID, + challenge: String, + keyId: String, + appProfile: String, + endpoint: String, + endpointEpoch: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.enrollAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + try o.string("key_id", keyId, field: "key_id") + try o.string("app_profile", appProfile, field: "app_profile") + try o.string("endpoint", endpoint, field: "endpoint") + o.int("endpoint_epoch", endpointEpoch) + o.int("expires_at", expiresAt) + return encode(domain: enrollDomain, object: o) + } + + /// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func delegate( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + generation: Int64, + relayPubkey: String, + notBefore: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.delegateAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("generation", generation) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("not_before", notBefore) + o.int("expires_at", expiresAt) + return encode(domain: delegateDomain, object: o) + } + + /// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func rotateEndpoint( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64, + endpoint: String + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.rotateEndpointAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + try o.string("endpoint", endpoint, field: "endpoint") + return encode(domain: rotateEndpointDomain, object: o) + } + + /// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeDelegation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + relayPubkey: String, + generation: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeDelegationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("generation", generation) + return encode(domain: revokeDelegationDomain, object: o) + } + + /// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeInstallation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeInstallationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + return encode(domain: revokeInstallationDomain, object: o) + } + + // MARK: Internals + + private static func encode(domain: String, object: CanonicalObject) -> Data { + Data((domain + "\n" + object.encoded()).utf8) + } + + /// Ordered compact JSON object writer. Emission order == call order; + /// there is deliberately no sorting, no whitespace, and no `Encodable` + /// round-trip anywhere near these bytes. + struct CanonicalObject { + private var members: [String] = [] + + mutating func int(_ key: String, _ value: Int64) { + // Swift's Int64 description is shortest decimal notation, which + // is what the spec pins and what serde_json emits. + members.append("\"\(key)\":\(value)") + } + + mutating func uuid(_ key: String, _ value: UUID) { + // Canonical lowercase-hyphenated form, matching uuid::Uuid's + // serde serialization. Foundation's uuidString is uppercase. + members.append("\"\(key)\":\"\(value.uuidString.lowercased())\"") + } + + mutating func string(_ key: String, _ value: String, field: String? = nil) throws { + members.append("\"\(key)\":\"\(try Self.escape(value, field: field ?? key))\"") + } + + func encoded() -> String { + "{" + members.joined(separator: ",") + "}" + } + + /// Minimal JSON string escaping, byte-identical to serde_json: + /// `"` and `\` get two-character escapes; U+0008, U+0009, U+000A, + /// U+000C, U+000D get their short forms; the remaining C0 controls + /// get lowercase `\u00xx`. Nothing else is escaped (in particular + /// `/` is NOT escaped — the JSONSerialization behavior that makes it + /// unusable here). Non-ASCII input is rejected outright. + static func escape(_ s: String, field: String) throws -> String { + var out = String() + out.reserveCapacity(s.count) + for scalar in s.unicodeScalars { + switch scalar.value { + case 0x22: out += "\\\"" + case 0x5C: out += "\\\\" + case 0x08: out += "\\b" + case 0x09: out += "\\t" + case 0x0A: out += "\\n" + case 0x0C: out += "\\f" + case 0x0D: out += "\\r" + case 0x00...0x1F: out += String(format: "\\u%04x", scalar.value) + case 0x20...0x7E: out.unicodeScalars.append(scalar) + default: throw BuzzPushTranscriptError.nonASCIIInput(field: field) + } + } + return out + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift new file mode 100644 index 00000000000..39123704561 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift @@ -0,0 +1,143 @@ +import CryptoKit +import Foundation +import P256K + +public enum NostrHTTPAuthError: Error, Equatable { + case invalidHex + case signingFailed +} + +public struct VerifiedNostrEvent: Codable, Equatable, Sendable { + public let id: String + public let pubkey: String + public let createdAt: Int + public let kind: Int + public let tags: [[String]] + public let content: String + public let sig: String + + enum CodingKeys: String, CodingKey { + case id, pubkey, kind, tags, content, sig + case createdAt = "created_at" + } + + public init( + id: String, pubkey: String, createdAt: Int, kind: Int, + tags: [[String]], content: String, sig: String + ) { + self.id = id + self.pubkey = pubkey + self.createdAt = createdAt + self.kind = kind + self.tags = tags + self.content = content + self.sig = sig + } + + public func hasValidIDAndSignature() -> Bool { + guard let idBytes = Self.hexBytes(id), idBytes.count == 32, + let pubkeyBytes = Self.hexBytes(pubkey), pubkeyBytes.count == 32, + let signatureBytes = Self.hexBytes(sig), signatureBytes.count == 64, + let serialized = try? Self.canonicalSerialization( + pubkey: pubkey.lowercased(), createdAt: createdAt, kind: kind, + tags: tags, content: content + ) + else { return false } + let digest = Array(SHA256.hash(data: serialized)) + guard digest == idBytes, + let signature = try? P256K.Schnorr.SchnorrSignature( + dataRepresentation: Data(signatureBytes) + ) + else { return false } + var message = digest + let key = P256K.Schnorr.XonlyKey(dataRepresentation: pubkeyBytes) + return key.isValid(signature, for: &message) + } + + static func canonicalSerialization( + pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String + ) throws -> Data { + try JSONSerialization.data( + withJSONObject: [0, pubkey, createdAt, kind, tags, content], + options: [.withoutEscapingSlashes] + ) + } + + static func hexBytes(_ value: String) -> [UInt8]? { + guard value.count.isMultiple(of: 2) else { return nil } + var result: [UInt8] = [] + result.reserveCapacity(value.count / 2) + var index = value.startIndex + while index < value.endIndex { + let end = value.index(index, offsetBy: 2) + guard let byte = UInt8(value[index..) -> String { + bytes.map { String(format: "%02x", $0) }.joined() + } +} + +public enum NostrHTTPAuth { + public static func authorizationHeader( + url: URL, + method: String, + body: Data, + privateKeyHex: String, + createdAt: Int = Int(Date().timeIntervalSince1970), + auxiliaryRandomness: [UInt8]? = nil + ) throws -> String { + guard let privateKeyBytes = VerifiedNostrEvent.hexBytes(privateKeyHex), + privateKeyBytes.count == 32 + else { throw NostrHTTPAuthError.invalidHex } + do { + let privateKey = try P256K.Schnorr.PrivateKey( + dataRepresentation: privateKeyBytes + ) + let pubkey = VerifiedNostrEvent.hex(privateKey.xonly.bytes) + let payload = VerifiedNostrEvent.hex(SHA256.hash(data: body)) + let tags = [ + ["u", url.absoluteString], + ["method", method.uppercased()], + ["payload", payload], + ] + let serialized = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, createdAt: createdAt, kind: 27235, + tags: tags, content: "" + ) + let digest = Array(SHA256.hash(data: serialized)) + var message = digest + let signature: P256K.Schnorr.SchnorrSignature + if var randomness = auxiliaryRandomness { + guard randomness.count == 32 else { throw NostrHTTPAuthError.signingFailed } + signature = try privateKey.signature( + message: &message, auxiliaryRand: &randomness + ) + } else { + signature = try privateKey.signature( + message: &message, auxiliaryRand: nil + ) + } + let event = VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: 27235, + tags: tags, + content: "", + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + return "Nostr " + (try encoder.encode(event)).base64EncodedString() + } catch let error as NostrHTTPAuthError { + throw error + } catch { + throw NostrHTTPAuthError.signingFailed + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift new file mode 100644 index 00000000000..2f3dfaa660c --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -0,0 +1,197 @@ +import Foundation + +public enum PushLeaseError: Error, Equatable { + case unsupportedAuthority(String) + case acceptedAuthorityMissingSubscriptions + case emptySubscriptions +} + +public struct PushLeaseSnapshot: Codable, Equatable, Sendable { + public let communities: [PushLeaseCommunity] + + public init(communities: [PushLeaseCommunity]) { + self.communities = communities + } +} + +public struct PushLeaseCommunity: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let relayUrl: String + public let pubkey: String? + public let pushSubscriptionState: PushLeaseSubscriptionState + + public init( + id: String, + name: String, + relayUrl: String, + pubkey: String?, + pushSubscriptionState: PushLeaseSubscriptionState + ) { + self.id = id + self.name = name + self.relayUrl = relayUrl + self.pubkey = pubkey + self.pushSubscriptionState = pushSubscriptionState + } +} + +public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable { + public enum Authority: String, Codable, Sendable { + case desired + case accepted + } + + public let authority: String + public let desired: [PushLeaseSubscription] + public let accepted: [PushLeaseSubscription]? + + public init( + authority: String, + desired: [PushLeaseSubscription], + accepted: [PushLeaseSubscription]? = nil + ) { + self.authority = authority + self.desired = desired + self.accepted = accepted + } + + /// The app persists accepted authority only after the relay acknowledges the + /// corresponding lease. Until then the desired policy is used for snapshots. + public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] { + let subscriptions: [PushLeaseSubscription] + switch authority { + case Authority.desired.rawValue: + subscriptions = desired + case Authority.accepted.rawValue: + guard let accepted else { + throw PushLeaseError.acceptedAuthorityMissingSubscriptions + } + subscriptions = accepted + default: + throw PushLeaseError.unsupportedAuthority(authority) + } + guard !subscriptions.isEmpty else { + throw PushLeaseError.emptySubscriptions + } + return subscriptions + } +} + +public struct PushLeaseSubscription: Codable, Equatable, Sendable { + public let filter: PushLeaseFilter + public let notificationClass: String + public let ignore: [PushLeaseFilter] + public let suppress: PushLeaseSuppression? + + enum CodingKeys: String, CodingKey { + case filter + case notificationClass = "class" + case ignore + case suppress + } + + public init( + filter: PushLeaseFilter, + notificationClass: String, + ignore: [PushLeaseFilter] = [], + suppress: PushLeaseSuppression? = nil + ) { + self.filter = filter + self.notificationClass = notificationClass + self.ignore = ignore + self.suppress = suppress + } +} + +public struct PushLeaseSuppression: Codable, Equatable, Sendable { + public let pTagsMax: Int + + enum CodingKeys: String, CodingKey { + case pTagsMax = "p_tags_max" + } + + public init(pTagsMax: Int) { + self.pTagsMax = pTagsMax + } +} + +public struct PushLeaseFilter: Codable, Equatable, Sendable { + public let kinds: [Int] + public let authors: [String]? + public let pTags: [String]? + public let hTags: [String]? + public let eTags: [String]? + + enum CodingKeys: String, CodingKey { + case kinds + case authors + case pTags = "#p" + case hTags = "#h" + case eTags = "#e" + } + + public init( + kinds: [Int], + authors: [String]? = nil, + pTags: [String]? = nil, + hTags: [String]? = nil, + eTags: [String]? = nil + ) { + self.kinds = kinds + self.authors = authors + self.pTags = pTags + self.hTags = hTags + self.eTags = eTags + } + + public func queryFilter(since: Int?, limit: Int) -> [String: Any] { + var filter: [String: Any] = ["kinds": kinds, "limit": limit] + if let authors { filter["authors"] = authors } + if let pTags { filter["#p"] = pTags } + if let hTags { filter["#h"] = hTags } + if let eTags { filter["#e"] = eTags } + if let since { filter["since"] = since } + return filter + } + + public func matches(_ event: VerifiedNostrEvent) -> Bool { + guard kinds.contains(event.kind) else { return false } + if let authors, !authors.contains(event.pubkey.lowercased()) { return false } + if let pTags, !event.hasAnyTag(named: "p", values: pTags) { return false } + if let hTags, !event.hasAnyTag(named: "h", values: hTags) { return false } + if let eTags, !event.hasAnyTag(named: "e", values: eTags) { return false } + return true + } +} + +public enum PushLeaseMatcher { + public static func matches( + event: VerifiedNostrEvent, + subscription: PushLeaseSubscription + ) -> Bool { + guard subscription.filter.matches(event) else { return false } + if subscription.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = subscription.suppress?.pTagsMax, + event.tagCount(named: "p") > maximum + { + return false + } + return true + } +} + +extension VerifiedNostrEvent { + public func tagCount(named name: String) -> Int { + tags.reduce(into: 0) { count, tag in + if tag.count >= 2 && tag[0] == name { count += 1 } + } + } + + public func hasAnyTag(named name: String, values: [String]) -> Bool { + let expected = Set(values.map { $0.lowercased() }) + return tags.contains { tag in + tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased()) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift new file mode 100644 index 00000000000..657c925e179 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift @@ -0,0 +1,29 @@ +import Foundation +import XCTest +@testable import BuzzPushKit + +final class APNsRegistrationBufferTests: XCTestCase { + func testReplaysTokenAfterChannelAttachment() { + let buffer = APNsRegistrationBuffer() + buffer.recordToken(Data([0x01, 0xAB, 0x00])) + var delivered: [APNsRegistrationUpdate] = [] + buffer.attach { delivered.append($0) } + XCTAssertEqual(delivered, [ + APNsRegistrationUpdate(method: "apnsTokenChanged", arguments: ["token": "01ab00"]) + ]) + XCTAssertNil(buffer.pending) + } + + func testKeepsLatestUpdateAndDeliversLiveFailures() { + let buffer = APNsRegistrationBuffer() + buffer.recordToken(Data([0x01])) + buffer.recordError("offline") + var delivered: [APNsRegistrationUpdate] = [] + buffer.attach { delivered.append($0) } + buffer.recordError("denied") + XCTAssertEqual(delivered, [ + APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "offline"]), + APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "denied"]), + ]) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift new file mode 100644 index 00000000000..b263c8d72ec --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -0,0 +1,992 @@ +import CryptoKit +import Foundation +import Security +import XCTest + +@testable import BuzzPushKit + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class BuzzDevPushEnrollmentDriverTests: XCTestCase { + private static let gatewayURL = URL(string: "http://push.example/")! + private static let relayURL = URL(string: "wss://relay.example/")! + private static let relayPubkey = String(repeating: "a", count: 64) + private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" + private static let secondChallengeId = "33333333-3333-4333-8333-333333333333" + private static let installationHandle = "22222222-2222-4222-8222-222222222222" + private static let installationId = "000102030405060708090a0b0c0d0e0f" + private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + private static let now: Int64 = 1_752_620_000 + private static let expiresAt: Int64 = 1_752_624_000 + private static let endpoint = + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + fileprivate static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" + fileprivate static let attestation = Data("test-attestation".utf8).base64EncodedString() + fileprivate static let assertion = Data("buzz-dev-app-assertion-v1".utf8).base64EncodedString() + + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testEnrollmentPinsTranscriptsAndPersistsOpaqueGrant() async throws { + let store = MemoryGrantStore() + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/nostr+json") + return Self.response( + request, + status: 200, + json: [ + "push": [ + "keys": [ + ["id": "current", "pubkey": Self.relayPubkey, "current": true] + ] + ] + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + let body = try Self.body(request) + XCTAssertEqual(body["v"] as? Int, 1) + XCTAssertEqual(body.count, 1) + let id = challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId + return Self.response( + request, + status: 200, + json: [ + "challenge_id": id, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + let body = try Self.body(request) + XCTAssertEqual(body["endpoint"] as? String, Self.endpoint) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId) + XCTAssertEqual(body["challenge"] as? String, Self.challenge) + XCTAssertEqual(body["key_id"] as? String, Self.keyId) + XCTAssertEqual(body["attestation"] as? String, Self.attestation) + XCTAssertEqual(body["app_profile"] as? String, "buzz-ios-dogfood") + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["challenge_id"] as? String, Self.secondChallengeId) + XCTAssertEqual(body["challenge"] as? String, Self.challenge) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["not_before"] as? Int64, Self.now) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["assertion"] as? String, Self.assertion) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail( + "Unexpected request \(request.httpMethod ?? "nil") \(request.url?.absoluteString ?? "nil")" + ) + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.relayOrigin, "wss://relay.example") + try assertMatchesVector( + "enroll", + actual: appAttest.clientData[0], + expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", + fixture: makeFixtureTranscript(name: "enroll", replacements: []) + ) + try assertMatchesVector( + "delegate", + actual: appAttest.clientData[1], + expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", + fixture: makeFixtureTranscript( + name: "delegate", + replacements: [ + (Self.firstChallengeId, Self.secondChallengeId) + ] + ) + ) + XCTAssertEqual( + record, + BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: Self.installationId, + endpointGrant: "opaque-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + ) + XCTAssertEqual(store.saved, [record]) + } + + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { + let relayURL = URL(string: "wss://relay.example:8443/")! + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example:8443/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: relayURL + ) + + XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") + } + + #if DEBUG + func testDevelopmentAttestationMatchesGatewayBypassShape() async throws { + let entropy = Data(repeating: 0xAB, count: 32) + let provider = BuzzDevAppAttestProvider(randomBytes: { entropy }) + let prepared = try await provider.prepareAttestation() + let bytes = try XCTUnwrap(Data(base64Encoded: prepared.attestation)) + XCTAssertEqual( + bytes, + Data("buzz-dev-app-attest-v1:".utf8) + entropy + ) + XCTAssertEqual( + prepared.keyId, + Data(SHA256.hash(data: bytes)).base64EncodedString() + ) + let assertion = try await provider.assertion(clientData: Data("transcript".utf8)) + XCTAssertEqual(assertion, Self.assertion) + } + #endif + + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { + let service = RecordingDCAppAttestService(isSupported: false) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Expected App Attest to be unavailable") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported) + } + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws { + let service = RecordingDCAppAttestService( + generatedKeyId: Self.keyId, + attestationObject: Data([0x01, 0x02, 0x03]) + ) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("enrollment transcript".utf8) + + let prepared = try await provider.prepareAttestation() + let attestation = try await provider.attestation(prepared, clientData: clientData) + + XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: "")) + XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId]) + XCTAssertEqual(attestation.keyId, Self.keyId) + XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString()) + XCTAssertEqual(service.attestedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.attestationClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + } + + func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws { + let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06])) + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("delegation transcript".utf8) + + let assertion = try await provider.assertion(clientData: clientData) + + XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) + XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.assertionClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws { + for invalidKeyId in [ + "not-a-key-id", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Accepted invalid generated key ID: \(invalidKeyId)") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty) + } + } + + func testRealAppAttestRejectsMismatchedPreparedKey() async throws { + let service = RecordingDCAppAttestService() + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() + + do { + _ = try await provider.attestation( + BuzzDevAttestation(keyId: otherKeyId, attestation: ""), + clientData: Data("enrollment transcript".utf8) + ) + XCTFail("Expected the prepared key ID to match persistent state") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(service.attestedKeyIds.isEmpty) + } + + func testRealAppAttestForwardsServiceErrors() async throws { + let expected = NSError(domain: "DeviceCheckTest", code: 41) + let service = RecordingDCAppAttestService(error: expected) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.assertion(clientData: Data("delegation transcript".utf8)) + XCTFail("Expected the DeviceCheck error") + } catch { + XCTAssertEqual((error as NSError).domain, expected.domain) + XCTAssertEqual((error as NSError).code, expected.code) + } + } + + func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws { + var capturedQuery: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + copyMatching: { query, result in + capturedQuery = query as! [String: Any] + result?.pointee = Data(Self.keyId.utf8) as CFData + return errSecSuccess + } + ) + + XCTAssertEqual(try store.keyId(), Self.keyId) + XCTAssertEqual( + capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String) + XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1") + XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz") + XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true) + XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String) + } + + func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws { + let missing = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecItemNotFound } + ) + XCTAssertNil(try missing.keyId()) + + for invalidKeyId in [ + "bad", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let invalid = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, result in + result?.pointee = Data(invalidKeyId.utf8) as CFData + return errSecSuccess + } + ) + XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") { + XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + } + } + + func testKeychainStoreUpdatesExistingKeyId() throws { + var updatedQuery: [String: Any] = [:] + var updatedValues: [String: Any] = [:] + var addCallCount = 0 + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { query, values in + updatedQuery = query as! [String: Any] + updatedValues = values as! [String: Any] + return errSecSuccess + }, + add: { _, _ in + addCallCount += 1 + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(updatedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(updatedValues[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual(addCallCount, 0) + } + + func testKeychainStoreAddsMissingKeyIdWithDeviceOnlyAccessibility() throws { + var addedItem: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + update: { _, _ in errSecItemNotFound }, + add: { item, _ in + addedItem = item as! [String: Any] + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(addedItem[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual( + addedItem[kSecAttrAccessible as String] as? String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + ) + XCTAssertEqual(addedItem[kSecAttrAccessGroup as String] as? String, "group.buzz") + } + + func testKeychainStoreSurfacesReadUpdateAndAddErrors() throws { + let readFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try readFailure.keyId()) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let updateFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let addFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecItemNotFound }, + add: { _, _ in errSecDuplicateItem } + ) + XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem)) + } + } + + func testReusesPersistedUnexpiredGrant() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + guard request.httpMethod == "GET" else { + XCTFail("Persisted grant reuse must not call the gateway") + return Self.response(request, status: 500, json: [:]) + } + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record, existing) + XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://first.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: String(repeating: "f", count: 32), + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 4, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, "https://second.example/") + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: URL(string: "wss://second.example/")! + ) + + XCTAssertEqual(record.relayOrigin, "wss://second.example") + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(record.generation, existing.generation) + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertNotEqual(record.installationId, existing.installationId) + XCTAssertEqual(store.saved.count, 2) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws { + let secondRelayPubkey = String(repeating: "b", count: 64) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://first.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: String(repeating: "f", count: 32), + endpointGrant: "first-relay-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 7, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://second.example/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["relay_pubkey"] as? String, secondRelayPubkey) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "second-relay-grant"] + ) + case ("POST", "http://push.example/v1/installations"): + XCTFail("A second relay must not create a duplicate APNs installation") + return Self.response(request, status: 500, json: [:]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: URL(string: "wss://second.example/")! + ) + + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.relayPubkey, secondRelayPubkey) + XCTAssertEqual(record.endpointGrant, "second-relay-grant") + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(appAttest.clientData.count, 1) + XCTAssertEqual(store.saved.count, 2) + } + + func testExpiredGrantReenrollsButReusesRelayLeaseAddress() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 7, + expiresAt: Self.now + 300 + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver( + store: store, + appAttest: RecordingAppAttest(), + installationIdBytes: { + XCTFail("Grant refresh must reuse the persisted installation id") + return Data(repeating: 0xFF, count: 16) + } + ) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "refreshed-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(record.endpointGrant, "refreshed-grant") + } + + func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "push": [ + "keys": [ + ["pubkey": Self.relayPubkey, "current": true], + ["pubkey": String(repeating: "b", count: 64), "current": true], + ] + ] + ] + ) + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected an invalid relay descriptor") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor) + } + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testFailsLoudlyOnUnexpectedGatewayStatus() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + if request.httpMethod == "GET" { + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + } + return Self.response(request, status: 400, json: ["error": "invalid_request"]) + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected the gateway error") + } catch let error as BuzzDevPushEnrollmentError { + XCTAssertEqual( + error, + .unexpectedStatus( + route: "v1/installations/challenges", + expected: 200, + actual: 400, + body: "{\"error\":\"invalid_request\"}" + ) + ) + } + } + + private func makeDriver( + store: BuzzPushEndpointGrantStore, + appAttest: BuzzDevAppAttesting, + installationIdBytes: @escaping () throws -> Data = { + Data(0..<16) + } + ) throws -> BuzzDevPushEnrollmentDriver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: Self.gatewayURL, + store: store, + session: URLSession(configuration: configuration), + appAttest: appAttest, + now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }, + lifetimeSeconds: Self.expiresAt - Self.now, + installationIdBytes: installationIdBytes + ) + } + + private func makeFixtureTranscript( + name: String, + replacements: [(String, String)] + ) throws -> (bytes: Data, sha256: String) { + let fixture = try Self.fixture() + let vector = try XCTUnwrap(fixture.vectors.first { $0.name == name }) + let transcript = replacements.reduce(vector.transcript) { + $0.replacingOccurrences(of: $1.0, with: $1.1) + } + return (Data(transcript.utf8), Self.hex(SHA256.hash(data: Data(transcript.utf8)))) + } + + private func assertMatchesVector( + _ name: String, + actual: Data, + expectedSHA256: String, + fixture: (bytes: Data, sha256: String), + file: StaticString = #filePath, + line: UInt = #line + ) throws { + XCTAssertEqual( + fixture.sha256, + expectedSHA256, + "\(name) substituted gateway vector SHA-256", + file: file, + line: line + ) + XCTAssertEqual( + actual, fixture.bytes, "\(name) exact transcript bytes", file: file, line: line) + XCTAssertEqual( + Self.hex(SHA256.hash(data: actual)), + fixture.sha256, + "\(name) transcript SHA-256", + file: file, + line: line + ) + } + + private struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let transcript: String + } + let vectors: [Vector] + } + + private static func fixture() throws -> Fixture { + let path = try XCTUnwrap( + Bundle.module.url( + forResource: "app_attest_transcripts", + withExtension: "json" + ), + "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)" + ) + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(Fixture.self, from: data) + } + + private static func body(_ request: URLRequest) throws -> [String: Any] { + let data: Data + if let httpBody = request.httpBody { + data = httpBody + } else { + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var bytes = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw try XCTUnwrap(stream.streamError) + } + if count == 0 { break } + bytes.append(buffer, count: count) + } + data = bytes + } + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static func response( + _ request: URLRequest, + status: Int, + json: [String: Any] + ) -> (HTTPURLResponse, Data) { + let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } + + private static func hex(_ data: D) -> String where D.Element == UInt8 { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private final class MemoryGrantStore: BuzzPushEndpointGrantStore { + var saved: [BuzzPushEndpointGrantRecord] + init(records: [BuzzPushEndpointGrantRecord] = []) { saved = records } + func records() throws -> [BuzzPushEndpointGrantRecord] { saved } + func save(_ record: BuzzPushEndpointGrantRecord) throws { + saved.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + saved.append(record) + } +} + +private final class RecordingAppAttest: BuzzDevAppAttesting { + var clientData: [Data] = [] + + func prepareAttestation() async throws -> BuzzDevAttestation { + BuzzDevAttestation( + keyId: BuzzDevPushEnrollmentDriverTests.keyId, + attestation: BuzzDevPushEnrollmentDriverTests.attestation + ) + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + self.clientData.append(clientData) + return prepared + } + + func assertion(clientData: Data) async throws -> String { + self.clientData.append(clientData) + return BuzzDevPushEnrollmentDriverTests.assertion + } +} + +private final class MemoryAppAttestKeyIdStore: BuzzAppAttestKeyIdStoring { + var keyIdValue: String? + var savedKeyIds: [String] = [] + + init(keyId: String? = nil) { + keyIdValue = keyId + } + + func keyId() throws -> String? { keyIdValue } + + func saveKeyId(_ keyId: String) throws { + savedKeyIds.append(keyId) + keyIdValue = keyId + } +} + +private final class RecordingDCAppAttestService: BuzzDCAppAttestServicing { + let isSupported: Bool + let generatedKeyId: String + let attestationObject: Data + let assertionObject: Data + let error: Error? + + var generateKeyCallCount = 0 + var attestedKeyIds: [String] = [] + var attestationClientDataHashes: [Data] = [] + var assertedKeyIds: [String] = [] + var assertionClientDataHashes: [Data] = [] + + init( + isSupported: Bool = true, + generatedKeyId: String = BuzzDevPushEnrollmentDriverTests.keyId, + attestationObject: Data = Data("attestation-object".utf8), + assertionObject: Data = Data("assertion-object".utf8), + error: Error? = nil + ) { + self.isSupported = isSupported + self.generatedKeyId = generatedKeyId + self.attestationObject = attestationObject + self.assertionObject = assertionObject + self.error = error + } + + func generateKey() async throws -> String { + generateKeyCallCount += 1 + if let error { throw error } + return generatedKeyId + } + + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data { + attestedKeyIds.append(keyId) + attestationClientDataHashes.append(clientDataHash) + if let error { throw error } + return attestationObject + } + + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data { + assertedKeyIds.append(keyId) + assertionClientDataHashes.append(clientDataHash) + if let error { throw error } + return assertionObject + } +} + +private final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = + try handler?(request) + ?? { + throw URLError(.unsupportedURL) + }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift new file mode 100644 index 00000000000..7acd9f5c53c --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +@Test func `Round-trip navigation target through notification user info`() { + let target = BuzzPushNavigationTarget( + eventID: String(repeating: "A", count: 64), + communityID: "community-id", + channelID: "123E4567-E89B-42D3-A456-426614174000" + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target + ) + #expect(target.eventID == String(repeating: "a", count: 64)) + #expect(target.channelID == "123e4567-e89b-42d3-a456-426614174000") +} + +@Test func `Reject incomplete navigation target`() { + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "event-id", + "community_id": "community-id", + ] + ] + ) == nil + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": String(repeating: "a", count: 64), + "community_id": "community-id", + "channel_id": "not-a-channel", + ] + ] + ) == nil + ) +} + +@Test func `Buffer preserves cold-start target until consumed`() { + let first = BuzzPushNavigationTarget( + eventID: String(repeating: "a", count: 64), + communityID: "community-id", + channelID: "123e4567-e89b-42d3-a456-426614174000" + ) + let second = BuzzPushNavigationTarget( + eventID: String(repeating: "b", count: 64), + communityID: "community-id", + channelID: "123e4567-e89b-42d3-a456-426614174000" + ) + let buffer = BuzzPushNavigationBuffer() + + buffer.record(first) + buffer.remove(ifMatching: second) + #expect(buffer.peek() == first) + #expect(buffer.take() == first) + #expect(buffer.take() == nil) +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift new file mode 100644 index 00000000000..45e484c4378 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -0,0 +1,298 @@ +import Foundation +import XCTest + +@testable import BuzzPushKit + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class BuzzPushNotificationResolverTests: XCTestCase { + private static let privateKey = String(repeating: "0", count: 63) + "1" + private static let ownPubkey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + private static let now = Int(Date().timeIntervalSince1970) + private static let gatewayBody = "Reconnect to your relay now" + private static let channelID = "123e4567-e89b-42d3-a456-426614174000" + + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testResolveReturnsNilWhenCommunitiesDataIsMissing() { + let result = resolve(makeResolver(communitiesData: nil)) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilWhenCommunitiesDataIsUndecodable() { + let result = resolve(makeResolver(communitiesData: Data("not json".utf8))) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilOnKeychainMiss() throws { + let result = resolve( + makeResolver( + communitiesData: try snapshotData([community()]), + privateKeys: [:] + )) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilForNon2xxRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 503, data: Data()) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testResolveReturnsNilForUndecodableRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: Data("not events".utf8)) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testDecodeResolutionFiltersOwnPubkeyEvent() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event(pubkey: Self.ownPubkey, content: "This should be filtered")], + community: community() + ) + + XCTAssertNil(result) + } + + func testDecodeResolutionReturnsNilWhenSanitizedPreviewIsEmpty() { + let event = event(content: " \n\t ") + + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event], + community: community() + ) + + XCTAssertNil(result) + } + + func testPreviewBodySanitizesCodeLinksAndWhitespace() { + let content = """ + Before ```swift + print("secret") + ``` `inline` [docs](https://example.com/docs) + ![image](https://example.com/image.png) https://example.com/raw + After + """ + + XCTAssertEqual( + BuzzPushNotificationResolver.previewBody(content), + "Before [code] inline docs image [link] After" + ) + } + + func testPreviewBodyTruncatesTo178CharactersIncludingEllipsis() { + let preview = BuzzPushNotificationResolver.previewBody(String(repeating: "x", count: 200)) + + XCTAssertEqual(preview.count, 178) + XCTAssertEqual(preview, String(repeating: "x", count: 177) + "…") + } + + func testDecodeResolutionUsesLowestIDWhenCreatedAtTies() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [ + event(id: "a", content: "lower ID", createdAt: Self.now), + event(id: "b", content: "higher ID", createdAt: Self.now), + ], + community: community() + ) + + XCTAssertEqual(result?.1.id, "a") + XCTAssertEqual(result?.0.body, "lower ID") + } + + func testResolveSucceedsAndMutatesGatewayContent() throws { + let event = try JSONDecoder().decode( + VerifiedNostrEvent.self, + from: Data(Self.fixtureEvent.utf8) + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([event])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community()]) + ))) + + XCTAssertNotEqual(result.title, Self.gatewayBody) + XCTAssertNotEqual(result.body, Self.gatewayBody) + XCTAssertEqual(result.title, String(event.pubkey.prefix(8)) + "…") + XCTAssertEqual(result.body, "Hello Buzz") + XCTAssertEqual(result.subtitle, "Community") + XCTAssertEqual(result.threadIdentifier, Self.channelID) + XCTAssertEqual( + result.navigationTarget, + BuzzPushNavigationTarget( + eventID: event.id, + communityID: "community-id", + channelID: Self.channelID + ) + ) + } + + func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws { + URLProtocolStub.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "https://relay.example/query") + return Self.response(request, status: 200, data: Data("[]".utf8)) + } + + let result = resolve( + makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.example")]) + )) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + private func makeResolver( + communitiesData: Data?, + privateKeys: [String: String] = ["community-id": privateKey] + ) -> BuzzPushNotificationResolver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return BuzzPushNotificationResolver( + session: URLSession(configuration: configuration), + loadCommunitiesData: { communitiesData }, + loadPrivateKey: { privateKeys[$0] } + ) + } + + private func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? { + let completed = expectation(description: "resolver completed") + var result: BuzzPushResolution? + resolver.resolve { + result = $0 + completed.fulfill() + } + wait(for: [completed], timeout: 2) + return result + } + + private func community( + id: String = "community-id", + name: String = "Community", + relayUrl: String = "https://relay.example", + pubkey: String? = ownPubkey + ) -> PushLeaseCommunity { + PushLeaseCommunity( + id: id, + name: name, + relayUrl: relayUrl, + pubkey: pubkey, + pushSubscriptionState: PushLeaseSubscriptionState( + authority: "accepted", + desired: [], + accepted: [ + PushLeaseSubscription( + filter: PushLeaseFilter( + kinds: [9, 40002, 45001, 45003], + hTags: [Self.channelID] + ), + notificationClass: "default" + ) + ] + ) + ) + } + + private func snapshotData(_ communities: [PushLeaseCommunity]) throws -> Data { + try JSONEncoder().encode(PushLeaseSnapshot(communities: communities)) + } + + private func event( + id: String = "event-id", + pubkey: String = "author-pubkey", + content: String, + createdAt: Int = now, + kind: Int = 9, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: "signature" + ) + } + + private static let fixtureEvent = #""" + {"kind":9,"created_at":1785551670,"tags":[["h","123e4567-e89b-42d3-a456-426614174000"]],"content":" Hello [Buzz](https://buzz.block.xyz) ","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","id":"233ccf24ec7c94808f9ef08b0c986b6df1bc3843ff72a9f8d016e2a77c77429b","sig":"d39dcd413839b872ed75a979b2c1542247fde636709966905c9e424e227a43897dc67b71ec84178a3faad0634f9bcdf0b48a56ebac84a2ac6e58124b8b6476e6"} + """# + + private static func response( + _ request: URLRequest, + status: Int, + data: Data + ) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } +} + +private final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift new file mode 100644 index 00000000000..3cb242bc4cf --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -0,0 +1,159 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import BuzzPushKit + +/// Replays the gateway-generated known-answer vectors +/// (`crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`) +/// against the Swift canonical encoder. Byte-for-byte transcript equality and +/// SHA-256 equality are both asserted, so a drift on either side breaks a test +/// instead of silently stranding iOS clients with `401 invalid_attestation`. +final class BuzzPushTranscriptTests: XCTestCase { + // MARK: Fixture + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let domain: String + let transcript: String + let sha256: String + } + + let vectors: [Vector] + } + + static func fixture( + file: StaticString = #filePath, + line: UInt = #line + ) throws -> Fixture { + let path = try XCTUnwrap( + Bundle.module.url( + forResource: "app_attest_transcripts", + withExtension: "json" + ), + "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)", + file: file, + line: line + ) + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(Fixture.self, from: data) + } + + // Deterministic inputs mirroring the fixture's `inputs` block. + static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! + static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" + static let appProfile = "buzz-ios-dogfood" + static let endpoint = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + static let relayPubkey = String(repeating: "a", count: 64) + static let notBefore: Int64 = 1_752_620_000 + static let expiresAt: Int64 = 1_752_624_000 + + private func assertMatchesVector(_ name: String, _ bytes: Data, + file: StaticString = #filePath, line: UInt = #line) throws { + guard let vector = try Self.fixture(file: file, line: line).vectors.first(where: { $0.name == name }) else { + XCTFail("missing fixture vector \(name)", file: file, line: line) + return + } + XCTAssertEqual(String(decoding: bytes, as: UTF8.self), vector.transcript, + "\(name) transcript bytes drifted from gateway ground truth", + file: file, line: line) + let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + XCTAssertEqual(digest, vector.sha256, + "\(name) sha256 drifted from gateway ground truth", + file: file, line: line) + } + + // MARK: Known-answer vectors + + func testEnrollVector() throws { + try assertMatchesVector("enroll", BuzzPushTranscript.enroll( + challengeId: Self.challengeId, + challenge: Self.challenge, + keyId: Self.keyId, + appProfile: Self.appProfile, + endpoint: Self.endpoint, + endpointEpoch: 1, + expiresAt: Self.expiresAt + )) + } + + func testDelegateVector() throws { + try assertMatchesVector("delegate", BuzzPushTranscript.delegate( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + )) + } + + func testRotateEndpointVector() throws { + try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2, + endpoint: Self.endpoint + )) + } + + func testRevokeDelegationVector() throws { + try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + relayPubkey: Self.relayPubkey, + generation: 2 + )) + } + + func testRevokeInstallationVector() throws { + try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2 + )) + } + + func testAllFixtureVectorsCovered() throws { + XCTAssertEqual( + Set(try Self.fixture().vectors.map(\.name)), + ["enroll", "delegate", "rotate_endpoint", "revoke_delegation", "revoke_installation"], + "fixture gained or lost a vector; add/remove the matching known-answer test" + ) + } + + // MARK: Escaping edges (the exact JSONSerialization failure modes) + + func testSolidusIsNotEscaped() throws { + // The whole reason this encoder exists: '/' must pass through raw. + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("https://push.buzz.xyz/v1", field: "audience"), + "https://push.buzz.xyz/v1") + } + + func testMinimalEscaping() throws { + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("a\"b\\c\u{08}\u{09}\u{0A}\u{0C}\u{0D}\u{01}", field: "x"), + "a\\\"b\\\\c\\b\\t\\n\\f\\r\\u0001") + } + + func testNonASCIIRejected() { + XCTAssertThrowsError(try BuzzPushTranscript.CanonicalObject.escape("caf\u{00E9}", field: "app_profile")) { + XCTAssertEqual($0 as? BuzzPushTranscriptError, .nonASCIIInput(field: "app_profile")) + } + } + + func testUUIDLowercased() throws { + var o = BuzzPushTranscript.CanonicalObject() + o.uuid("k", UUID(uuidString: "ABCDEF12-3456-4789-8ABC-DEF123456789")!) + XCTAssertEqual(o.encoded(), "{\"k\":\"abcdef12-3456-4789-8abc-def123456789\"}") + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures new file mode 120000 index 00000000000..cbfddb7a246 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures @@ -0,0 +1 @@ +../../../../../crates/buzz-push-gateway/tests/vectors \ No newline at end of file diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift new file mode 100644 index 00000000000..9cffaa5cd28 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift @@ -0,0 +1,76 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import BuzzPushKit + +final class NostrHTTPAuthTests: XCTestCase { + private let privateKey = String(repeating: "0", count: 63) + "1" + private let expectedPubkey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + func testAuthorizationHeaderConstructsValidNIP98Event() throws { + let body = Data("[{\"kinds\":[9]}]".utf8) + let url = URL(string: "https://relay.example/query")! + let header = try NostrHTTPAuth.authorizationHeader( + url: url, + method: "post", + body: body, + privateKeyHex: privateKey, + createdAt: 1_700_000_000, + auxiliaryRandomness: [UInt8](repeating: 0, count: 32) + ) + + XCTAssertTrue(header.hasPrefix("Nostr ")) + let encoded = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6)))) + let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: encoded) + XCTAssertEqual(event.pubkey, expectedPubkey) + XCTAssertEqual(event.createdAt, 1_700_000_000) + XCTAssertEqual(event.kind, 27235) + XCTAssertEqual(event.content, "") + XCTAssertEqual(event.tags, [ + ["u", "https://relay.example/query"], + ["method", "POST"], + ["payload", SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()], + ]) + XCTAssertTrue(event.hasValidIDAndSignature()) + } + + func testEventVerificationRejectsChangedIDSignatureAndContent() throws { + let event = try makeEvent() + XCTAssertTrue(event.hasValidIDAndSignature()) + XCTAssertFalse(copy(event, id: String(repeating: "0", count: 64)).hasValidIDAndSignature()) + XCTAssertFalse(copy(event, sig: String(repeating: "0", count: 128)).hasValidIDAndSignature()) + XCTAssertFalse(copy(event, content: "tampered").hasValidIDAndSignature()) + } + + private func makeEvent() throws -> VerifiedNostrEvent { + let header = try NostrHTTPAuth.authorizationHeader( + url: URL(string: "https://relay.example/query")!, + method: "POST", + body: Data(), + privateKeyHex: privateKey, + createdAt: 1_700_000_000, + auxiliaryRandomness: [UInt8](repeating: 0, count: 32) + ) + let data = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6)))) + return try JSONDecoder().decode(VerifiedNostrEvent.self, from: data) + } + + private func copy( + _ event: VerifiedNostrEvent, + id: String? = nil, + content: String? = nil, + sig: String? = nil + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id ?? event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: content ?? event.content, + sig: sig ?? event.sig + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift new file mode 100644 index 00000000000..3c8190d3934 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -0,0 +1,109 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushLeaseTests: XCTestCase { + private let mine = String(repeating: "a", count: 64) + private let other = String(repeating: "b", count: 64) + + func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws { + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default" + ) + XCTAssertEqual( + try PushLeaseSubscriptionState( + authority: "desired", + desired: [subscription] + ).authoritativeSubscriptions(), + [subscription] + ) + XCTAssertThrowsError( + try PushLeaseSubscriptionState( + authority: "accepted", + desired: [subscription] + ).authoritativeSubscriptions() + ) { error in + XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions) + } + } + + func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() { + let filter = PushLeaseFilter( + kinds: [7, 1059], + authors: [other], + pTags: [mine], + hTags: ["channel"], + eTags: [String(repeating: "c", count: 64)] + ) + let query = filter.queryFilter(since: 1_000, limit: 10) + + XCTAssertEqual(query["kinds"] as? [Int], [7, 1059]) + XCTAssertEqual(query["authors"] as? [String], [other]) + XCTAssertEqual(query["#p"] as? [String], [mine]) + XCTAssertEqual(query["#h"] as? [String], ["channel"]) + XCTAssertEqual(query["since"] as? Int, 1_000) + } + + func testPushEligibleKindAbsentFromOldConstantMatchesLease() { + let event = makeEvent(kind: 1059, tags: [["p", mine]]) + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]), + notificationClass: "default" + ) + + XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription)) + } + + func testIgnoreAndHellthreadSuppressionRejectCandidates() { + let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) + let ignoreSubscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default", + ignore: [PushLeaseFilter(kinds: [9], authors: [other])] + ) + XCTAssertFalse( + PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription) + ) + + let hellthread = makeEvent( + kind: 9, + tags: (0..<21).map { ["p", String(format: "%064x", $0)] } + ) + let suppressed = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], authors: [other]), + notificationClass: "default", + suppress: PushLeaseSuppression(pTagsMax: 20) + ) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed)) + } + + func testDecodesSnapshotContractFromDartShape() throws { + let json = """ + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]} + """ + let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.communities.count, 1) + XCTAssertEqual( + try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count, + 1 + ) + } + + private func makeEvent( + kind: Int, + pubkey: String? = nil, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: String(repeating: "d", count: 64), + pubkey: pubkey ?? other, + createdAt: 1_000, + kind: kind, + tags: tags, + content: "message", + sig: String(repeating: "e", count: 128) + ) + } +} diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 1f35905051e..466fd8fa0f5 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -1,12 +1,22 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG // Default app bundle identifier. Internal/custom builds can override this // without patching tracked files by writing // `mobile/ios/Flutter/AppOverrides.xcconfig` containing // `BUNDLE_IDENTIFIER = your.app.id` (gitignored). -BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile APP_DISPLAY_NAME = Buzz +BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 + +// Push is a build capability, not a runtime rollout switch. Normal Debug +// builds compile out the bridge and exclude the extension product. Internal +// dogfood builds opt in by including PushEnabled.xcconfig from the ignored +// AppOverrides.xcconfig file. +BUZZ_PUSH_ENABLED = NO +BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements +EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): a per-worktree bundle identifier diff --git a/mobile/ios/Flutter/PushEnabled.xcconfig b/mobile/ios/Flutter/PushEnabled.xcconfig new file mode 100644 index 00000000000..79a72d2a614 --- /dev/null +++ b/mobile/ios/Flutter/PushEnabled.xcconfig @@ -0,0 +1,15 @@ +// Complete internal iOS push capability overlay. This file is inert unless an +// ignored AppOverrides.xcconfig explicitly includes it. +BUZZ_PUSH_ENABLED = YES +BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile +BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = production +BUZZ_APP_ATTEST_ENVIRONMENT = production +BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush.entitlements +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) BUZZ_PUSH_ENABLED +EXCLUDED_SOURCE_FILE_NAMES = + +// BUZZ_PUSH_ENABLED=true, base64-encoded for Flutter's DART_DEFINES setting. +DART_DEFINES = $(inherited),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ== diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index d287c5fb432..c33c5a44217 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -4,8 +4,15 @@ // Defaults for the iOS Release build. Internal/custom builds (e.g. an // enterprise-signed distribution) override these without patching tracked // files by writing `mobile/ios/Flutter/AppOverrides.xcconfig` (gitignored). -BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUNDLE_IDENTIFIER = xyz.block.buzz.mobile APP_DISPLAY_NAME = Buzz CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY = iPhone Developer +BUZZ_DEVELOPMENT_TEAM = EYF346PHUG + +// App Store builds remain dormant until a later rollout. The internal build +// pipeline explicitly includes PushEnabled.xcconfig from AppOverrides.xcconfig. +BUZZ_PUSH_ENABLED = NO +BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements +EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/NotificationService/Info.plist b/mobile/ios/NotificationService/Info.plist new file mode 100644 index 00000000000..e66f3a9505d --- /dev/null +++ b/mobile/ios/NotificationService/Info.plist @@ -0,0 +1,35 @@ + + + + + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/mobile/ios/NotificationService/NotificationService.entitlements b/mobile/ios/NotificationService/NotificationService.entitlements new file mode 100644 index 00000000000..2187d2c03bd --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift new file mode 100644 index 00000000000..8d6a2d52981 --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -0,0 +1,108 @@ +import BuzzPushKit +import Foundation +import Security +import UserNotifications + +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + private lazy var resolver: BuzzPushNotificationResolving = { + let appGroupIdentifier = + Bundle.main.object( + forInfoDictionaryKey: "BuzzAppGroupIdentifier" + ) as? String + let keychainAccessGroup = + Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + return BuzzPushNotificationResolver( + session: .shared, + loadCommunitiesData: { + Self.loadCommunitiesData(appGroupIdentifier: appGroupIdentifier) + }, + loadPrivateKey: { communityID in + Self.loadPrivateKey( + communityID: communityID, + keychainAccessGroup: keychainAccessGroup + ) + } + ) + }() + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + bestAttemptContent = content + var cleanUserInfo = content.userInfo + cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey) + content.userInfo = cleanUserInfo + + resolver.resolve { [weak self] resolution in + guard let self else { return } + if let resolution { + content.title = resolution.title + content.body = resolution.body + if let subtitle = resolution.subtitle { + content.subtitle = subtitle + } + if let threadIdentifier = resolution.threadIdentifier { + content.threadIdentifier = threadIdentifier + } + if let navigationTarget = resolution.navigationTarget { + var userInfo = content.userInfo + userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue + content.userInfo = userInfo + } + } + self.finish(content) + } + } + + override func serviceExtensionTimeWillExpire() { + if let bestAttemptContent { + finish(bestAttemptContent) + } + } + + private func finish(_ content: UNNotificationContent) { + guard let contentHandler else { return } + self.contentHandler = nil + contentHandler(content) + } + + private static func loadPrivateKey( + communityID: String, + keychainAccessGroup: String? + ) -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "buzz.push.nse.signing", + kSecAttrAccount as String: communityID, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let keychainAccessGroup, !keychainAccessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = keychainAccessGroup + } + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func loadCommunitiesData(appGroupIdentifier: String?) -> Data? { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { return nil } + return try? Data(contentsOf: container.appendingPathComponent("push-communities.json")) + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index f6f66ee2dc2..d4a4ca60d8e 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; }; + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; }; @@ -26,6 +30,8 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + BZZ00000000000000000023 /* PushNativeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000024 /* PushNativeState.swift */; }; + BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000027 /* PushEndpointGrantStore.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -43,6 +49,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + BZZ00000000000000000004 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -56,6 +73,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; + BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = ""; }; + BZZ00000000000000000028 /* RunnerPush.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = RunnerPush.entitlements; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -80,6 +103,8 @@ 57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = ""; }; + BZZ00000000000000000027 /* PushEndpointGrantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEndpointGrantStore.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -96,6 +121,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + BZZ0000000000000000000C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000020 /* BuzzPushKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3C28C6B702C81085E6F96F2A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -108,6 +141,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */, 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -161,6 +195,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + BZZ0000000000000000000B /* NotificationService */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -175,6 +210,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + BZZ00000000000000000009 /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -186,9 +222,13 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + BZZ0000000000000000000A /* Runner.entitlements */, + BZZ00000000000000000028 /* RunnerPush.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + BZZ00000000000000000024 /* PushNativeState.swift */, + BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, @@ -215,6 +255,16 @@ name = Frameworks; sourceTree = ""; }; + BZZ0000000000000000000B /* NotificationService */ = { + isa = PBXGroup; + children = ( + BZZ00000000000000000006 /* NotificationService.swift */, + BZZ00000000000000000007 /* Info.plist */, + BZZ00000000000000000008 /* NotificationService.entitlements */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -247,6 +297,7 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + BZZ00000000000000000004 /* Embed App Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, ); @@ -255,10 +306,33 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + BZZ0000000000000000000E /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + BZZ0000000000000000000D /* Sources */, + BZZ0000000000000000000C /* Frameworks */, + BZZ00000000000000000010 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); + productName = NotificationService; + productReference = BZZ00000000000000000009 /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -269,6 +343,9 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + BZZ0000000000000000000E = { + CreatedOnToolsVersion = 15.0; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -288,17 +365,28 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + BZZ0000000000000000000E /* NotificationService */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + BZZ00000000000000000010 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -417,6 +505,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + BZZ0000000000000000000D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000001 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -430,6 +526,8 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + BZZ00000000000000000023 /* PushNativeState.swift in Sources */, + BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, @@ -456,6 +554,21 @@ }; /* End PBXTargetDependency section */ +/* Begin XCLocalSwiftPackageReference section */ + BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = BuzzPushKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BZZ00000000000000000022 /* BuzzPushKit */ = { + isa = XCSwiftPackageProductDependency; + package = BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */; + productName = BuzzPushKit; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -535,8 +648,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -723,8 +837,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -746,8 +861,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -761,9 +877,91 @@ }; name = Release; }; + BZZ00000000000000000011 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + BZZ00000000000000000012 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + BZZ00000000000000000015 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BZZ00000000000000000011 /* Debug */, + BZZ00000000000000000012 /* Release */, + BZZ00000000000000000015 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..320fe8c569c --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "08a49d61c7de953c8fb77e34cc8578189c85a707e518c346697669ad28235ec0", + "pins" : [ + { + "identity" : "swift-secp256k1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git", + "state" : { + "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", + "version" : "0.21.1" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index e4ee6dbd916..f605694b618 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,11 +1,28 @@ import AVFoundation import Flutter import UIKit -import UserNotifications + +#if BUZZ_PUSH_ENABLED + import BuzzPushKit + import UserNotifications +#endif @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? + #if BUZZ_PUSH_ENABLED + private var pushChannel: FlutterMethodChannel? + private let apnsRegistrationBuffer = APNsRegistrationBuffer() + private let pushNavigationBuffer = BuzzPushNavigationBuffer() + private var apnsDeviceToken: Data? + private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore( + accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + ) + private var enrollmentTask: Task? + private var appGroupIdentifier: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String + } + #endif private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? @@ -17,7 +34,9 @@ import UserNotifications _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + #if BUZZ_PUSH_ENABLED + UNUserNotificationCenter.current().delegate = self + #endif return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -31,6 +50,18 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + #if BUZZ_PUSH_ENABLED + pushChannel = FlutterMethodChannel( + name: "buzz/push", + binaryMessenger: messenger + ) + pushChannel?.setMethodCallHandler { [weak self] call, result in + self?.handlePushMethodCall(call, result: result) + } + apnsRegistrationBuffer.attach { [weak self] update in + self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments) + } + #endif qrScannerChannel = FlutterMethodChannel( name: "buzz/qr_scanner", binaryMessenger: messenger @@ -195,6 +226,248 @@ import UserNotifications .safeAreaInsets.top ?? 0 } + #if BUZZ_PUSH_ENABLED + override func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + apnsDeviceToken = deviceToken + apnsRegistrationBuffer.recordToken(deviceToken) + } + + override func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) + apnsRegistrationBuffer.recordError(error.localizedDescription) + } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier, + let target = BuzzPushNavigationTarget.decodeIfPresent( + from: response.notification.request.content.userInfo + ) + { + pushNavigationBuffer.record(target) + deliverPushNavigationTarget(target) + } + super.userNotificationCenter( + center, + didReceive: response, + withCompletionHandler: completionHandler + ) + } + + private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) { + pushChannel?.invokeMethod( + "notificationOpened", + arguments: target.flutterArguments + ) { [weak self] result in + guard result as? String == "handled" else { return } + self?.pushNavigationBuffer.remove(ifMatching: target) + } + } + + private func handlePushMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "requestAuthorization": + requestPushAuthorization(result: result) + case "takePendingNotificationResponse": + result(pushNavigationBuffer.take()?.flutterArguments) + case "saveCommunitySnapshot": + guard let arguments = call.arguments as? [String: Any], + let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String] + else { + result( + FlutterError( + code: "invalid_arguments", message: "Expected communities array.", details: nil)) + return + } + do { + try savePushCommunitySnapshot(communities) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") + as? String + ) + result(nil) + } catch { + result( + FlutterError( + code: "save_failed", message: "Unable to save push community credentials.", + details: error.localizedDescription)) + } + case "endpointGrants": + do { + result(try endpointGrantStore.records().map(\.flutterArguments)) + } catch { + result( + FlutterError( + code: "endpoint_grant_read_failed", + message: "Unable to read persisted push endpoint grants.", + details: error.localizedDescription + ) + ) + } + case "enrollPush": + handleDevPushEnrollment(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func requestPushAuthorization(result: @escaping FlutterResult) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + granted, error in + DispatchQueue.main.async { + if let error { + result( + FlutterError( + code: "notification_authorization_failed", + message: "Unable to request notification authorization.", + details: error.localizedDescription + ) + ) + return + } + guard granted else { + result(false) + return + } + UIApplication.shared.registerForRemoteNotifications() + result(true) + } + } + } + + private func handleDevPushEnrollment( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard enrollmentTask == nil else { + result( + FlutterError( + code: "enrollment_in_progress", + message: "Development push enrollment is already running.", + details: nil + ) + ) + return + } + guard let deviceToken = apnsDeviceToken else { + result( + FlutterError( + code: "missing_apns_token", + message: "APNs has not supplied a device token.", + details: nil + ) + ) + return + } + guard !deviceToken.isEmpty else { + result( + FlutterError( + code: "invalid_apns_token", + message: "APNs supplied an empty device token.", + details: nil + ) + ) + return + } + guard let arguments = call.arguments as? [String: Any], + let relayText = arguments["relayUrl"] as? String, + let relayURL = URL(string: relayText), + let gatewayText = arguments["gatewayUrl"] as? String, + let gatewayURL = URL(string: gatewayText) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Development push enrollment requires relayUrl and gatewayUrl.", + details: nil + ) + ) + return + } + + do { + let driver: BuzzDevPushEnrollmentDriver + #if DEBUG + driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore + ) + #else + driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + ) + #endif + enrollmentTask = Task { [weak self] in + defer { self?.enrollmentTask = nil } + do { + let record = try await driver.enroll( + deviceToken: deviceToken, + relayURL: relayURL + ) + await MainActor.run { result(record.flutterArguments) } + } catch { + await MainActor.run { + result( + FlutterError( + code: "dev_enrollment_failed", + message: "Development push enrollment failed.", + details: error.localizedDescription + ) + ) + } + } + } + } catch { + result( + FlutterError( + code: "dev_enrollment_configuration_failed", + message: "Development push enrollment is not configured.", + details: error.localizedDescription + ) + ) + } + } + + private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { + guard let appGroupIdentifier else { + throw NSError( + domain: "BuzzPush", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) + } + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + else { + throw NSError( + domain: "BuzzPush", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) + } + let data = try JSONSerialization.data( + withJSONObject: ["communities": communities], options: [.sortedKeys]) + let destination = container.appendingPathComponent("push-communities.json") + try data.write(to: destination, options: [.atomic]) + } + #endif + private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -389,8 +662,7 @@ import UserNotifications ) destinationVideo.preferredTransform = sourceVideo.preferredTransform - if - let sourceAudio, + if let sourceAudio, let destinationAudio = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid @@ -512,7 +784,8 @@ import UserNotifications do { let durationSeconds = CMTimeGetSeconds(asset.duration) - let middleTime = durationSeconds.isFinite && durationSeconds > 0 + let middleTime = + durationSeconds.isFinite && durationSeconds > 0 ? min(durationSeconds / 2, 1) : 0 let candidateTimes = [0, 0.1, middleTime] @@ -532,11 +805,12 @@ import UserNotifications } guard let posterImage else { - throw lastError ?? NSError( - domain: "BuzzVideoPoster", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."] - ) + throw lastError + ?? NSError( + domain: "BuzzVideoPoster", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."] + ) } guard let jpegData = try MediaSanitizer.encodeJpeg(UIImage(cgImage: posterImage)) else { throw NSError( @@ -632,3 +906,15 @@ import UserNotifications ) } } + +#if BUZZ_PUSH_ENABLED + extension BuzzPushNavigationTarget { + fileprivate var flutterArguments: [String: String] { + [ + "eventId": eventID, + "communityId": communityID, + "channelId": channelID, + ] + } + } +#endif diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 80292ff8bb7..baf248cfa5b 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -2,6 +2,10 @@ + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift new file mode 100644 index 00000000000..ca4c6ace787 --- /dev/null +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -0,0 +1,108 @@ +#if BUZZ_PUSH_ENABLED + import BuzzPushKit + import Foundation + import Security + + /// Keychain-backed endpoint grant storage. The opaque grant is never written to + /// UserDefaults or logs. Dart can read the closed record through the push bridge. + final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { + private static let service = "buzz.push.endpoint-grants" + private static let account = "v1" + + private let accessGroup: String? + + init(accessGroup: String?) { + self.accessGroup = accessGroup + } + + func records() throws -> [BuzzPushEndpointGrantRecord] { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read") + } + do { + return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data) + } catch { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"] + ) + } + } + + func save(_ record: BuzzPushEndpointGrantRecord) throws { + var all = try records() + all.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + all.append(record) + try replace(all) + } + + private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws { + let data = try JSONEncoder().encode(records) + let updateStatus = SecItemUpdate( + baseQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") + } + + var add = baseQuery() + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") + } + } + + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" + ] + ) + } + } + + extension BuzzPushEndpointGrantRecord { + var flutterArguments: [String: Any] { + let arguments: [String: Any] = [ + "relayOrigin": relayOrigin, + "relayPubkey": relayPubkey, + "installationId": installationId, + "endpointGrant": endpointGrant, + "endpointHash": endpointHash, + "appProfile": appProfile, + "endpointEpoch": endpointEpoch, + "generation": generation, + "expiresAt": expiresAt, + ] + return arguments + } + } +#endif diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift new file mode 100644 index 00000000000..cbcb161110f --- /dev/null +++ b/mobile/ios/Runner/PushNativeState.swift @@ -0,0 +1,40 @@ +#if BUZZ_PUSH_ENABLED + import Foundation + import Security + +enum BuzzPushKeychain { + static let service = "buzz.push.nse.signing" + + static func replace(signingKeys: [String: String], accessGroup: String?) throws { + var query = baseQuery(accessGroup: accessGroup) + SecItemDelete(query as CFDictionary) + for (communityID, privateKeyHex) in signingKeys { + query[kSecAttrAccount as String] = communityID + query[kSecValueData as String] = Data(privateKeyHex.utf8) + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + SecItemDelete(baseQuery(accessGroup: accessGroup) as CFDictionary) + throw NSError( + domain: NSOSStatusErrorDomain, code: Int(status), + userInfo: [NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) ?? "Keychain write failed" as CFString] + ) + } + query.removeValue(forKey: kSecValueData as String) + query.removeValue(forKey: kSecAttrAccessible as String) + query.removeValue(forKey: kSecAttrAccount as String) + } + } + + private static func baseQuery(accessGroup: String?) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } +} +#endif diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements new file mode 100644 index 00000000000..0c67376ebac --- /dev/null +++ b/mobile/ios/Runner/Runner.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/ios/Runner/RunnerPush.entitlements b/mobile/ios/Runner/RunnerPush.entitlements new file mode 100644 index 00000000000..8b77cecac88 --- /dev/null +++ b/mobile/ios/Runner/RunnerPush.entitlements @@ -0,0 +1,18 @@ + + + + + aps-environment + $(BUZZ_IOS_PUSH_ENVIRONMENT) + com.apple.developer.devicecheck.appattest-environment + $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index f2726b9f13a..a968302b3e1 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -19,6 +19,8 @@ import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; +import 'shared/push/push_subscription_provider.dart'; +import 'shared/push/push_capability.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; @@ -87,6 +89,9 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); + if (buzzPushCapabilityEnabled) { + ref.watch(pushSubscriptionSyncProvider); + } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; } diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 391a4f1a495..56e86a53f03 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -3,6 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; +import '../../shared/community/community.dart'; +import '../../shared/community/community_provider.dart'; import '../invites/invite_join_provider.dart'; import '../invites/invite_join_sheet.dart'; import 'channel.dart'; @@ -37,6 +39,7 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget { class _DeepLinkDispatcherState extends ConsumerState { bool _preparingInvite = false; + String? _switchingCommunityId; @override void initState() { @@ -57,6 +60,12 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.listen>>(channelsProvider, (_, _) { _maybeDispatch(ref.read(pendingDeepLinkProvider)); }); + ref.listen>(activeCommunityProvider, (_, _) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + }); + ref.listen>>(communityListProvider, (_, _) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + }); } return widget.child; @@ -72,6 +81,7 @@ class _DeepLinkDispatcherState extends ConsumerState { !widget.dispatchMessageLinks) { return; } + if (link is MessageDeepLink && !_prepareNotificationCommunity(link)) return; final channelId = switch (link) { MessageDeepLink(:final channelId) => channelId, @@ -115,6 +125,59 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); } + bool _prepareNotificationCommunity(MessageDeepLink link) { + final communityId = link.communityId; + if (communityId == null) return true; + + final communities = ref.read(communityListProvider).asData?.value; + if (communities == null) return false; + if (!communities.any((community) => community.id == communityId)) { + ref.read(pendingDeepLinkProvider.notifier).consume(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Notification community is no longer available'), + ), + ); + return false; + } + + final activeCommunity = ref.read(activeCommunityProvider).asData?.value; + if (activeCommunity?.id == communityId) return true; + _switchCommunity(communityId); + return false; + } + + void _switchCommunity(String communityId) { + if (_switchingCommunityId != null) return; + _switchingCommunityId = communityId; + Future.microtask(() async { + var switched = false; + try { + await ref + .read(communityListProvider.notifier) + .switchCommunity(communityId); + switched = true; + } catch (error) { + debugPrint( + 'notification-routing: failed to switch to community ' + '$communityId: $error', + ); + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not open the notification community'), + ), + ); + } + } finally { + _switchingCommunityId = null; + if (mounted && switched) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + } + } + }); + } + void _maybeDispatchInvite(InviteDeepLink link) { if (_preparingInvite) return; _preparingInvite = true; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b207c..fb8ce50aebb 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,10 +3,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'shared/push/push_bootstrap.dart'; +import 'shared/push/push_capability.dart'; +import 'shared/push/push_bridge.dart'; import 'shared/theme/theme_provider.dart'; -void main() async { +void main() => runBuzzApp(const App()); + +Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); + if (buzzPushCapabilityEnabled) { + installBuzzPushMethodHandler(); + await syncPendingBuzzPushNotificationResponse(); + } // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); @@ -14,7 +23,7 @@ void main() async { runApp( ProviderScope( overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: const App(), + child: buzzPushCapabilityEnabled ? BuzzPushBootstrap(child: app) : app, ), ); } diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index ade2220264a..12d88bdcf75 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -24,6 +24,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); final communities = await storage.loadAll(); if (communities.isEmpty) { + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -36,6 +37,7 @@ class AuthNotifier extends AsyncNotifier { await storage.saveActiveId(active.id); if (_hasValidNsec(active.nsec)) { + await syncCommunitySnapshot(ref, communities); return AuthState(status: AuthStatus.authenticated, community: active); } @@ -47,6 +49,7 @@ class AuthNotifier extends AsyncNotifier { } await storage.clearActiveId(); + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -57,6 +60,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); await storage.save(community); await storage.saveActiveId(community.id); + await syncStoredCommunitySnapshot(ref); // Invalidate community providers so other consumers pick up the new data. ref.invalidate(communityListProvider); @@ -71,13 +75,24 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); final activeId = await storage.loadActiveId(); if (activeId != null) { + final communities = await storage.loadAll(); + final activeIndex = communities.indexWhere( + (community) => community.id == activeId, + ); + if (activeIndex >= 0) { + await ref.read(communityPushLeaseDeactivatorProvider)( + communities[activeIndex], + ); + } await storage.remove(activeId); await storage.clearActiveId(); } // Check if other communities remain — switch to the next one instead of - // forcing the user back to the pairing screen. + // forcing the user back to the pairing screen. Refreshing the complete + // snapshot also removes revoked keys from the extension Keychain. final remaining = await storage.loadAll(); + await syncCommunitySnapshot(ref, remaining); // Invalidate community providers so other consumers pick up the change. ref.invalidate(communityListProvider); diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 409de10e399..161c5dbd19e 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -1,5 +1,7 @@ import 'package:uuid/uuid.dart'; +import '../push/push_subscription.dart'; + const _uuid = Uuid(); const _sentinel = Object(); @@ -12,6 +14,7 @@ class Community { final String? pubkey; final String? nsec; final SensitiveActionPolicy sensitiveActionPolicy; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; final DateTime addedAt; const Community({ @@ -21,6 +24,7 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), required this.addedAt, }); @@ -49,6 +53,7 @@ class Community { Object? pubkey = _sentinel, Object? nsec = _sentinel, SensitiveActionPolicy? sensitiveActionPolicy, + BuzzPushLeaseSubscriptionState? pushSubscriptionState, }) { return Community( id: id, @@ -58,6 +63,8 @@ class Community { nsec: nsec == _sentinel ? this.nsec : nsec as String?, sensitiveActionPolicy: sensitiveActionPolicy ?? this.sensitiveActionPolicy, + pushSubscriptionState: + pushSubscriptionState ?? this.pushSubscriptionState, addedAt: addedAt, ); } @@ -69,6 +76,7 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, + 'pushSubscriptionState': pushSubscriptionState.toJson(), 'addedAt': addedAt.toIso8601String(), }; @@ -82,6 +90,11 @@ class Community { (value) => value.name == json['sensitiveActionPolicy'], orElse: () => SensitiveActionPolicy.disabledByUser, ), + pushSubscriptionState: json['pushSubscriptionState'] == null + ? const BuzzPushLeaseSubscriptionState.desired() + : BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), addedAt: DateTime.parse(json['addedAt'] as String), ); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index d014c23b505..ceb2545cbab 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,6 +1,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; +import '../push/dev_push_lease.dart'; +import '../push/push_bridge.dart'; +import '../push/push_capability.dart'; +import '../push/push_subscription.dart'; +import '../relay/signed_event_relay.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -8,11 +14,131 @@ final communityStorageProvider = Provider((ref) { return CommunityStorage(); }); +typedef CommunitySnapshotWriter = + Future Function(List communities); + +/// Writes the complete persisted community set to storage shared with the iOS +/// notification service extension. Tests override this provider to verify that +/// every persistence path refreshes (or clears) the native snapshot. +final communitySnapshotWriterProvider = Provider(( + ref, +) { + return registerBuzzPushCommunitySnapshot; +}); + +final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) { + return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider)); +}); + +typedef CommunityPushLeaseDeactivator = + Future Function(Community community); + +final communityPushLeaseDeactivatorProvider = + Provider((ref) { + return (community) => _deactivateCommunityPushLease(community); + }); + +Future _deactivateCommunityPushLease(Community community) async { + if (!buzzPushCapabilityEnabled) return; + final state = community.pushSubscriptionState; + final acceptedGeneration = state.acceptedGeneration; + final installationId = state.acceptedInstallationId; + final nsec = community.nsec; + if (acceptedGeneration == null || + installationId == null || + nsec == null || + nsec.isEmpty) { + return; + } + try { + final decoded = nostr.Nip19.decode(payload: nsec); + final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; + final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final uri = Uri.parse(community.relayUrl); + final httpScheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => uri.scheme, + }; + final wsScheme = httpScheme == 'https' ? 'wss' : 'ws'; + final wsUrl = uri.replace(scheme: wsScheme).toString(); + // Skip over the one renewal generation that could already be in flight + // when removal begins. Strict relay monotonicity then makes any stale + // active publication lose to this tombstone. + final generation = acceptedGeneration + 2; + await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: installationId, + generation: generation, + nsec: nsec, + memberPubkey: memberPubkey, + submit: ({required kind, required content, required tags, createdAt}) => + submitSignedEventOnce( + wsUrl: wsUrl, + nsec: nsec, + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + ), + ); + pushLeaseCleanupError.value = null; + } catch (error, stackTrace) { + // Community removal remains local-first. A failed best-effort tombstone is + // observable here and the already-bounded relay lease expires naturally. + reportPushLeaseCleanupError(error, stackTrace); + } +} + +class _CommunitySnapshotSync { + _CommunitySnapshotSync(this._writer); + + final CommunitySnapshotWriter _writer; + String? _lastSuccessfulSnapshot; + + Future write(List communities) async { + final fingerprint = communities + .map( + (community) => [ + community.id, + community.name, + community.relayUrl, + community.pubkey, + community.nsec, + buzzPushSubscriptionStateFingerprint( + community.pushSubscriptionState, + ), + ].join('\u0000'), + ) + .join('\u0001'); + if (fingerprint == _lastSuccessfulSnapshot) return; + + await _writer(communities); + _lastSuccessfulSnapshot = fingerprint; + } +} + +Future syncCommunitySnapshot(Ref ref, List communities) async { + try { + await ref.read(_communitySnapshotSyncProvider).write(communities); + pushCommunitySnapshotError.value = null; + } catch (error, stackTrace) { + reportPushCommunitySnapshotError(error, stackTrace); + } +} + +Future syncStoredCommunitySnapshot(Ref ref) async { + final communities = await ref.read(communityStorageProvider).loadAll(); + await syncCommunitySnapshot(ref, communities); +} + class CommunityListNotifier extends AsyncNotifier> { @override Future> build() async { final storage = ref.read(communityStorageProvider); - return storage.loadAll(); + final communities = await storage.loadAll(); + await syncCommunitySnapshot(ref, communities); + return communities; } /// Add a community. If one with the same relay URL already exists, update @@ -36,20 +162,31 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[existingIndex] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return existing.id; } await storage.save(community); - state = AsyncData([...current, community]); + final updatedList = [...current, community]; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return community.id; } Future removeCommunity(String id) async { final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final removedIndex = current.indexWhere((community) => community.id == id); + if (removedIndex >= 0) { + await ref.read(communityPushLeaseDeactivatorProvider)( + current[removedIndex], + ); + } await storage.remove(id); - final current = state.value ?? []; - state = AsyncData(current.where((w) => w.id != id).toList()); + final updatedList = current.where((w) => w.id != id).toList(); + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); // If we removed the active community, switch to another or sign out. final activeId = await storage.loadActiveId(); @@ -78,6 +215,60 @@ class CommunityListNotifier extends AsyncNotifier> { ref.invalidate(authProvider); } + Future updateDesiredPushSubscriptions( + String id, + List desired, + ) async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + if (buzzPushSubscriptionsFingerprint( + community.pushSubscriptionState.desired, + ) == + buzzPushSubscriptionsFingerprint(desired)) { + return; + } + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState.withDesired( + desired, + ), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + } + + Future markPushLeaseAccepted( + String id, { + required List subscriptions, + required int generation, + required int grantGeneration, + required String installationId, + }) async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState.withAccepted( + subscriptions: subscriptions, + generation: generation, + grantGeneration: grantGeneration, + installationId: installationId, + ), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + } + Future renameCommunity(String id, String name) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? []; @@ -90,6 +281,7 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[index] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); } } diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index ca785f9df39..c58acefd218 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -72,6 +72,10 @@ class ChannelDeepLink extends BuzzDeepLink { /// A parsed `buzz://message` deep link. class MessageDeepLink extends BuzzDeepLink { + /// Local community identifier for notification-originated links. + /// Canonical shared links omit this because community IDs are device-local. + final String? communityId; + /// Channel UUID from the `channel` query param. final String channelId; @@ -82,6 +86,7 @@ class MessageDeepLink extends BuzzDeepLink { final String? threadRootId; const MessageDeepLink({ + this.communityId, required this.channelId, required this.messageId, this.threadRootId, @@ -90,16 +95,18 @@ class MessageDeepLink extends BuzzDeepLink { @override bool operator ==(Object other) => other is MessageDeepLink && + other.communityId == communityId && other.channelId == channelId && other.messageId == messageId && other.threadRootId == threadRootId; @override - int get hashCode => Object.hash(channelId, messageId, threadRootId); + int get hashCode => + Object.hash(communityId, channelId, messageId, threadRootId); @override String toString() => - 'MessageDeepLink(channel: $channelId, id: $messageId, ' + 'MessageDeepLink(community: $communityId, channel: $channelId, id: $messageId, ' 'thread: $threadRootId)'; } diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 4875d94fcfa..0f8c052c282 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; +import '../push/push_bridge.dart'; /// Holds supported deep links until they can be dispatched. /// @@ -22,17 +23,27 @@ class PendingDeepLinkNotifier extends Notifier { StreamSubscription? _subscription; final Queue _waiting = Queue(); + VoidCallback? _pushNotificationListener; @override BuzzDeepLink? build() { _waiting.clear(); final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream; _subscription = stream.listen(open); + _pushNotificationListener = () { + final link = pendingPushNotificationLink.value; + if (link != null) _enqueue(link); + }; + pendingPushNotificationLink.addListener(_pushNotificationListener!); ref.onDispose(() { _subscription?.cancel(); _subscription = null; + if (_pushNotificationListener case final listener?) { + pendingPushNotificationLink.removeListener(listener); + } + _pushNotificationListener = null; }); - return null; + return pendingPushNotificationLink.value; } /// Parse and park an incoming URI. Unsupported links are ignored loudly. @@ -42,17 +53,24 @@ class PendingDeepLinkNotifier extends Notifier { debugPrint('deep-link: ignoring unsupported link: $uri'); return; } - if (state == null) { - state = link; - } else { - _waiting.addLast(link); - } + _enqueue(link); } /// Acknowledge the current link and expose the next queued link, if any. void consume() { + if (pendingPushNotificationLink.value == state) { + pendingPushNotificationLink.value = null; + } state = _waiting.isEmpty ? null : _waiting.removeFirst(); } + + void _enqueue(BuzzDeepLink link) { + if (state == null) { + state = link; + } else { + _waiting.addLast(link); + } + } } final pendingDeepLinkProvider = diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart new file mode 100644 index 00000000000..2a41c3220d5 --- /dev/null +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -0,0 +1,756 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip44.dart'; +import '../relay/nostr_models.dart'; +import '../relay/signed_event_relay.dart'; +import 'push_bridge.dart'; +import 'push_subscription.dart'; + +const buzzPushLeaseKind = 30350; +const buzzDevPushAppProfile = 'buzz-ios-dogfood'; +const buzzPushTransport = 'apns'; +const _maxSafeJsonInteger = 9007199254740991; +const _maxLeaseLifetimeSeconds = 2592000; +const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$'; +const _installationIdPattern = r'^[0-9a-f]{32}$'; + +class BuzzPushLeaseDescriptor { + final String origin; + final String executorKeyId; + final String executorPubkey; + final String transport; + final int maxLeaseTtlSeconds; + final int maxContentLength; + final int maxPlaintextLength; + final int maxEndpointLength; + final int maxStringLength; + + const BuzzPushLeaseDescriptor({ + required this.origin, + required this.executorKeyId, + required this.executorPubkey, + required this.transport, + required this.maxLeaseTtlSeconds, + required this.maxContentLength, + required this.maxPlaintextLength, + required this.maxEndpointLength, + required this.maxStringLength, + }); + + factory BuzzPushLeaseDescriptor.fromRelayInformation( + Map information, + ) { + _requireExactKeys( + information, + required: const {}, + allowed: const { + 'name', + 'description', + 'pubkey', + 'contact', + 'supported_nips', + 'supported_extensions', + 'software', + 'version', + 'limitation', + 'retention', + 'relay_countries', + 'language_tags', + 'tags', + 'posting_policy', + 'payments_url', + 'fees', + 'icon', + 'self', + 'pairing_relay_url', + 'push', + }, + name: 'NIP-11 document', + ); + final extensions = _stringList( + information['supported_extensions'], + name: 'supported_extensions', + ); + if (!extensions.contains('nip-pl')) { + throw const FormatException('NIP-11 does not advertise nip-pl'); + } + + final push = _stringMap(information['push'], name: 'push'); + _requireExactKeys( + push, + required: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'urgent_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + allowed: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'urgent_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + name: 'push descriptor', + ); + + final origin = _canonicalOrigin(push['origin']); + final keys = _mapList(push['keys'], name: 'push.keys'); + final keyIds = {}; + final currentKeys = >[]; + for (final key in keys) { + _requireExactKeys( + key, + required: const {'id', 'pubkey'}, + allowed: const {'id', 'pubkey', 'current', 'retiring'}, + name: 'push key', + ); + final id = _nonEmptyString(key['id'], name: 'push key id'); + if (!keyIds.add(id)) { + throw FormatException('Duplicate push key id: $id'); + } + _lowercaseHex64(key['pubkey'], name: 'push key pubkey'); + final current = key['current']; + final retiring = key['retiring']; + if (current != null && current is! bool) { + throw const FormatException('push key current must be a boolean'); + } + if (retiring != null && retiring is! bool) { + throw const FormatException('push key retiring must be a boolean'); + } + if (current == true) currentKeys.add(key); + } + if (currentKeys.length != 1) { + throw const FormatException( + 'push descriptor must contain exactly one current key', + ); + } + final currentKey = currentKeys.single; + + final profiles = _mapList(push['app_profiles'], name: 'app_profiles'); + final profileIds = {}; + String? transport; + for (final profile in profiles) { + _requireExactKeys( + profile, + required: const {'id', 'transport'}, + allowed: const {'id', 'transport'}, + name: 'app profile', + ); + final id = _nonEmptyString(profile['id'], name: 'app profile id'); + if (!profileIds.add(id)) { + throw FormatException('Duplicate app profile id: $id'); + } + final candidate = _nonEmptyString( + profile['transport'], + name: 'app profile transport', + ); + if (id == buzzDevPushAppProfile) transport = candidate; + } + if (transport != buzzPushTransport) { + throw const FormatException( + 'NIP-11 does not advertise the dogfood APNs profile', + ); + } + + final pushKinds = _intList(push['push_kinds'], name: 'push_kinds'); + if (!buzzPushEligibleKinds.every(pushKinds.contains)) { + throw const FormatException( + 'NIP-11 does not advertise every Buzz message kind for push', + ); + } + final urgentKinds = _intList( + push['urgent_kinds'], + name: 'urgent_kinds', + allowEmpty: true, + ); + if (urgentKinds.any((kind) => !pushKinds.contains(kind))) { + throw const FormatException( + 'urgent_kinds must be a subset of push_kinds', + ); + } + final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); + if (hGrammar != 'uuid-v4-lowercase') { + throw const FormatException('Unsupported push h_grammar'); + } + + final classSupport = _stringMap( + push['class_support'], + name: 'class_support', + ); + final supportedClasses = _stringList( + classSupport[buzzPushTransport], + name: 'class_support.apns', + ); + const knownClasses = {'silent', 'default', 'time_sensitive', 'urgent'}; + if (supportedClasses.any((value) => !knownClasses.contains(value))) { + throw const FormatException('class_support contains an unknown class'); + } + if (!supportedClasses.contains('default')) { + throw const FormatException('APNs does not support the default class'); + } + + final limitation = _stringMap(push['limitation'], name: 'limitation'); + _requireExactKeys( + limitation, + required: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + allowed: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + name: 'push limitation', + ); + for (final entry in limitation.entries) { + _positiveInt(entry.value, name: 'limitation.${entry.key}'); + } + final maxStringLength = limitation['max_string_len'] as int; + _checkStringLength(origin, maxStringLength, name: 'origin'); + _checkStringLength( + currentKey['id'] as String, + maxStringLength, + name: 'push key id', + ); + final maxLeaseTtl = limitation['max_lease_ttl'] as int; + if (maxLeaseTtl > _maxLeaseLifetimeSeconds) { + throw const FormatException('max_lease_ttl exceeds the NIP-PL v1 limit'); + } + + return BuzzPushLeaseDescriptor( + origin: origin, + executorKeyId: currentKey['id'] as String, + executorPubkey: currentKey['pubkey'] as String, + transport: transport!, + maxLeaseTtlSeconds: maxLeaseTtl, + maxContentLength: limitation['max_content_len'] as int, + maxPlaintextLength: limitation['max_plaintext_len'] as int, + maxEndpointLength: limitation['max_endpoint_len'] as int, + maxStringLength: maxStringLength, + ); + } +} + +Future fetchBuzzPushLeaseDescriptor( + String relayBaseUrl, { + http.Client? client, + Duration timeout = const Duration(seconds: 8), +}) async { + final uri = Uri.tryParse(relayBaseUrl); + if (uri == null || + !const {'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty) { + throw FormatException('Invalid relay HTTP URL: $relayBaseUrl'); + } + final requestUri = uri.resolve('/'); + final ownedClient = client ?? http.Client(); + try { + final response = await ownedClient + .get(requestUri, headers: const {'Accept': 'application/nostr+json'}) + .timeout(timeout); + if (response.statusCode != 200) { + throw StateError( + 'NIP-11 request failed with HTTP ${response.statusCode}: ${response.body}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('NIP-11 response must be a JSON object'); + } + return BuzzPushLeaseDescriptor.fromRelayInformation(decoded); + } finally { + if (client == null) ownedClient.close(); + } +} + +class BuzzPushLeasePublication { + final String eventId; + final int expiration; + final String plaintext; + + const BuzzPushLeasePublication({ + required this.eventId, + required this.expiration, + required this.plaintext, + }); +} + +typedef BuzzPushLeaseSubmit = + Future Function({ + required int kind, + required String content, + required List> tags, + int? createdAt, + }); + +Future publishBuzzDevPushLease({ + required BuzzPushEndpointGrant grant, + int? leaseGeneration, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required List subscriptions, + required BuzzPushLeaseSubmit submit, + DateTime Function() now = DateTime.now, +}) async { + _validateGrant(grant, descriptor); + final effectiveLeaseGeneration = leaseGeneration ?? grant.generation; + if (effectiveLeaseGeneration <= 0 || + effectiveLeaseGeneration > _maxSafeJsonInteger) { + throw const FormatException('Lease generation is invalid'); + } + final normalizedMemberPubkey = _lowercaseHex64( + memberPubkey, + name: 'member pubkey', + ); + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { + throw const FormatException('Signing key must be a 32-byte nsec'); + } + final signingPubkey = nostr.Keys(decoded.data).public; + if (signingPubkey != normalizedMemberPubkey) { + throw const FormatException( + 'Authenticated signing key does not match the lease member pubkey', + ); + } + + final nowSeconds = now().millisecondsSinceEpoch ~/ 1000; + final expiration = min( + grant.expiresAt, + nowSeconds + descriptor.maxLeaseTtlSeconds, + ); + if (expiration <= nowSeconds) { + throw const FormatException('Endpoint grant is already expired'); + } + + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'app_profile': grant.appProfile, + 'transport': descriptor.transport, + 'endpoint': grant.endpointGrant, + 'generation': effectiveLeaseGeneration, + 'active': true, + 'subscriptions': [ + for (final subscription in subscriptions) subscription.toJson(), + ], + }; + validateBuzzPushLeasePlaintext( + plaintextMap, + maxEndpointLength: descriptor.maxEndpointLength, + maxStringLength: descriptor.maxStringLength, + ); + final plaintext = jsonEncode(plaintextMap); + if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { + throw const FormatException('lease plaintext exceeds the advertised limit'); + } + final conversationKey = getConversationKey( + decoded.data, + descriptor.executorPubkey, + ); + final content = nip44Encrypt(conversationKey, plaintext); + if (utf8.encode(content).length > descriptor.maxContentLength) { + throw const FormatException( + 'lease ciphertext exceeds the advertised limit', + ); + } + final acknowledged = await submit( + kind: buzzPushLeaseKind, + content: content, + tags: [ + ['d', grant.installationId], + ['expiration', '$expiration'], + ['exec', descriptor.executorKeyId], + ], + createdAt: nowSeconds, + ); + if (acknowledged.id.isEmpty) { + throw StateError('Relay returned an empty event id for the push lease'); + } + return BuzzPushLeasePublication( + eventId: acknowledged.id, + expiration: expiration, + plaintext: plaintext, + ); +} + +Future publishBuzzDevPushLeaseThroughRelay({ + required BuzzPushEndpointGrant grant, + int? leaseGeneration, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required List subscriptions, + required SignedEventRelay relay, + DateTime Function() now = DateTime.now, +}) => publishBuzzDevPushLease( + grant: grant, + leaseGeneration: leaseGeneration, + descriptor: descriptor, + nsec: nsec, + memberPubkey: memberPubkey, + subscriptions: subscriptions, + submit: relay.submit, + now: now, +); + +/// Publishes the minimal higher-generation inactive lease used when a +/// community is removed from this device. Gateway delegation is intentionally +/// untouched because it is scoped to the installation and relay key, not the +/// community. +Future publishBuzzPushLeaseTombstone({ + required BuzzPushLeaseDescriptor descriptor, + required String installationId, + required int generation, + required String nsec, + required String memberPubkey, + required BuzzPushLeaseSubmit submit, + DateTime Function() now = DateTime.now, +}) async { + if (!RegExp(_installationIdPattern).hasMatch(installationId)) { + throw const FormatException( + 'Installation id must be 16 random bytes encoded as lowercase hex', + ); + } + if (generation <= 0 || generation > _maxSafeJsonInteger) { + throw const FormatException('Tombstone generation is invalid'); + } + final normalizedMemberPubkey = _lowercaseHex64( + memberPubkey, + name: 'member pubkey', + ); + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { + throw const FormatException('Signing key must be a 32-byte nsec'); + } + if (nostr.Keys(decoded.data).public != normalizedMemberPubkey) { + throw const FormatException( + 'Authenticated signing key does not match the lease member pubkey', + ); + } + + final nowSeconds = now().millisecondsSinceEpoch ~/ 1000; + final expiration = nowSeconds + descriptor.maxLeaseTtlSeconds; + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'generation': generation, + 'active': false, + }; + validateBuzzPushLeaseTombstonePlaintext(plaintextMap); + final plaintext = jsonEncode(plaintextMap); + if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { + throw const FormatException('lease plaintext exceeds the advertised limit'); + } + final content = nip44Encrypt( + getConversationKey(decoded.data, descriptor.executorPubkey), + plaintext, + ); + if (utf8.encode(content).length > descriptor.maxContentLength) { + throw const FormatException( + 'lease ciphertext exceeds the advertised limit', + ); + } + final acknowledged = await submit( + kind: buzzPushLeaseKind, + content: content, + tags: [ + ['d', installationId], + ['expiration', '$expiration'], + ['exec', descriptor.executorKeyId], + ], + createdAt: nowSeconds, + ); + if (acknowledged.id.isEmpty) { + throw StateError('Relay returned an empty event id for the push tombstone'); + } + return BuzzPushLeasePublication( + eventId: acknowledged.id, + expiration: expiration, + plaintext: plaintext, + ); +} + +Future publishBuzzPushLeaseTombstoneThroughRelay({ + required BuzzPushLeaseDescriptor descriptor, + required String installationId, + required int generation, + required String nsec, + required String memberPubkey, + required SignedEventRelay relay, + DateTime Function() now = DateTime.now, +}) => publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: installationId, + generation: generation, + nsec: nsec, + memberPubkey: memberPubkey, + submit: relay.submit, + now: now, +); + +void validateBuzzPushLeaseTombstonePlaintext(Map plaintext) { + _requireExactKeys( + plaintext, + required: const {'v', 'origin', 'generation', 'active'}, + allowed: const {'v', 'origin', 'generation', 'active'}, + name: 'lease tombstone plaintext', + ); + if (plaintext['v'] != 1 || plaintext['active'] != false) { + throw const FormatException('lease tombstone must be inactive v1'); + } + _canonicalOrigin(plaintext['origin']); + final generation = _positiveInt(plaintext['generation'], name: 'generation'); + if (generation > _maxSafeJsonInteger) { + throw const FormatException( + 'generation exceeds the safe JSON integer range', + ); + } +} + +void validateBuzzPushLeasePlaintext( + Map plaintext, { + int maxEndpointLength = 4096, + int maxStringLength = 512, +}) { + _requireExactKeys( + plaintext, + required: const { + 'v', + 'origin', + 'app_profile', + 'transport', + 'endpoint', + 'generation', + 'active', + 'subscriptions', + }, + allowed: const { + 'v', + 'origin', + 'app_profile', + 'transport', + 'endpoint', + 'generation', + 'active', + 'subscriptions', + }, + name: 'lease plaintext', + ); + if (plaintext['v'] != 1) { + throw const FormatException('lease version must be 1'); + } + final origin = _canonicalOrigin(plaintext['origin']); + _checkStringLength(origin, maxStringLength, name: 'origin'); + if (plaintext['app_profile'] != buzzDevPushAppProfile) { + throw const FormatException('lease app_profile must be buzz-ios-dogfood'); + } + if (plaintext['transport'] != buzzPushTransport) { + throw const FormatException('lease transport must be apns'); + } + _checkStringLength( + buzzDevPushAppProfile, + maxStringLength, + name: 'app_profile', + ); + _checkStringLength(buzzPushTransport, maxStringLength, name: 'transport'); + final endpoint = _nonEmptyString(plaintext['endpoint'], name: 'endpoint'); + _checkStringLength(endpoint, maxEndpointLength, name: 'endpoint'); + final generation = _positiveInt(plaintext['generation'], name: 'generation'); + if (generation > _maxSafeJsonInteger) { + throw const FormatException( + 'generation exceeds the safe JSON integer range', + ); + } + if (plaintext['active'] != true) { + throw const FormatException('push lease must be active'); + } + + final subscriptions = _mapList( + plaintext['subscriptions'], + name: 'subscriptions', + ); + if (subscriptions.isEmpty || + subscriptions.length > buzzPushMaxSubscriptions) { + throw const FormatException('push lease subscription count is invalid'); + } + for (final subscription in subscriptions) { + BuzzPushSubscription.fromJson(subscription); + } +} + +void _validateGrant( + BuzzPushEndpointGrant grant, + BuzzPushLeaseDescriptor descriptor, +) { + if (utf8.encode(grant.installationId).length > 64 || + !RegExp(_installationIdPattern).hasMatch(grant.installationId)) { + throw const FormatException( + 'Installation id must be 16 random bytes encoded as lowercase hex', + ); + } + if (grant.relayPubkey != descriptor.executorPubkey) { + throw const FormatException( + 'Stored endpoint grant is delegated to a different relay key', + ); + } + if (grant.appProfile != buzzDevPushAppProfile) { + throw const FormatException('Endpoint grant is not for buzz-ios-dogfood'); + } + if (grant.endpointGrant.isEmpty || + utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) { + throw const FormatException( + 'Endpoint grant violates the advertised endpoint limit', + ); + } + if (grant.endpointEpoch <= 0) { + throw const FormatException('Endpoint grant epoch is invalid'); + } + if (grant.generation <= 0 || grant.generation > _maxSafeJsonInteger) { + throw const FormatException('Endpoint grant generation is invalid'); + } +} + +String _canonicalOrigin(Object? value) { + final origin = _nonEmptyString(value, name: 'origin'); + final uri = Uri.tryParse(origin); + if (uri == null || + !const {'ws', 'wss'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.path.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + '${uri.scheme}://${uri.authority}' != origin) { + throw FormatException('Invalid canonical push origin: $origin'); + } + return origin; +} + +void _checkStringLength(String value, int maximum, {required String name}) { + if (maximum <= 0 || utf8.encode(value).length > maximum) { + throw FormatException('$name exceeds its advertised byte limit'); + } +} + +String _lowercaseHex64(Object? value, {required String name}) { + final text = _nonEmptyString(value, name: name); + if (!RegExp(_lowercaseHex64Pattern).hasMatch(text)) { + throw FormatException('$name must be exactly 64 lowercase hex characters'); + } + return text; +} + +String _nonEmptyString(Object? value, {required String name}) { + if (value is! String || value.isEmpty) { + throw FormatException('$name must be a non-empty string'); + } + return value; +} + +int _positiveInt(Object? value, {required String name}) { + if (value is! int || value <= 0) { + throw FormatException('$name must be a positive integer'); + } + return value; +} + +Map _stringMap(Object? value, {required String name}) { + if (value is! Map) { + throw FormatException('$name must be an object'); + } + return value; +} + +List> _mapList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty array'); + } + return [ + for (final item in value) + if (item is Map) + item + else + throw FormatException('$name entries must be objects'), + ]; +} + +List _stringList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty string array'); + } + return [ + for (final item in value) + if (item is String && item.isNotEmpty) + item + else + throw FormatException('$name entries must be non-empty strings'), + ]; +} + +List _intList( + Object? value, { + required String name, + bool allowEmpty = false, +}) { + if (value is! List || (!allowEmpty && value.isEmpty)) { + throw FormatException( + '$name must be ${allowEmpty ? 'an' : 'a non-empty'} integer array', + ); + } + return [ + for (final item in value) + if (item is int && item >= 0) + item + else + throw FormatException('$name entries must be non-negative integers'), + ]; +} + +void _requireExactKeys( + Map value, { + required Set required, + required Set allowed, + required String name, +}) { + final missing = required.difference(value.keys.toSet()); + if (missing.isNotEmpty) { + throw FormatException('$name is missing ${missing.join(', ')}'); + } + final unexpected = value.keys.toSet().difference(allowed); + if (unexpected.isNotEmpty) { + throw FormatException('$name contains unknown field ${unexpected.first}'); + } +} diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart new file mode 100644 index 00000000000..4d8596bfeea --- /dev/null +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import '../relay/signed_event_relay.dart'; +import 'dev_push_lease.dart'; +import 'push_bridge.dart'; +import 'push_subscription.dart'; + +/// Starts the push lifecycle only after authenticated relay connectivity and a +/// push-capable NIP-11 descriptor are both present. +class BuzzPushBootstrap extends HookConsumerWidget { + const BuzzPushBootstrap({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(apnsDeviceToken); + useListenable(pushAuthorizationGranted); + final authorizationAttempt = useRef(null); + final publicationAttempt = useRef(null); + final session = ref.watch(relaySessionProvider); + final config = ref.watch(relayConfigProvider); + final community = ref.watch(activeCommunityProvider).value; + final memberPubkey = ref.watch(myPubkeyProvider); + + useEffect(() { + if (!_ready(session, config, community, memberPubkey)) return null; + final attempt = '${community!.id}|${config.baseUrl}'; + if (authorizationAttempt.value == attempt) return null; + authorizationAttempt.value = attempt; + unawaited( + _authorize(config.baseUrl).catchError((Object error, StackTrace stack) { + authorizationAttempt.value = null; + debugPrint('Push authorization bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + }), + ); + return null; + }, [session.status, config.baseUrl, community?.id, memberPubkey]); + + final token = apnsDeviceToken.value; + final authorized = pushAuthorizationGranted.value; + useEffect( + () { + if (!_ready(session, config, community, memberPubkey) || + authorized != true || + token == null) { + return null; + } + final state = community!.pushSubscriptionState; + if (state.desired.isEmpty) return null; + final desiredFingerprint = buzzPushSubscriptionsFingerprint( + state.desired, + ); + final acceptedFingerprint = state.accepted == null + ? '-' + : buzzPushSubscriptionsFingerprint(state.accepted!); + final attempt = [ + community.id, + config.baseUrl, + token, + desiredFingerprint, + acceptedFingerprint, + state.acceptedGeneration, + state.acceptedGrantGeneration, + state.acceptedInstallationId, + ].join('|'); + if (publicationAttempt.value == attempt) return null; + publicationAttempt.value = attempt; + final relay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec!, + ); + unawaited( + _publish(ref, config, community, memberPubkey!, relay).catchError(( + Object error, + StackTrace stack, + ) { + publicationAttempt.value = null; + debugPrint('Push lease bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + }), + ); + return null; + }, + [ + session.status, + config.baseUrl, + community?.id, + community?.pushSubscriptionState, + memberPubkey, + authorized, + token, + ], + ); + + return child; + } + + static bool _ready( + SessionState session, + RelayConfig config, + Community? community, + String? memberPubkey, + ) => + session.status == SessionStatus.connected && + community != null && + config.nsec != null && + config.nsec!.isNotEmpty && + memberPubkey != null && + memberPubkey.isNotEmpty; + + static Future _authorize(String relayBaseUrl) async { + await fetchBuzzPushLeaseDescriptor(relayBaseUrl); + await requestBuzzPushAuthorization(); + } + + static Future _publish( + WidgetRef ref, + RelayConfig config, + Community community, + String memberPubkey, + SignedEventRelay relay, + ) async { + final state = community.pushSubscriptionState; + final desired = state.desired; + final desiredFingerprint = buzzPushSubscriptionsFingerprint(desired); + final acceptedFingerprint = state.accepted == null + ? null + : buzzPushSubscriptionsFingerprint(state.accepted!); + final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final grant = await enrollBuzzPush(config.wsUrl, Env.pushGatewayUrl); + if (state.authority == BuzzPushLeaseSubscriptionAuthority.accepted && + acceptedFingerprint == desiredFingerprint && + state.acceptedGrantGeneration == grant.generation && + state.acceptedInstallationId == grant.installationId) { + return; + } + // Relay lease replacement and gateway delegation are independent state + // machines. Subscription changes advance only the kind-30350 generation; + // the opaque grant remains reusable until its own authority changes. + final leaseGeneration = (state.acceptedGeneration ?? 0) + 1; + + await publishBuzzDevPushLeaseThroughRelay( + grant: grant, + leaseGeneration: leaseGeneration, + descriptor: descriptor, + nsec: config.nsec!, + memberPubkey: memberPubkey, + subscriptions: desired, + relay: relay, + ); + await ref + .read(communityListProvider.notifier) + .markPushLeaseAccepted( + community.id, + subscriptions: desired, + generation: leaseGeneration, + grantGeneration: grant.generation, + installationId: grant.installationId, + ); + } +} diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart new file mode 100644 index 00000000000..c91c24ed871 --- /dev/null +++ b/mobile/lib/shared/push/push_bridge.dart @@ -0,0 +1,245 @@ +import 'package:nostr/nostr.dart' as nostr; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../community/community.dart'; +import '../deeplink/deep_link.dart'; +import '../relay/relay_provider.dart'; +import 'push_snapshot.dart'; + +const _channel = MethodChannel('buzz/push'); + +/// Latest APNs registration state, including callbacks replayed by iOS after +/// the Flutter method channel attaches. +final apnsDeviceToken = ValueNotifier(null); +final apnsRegistrationError = ValueNotifier(null); +final pushAuthorizationGranted = ValueNotifier(null); + +final pushEndpointGrants = ValueNotifier>([]); +final pushEndpointGrantError = ValueNotifier(null); + +/// The most recent notification response waiting for app navigation. +/// +/// Native iOS buffers cold-start responses until Dart asks for them. This +/// notifier also carries warm responses into the existing deep-link pipeline. +final pendingPushNotificationLink = ValueNotifier(null); + +final _pushEventIdPattern = RegExp(r'^[0-9a-f]{64}$'); +final _pushChannelIdPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', +); + +MessageDeepLink? _pushNotificationLink(Object? arguments) { + if (arguments is! Map) return null; + final eventId = arguments['eventId']; + final communityId = arguments['communityId']; + final channelId = arguments['channelId']; + final normalizedEventId = eventId is String ? eventId.toLowerCase() : ''; + final normalizedChannelId = channelId is String + ? channelId.toLowerCase() + : ''; + if (eventId is! String || + !_pushEventIdPattern.hasMatch(normalizedEventId) || + communityId is! String || + communityId.isEmpty || + channelId is! String || + !_pushChannelIdPattern.hasMatch(normalizedChannelId)) { + return null; + } + return MessageDeepLink( + communityId: communityId, + channelId: normalizedChannelId, + messageId: normalizedEventId, + ); +} + +/// Pulls a notification response that arrived before the Flutter method +/// handler was installed. +Future syncPendingBuzzPushNotificationResponse() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final arguments = await _channel.invokeMapMethod( + 'takePendingNotificationResponse', + ); + final link = _pushNotificationLink(arguments); + if (link != null) pendingPushNotificationLink.value = link; + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +Future requestBuzzPushAuthorization() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return false; + try { + final granted = + await _channel.invokeMethod('requestAuthorization') ?? false; + pushAuthorizationGranted.value = granted; + return granted; + } on MissingPluginException { + pushAuthorizationGranted.value = false; + return false; + } +} + +class BuzzPushEndpointGrant { + final String relayOrigin; + final String relayPubkey; + final String installationId; + final String endpointGrant; + final String endpointHash; + final String appProfile; + final int endpointEpoch; + final int generation; + final int expiresAt; + + const BuzzPushEndpointGrant({ + required this.relayOrigin, + required this.relayPubkey, + required this.installationId, + required this.endpointGrant, + required this.endpointHash, + required this.appProfile, + required this.endpointEpoch, + required this.generation, + required this.expiresAt, + }); + + factory BuzzPushEndpointGrant.fromMap(Map map) { + final generation = map['generation'] as int; + return BuzzPushEndpointGrant( + relayOrigin: map['relayOrigin'] as String, + relayPubkey: map['relayPubkey'] as String, + installationId: map['installationId'] as String, + endpointGrant: map['endpointGrant'] as String, + endpointHash: map['endpointHash'] as String, + appProfile: map['appProfile'] as String, + endpointEpoch: map['endpointEpoch'] as int, + generation: generation, + expiresAt: map['expiresAt'] as int, + ); + } +} + +Future> readBuzzPushEndpointGrants() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return const []; + try { + final raw = await _channel.invokeListMethod('endpointGrants'); + final grants = [ + for (final value in raw ?? const []) + BuzzPushEndpointGrant.fromMap(value as Map), + ]; + pushEndpointGrants.value = grants; + pushEndpointGrantError.value = null; + return grants; + } catch (error) { + pushEndpointGrantError.value = error.toString(); + rethrow; + } +} + +Future enrollBuzzPush( + String relayUrl, + String gatewayUrl, +) async { + final raw = await _channel.invokeMapMethod('enrollPush', { + 'relayUrl': relayUrl, + 'gatewayUrl': gatewayUrl, + }); + if (raw == null) { + throw StateError('Native push enrollment returned no grant.'); + } + final grant = BuzzPushEndpointGrant.fromMap(raw); + await readBuzzPushEndpointGrants(); + return grant; +} + +/// Latest failure to export the community snapshot used by the iOS +/// notification service extension. Snapshot export is push enrichment and must +/// never gate authentication or community persistence. +final pushCommunitySnapshotError = ValueNotifier(null); +final pushLeaseCleanupError = ValueNotifier(null); + +void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) { + pushCommunitySnapshotError.value = error.toString(); + debugPrint('Push community snapshot export failed: $error'); + debugPrintStack(stackTrace: stackTrace); +} + +void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) { + pushLeaseCleanupError.value = error.toString(); + debugPrint( + 'Push lease cleanup failed; relay expiry remains the fallback: $error', + ); + debugPrintStack(stackTrace: stackTrace); +} + +Future registerBuzzPushCommunitySnapshot( + List communities, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final snapshots = [ + for (final community in communities) + BuzzPushCommunitySnapshot( + id: community.id, + name: community.name, + relayUrl: community.relayUrl, + pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + pushSubscriptionState: community.pushSubscriptionState, + ), + ]; + final signingKeys = {}; + for (final community in communities) { + final nsec = community.nsec; + if (nsec == null || nsec.isEmpty) continue; + try { + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || + decoded.data.length != 64) { + continue; + } + signingKeys[community.id] = decoded.data; + } catch (_) { + // Native storage is fail-closed; malformed keys are never exported. + } + } + await _channel.invokeMethod('saveCommunitySnapshot', { + 'communities': [for (final snapshot in snapshots) snapshot.toJson()], + 'signingKeys': signingKeys, + }); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +void installBuzzPushMethodHandler() { + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'apnsTokenChanged': + final args = call.arguments; + if (args is Map) { + final token = args['token']; + if (token is String && token.isNotEmpty) { + apnsDeviceToken.value = token; + apnsRegistrationError.value = null; + } + } + return null; + case 'apnsRegistrationFailed': + final args = call.arguments; + final message = args is Map ? args['message'] : null; + apnsRegistrationError.value = message is String && message.isNotEmpty + ? message + : 'APNs registration failed'; + debugPrint('APNs registration failed: ${apnsRegistrationError.value}'); + return null; + case 'notificationOpened': + final link = _pushNotificationLink(call.arguments); + if (link == null) return 'ignored'; + pendingPushNotificationLink.value = link; + return 'handled'; + default: + throw MissingPluginException('Unknown buzz/push method ${call.method}'); + } + }); +} diff --git a/mobile/lib/shared/push/push_capability.dart b/mobile/lib/shared/push/push_capability.dart new file mode 100644 index 00000000000..55aafb85165 --- /dev/null +++ b/mobile/lib/shared/push/push_capability.dart @@ -0,0 +1,6 @@ +/// Compile-time iOS push capability. It is false for every normal build and is +/// injected as a Dart define only by the tracked PushEnabled.xcconfig overlay. +const buzzPushCapabilityEnabled = bool.fromEnvironment( + 'BUZZ_PUSH_ENABLED', + defaultValue: false, +); diff --git a/mobile/lib/shared/push/push_snapshot.dart b/mobile/lib/shared/push/push_snapshot.dart new file mode 100644 index 00000000000..2760d26ae86 --- /dev/null +++ b/mobile/lib/shared/push/push_snapshot.dart @@ -0,0 +1,38 @@ +import 'push_subscription.dart'; + +/// The minimum community state shared with the iOS notification extension. +class BuzzPushCommunitySnapshot { + final String id; + final String name; + final String relayUrl; + final String? pubkey; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; + + const BuzzPushCommunitySnapshot({ + required this.id, + required this.name, + required this.relayUrl, + this.pubkey, + required this.pushSubscriptionState, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'relayUrl': relayUrl, + if (pubkey != null) 'pubkey': pubkey, + 'pushSubscriptionState': pushSubscriptionState.toJson(), + }; + + factory BuzzPushCommunitySnapshot.fromJson(Map json) { + return BuzzPushCommunitySnapshot( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), + ); + } +} diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart new file mode 100644 index 00000000000..acd2e96e0ae --- /dev/null +++ b/mobile/lib/shared/push/push_subscription.dart @@ -0,0 +1,479 @@ +import 'dart:convert'; + +/// User-visible Buzz message kinds. This mirrors +/// `EventKind.channelMessageEventKinds` without importing feature code into +/// the shared push layer. +const buzzPushEligibleKinds = [9, 40002, 45001, 45003]; +const buzzPushSelfDirectedKinds = buzzPushEligibleKinds; +const buzzPushRenderableKinds = buzzPushEligibleKinds; +const buzzPushChannelKinds = [9]; +const buzzPushChannelChunkSize = 50; +const buzzPushMaxSubscriptions = 16; +const buzzPushMaxIgnoreFilters = 8; +const buzzPushHellthreadParticipantLimit = 20; + +const _supportedNotificationClasses = {'silent', 'default', 'time_sensitive'}; +const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; +final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); +final _channelIdPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', +); + +enum BuzzPushLeaseSubscriptionAuthority { desired, accepted } + +class BuzzPushFilter { + final List kinds; + final List? authors; + final List? pTags; + final List? hTags; + final List? eTags; + + BuzzPushFilter({ + required Iterable kinds, + Iterable? authors, + Iterable? pTags, + Iterable? hTags, + Iterable? eTags, + }) : kinds = List.unmodifiable(kinds), + authors = _optionalList(authors), + pTags = _optionalList(pTags), + hTags = _optionalList(hTags), + eTags = _optionalList(eTags) { + _validate(); + } + + Map toJson() => { + 'kinds': kinds, + if (authors != null) 'authors': authors, + if (pTags != null) '#p': pTags, + if (hTags != null) '#h': hTags, + if (eTags != null) '#e': eTags, + }; + + factory BuzzPushFilter.fromJson(Map json) { + _rejectUnknownKeys(json, _filterKeys, 'push filter'); + return BuzzPushFilter( + kinds: _intList(json, 'kinds'), + authors: _optionalStringList(json, 'authors'), + pTags: _optionalStringList(json, '#p'), + hTags: _optionalStringList(json, '#h'), + eTags: _optionalStringList(json, '#e'), + ); + } + + void _validate() { + if (kinds.isEmpty || + kinds.any((kind) => !buzzPushEligibleKinds.contains(kind))) { + throw const FormatException('Push filter contains invalid kinds.'); + } + for (final value in [...?authors, ...?pTags, ...?eTags]) { + if (!_exactHexPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains a non-exact hex value.', + ); + } + } + for (final value in hTags ?? const []) { + if (!_channelIdPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains an invalid channel ID.', + ); + } + } + } +} + +class BuzzPushSuppression { + final int pTagsMax; + + const BuzzPushSuppression({required this.pTagsMax}) : assert(pTagsMax > 0); + + Map toJson() => {'p_tags_max': pTagsMax}; + + factory BuzzPushSuppression.fromJson(Map json) { + _rejectUnknownKeys(json, const {'p_tags_max'}, 'push suppression'); + final value = json['p_tags_max']; + if (value is! int || value <= 0) { + throw const FormatException('p_tags_max must be a positive integer.'); + } + return BuzzPushSuppression(pTagsMax: value); + } +} + +class BuzzPushSubscription { + final BuzzPushFilter filter; + final String notificationClass; + final List ignore; + final BuzzPushSuppression? suppress; + + BuzzPushSubscription({ + required this.filter, + required this.notificationClass, + Iterable ignore = const [], + this.suppress, + }) : ignore = List.unmodifiable(ignore) { + if (!_supportedNotificationClasses.contains(notificationClass)) { + throw const FormatException('Unsupported push notification class.'); + } + if (filter.authors == null && + filter.pTags == null && + filter.hTags == null) { + throw const FormatException('Push subscription filter is not narrowed.'); + } + if (this.ignore.length > buzzPushMaxIgnoreFilters) { + throw const FormatException( + 'Push subscription has too many ignore filters.', + ); + } + } + + Map toJson() => { + 'filter': filter.toJson(), + 'class': notificationClass, + if (ignore.isNotEmpty) + 'ignore': [for (final filter in ignore) filter.toJson()], + if (suppress != null) 'suppress': suppress!.toJson(), + }; + + factory BuzzPushSubscription.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'filter', + 'class', + 'ignore', + 'suppress', + }, 'push subscription'); + final filter = json['filter']; + final notificationClass = json['class']; + final ignore = json['ignore']; + final suppress = json['suppress']; + if (filter is! Map || notificationClass is! String) { + throw const FormatException('Malformed push subscription.'); + } + if (ignore != null && ignore is! List) { + throw const FormatException('Push subscription ignore must be a list.'); + } + if (suppress != null && suppress is! Map) { + throw const FormatException( + 'Push subscription suppress must be an object.', + ); + } + return BuzzPushSubscription( + filter: BuzzPushFilter.fromJson(Map.from(filter)), + notificationClass: notificationClass, + ignore: [ + for (final raw in ignore as List? ?? const []) + if (raw is Map) + BuzzPushFilter.fromJson(Map.from(raw)) + else + throw const FormatException('Malformed push ignore filter.'), + ], + suppress: suppress == null + ? null + : BuzzPushSuppression.fromJson(Map.from(suppress)), + ); + } +} + +/// Desired and relay-accepted lease subscriptions are intentionally separate. +/// Keeping both sets makes relay rejection or expiry detectable instead of +/// assuming the desired lease was accepted unchanged. +class BuzzPushLeaseSubscriptionState { + final BuzzPushLeaseSubscriptionAuthority authority; + final List desired; + final List? accepted; + + /// Monotonic generation of the relay-facing kind-30350 lease. + final int? acceptedGeneration; + + /// Generation sealed into the gateway's opaque relay delegation grant. + final int? acceptedGrantGeneration; + final String? acceptedInstallationId; + + const BuzzPushLeaseSubscriptionState.desired({ + this.desired = const [], + this.accepted, + this.acceptedGeneration, + this.acceptedGrantGeneration, + this.acceptedInstallationId, + }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; + + BuzzPushLeaseSubscriptionState.accepted({ + required Iterable desired, + required Iterable acceptedSubscriptions, + required this.acceptedGeneration, + required this.acceptedGrantGeneration, + required this.acceptedInstallationId, + }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, + desired = List.unmodifiable(desired), + accepted = List.unmodifiable(acceptedSubscriptions) { + if (acceptedGeneration == null || acceptedGeneration! <= 0) { + throw const FormatException( + 'Accepted push authority requires a positive lease generation.', + ); + } + if (acceptedGrantGeneration == null || acceptedGrantGeneration! <= 0) { + throw const FormatException( + 'Accepted push authority requires a positive gateway grant generation.', + ); + } + if (acceptedInstallationId == null || acceptedInstallationId!.isEmpty) { + throw const FormatException( + 'Accepted push authority requires an installation ID.', + ); + } + } + + List get authoritative => switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => desired, + BuzzPushLeaseSubscriptionAuthority.accepted => accepted!, + }; + + BuzzPushLeaseSubscriptionState withDesired( + Iterable subscriptions, + ) { + final updated = List.unmodifiable(subscriptions); + return switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => + BuzzPushLeaseSubscriptionState.desired( + desired: updated, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + acceptedGrantGeneration: acceptedGrantGeneration, + acceptedInstallationId: acceptedInstallationId, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: updated, + acceptedSubscriptions: accepted!, + acceptedGeneration: acceptedGeneration, + acceptedGrantGeneration: acceptedGrantGeneration, + acceptedInstallationId: acceptedInstallationId, + ), + }; + } + + BuzzPushLeaseSubscriptionState withAccepted({ + required Iterable subscriptions, + required int generation, + required int grantGeneration, + required String installationId, + }) => BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: subscriptions, + acceptedGeneration: generation, + acceptedGrantGeneration: grantGeneration, + acceptedInstallationId: installationId, + ); + + Map toJson() => { + 'authority': authority.name, + 'desired': [for (final subscription in desired) subscription.toJson()], + if (accepted != null) + 'accepted': [for (final subscription in accepted!) subscription.toJson()], + if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, + if (acceptedGrantGeneration != null) + 'acceptedGrantGeneration': acceptedGrantGeneration, + if (acceptedInstallationId != null) + 'acceptedInstallationId': acceptedInstallationId, + }; + + factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'authority', + 'desired', + 'accepted', + 'acceptedGeneration', + 'acceptedGrantGeneration', + 'acceptedInstallationId', + }, 'push subscription state'); + final authority = json['authority']; + final desired = _subscriptionList( + json['desired'], + 'desired', + allowEmpty: authority == 'desired', + ); + final acceptedRaw = json['accepted']; + final accepted = acceptedRaw == null + ? null + : _subscriptionList(acceptedRaw, 'accepted'); + final acceptedGeneration = json['acceptedGeneration']; + // Pre-MVP snapshots coupled relay and gateway generations; interpreting + // that single value as both preserves them until the next publication. + final acceptedGrantGeneration = + json['acceptedGrantGeneration'] ?? acceptedGeneration; + final acceptedInstallationId = json['acceptedInstallationId']; + if (acceptedGeneration != null && acceptedGeneration is! int) { + throw const FormatException( + 'Accepted push lease generation must be an integer.', + ); + } + if (acceptedGrantGeneration != null && acceptedGrantGeneration is! int) { + throw const FormatException( + 'Accepted gateway grant generation must be an integer.', + ); + } + if (acceptedInstallationId != null && acceptedInstallationId is! String) { + throw const FormatException( + 'Accepted push installation ID must be a string.', + ); + } + return switch (authority) { + 'desired' => BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration as int?, + acceptedGrantGeneration: acceptedGrantGeneration as int?, + acceptedInstallationId: acceptedInstallationId as String?, + ), + 'accepted' + when accepted != null && + acceptedGeneration is int && + acceptedGrantGeneration is int && + acceptedInstallationId is String => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted, + acceptedGeneration: acceptedGeneration, + acceptedGrantGeneration: acceptedGrantGeneration, + acceptedInstallationId: acceptedInstallationId, + ), + 'accepted' => throw const FormatException( + 'Accepted push authority requires accepted subscriptions and generations.', + ), + _ => throw const FormatException('Unknown push subscription authority.'), + }; + } +} + +List buildDesiredBuzzPushSubscriptions({ + required String myPubkey, + Iterable channelIds = const [], + Iterable mutedChannelIds = const [], +}) { + final normalizedPubkey = myPubkey.toLowerCase(); + if (!_exactHexPattern.hasMatch(normalizedPubkey)) { + throw const FormatException('Push subscription pubkey must be exact hex.'); + } + + final normalizedChannelIds = channelIds.map(_normalizeChannelID).toSet(); + final normalizedMuted = mutedChannelIds.map(_normalizeChannelID).toSet(); + final activeMuted = + normalizedMuted.intersection(normalizedChannelIds).toList()..sort(); + final mutedIgnoreFilters = []; + for (final chunk in _chunks(activeMuted, buzzPushChannelChunkSize)) { + mutedIgnoreFilters.add( + BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + ); + } + if (mutedIgnoreFilters.length + 1 > buzzPushMaxIgnoreFilters) { + throw const FormatException('Too many muted channels for a push lease.'); + } + + final selfAuthored = BuzzPushFilter( + kinds: buzzPushRenderableKinds, + authors: [normalizedPubkey], + ); + final ignores = [selfAuthored, ...mutedIgnoreFilters]; + const suppression = BuzzPushSuppression( + pTagsMax: buzzPushHellthreadParticipantLimit, + ); + final subscriptions = [ + BuzzPushSubscription( + filter: BuzzPushFilter( + kinds: buzzPushSelfDirectedKinds, + pTags: [normalizedPubkey], + ), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ]; + + final channels = normalizedChannelIds.difference(normalizedMuted).toList() + ..sort(); + for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) { + subscriptions.add( + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ); + } + if (subscriptions.length > buzzPushMaxSubscriptions) { + throw const FormatException('Too many channels for a push lease.'); + } + return List.unmodifiable(subscriptions); +} + +String buzzPushSubscriptionsFingerprint( + List subscriptions, +) => jsonEncode([ + for (final subscription in subscriptions) subscription.toJson(), +]); + +String buzzPushSubscriptionStateFingerprint( + BuzzPushLeaseSubscriptionState state, +) => jsonEncode(state.toJson()); + +List> _chunks(List values, int size) => [ + for (var offset = 0; offset < values.length; offset += size) + values.sublist(offset, (offset + size).clamp(0, values.length)), +]; + +String _normalizeChannelID(String value) { + final normalized = value.toLowerCase(); + if (!_channelIdPattern.hasMatch(normalized)) { + throw const FormatException('Push subscription channel ID is invalid.'); + } + return normalized; +} + +List? _optionalList(Iterable? values) => + values == null ? null : List.unmodifiable(values); + +List _intList(Map json, String key) { + final raw = json[key]; + if (raw is! List || raw.any((value) => value is! int)) { + throw FormatException('$key must be an integer list.'); + } + return raw.cast(); +} + +List? _optionalStringList(Map json, String key) { + if (!json.containsKey(key)) return null; + final raw = json[key]; + if (raw is! List || raw.isEmpty || raw.any((value) => value is! String)) { + throw FormatException('$key must be a non-empty string list.'); + } + return raw.cast(); +} + +List _subscriptionList( + Object? raw, + String label, { + bool allowEmpty = false, +}) { + if (raw is! List || (!allowEmpty && raw.isEmpty)) { + throw FormatException('$label subscriptions must be a non-empty list.'); + } + return [ + for (final item in raw) + if (item is Map) + BuzzPushSubscription.fromJson(Map.from(item)) + else + throw FormatException('Malformed $label subscription.'), + ]; +} + +void _rejectUnknownKeys( + Map json, + Set allowed, + String label, +) { + final unknown = json.keys.where((key) => !allowed.contains(key)); + if (unknown.isNotEmpty) { + throw FormatException('$label contains unknown field ${unknown.first}.'); + } +} diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart new file mode 100644 index 00000000000..653de293527 --- /dev/null +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../features/channels/channel.dart'; +import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; +import '../../features/channels/channels_provider.dart'; +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import 'push_subscription.dart'; + +/// Keeps the persisted desired lease and the App Group snapshot aligned with +/// the active identity, joined channels, and mute state. This is desired client +/// policy only. Relay-accepted authority is introduced by lease publication. +final pushSubscriptionSyncProvider = Provider((ref) { + final active = ref.watch(activeCommunityProvider).value; + final channels = ref.watch(channelsProvider).value; + final mutes = ref.watch(channelMutesProvider); + if (active == null || channels == null || !mutes.isReady) return; + + final subscriptions = desiredBuzzPushSubscriptions( + community: active, + channels: channels, + mutedChannelIds: [ + for (final entry in mutes.store.channels.entries) + if (entry.value.muted) entry.key, + ], + ); + if (subscriptions == null) return; + unawaited( + ref + .read(communityListProvider.notifier) + .updateDesiredPushSubscriptions(active.id, subscriptions), + ); +}); + +List? desiredBuzzPushSubscriptions({ + required Community community, + required Iterable channels, + required Iterable mutedChannelIds, +}) { + final pubkey = community.pubkey ?? pubkeyFromNsec(community.nsec); + if (pubkey == null || pubkey.isEmpty) return null; + return buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: [ + // Activity surfaces channel-wide traffic only for joined DM channels. + // Other message kinds enter the inbox through an exact #p mention or + // participant-thread tag, so subscribing to every joined channel would + // over-notify compared with the product predicate. + for (final channel in channels) + if (channel.isDm && channel.isMember && !channel.isArchived) channel.id, + ], + mutedChannelIds: mutedChannelIds, + ); +} diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 061dd6cb386..00fcf65b716 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -62,7 +62,8 @@ class RelayConfig { /// Compile-time environment config via --dart-define. /// /// Run with: -/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000 +/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000 \ +/// --dart-define=BUZZ_PUSH_GATEWAY_URL=http://localhost:8080 /// /// Or create a `.env.json` and use --dart-define-from-file=.env.json class Env { @@ -70,6 +71,10 @@ class Env { 'BUZZ_RELAY_URL', defaultValue: 'http://localhost:3000', ); + static const pushGatewayUrl = String.fromEnvironment( + 'BUZZ_PUSH_GATEWAY_URL', + defaultValue: 'https://push.buzz.xyz', + ); } class RelayConfigNotifier extends Notifier { diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index a739b765941..b0106639eb7 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:nostr/nostr.dart' as nostr; import 'nostr_models.dart'; import 'relay_session.dart'; +import 'relay_socket.dart'; /// Signs and submits Nostr events through the relay WebSocket connection. class SignedEventRelay { @@ -57,3 +60,73 @@ class SignedEventRelay { return _session.publish(nostrEvent); } } + +/// Publishes one signed event over a short-lived authenticated NIP-42 socket. +/// +/// This is used for community-removal tombstones because the community being +/// removed is not necessarily the app's active relay session. +Future submitSignedEventOnce({ + required String wsUrl, + required String nsec, + required int kind, + required String content, + required List> tags, + int? createdAt, + Duration timeout = const Duration(seconds: 12), +}) async { + final privateKey = nostr.Nip19.decode(payload: nsec).data; + if (privateKey.isEmpty) throw const FormatException('Invalid nsec'); + final signed = nostr.Event.from( + kind: kind, + content: content, + tags: tags, + secretKey: privateKey, + createdAt: createdAt, + verify: false, + ); + final event = NostrEvent.fromJson(signed.toMap()); + final result = Completer(); + late final RelaySocket socket; + socket = RelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: (message) { + if (message case [ + 'OK', + final String eventId, + final bool accepted, + final String detail, + ..., + ] when eventId == event.id) { + if (accepted) { + result.complete( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: detail, + sig: event.sig, + ), + ); + } else { + result.completeError(Exception('Relay rejected event: $detail')); + } + } + }, + onConnected: () => socket.send(['EVENT', event.toJson()]), + onDisconnected: (error) { + if (!result.isCompleted) { + result.completeError(error ?? Exception('Relay disconnected')); + } + }, + ); + final resultFuture = result.future.timeout(timeout); + try { + await socket.connect(); + return await resultFuture; + } finally { + await socket.disconnect(); + } +} diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index ea6bc226c21..649d005d81b 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -157,6 +157,54 @@ void main() { expect(destination.link, same(link)); }); + testWidgets('switches to the notification community before dispatch', ( + tester, + ) async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.save(_firstCommunity); + await storage.save(_notificationCommunity); + await storage.saveActiveId(_firstCommunity.id); + const link = MessageDeepLink( + communityId: 'community-2', + channelId: 'channel-1', + messageId: 'message-2', + ); + + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(await storage.loadActiveId(), _notificationCommunity.id); + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.link, link); + }); + testWidgets('retains invite and surfaces prepare failure', (tester) async { const link = InviteDeepLink( relayUrl: 'wss://relay.example.com', @@ -404,6 +452,20 @@ final _channel = Channel( isMember: true, ); +final _firstCommunity = Community( + id: 'community-1', + name: 'First', + relayUrl: 'wss://first.example', + addedAt: DateTime(2026), +); + +final _notificationCommunity = Community( + id: 'community-2', + name: 'Notification', + relayUrl: 'wss://notification.example', + addedAt: DateTime(2026), +); + class _CountingCommunityStorage extends CommunityStorage { int loadCalls = 0; diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index 7a1e6a66083..b078c6ecddc 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -5,6 +6,7 @@ import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; import '../community/community_storage_test.dart'; @@ -20,8 +22,16 @@ void main() { ); await storage.save(invalid); await storage.saveActiveId(invalid.id); + final snapshots = >[]; final container = ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(storage)], + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + ], ); addTearDown(container.dispose); @@ -30,9 +40,159 @@ void main() { expect(auth.status, AuthStatus.unauthenticated); expect(await storage.loadAll(), isEmpty); expect(await storage.loadActiveId(), isNull); + expect(snapshots.last, isEmpty); }, ); + test('authenticate exports the complete stored community snapshot', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final existing = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + final added = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(existing); + final snapshots = >[]; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(added); + + expect(snapshots.last.map((community) => community.id), { + existing.id, + added.id, + }); + }); + + test( + 'sign out removes the active community from the shared snapshot', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final first = Community.create( + name: 'First', + relayUrl: 'https://first.example', + nsec: nostr.Keys.generate().nsec, + ); + final second = Community.create( + name: 'Second', + relayUrl: 'https://second.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(first); + await storage.save(second); + await storage.saveActiveId(first.id); + final snapshots = >[]; + final deactivatedCommunityIds = []; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseDeactivatorProvider.overrideWithValue(( + community, + ) async { + deactivatedCommunityIds.add(community.id); + }), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + + await container.read(authProvider.notifier).signOut(); + + expect( + snapshots.any((snapshot) { + return snapshot.length == 1 && snapshot.single.id == second.id; + }), + isTrue, + ); + expect( + snapshots.last.map((community) => community.id), + isNot(contains(first.id)), + ); + expect(deactivatedCommunityIds, [first.id]); + }, + ); + + test( + 'snapshot export failure does not gate startup authentication', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(community); + await storage.saveActiveId(community.id); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + final auth = await container.read(authProvider.future); + + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect(pushCommunitySnapshotError.value, contains('save_failed')); + }, + ); + + test('snapshot export failure does not gate direct authentication', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(community); + + final auth = await container.read(authProvider.future); + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect((await storage.loadAll()).single.id, community.id); + }); + test('falls through to the next valid saved community', () async { final storage = CommunityStorage(secure: FakeSecureStorage()); final invalid = Community.create( diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index c58e03c56ae..2f3a13f2a00 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:nostr/nostr.dart' as nostr; import 'community_storage_test.dart'; @@ -10,17 +13,31 @@ void main() { late FakeSecureStorage fakeSecure; late CommunityStorage communityStorage; late ProviderContainer container; + late List> snapshots; + late List deactivatedCommunityIds; setUp(() { fakeSecure = FakeSecureStorage(); communityStorage = CommunityStorage(secure: fakeSecure); + snapshots = []; + deactivatedCommunityIds = []; }); tearDown(() => container.dispose()); ProviderContainer createContainer() { return ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(communityStorage)], + overrides: [ + communityStorageProvider.overrideWithValue(communityStorage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseDeactivatorProvider.overrideWithValue(( + community, + ) async { + deactivatedCommunityIds.add(community.id); + }), + ], ); } @@ -29,6 +46,40 @@ void main() { container = createContainer(); final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(snapshots, [isEmpty]); + }); + + test('exports migrated communities on startup', () async { + final community = Community.create( + name: 'Migrated', + relayUrl: 'https://migrated.example.com', + nsec: nostr.Keys.generate().nsec, + ); + // Seed legacy storage to exercise the same migration path as an app + // upgrade. + fakeSecure['buzz_workspaces'] = jsonEncode([community.toJson()]); + + container = createContainer(); + await container.read(communityListProvider.future); + + expect(snapshots.single.single.id, community.id); + expect(fakeSecure['buzz_workspaces'], isNull); + }); + + test('skips an unchanged snapshot after provider invalidation', () async { + final community = Community.create( + name: 'Stored', + relayUrl: 'https://stored.example.com', + nsec: nostr.Keys.generate().nsec, + ); + await communityStorage.save(community); + container = createContainer(); + + await container.read(communityListProvider.future); + container.invalidate(communityListProvider); + await container.read(communityListProvider.future); + + expect(snapshots, hasLength(1)); }); test('addCommunity adds to list', () async { @@ -61,6 +112,7 @@ void main() { final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(deactivatedCommunityIds, [ws.id]); }); test('renameCommunity updates name', () async { diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index be14ef43178..2bb41ca24c8 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; /// In-memory fake that extends Fake to satisfy all FlutterSecureStorage /// interface methods, but implements the core read/write/delete with real @@ -117,6 +118,36 @@ void main() { expect(loaded.first.name, 'Test'); expect(loaded.first.relayUrl, 'https://relay.example.com'); expect(loaded.first.pubkey, 'abc123'); + expect( + loaded.first.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); + + test('round-trips desired push subscription state', () async { + final pubkey = 'a' * 64; + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: const ['123e4567-e89b-42d3-a456-426614174000'], + ); + final community = + Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + pubkey: pubkey, + ).copyWith( + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ), + ); + + await storage.save(community); + final loaded = (await storage.loadAll()).single; + + expect( + loaded.pushSubscriptionState.toJson(), + community.pushSubscriptionState.toJson(), + ); }); test('save updates existing community with same id', () async { diff --git a/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart new file mode 100644 index 00000000000..b13f42df6d2 --- /dev/null +++ b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart @@ -0,0 +1,49 @@ +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + setUp(() { + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + pendingPushNotificationLink.value = null; + }); + + tearDown(() { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + pendingPushNotificationLink.value = null; + }); + + test('parks and consumes a native notification message link', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(pendingDeepLinkProvider), isNull); + + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + await pumpEventQueue(); + + expect(container.read(pendingDeepLinkProvider), link); + container.read(pendingDeepLinkProvider.notifier).consume(); + expect(container.read(pendingDeepLinkProvider), isNull); + expect(pendingPushNotificationLink.value, isNull); + }); + + test('preserves a cold-start target present before provider build', () { + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(pendingDeepLinkProvider), link); + }); +} diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart new file mode 100644 index 00000000000..6da2dd2116a --- /dev/null +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -0,0 +1,436 @@ +import 'dart:convert'; + +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:buzz/shared/relay/signed_event_relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + final signer = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + final descriptor = _descriptor(relay.public); + final grant = _grant(relay.public); + final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000); + + test('publishes strict kind-30350 lease and waits for accepted OK', () async { + Map? submitted; + final publication = await publishBuzzDevPushLease( + grant: grant, + leaseGeneration: 7, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + notificationClass: 'default', + ), + ], + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submitted = { + 'kind': kind, + 'content': content, + 'tags': tags, + 'createdAt': createdAt, + }; + return const NostrEvent( + id: 'accepted-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(publication.eventId, 'accepted-id'); + expect(grant.relayOrigin, descriptor.origin); + expect(jsonDecode(publication.plaintext)['origin'], descriptor.origin); + expect(submitted!['kind'], buzzPushLeaseKind); + expect(submitted!['createdAt'], 1752620000); + expect(submitted!['tags'], [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ]); + final plaintext = nip44Decrypt( + getConversationKey(relay.secret, signer.public), + submitted!['content'] as String, + ); + expect(jsonDecode(plaintext), { + 'v': 1, + 'origin': 'wss://tenant.example:8443', + 'app_profile': 'buzz-ios-dogfood', + 'transport': 'apns', + 'endpoint': 'opaque-grant', + 'generation': 7, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [9], + '#p': [signer.public], + }, + 'class': 'default', + }, + ], + }); + }); + + test( + 'mutation control rejects relay OK false then accepts restored event', + () async { + final events = []; + final acknowledgements = >[]; + final session = RelaySessionNotifier(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + authProvider.overrideWith(() => _UnauthenticatedAuthNotifier()), + ], + ); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + addTearDown(container.dispose); + final socket = _MutatingRelaySocket( + events, + onEvent: (event) => event.kind == 40002 + ? (accepted: false, message: 'invalid: kind not push-eligible') + : (accepted: true, message: 'saved'), + onAcknowledgement: acknowledgements.add, + ); + session.debugAttachSocketForTest(socket); + final relayClient = SignedEventRelay(session: session, nsec: signer.nsec); + + final mutated = relayClient.submit( + kind: 40002, + content: 'mutated', + tags: const [], + createdAt: 1752620000, + ); + final rejection = expectLater( + mutated, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: kind not push-eligible'), + ), + ), + ); + await _deliverAcknowledgement(acknowledgements, session); + await rejection; + final acceptedFuture = relayClient.submit( + kind: buzzPushLeaseKind, + content: 'restored', + tags: [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ], + createdAt: 1752620001, + ); + await _deliverAcknowledgement(acknowledgements, session); + final accepted = await acceptedFuture; + + expect(accepted.content, 'saved'); + expect(events.map((event) => event.kind), [40002, buzzPushLeaseKind]); + expect(events.every((event) => event.pubkey == signer.public), isTrue); + for (final event in events) { + expect( + () => nostr.Event( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ), + returnsNormally, + ); + } + }, + ); + + test('strict plaintext validator accepts message kinds only', () { + final valid = { + 'v': 1, + 'origin': 'wss://tenant.example:8443', + 'app_profile': 'buzz-ios-dogfood', + 'transport': 'apns', + 'endpoint': 'opaque-grant', + 'generation': 1, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [7], + '#p': [signer.public], + }, + 'class': 'default', + }, + ], + }; + + expect( + () => validateBuzzPushLeasePlaintext(valid), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Push filter contains invalid kinds.', + ), + ), + ); + + ((valid['subscriptions'] as List).single['filter'] as Map)['kinds'] = [ + 9, + 40002, + 45001, + 45003, + ]; + expect(() => validateBuzzPushLeasePlaintext(valid), returnsNormally); + }); + + test('propagates relay rejection instead of accepting locally', () async { + await expectLater( + publishBuzzDevPushLease( + grant: grant, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + notificationClass: 'default', + ), + ], + now: () => now, + submit: + ({ + required kind, + required content, + required tags, + createdAt, + }) async => throw Exception('invalid: origin mismatch'), + ), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: origin mismatch'), + ), + ), + ); + }); + + test('publishes a minimal higher-generation inactive tombstone', () async { + Map? submitted; + final publication = await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: grant.installationId, + generation: 3, + nsec: signer.nsec, + memberPubkey: signer.public, + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submitted = { + 'kind': kind, + 'content': content, + 'tags': tags, + 'createdAt': createdAt, + }; + return const NostrEvent( + id: 'tombstone-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(publication.eventId, 'tombstone-id'); + expect(jsonDecode(publication.plaintext), { + 'v': 1, + 'origin': descriptor.origin, + 'generation': 3, + 'active': false, + }); + expect(submitted!['kind'], buzzPushLeaseKind); + expect(submitted!['tags'], [ + ['d', grant.installationId], + ['expiration', '1755212000'], + ['exec', descriptor.executorKeyId], + ]); + final plaintext = nip44Decrypt( + getConversationKey(relay.secret, signer.public), + submitted!['content'] as String, + ); + expect(jsonDecode(plaintext), jsonDecode(publication.plaintext)); + }); + + test('descriptor rejects canonical origin with a trailing slash', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['origin'] = + 'wss://tenant.example:8443/'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects urgent kinds outside push kinds', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['urgent_kinds'] = [7]; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unsupported h grammar', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = 'opaque'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unknown push fields', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['future'] = true; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); +} + +class _UnauthenticatedAuthNotifier extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class _MutatingRelaySocket extends RelaySocket { + final List events; + final ({bool accepted, String message}) Function(NostrEvent event) onEvent; + final void Function(List message) _onAcknowledgement; + + _MutatingRelaySocket( + this.events, { + required this.onEvent, + required void Function(List message) onAcknowledgement, + }) : _onAcknowledgement = onAcknowledgement, + super( + wsUrl: 'wss://tenant.example:8443', + nsec: null, + onMessage: _ignoreMessage, + onConnected: _ignoreConnected, + onDisconnected: _ignoreDisconnected, + ); + + @override + SocketState get state => SocketState.connected; + + @override + void send(List payload) { + if (payload case ['EVENT', final Map eventJson]) { + final event = NostrEvent.fromJson(eventJson); + events.add(event); + final response = onEvent(event); + _onAcknowledgement(['OK', event.id, response.accepted, response.message]); + } + } + + @override + Future disconnect() async {} + + @override + void dispose() {} +} + +void _ignoreConnected() {} +void _ignoreDisconnected(Object? _) {} +void _ignoreMessage(List _) {} + +Future _deliverAcknowledgement( + List> acknowledgements, + RelaySessionNotifier session, +) async { + while (acknowledgements.isEmpty) { + await Future.delayed(Duration.zero); + } + session.debugHandleMessage(acknowledgements.removeAt(0)); +} + +BuzzPushLeaseDescriptor _descriptor(String relayPubkey) => + BuzzPushLeaseDescriptor.fromRelayInformation(_descriptorJson(relayPubkey)); + +Map _descriptorJson(String relayPubkey) => { + 'supported_extensions': ['nip-er', 'nip-pl'], + 'push': { + 'origin': 'wss://tenant.example:8443', + 'keys': [ + {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, + ], + 'app_profiles': [ + {'id': 'buzz-ios-dogfood', 'transport': 'apns'}, + ], + 'push_kinds': [9, 40002, 45001, 45003], + 'urgent_kinds': [], + 'h_grammar': 'uuid-v4-lowercase', + 'class_support': { + 'apns': ['silent', 'default', 'time_sensitive'], + }, + 'limitation': { + 'max_lease_ttl': 2592000, + 'max_leases_per_pubkey': 16, + 'max_subscriptions_per_lease': 16, + 'max_kinds': 16, + 'max_authors': 20, + 'max_h': 50, + 'max_tag_values': 20, + 'max_ignore': 8, + 'max_content_len': 65536, + 'max_plaintext_len': 32768, + 'max_endpoint_len': 4096, + 'max_string_len': 512, + }, + }, +}; + +BuzzPushEndpointGrant _grant(String relayPubkey) => BuzzPushEndpointGrant( + relayOrigin: 'wss://tenant.example:8443', + relayPubkey: relayPubkey, + installationId: 'c' * 32, + endpointGrant: 'opaque-grant', + endpointHash: 'd' * 64, + appProfile: 'buzz-ios-dogfood', + endpointEpoch: 1, + generation: 1, + expiresAt: 1756212000, +); diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart new file mode 100644 index 00000000000..e9445e88cab --- /dev/null +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -0,0 +1,216 @@ +import 'dart:async'; + +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _channel = MethodChannel('buzz/push'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + apnsDeviceToken.value = null; + apnsRegistrationError.value = null; + pushAuthorizationGranted.value = null; + pushEndpointGrants.value = const []; + pushEndpointGrantError.value = null; + pendingPushNotificationLink.value = null; + installBuzzPushMethodHandler(); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + debugDefaultTargetPlatformOverride = null; + }); + + test('captures APNs token success and clears the previous error', () async { + apnsRegistrationError.value = 'old error'; + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('apnsTokenChanged', {'token': '01ab'}), + ), + (_) {}, + ); + expect(apnsDeviceToken.value, '01ab'); + expect(apnsRegistrationError.value, isNull); + }); + + test('records denied authorization so enrollment stays blocked', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'requestAuthorization'); + return false; + }); + + expect(await requestBuzzPushAuthorization(), isFalse); + expect(pushAuthorizationGranted.value, isFalse); + }); + + test('reads and exposes persisted endpoint grants on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'endpointGrants'); + return [_grantMap('opaque-grant')]; + }); + + final grants = await readBuzzPushEndpointGrants(); + + expect(grants, hasLength(1)); + expect(grants.single.relayOrigin, 'wss://relay.example'); + expect(grants.single.relayPubkey, 'a' * 64); + expect(grants.single.installationId, 'c' * 32); + expect(grants.single.endpointGrant, 'opaque-grant'); + expect(grants.single.endpointHash, 'b' * 64); + expect(grants.single.appProfile, 'buzz-ios-dogfood'); + expect(grants.single.endpointEpoch, 1); + expect(grants.single.generation, 1); + expect(grants.single.expiresAt, 1752624000); + expect(pushEndpointGrants.value.single.endpointGrant, 'opaque-grant'); + expect(pushEndpointGrantError.value, isNull); + }); + + test( + 'debug enrollment carries the configured relay and gateway URLs', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final methods = []; + final enrollmentArguments = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + methods.add(call.method); + if (call.method == 'enrollPush') { + enrollmentArguments.add(call.arguments); + return _grantMap('new-grant'); + } + if (call.method == 'endpointGrants') { + return [_grantMap('new-grant')]; + } + fail('Unexpected method ${call.method}'); + }); + + final firstGrant = await enrollBuzzPush( + 'wss://relay.example/', + 'https://gateway-one.example/', + ); + final secondGrant = await enrollBuzzPush( + 'wss://relay.example/', + 'https://gateway-two.example/', + ); + + expect(firstGrant.endpointGrant, 'new-grant'); + expect(secondGrant.endpointGrant, 'new-grant'); + expect(enrollmentArguments, [ + { + 'relayUrl': 'wss://relay.example/', + 'gatewayUrl': 'https://gateway-one.example/', + }, + { + 'relayUrl': 'wss://relay.example/', + 'gatewayUrl': 'https://gateway-two.example/', + }, + ]); + expect(methods, [ + 'enrollPush', + 'endpointGrants', + 'enrollPush', + 'endpointGrants', + ]); + expect(pushEndpointGrants.value.single.endpointGrant, 'new-grant'); + }, + ); + + test('development push gateway matches the compiled configuration', () { + const expectedGateway = String.fromEnvironment( + 'BUZZ_PUSH_GATEWAY_URL', + defaultValue: 'https://push.buzz.xyz', + ); + expect(Env.pushGatewayUrl, expectedGateway); + }); + + test('exposes APNs registration failure', () async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('apnsRegistrationFailed', {'message': 'denied'}), + ), + (_) {}, + ); + expect(apnsRegistrationError.value, 'denied'); + }); + + test('turns a warm notification response into a message link', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 'a' * 64, + 'communityId': 'community-id', + 'channelId': '123e4567-e89b-42d3-a456-426614174000', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'handled'); + expect( + pendingPushNotificationLink.value, + MessageDeepLink( + communityId: 'community-id', + channelId: '123e4567-e89b-42d3-a456-426614174000', + messageId: 'a' * 64, + ), + ); + }); + + test('pulls a cold-start notification response from native iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'takePendingNotificationResponse'); + return { + 'eventId': 'b' * 64, + 'communityId': 'community-id', + 'channelId': '123e4567-e89b-42d3-a456-426614174000', + }; + }); + + await syncPendingBuzzPushNotificationResponse(); + + expect( + pendingPushNotificationLink.value, + MessageDeepLink( + communityId: 'community-id', + channelId: '123e4567-e89b-42d3-a456-426614174000', + messageId: 'b' * 64, + ), + ); + }); +} + +Map _grantMap(String endpointGrant) => { + 'relayOrigin': 'wss://relay.example', + 'relayPubkey': 'a' * 64, + 'installationId': 'c' * 32, + 'endpointGrant': endpointGrant, + 'endpointHash': 'b' * 64, + 'appProfile': 'buzz-ios-dogfood', + 'endpointEpoch': 1, + 'generation': 1, + 'expiresAt': 1752624000, +}; diff --git a/mobile/test/shared/push/push_snapshot_test.dart b/mobile/test/shared/push/push_snapshot_test.dart new file mode 100644 index 00000000000..730edf8e3fc --- /dev/null +++ b/mobile/test/shared/push/push_snapshot_test.dart @@ -0,0 +1,28 @@ +import 'package:buzz/shared/push/push_snapshot.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('push community snapshot carries explicit subscription authority', () { + final subscription = buildDesiredBuzzPushSubscriptions( + myPubkey: 'a' * 64, + ).single; + final snapshot = BuzzPushCommunitySnapshot( + id: 'community', + name: 'Team', + relayUrl: 'https://relay.example.com', + pubkey: 'a' * 64, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ), + ); + + final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); + + expect(decoded.toJson(), snapshot.toJson()); + expect( + decoded.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); +} diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart new file mode 100644 index 00000000000..d7571065948 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -0,0 +1,66 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/push/push_subscription_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const activeID = '123e4567-e89b-42d3-a456-426614174000'; + const archivedID = '123e4567-e89b-42d3-a456-426614174001'; + const nonMemberID = '123e4567-e89b-42d3-a456-426614174002'; + + test( + 'derives desired subscriptions from nsec, membership, and mute state', + () { + final nsec = nostr.Keys.generate().nsec; + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + nsec: nsec, + ), + channels: [ + channel(activeID), + channel(archivedID, archived: true), + channel(nonMemberID, isMember: false), + ], + mutedChannelIds: const [activeID, archivedID], + ); + + expect(subscriptions, isNotNull); + expect(subscriptions, hasLength(1)); + expect(subscriptions!.single.filter.pTags, hasLength(1)); + expect(subscriptions.single.ignore, hasLength(2)); + expect(subscriptions.single.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.single.ignore.last.hTags, [activeID]); + }, + ); + + test('returns no desired subscriptions without a signing identity', () { + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + ), + channels: [channel(activeID)], + mutedChannelIds: const [], + ); + + expect(subscriptions, isNull); + }); +} + +Channel channel(String id, {bool isMember = true, bool archived = false}) => + Channel( + id: id, + name: id, + channelType: 'dm', + visibility: 'open', + description: '', + createdBy: 'author', + createdAt: DateTime(2026), + memberCount: 1, + isMember: isMember, + archivedAt: archived ? DateTime(2026) : null, + ); diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart new file mode 100644 index 00000000000..9c666a9e063 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; + +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final me = 'a' * 64; + const channelA = '123e4567-e89b-42d3-a456-426614174000'; + const channelB = '123e4567-e89b-42d3-a456-426614174001'; + + test( + 'desired subscription state round-trips with an accepted-state seam', + () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [channelB, channelA], + ); + final state = BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson( + jsonDecode(jsonEncode(state.toJson())) as Map, + ); + + expect(decoded.authority, BuzzPushLeaseSubscriptionAuthority.desired); + expect(decoded.accepted, isNull); + expect(decoded.toJson(), state.toJson()); + expect(decoded.authoritative, hasLength(2)); + expect(decoded.authoritative.last.filter.hTags, [channelA, channelB]); + }, + ); + + test('accepted authority requires observed accepted subscriptions', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + + expect( + () => BuzzPushLeaseSubscriptionState.fromJson({ + 'authority': 'accepted', + 'desired': [subscription.toJson()], + }), + throwsFormatException, + ); + }); + + test('tracks relay lease and gateway grant generations independently', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + final state = + BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted( + subscriptions: [subscription], + generation: 9, + grantGeneration: 3, + installationId: 'c' * 32, + ); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); + expect(decoded.acceptedGeneration, 9); + expect(decoded.acceptedGrantGeneration, 3); + + final migrated = BuzzPushLeaseSubscriptionState.fromJson({ + ...state.toJson()..remove('acceptedGrantGeneration'), + }); + expect(migrated.acceptedGeneration, 9); + expect(migrated.acceptedGrantGeneration, 9); + }); + + test('builds aligned self and unmuted channel subscriptions', () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me.toUpperCase(), + channelIds: [channelB, channelA], + mutedChannelIds: [channelB, '123e4567-e89b-42d3-a456-426614174099'], + ); + + expect(subscriptions, hasLength(2)); + expect(subscriptions.first.filter.kinds, buzzPushSelfDirectedKinds); + expect(subscriptions.first.filter.kinds, isNot(contains(7))); + expect(subscriptions.first.filter.pTags, [me]); + expect(subscriptions.last.filter.kinds, buzzPushChannelKinds); + expect(subscriptions.last.filter.hTags, [channelA]); + expect(subscriptions.first.ignore, hasLength(2)); + expect(subscriptions.first.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.first.ignore.last.hTags, [channelB]); + expect( + subscriptions.first.suppress?.pTagsMax, + buzzPushHellthreadParticipantLimit, + ); + }); + + test('chunks channel subscriptions to relay limits deterministically', () { + final channels = [ + for (var i = 0; i < 51; i++) + '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', + ]..shuffle(); + + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: channels, + ); + + expect(subscriptions, hasLength(3)); + expect(subscriptions[1].filter.hTags, hasLength(50)); + expect(subscriptions[2].filter.hTags, hasLength(1)); + expect( + subscriptions[1].filter.hTags, + orderedEquals([...subscriptions[1].filter.hTags!]..sort()), + ); + }); + + test('rejects malformed and unsupported subscription fields', () { + final valid = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + ).single.toJson(); + + expect( + () => buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: const ['not-a-channel'], + ), + throwsFormatException, + ); + + expect( + () => BuzzPushSubscription.fromJson({...valid, 'unexpected': true}), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [7], + '#p': [me], + }, + 'class': 'default', + }), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [9], + '#p': ['not-a-pubkey'], + }, + 'class': 'default', + }), + throwsFormatException, + ); + }); +} diff --git a/scripts/mobile-worktree-clean.sh b/scripts/mobile-worktree-clean.sh index a64f52a0d16..644737b7e44 100755 --- a/scripts/mobile-worktree-clean.sh +++ b/scripts/mobile-worktree-clean.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # Uninstalls stale worktree-suffixed Buzz debug builds from booted iOS -# simulators and connected Android devices/emulators. Production installs -# (com.buzz.buzzMobile / xyz.block.buzz.mobile, no suffix) are never touched: -# only identifiers with a worktree suffix appended after the production id -# are matched. Run `just mobile-clean` (or this script directly); pass -# --dry-run to list what would be removed without uninstalling. +# simulators and connected Android devices/emulators. Unsuffixed app installs +# (`xyz.block.buzz.dogfood.mobile` and `xyz.block.buzz.mobile`) are never +# touched. Only identifiers with a worktree suffix appended after the dogfood +# or production id are matched. Run `just mobile-clean` (or this script +# directly); pass --dry-run to list what would be removed without uninstalling. set -euo pipefail -ios_prefix="com.buzz.buzzMobile." +ios_prefix="xyz.block.buzz.dogfood.mobile." android_prefix="xyz.block.buzz.mobile." dry_run=0 diff --git a/scripts/mobile-worktree-overrides.sh b/scripts/mobile-worktree-overrides.sh index 176180db1e9..54bd325452a 100755 --- a/scripts/mobile-worktree-overrides.sh +++ b/scripts/mobile-worktree-overrides.sh @@ -70,7 +70,7 @@ case "$android_slug" in [0-9]*) android_slug="w_$android_slug" ;; esac -ios_bundle_id="com.buzz.buzzMobile.${ios_slug}" +ios_bundle_id="xyz.block.buzz.dogfood.mobile.${ios_slug}" android_suffix=".${android_slug}" cat > "$ios_overrides" < dict: + result = subprocess.run( + ["plutil", "-convert", "json", "-o", "-", str(path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "no error detail" + raise RuntimeError(f"plutil failed to parse {path}: {detail}") + if not result.stdout: + raise RuntimeError(f"plutil returned no JSON for {path}") + return json.loads(result.stdout) + + +def semantic_rows(project: dict) -> list[str]: + objects = project["objects"] + root = objects[project["rootObject"]] + rows = [] + for target_id in root["targets"]: + target = objects[target_id] + if target.get("isa") != "PBXNativeTarget": + continue + configuration_list = objects[target["buildConfigurationList"]] + for configuration_id in configuration_list["buildConfigurations"]: + configuration = objects[configuration_id] + settings = configuration.get("buildSettings", {}) + base_reference = configuration.get("baseConfigurationReference") + base_path = objects.get(base_reference, {}).get("path", "-") + rows.append( + " ".join( + [ + target.get("name", "-"), + configuration.get("name", "-"), + base_path, + settings.get("DEVELOPMENT_TEAM", "-"), + settings.get("CODE_SIGN_ENTITLEMENTS", "-"), + settings.get("PRODUCT_BUNDLE_IDENTIFIER", "-"), + ] + ) + ) + return sorted(rows) + + +def main() -> int: + path = Path(sys.argv[1]) if len(sys.argv) > 1 else PBXPROJ + try: + actual = semantic_rows(parse_project(path)) + except (KeyError, TypeError, json.JSONDecodeError, RuntimeError) as error: + print(f"FAIL: {error}", file=sys.stderr) + return 1 + + if actual != EXPECTED_ROWS: + print("FAIL: unexpected iOS target configuration semantics", file=sys.stderr) + print("expected:", *EXPECTED_ROWS, sep="\n", file=sys.stderr) + print("actual:", *actual, sep="\n", file=sys.stderr) + return 1 + + print("iOS target configuration semantics match") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index dccffd2ef2b..d3472410f69 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -64,7 +64,7 @@ out="$("$wt/scripts/mobile-worktree-overrides.sh")" ios="$wt/mobile/ios/Flutter/WorktreeOverrides.xcconfig" android="$wt/mobile/android/worktree.properties" [[ -f "$ios" && -f "$android" ]] || fail "worktree must write both override files" -grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \ && pass "iOS bundle identifier keys to the sanitized worktree directory name" \ || fail "iOS bundle identifier must key to the worktree dir, got: $(cat "$ios")" grep -q '^APP_DISPLAY_NAME = Buzz (Fix_Thing-2)$' "$ios" \ @@ -83,7 +83,7 @@ printf '%s' "$out" | grep -q 'Worktree Feature_Work-1' \ # ── Branch switch in the same worktree: identity stable, label follows ─────── git -C "$wt" checkout -q -b "another/branch-name" "$wt/scripts/mobile-worktree-overrides.sh" > /dev/null -grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \ && grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \ && pass "branch switch keeps the install identity stable (per worktree)" \ || fail "install identity must not change on branch switch" @@ -123,10 +123,17 @@ grep -q '^applicationIdSuffix=\.w_2fast$' "$wt2/mobile/android/worktree.properti # ── Tracked build files: overrides are debug-only, release stays production ── debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig" release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig" +push_xcconfig="$repo_root/mobile/ios/Flutter/PushEnabled.xcconfig" +pbxproj="$repo_root/mobile/ios/Runner.xcodeproj/project.pbxproj" +runner_entitlements="$repo_root/mobile/ios/Runner/Runner.entitlements" +runner_push_entitlements="$repo_root/mobile/ios/Runner/RunnerPush.entitlements" gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' "$debug_xcconfig" \ + && pass "Debug.xcconfig defaults to the dogfood bundle identifier" \ + || fail "Debug.xcconfig must default to xyz.block.buzz.dogfood.mobile" grep -q 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \ && pass "Debug.xcconfig includes WorktreeOverrides" \ || fail "Debug.xcconfig must include WorktreeOverrides.xcconfig" @@ -137,15 +144,377 @@ if [[ -n "$worktree_line" && -n "$app_line" && "$worktree_line" -lt "$app_line" else fail "Debug.xcconfig must include AppOverrides.xcconfig after WorktreeOverrides.xcconfig" fi +grep -q '^ios_prefix="xyz.block.buzz.dogfood.mobile\."$' "$clean_script" \ + && pass "cleanup targets the iOS dogfood worktree prefix" \ + || fail "cleanup must share the iOS dogfood prefix used by worktree overrides" + grep -q 'WorktreeOverrides' "$release_xcconfig" \ && fail "Release.xcconfig must not include WorktreeOverrides.xcconfig" \ || pass "Release.xcconfig does not include WorktreeOverrides" -grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile$' "$release_xcconfig" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.mobile$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production bundle identifier" \ - || fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = com.buzz.buzzMobile" + || fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = xyz.block.buzz.mobile" grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production display name" \ || fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz" + +# These checks assert declarations in the two tracked xcconfigs only. They do +# not prove resolved build settings. The later gitignored includes +# (WorktreeOverrides.xcconfig and AppOverrides.xcconfig) can override these +# declarations and are explicitly outside this tracked-source assertion. The +# value check and declaration census are complementary: xcconfig is last-wins, +# while the census deliberately flags even a harmless duplicate declaration so +# a human reviews the changed declaration surface. +assert_xcconfig_value() { + # $1: file, $2: anchored value regex, $3: pass/failure description + if grep -qE "$2" "$1"; then + pass "$3" + else + fail "$3" + fi +} + +assert_xcconfig_declaration_count() { + # $1: file, $2: key, $3: expected count, $4: configuration label + local file="$1" key="$2" expected="$3" label="$4" count + count=$(grep -cE "^[[:space:]]*$key([[:space:]]*\[[^]]*\])*[[:space:]]*=" "$file" || true) + if [[ "$count" -eq "$expected" ]]; then + if [[ "$expected" -eq 0 ]]; then + pass "$label $key has no tracked declaration sites" + elif [[ "$expected" -eq 1 ]]; then + pass "$label $key has one tracked declaration site" + else + pass "$label $key has $count tracked declaration sites" + fi + elif [[ "$expected" -eq 0 ]]; then + fail "$label $key has $count tracked declaration sites; expected zero" + elif [[ "$expected" -eq 1 ]]; then + fail "$label $key has $count tracked declaration sites; expected exactly one" + else + fail "$label $key has $count tracked declaration sites; expected $expected" + fi +} + +assert_single_xcconfig_declaration() { + # $1: file, $2: key, $3: configuration label + assert_xcconfig_declaration_count "$1" "$2" 1 "$3" +} + +for config in "$debug_xcconfig" "$release_xcconfig"; do + assert_xcconfig_value "$config" '^BUZZ_PUSH_ENABLED = NO$' \ + "$(basename "$config") defaults push capability off" + assert_xcconfig_value "$config" \ + '^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements$' \ + "$(basename "$config") uses the push-free Runner entitlements" + assert_xcconfig_value "$config" \ + '^EXCLUDED_SOURCE_FILE_NAMES = NotificationService\.appex PushNativeState\.swift PushEndpointGrantStore\.swift$' \ + "$(basename "$config") excludes native push sources and extension product" +done + +assert_xcconfig_value "$push_xcconfig" '^BUZZ_PUSH_ENABLED = YES$' \ + "PushEnabled explicitly enables the capability" +assert_xcconfig_value "$push_xcconfig" \ + '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' \ + "PushEnabled selects the internal dogfood bundle" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_IOS_PUSH_ENVIRONMENT = production$' \ + "PushEnabled uses production APNs transport for distribution" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_APP_ATTEST_ENVIRONMENT = production$' \ + "PushEnabled uses production App Attest" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \ + "PushEnabled derives the App Group from the dogfood bundle" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \ + "PushEnabled derives the Keychain access group from the dogfood bundle" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush\.entitlements$' \ + "PushEnabled selects push-capable Runner entitlements" +assert_xcconfig_value "$push_xcconfig" \ + '^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) BUZZ_PUSH_ENABLED$' \ + "PushEnabled compiles the native push bridge" +assert_xcconfig_value "$push_xcconfig" '^EXCLUDED_SOURCE_FILE_NAMES =$' \ + "PushEnabled restores the extension product and native push sources" +assert_xcconfig_value "$push_xcconfig" \ + '^DART_DEFINES = \$\(inherited\),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ==$' \ + "PushEnabled compiles the Dart push bootstrap" +assert_xcconfig_value "$release_xcconfig" \ + '^CODE_SIGN_STYLE = Automatic$' \ + "Release code signing style is declared as automatic" +assert_xcconfig_value "$release_xcconfig" \ + '^CODE_SIGN_IDENTITY = iPhone Developer$' \ + "Release code signing identity is declared as iPhone Developer" + +for key in BUNDLE_IDENTIFIER BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES; do + assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" + assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" +done + +for key in BUZZ_KEYCHAIN_ACCESS_GROUP BUZZ_IOS_PUSH_ENVIRONMENT BUZZ_APP_ATTEST_ENVIRONMENT BUZZ_APP_GROUP_IDENTIFIER; do + assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" + assert_xcconfig_declaration_count "$release_xcconfig" "$key" 0 "Release" + assert_single_xcconfig_declaration "$push_xcconfig" "$key" "PushEnabled" +done + +for key in CODE_SIGN_STYLE CODE_SIGN_IDENTITY; do + assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" + assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" +done + +assert_xcconfig_declaration_count \ + "$debug_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Debug" +assert_xcconfig_declaration_count \ + "$release_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Release" +assert_single_xcconfig_declaration "$debug_xcconfig" APP_DISPLAY_NAME "Debug" +assert_single_xcconfig_declaration "$release_xcconfig" APP_DISPLAY_NAME "Release" + +# SWIFT_ACTIVE_COMPILATION_CONDITIONS is checked separately from the closed +# identity census above. This is a tracked-source assertion only; resolved Xcode +# build settings are intentionally outside this Linux-compatible test. +assert_xcconfig_value "$debug_xcconfig" \ + '^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) DEBUG$' \ + "Debug Swift compilation conditions inherit DEBUG" + +debug_swift_condition_count=$(grep -cE \ + '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ + "$debug_xcconfig" || true) +if [[ "$debug_swift_condition_count" -eq 1 ]]; then + pass "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has one tracked declaration site" +else + fail "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has $debug_swift_condition_count tracked declaration sites; expected exactly one" +fi + +release_swift_condition_count=$(grep -cE \ + '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ + "$release_xcconfig" || true) +if [[ "$release_swift_condition_count" -eq 0 ]]; then + pass "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has no tracked declaration sites" +else + fail "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has $release_swift_condition_count tracked declaration sites; expected zero" +fi + +grep -q 'aps-environment' "$runner_entitlements" \ + && fail "push-free Runner entitlements must not contain aps-environment" \ + || pass "push-free Runner entitlements omit aps-environment" + +# Split the retired identifiers so the regression test does not match itself. +retired_bundle_id='com.buzz.buzz'"Mobile" +if git -C "$repo_root" grep -q -F "$retired_bundle_id"; then + fail "tracked files must not retain the retired iOS bundle identifier" +else + pass "tracked files do not retain the retired iOS bundle identifier" +fi +grep -q 'com.apple.developer.devicecheck.appattest-environment' "$runner_push_entitlements" \ + && pass "push-enabled Runner uses the App Attest entitlement key accepted by Apple" \ + || fail "push-enabled Runner must use com.apple.developer.devicecheck.appattest-environment" +retired_entitlement_key='com.apple.developer.app-attest.'"environment" +if grep -q "$retired_entitlement_key" "$runner_push_entitlements"; then + fail "push-enabled Runner must not retain the invalid App Attest entitlement key" +else + pass "push-enabled Runner omits the invalid App Attest entitlement key" +fi + +duplicate_pbx_object_ids=$(awk ' + # This bounded source-level smoke check recognizes the current two-tab + # object-key spellings. It is not a general OpenStep uniqueness check: + # measured exclusions include a comment before the key, a presentation + # comment spanning lines, and one-tab indentation (jb_b1/jb_b2/jb_b6). + # The macOS semantic check below owns their resolved build consequences. + function decomment(s, head, tailpart) { + while (match(s, /\/\*/)) { + head = substr(s, 1, RSTART - 1) + tailpart = substr(s, RSTART + 2) + if (!match(tailpart, /\*\//)) return head " " + s = head " " substr(tailpart, RSTART + RLENGTH) + } + return s + } + + /^\t\t/ { + line = decomment($0) + if (match(line, /^\t\t"?[[:alnum:]]+"?[[:space:]]*=/)) { + object_id = substr(line, RSTART, RLENGTH) + sub(/^\t\t"?/, "", object_id) + sub(/"?[[:space:]]*=$/, "", object_id) + if (++object_id_count[object_id] == 2) print object_id + } + } +' "$pbxproj" | sort) +if [[ -n "$duplicate_pbx_object_ids" ]]; then + fail "recognized iOS project object identifiers repeat: $(printf '%s\n' "$duplicate_pbx_object_ids" | paste -sd ' ' -)" +else + pass "recognized iOS project object identifiers do not repeat" +fi + +signing_map=$(awk ' + # PBX comments are separators, not text: strip them before parsing any + # object so a comment cannot hide a duplicate key from the ambiguity count. + function decomment(s, head, tailpart) { + while (match(s, /\/\*/)) { + head = substr(s, 1, RSTART - 1) + tailpart = substr(s, RSTART + 2) + if (!match(tailpart, /\*\//)) { return head " " } + s = head " " substr(tailpart, RSTART + RLENGTH) + } + return s + } + + FNR == 1 { pass++ } + + # Pass 1 indexes xcconfig paths and follows each PBXNativeTarget to its + # actual configuration-list object. Target names come from object fields, + # not presentation comments. + pass == 1 { + if (/isa[[:space:]]*=[[:space:]]*PBXFileReference/ && /\.xcconfig/) { + declaration = decomment($0) + if (match(declaration, /=[[:space:]]*\{[[:space:]]*isa[[:space:]]*=[[:space:]]*PBXFileReference[[:space:]]*;/)) { + declaration = substr(declaration, RSTART + RLENGTH) + } else { + declaration = "" + } + sub(/\}.*/, "", declaration) + xcconfig_path = "MISSING_PATH" + rest = declaration + path_matches = 0 + while (match(rest, /(^|;)[[:space:]]*path[[:space:]]*=[[:space:]]*[^;]+/)) { + candidate = substr(rest, RSTART, RLENGTH) + rest = substr(rest, RSTART + RLENGTH) + sub(/^;?[[:space:]]*path[[:space:]]*=[[:space:]]*/, "", candidate) + gsub(/"/, "", candidate) + sub(/[[:space:]]+$/, "", candidate) + path_matches++ + xcconfig_path = candidate + } + # More than one `path =` in one object means a decoy (a quoted value or + # an embedded comment) is shadowing the real key. Never guess which one + # the build uses: fail the row loudly instead. + if (path_matches > 1) xcconfig_path = "AMBIGUOUS_PATH" + xcconfig_paths[$1] = xcconfig_path + } + + if (/\/\* Begin PBXNativeTarget section \*\//) { + in_native_targets = 1 + next + } + if (/\/\* End PBXNativeTarget section \*\//) { + in_native_targets = 0 + next + } + if (!in_native_targets) next + + if (/^\t\t[^[:space:]]+ .* = \{$/) { + native_target_id = $1 + native_target_name = "" + native_target_list = "" + next + } + if (native_target_id != "" && /^\t\t\tname = /) { + native_target_name = $0 + sub(/^.*= */, "", native_target_name) + sub(/;.*/, "", native_target_name) + gsub(/"/, "", native_target_name) + next + } + if (native_target_id != "" && /^\t\t\tbuildConfigurationList = /) { + native_target_list = $3 + next + } + if (native_target_id != "" && /^\t\t\};/) { + if (native_target_list != "") { + if (native_target_name == "") native_target_name = "UNNAMED:" native_target_id + if (native_target_list in list_owners) { + list_owners[native_target_list] = "DUPLICATE:" list_owners[native_target_list] "+" native_target_name + } else { + list_owners[native_target_list] = native_target_name + } + } + native_target_id = "" + } + next + } + + # Pass 2 maps build-configuration object IDs through only those lists that + # real native targets own. PBXProject and other unowned lists are ignored. + pass == 2 { + if (/\/\* Begin XCConfigurationList section \*\//) { + in_configuration_lists = 1 + next + } + if (/\/\* End XCConfigurationList section \*\//) { + in_configuration_lists = 0 + next + } + if (!in_configuration_lists) next + + if (/^\t\t[^[:space:]]+ .* = \{$/) { + configuration_list_id = $1 + configuration_list_owner = configuration_list_id in list_owners ? list_owners[configuration_list_id] : "" + next + } + if (/buildConfigurations = \(/) { + in_list_configurations = 1 + next + } + if (in_list_configurations && /\);/) { + in_list_configurations = 0 + next + } + if (in_list_configurations && $1 ~ /^[[:alnum:]]+$/ && configuration_list_owner != "") { + if ($1 in targets) targets[$1] = "DUPLICATE:" targets[$1] "+" configuration_list_owner + else targets[$1] = configuration_list_owner + } + next + } + + # Pass 3 emits one row for each team-bearing build configuration. + !in_build_configuration && /\/\* (Debug|Release|Profile) \*\/ = \{/ { + in_build_configuration = 1 + configuration_id = $1 + configuration = $3 + base_configuration = "NONE" + team = "" + entitlements = "NONE" + depth = 0 + } + + in_build_configuration { + if (/baseConfigurationReference =/) { + base_configuration = $3 in xcconfig_paths ? xcconfig_paths[$3] : "UNRESOLVED:" $3 + } + if (/DEVELOPMENT_TEAM =/) { + team = $0 + sub(/^.*= */, "", team) + sub(/;.*/, "", team) + } + if (/CODE_SIGN_ENTITLEMENTS =/) { + entitlements = $0 + sub(/^.*= */, "", entitlements) + sub(/;.*/, "", entitlements) + } + + depth += gsub(/\{/, "{") - gsub(/\}/, "}") + if (depth == 0) { + if (team != "") { + target_name = configuration_id in targets ? targets[configuration_id] : "UNMAPPED:" configuration_id + print target_name, configuration, base_configuration, team, entitlements + } + in_build_configuration = 0 + } + } +' "$pbxproj" "$pbxproj" "$pbxproj" | sort) +expected_signing_map=$(printf '%s\n' \ + 'NotificationService Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ + 'NotificationService Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ + 'NotificationService Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ + 'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \ + 'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \ + 'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"') +if [[ "$signing_map" == "$expected_signing_map" ]]; then + pass "Runner and NotificationService signing settings match each build configuration" +else + fail "unexpected iOS signing map: $signing_map" +fi grep -q '$(APP_DISPLAY_NAME)' "$plist" \ && pass "Info.plist display name resolves from build settings" \ || fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)" From 6c049604742b43f94de40e3e9b29bd31b9d61e14 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 20 Aug 2026 13:48:18 -0700 Subject: [PATCH 02/27] fix(mobile): accept UUIDv5 push channel targets Signed-off-by: Tom Brow --- .../BuzzPushNavigationTarget.swift | 1 - .../BuzzPushNavigationTargetTests.swift | 31 ++++++++++- mobile/lib/shared/push/push_bridge.dart | 2 +- mobile/test/shared/push/push_bridge_test.dart | 54 ++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift index bde4a4494ca..926283a3b05 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -56,7 +56,6 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { let bytes = Array(value.utf8) guard bytes.count == 36, bytes[8] == 45, bytes[13] == 45, bytes[18] == 45, bytes[23] == 45, - bytes[14] == 52, [56, 57, 97, 98].contains(bytes[19]) else { return false } return bytes.enumerated().allSatisfy { index, byte in diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift index 7acd9f5c53c..4b9853e2c91 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -3,7 +3,7 @@ import Testing @testable import BuzzPushKit -@Test func `Round-trip navigation target through notification user info`() { +@Test func `Round-trip UUIDv4 navigation target through notification user info`() { let target = BuzzPushNavigationTarget( eventID: String(repeating: "A", count: 64), communityID: "community-id", @@ -19,7 +19,22 @@ import Testing #expect(target.channelID == "123e4567-e89b-42d3-a456-426614174000") } -@Test func `Reject incomplete navigation target`() { +@Test func `Decode UUIDv5 navigation target`() { + let target = BuzzPushNavigationTarget( + eventID: String(repeating: "B", count: 64), + communityID: "community-id", + channelID: "9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50" + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target + ) + #expect(target.channelID == "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50") +} + +@Test func `Reject incomplete or malformed navigation target`() { #expect( BuzzPushNavigationTarget.decodeIfPresent( from: [ @@ -42,6 +57,18 @@ import Testing ] ) == nil ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": String(repeating: "a", count: 64), + "community_id": "community-id", + "channel_id": "9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50", + ] + ] + ) == nil + ) } @Test func `Buffer preserves cold-start target until consumed`() { diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index c91c24ed871..3d5a818092c 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -26,7 +26,7 @@ final pendingPushNotificationLink = ValueNotifier(null); final _pushEventIdPattern = RegExp(r'^[0-9a-f]{64}$'); final _pushChannelIdPattern = RegExp( - r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', ); MessageDeepLink? _pushNotificationLink(Object? arguments) { diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index e9445e88cab..f59dd656603 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -150,7 +150,7 @@ void main() { expect(apnsRegistrationError.value, 'denied'); }); - test('turns a warm notification response into a message link', () async { + test('routes a warm notification response with a UUIDv4 channel', () async { final response = Completer(); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( @@ -178,6 +178,58 @@ void main() { ); }); + test('routes a warm notification response with a UUIDv5 channel', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 'b' * 64, + 'communityId': 'community-id', + 'channelId': '9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'handled'); + expect( + pendingPushNotificationLink.value, + MessageDeepLink( + communityId: 'community-id', + channelId: '9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50', + messageId: 'b' * 64, + ), + ); + }); + + test( + 'rejects a notification response with a malformed channel UUID', + () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 'c' * 64, + 'communityId': 'community-id', + 'channelId': '9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }, + ); + test('pulls a cold-start notification response from native iOS', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger From 8e7f5dc32b5396bafadcaf288a7d31925a0cde02 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 20 Aug 2026 13:53:29 -0700 Subject: [PATCH 03/27] fix(mobile): treat push target IDs as opaque Signed-off-by: Tom Brow --- .../BuzzPushNavigationTarget.swift | 26 ++------- .../BuzzPushNavigationTargetTests.swift | 35 ++++-------- mobile/lib/shared/push/push_bridge.dart | 17 ++---- mobile/test/shared/push/push_bridge_test.dart | 53 ++++++++++++------- 4 files changed, 51 insertions(+), 80 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift index 926283a3b05..9218655021a 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -10,9 +10,9 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { public let channelID: String public init(eventID: String, communityID: String, channelID: String) { - self.eventID = eventID.lowercased() + self.eventID = eventID self.communityID = communityID - self.channelID = channelID.lowercased() + self.channelID = channelID } public var userInfoValue: [String: String] { @@ -32,9 +32,9 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { let eventID = raw["event_id"] as? String, let communityID = raw["community_id"] as? String, let channelID = raw["channel_id"] as? String, + !eventID.isEmpty, !communityID.isEmpty, - isLowercaseHex64(eventID.lowercased()), - isChannelID(channelID.lowercased()) + !channelID.isEmpty else { return nil } @@ -45,24 +45,6 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { ) } - private static func isLowercaseHex64(_ value: String) -> Bool { - value.utf8.count == 64 - && value.utf8.allSatisfy { - ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) - } - } - - private static func isChannelID(_ value: String) -> Bool { - let bytes = Array(value.utf8) - guard bytes.count == 36, - bytes[8] == 45, bytes[13] == 45, bytes[18] == 45, bytes[23] == 45, - [56, 57, 97, 98].contains(bytes[19]) - else { return false } - return bytes.enumerated().allSatisfy { index, byte in - if [8, 13, 18, 23].contains(index) { return byte == 45 } - return (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) - } - } } /// Thread-safe one-item buffer spanning notification delivery and Flutter diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift index 4b9853e2c91..414e9446a2e 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -3,11 +3,11 @@ import Testing @testable import BuzzPushKit -@Test func `Round-trip UUIDv4 navigation target through notification user info`() { +@Test func `Round-trip opaque navigation target through notification user info`() { let target = BuzzPushNavigationTarget( - eventID: String(repeating: "A", count: 64), + eventID: "MESSAGE-ID", communityID: "community-id", - channelID: "123E4567-E89B-42D3-A456-426614174000" + channelID: "CHANNEL/GENERAL" ) #expect( @@ -15,23 +15,8 @@ import Testing from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] ) == target ) - #expect(target.eventID == String(repeating: "a", count: 64)) - #expect(target.channelID == "123e4567-e89b-42d3-a456-426614174000") -} - -@Test func `Decode UUIDv5 navigation target`() { - let target = BuzzPushNavigationTarget( - eventID: String(repeating: "B", count: 64), - communityID: "community-id", - channelID: "9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50" - ) - - #expect( - BuzzPushNavigationTarget.decodeIfPresent( - from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] - ) == target - ) - #expect(target.channelID == "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50") + #expect(target.eventID == "MESSAGE-ID") + #expect(target.channelID == "CHANNEL/GENERAL") } @Test func `Reject incomplete or malformed navigation target`() { @@ -39,7 +24,7 @@ import Testing BuzzPushNavigationTarget.decodeIfPresent( from: [ BuzzPushNavigationTarget.userInfoKey: [ - "event_id": "event-id", + "event_id": "message-id", "community_id": "community-id", ] ] @@ -50,9 +35,9 @@ import Testing BuzzPushNavigationTarget.decodeIfPresent( from: [ BuzzPushNavigationTarget.userInfoKey: [ - "event_id": String(repeating: "a", count: 64), + "event_id": "", "community_id": "community-id", - "channel_id": "not-a-channel", + "channel_id": "channel-id", ] ] ) == nil @@ -62,9 +47,9 @@ import Testing BuzzPushNavigationTarget.decodeIfPresent( from: [ BuzzPushNavigationTarget.userInfoKey: [ - "event_id": String(repeating: "a", count: 64), + "event_id": "message-id", "community_id": "community-id", - "channel_id": "9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50", + "channel_id": "", ] ] ) == nil diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 3d5a818092c..4e3fc6f887f 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -24,32 +24,23 @@ final pushEndpointGrantError = ValueNotifier(null); /// notifier also carries warm responses into the existing deep-link pipeline. final pendingPushNotificationLink = ValueNotifier(null); -final _pushEventIdPattern = RegExp(r'^[0-9a-f]{64}$'); -final _pushChannelIdPattern = RegExp( - r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', -); - MessageDeepLink? _pushNotificationLink(Object? arguments) { if (arguments is! Map) return null; final eventId = arguments['eventId']; final communityId = arguments['communityId']; final channelId = arguments['channelId']; - final normalizedEventId = eventId is String ? eventId.toLowerCase() : ''; - final normalizedChannelId = channelId is String - ? channelId.toLowerCase() - : ''; if (eventId is! String || - !_pushEventIdPattern.hasMatch(normalizedEventId) || + eventId.isEmpty || communityId is! String || communityId.isEmpty || channelId is! String || - !_pushChannelIdPattern.hasMatch(normalizedChannelId)) { + channelId.isEmpty) { return null; } return MessageDeepLink( communityId: communityId, - channelId: normalizedChannelId, - messageId: normalizedEventId, + channelId: channelId, + messageId: eventId, ); } diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index f59dd656603..730e011d6e5 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -150,16 +150,16 @@ void main() { expect(apnsRegistrationError.value, 'denied'); }); - test('routes a warm notification response with a UUIDv4 channel', () async { + test('routes a warm notification response with opaque IDs', () async { final response = Completer(); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( _channel.name, _channel.codec.encodeMethodCall( MethodCall('notificationOpened', { - 'eventId': 'a' * 64, + 'eventId': 'MESSAGE-ID', 'communityId': 'community-id', - 'channelId': '123e4567-e89b-42d3-a456-426614174000', + 'channelId': 'CHANNEL/GENERAL', }), ), response.complete, @@ -172,22 +172,22 @@ void main() { pendingPushNotificationLink.value, MessageDeepLink( communityId: 'community-id', - channelId: '123e4567-e89b-42d3-a456-426614174000', - messageId: 'a' * 64, + channelId: 'CHANNEL/GENERAL', + messageId: 'MESSAGE-ID', ), ); }); - test('routes a warm notification response with a UUIDv5 channel', () async { + test('rejects a notification response with an empty channel ID', () async { final response = Completer(); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( _channel.name, _channel.codec.encodeMethodCall( MethodCall('notificationOpened', { - 'eventId': 'b' * 64, + 'eventId': 'message-id', 'communityId': 'community-id', - 'channelId': '9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50', + 'channelId': '', }), ), response.complete, @@ -195,19 +195,12 @@ void main() { final envelope = await response.future; expect(envelope, isNotNull); - expect(_channel.codec.decodeEnvelope(envelope!), 'handled'); - expect( - pendingPushNotificationLink.value, - MessageDeepLink( - communityId: 'community-id', - channelId: '9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50', - messageId: 'b' * 64, - ), - ); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); }); test( - 'rejects a notification response with a malformed channel UUID', + 'rejects a notification response with a non-string message ID', () async { final response = Completer(); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -215,9 +208,9 @@ void main() { _channel.name, _channel.codec.encodeMethodCall( MethodCall('notificationOpened', { - 'eventId': 'c' * 64, + 'eventId': 42, 'communityId': 'community-id', - 'channelId': '9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50', + 'channelId': 'channel-id', }), ), response.complete, @@ -230,6 +223,26 @@ void main() { }, ); + test('rejects a notification response with an absent message ID', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'communityId': 'community-id', + 'channelId': 'channel-id', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }); + test('pulls a cold-start notification response from native iOS', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger From 0ba31e8e57f6d70196c33ddf5037197761025253 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 21 Aug 2026 21:09:38 -0700 Subject: [PATCH 04/27] feat(mobile): enrich push notification presentation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- docs/push-gateway-deployment.md | 5 + mobile/README.md | 16 + mobile/ios/BuzzPushKit/Package.swift | 5 +- .../BuzzCommunicationNotification.swift | 133 +++++ .../BuzzDevPushEnrollmentDriver.swift | 60 +- .../BuzzPushNotificationResolver.swift | 353 ++++++++++- .../BuzzPushPresentationCache.swift | 564 ++++++++++++++++++ .../Sources/BuzzPushKit/PushLease.swift | 4 + .../BuzzDevPushEnrollmentDriverTests.swift | 168 +++++- .../BuzzPushNotificationResolverTests.swift | 465 ++++++++++++++- .../BuzzPushPresentationCacheTests.swift | 426 +++++++++++++ mobile/ios/Flutter/Debug.xcconfig | 3 +- mobile/ios/Flutter/PushEnabled.xcconfig | 1 + mobile/ios/Flutter/Release.xcconfig | 3 +- .../NotificationService.swift | 36 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 16 +- mobile/ios/Runner/AppDelegate.swift | 40 +- .../Runner/PushPresentationCacheBridge.swift | 194 ++++++ mobile/ios/Runner/RunnerPush-Info.plist | 98 +++ mobile/ios/Runner/RunnerPush.entitlements | 2 + .../BuzzCommunicationNotificationTests.swift | 182 ++++++ .../features/channels/channels_provider.dart | 11 +- .../shared/profile/user_cache_provider.dart | 6 + mobile/lib/shared/push/push_bootstrap.dart | 7 +- mobile/lib/shared/push/push_bridge.dart | 17 +- .../shared/push/push_presentation_cache.dart | 168 ++++++ mobile/lib/shared/relay/media_image.dart | 9 + mobile/lib/shared/widgets/avatar_image.dart | 16 +- mobile/test/shared/push/push_bridge_test.dart | 34 ++ .../push/push_presentation_cache_test.dart | 80 +++ 30 files changed, 3062 insertions(+), 60 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift create mode 100644 mobile/ios/Runner/PushPresentationCacheBridge.swift create mode 100644 mobile/ios/Runner/RunnerPush-Info.plist create mode 100644 mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift create mode 100644 mobile/lib/shared/push/push_presentation_cache.dart create mode 100644 mobile/test/shared/push/push_presentation_cache_test.dart diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 08a09a289ba..8a2593c7773 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -150,6 +150,11 @@ profiles for both `xyz.block.buzz.dogfood.mobile` and `xyz.block.buzz.dogfood.mobile.NotificationService`; an app-only profile does not provision the extension. The App Store builder must continue omitting the overlay and extension profile until that rollout is separately approved. +Before enabling rich message presentation, enable Apple's Communication +Notifications capability on the parent dogfood App ID and regenerate its app +provisioning profile. The extension profile does not need that capability. +Apply the same parent-App-ID prerequisite to the eventual App Store rollout; +updating Block Apple portal records is a separately authorized release step. For each evaluation cohort, measure relay receipt-to-match, wake queue, relay-to- gateway, and gateway-to-APNs latencies from the histograms above. Track the diff --git a/mobile/README.md b/mobile/README.md index 5e393c6e05d..bfc796c74f8 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -112,6 +112,22 @@ artifact or enable the App Store profile in production. Validate dogfood APNs end to end by cutting an internal release, waiting for it to reach Mobile Releases/Comp Portal, and installing that signed artifact on a physical device. +Push-enabled parent app identifiers also require Apple's Communication +Notifications capability and a regenerated app provisioning profile. The +Notification Service Extension profile does not require that capability. +Enable it on the personal development App ID for local rich-presentation +validation. Enabling it on the Block dogfood and eventual App Store App IDs is +a release follow-up and is not performed by this repository change. Without a +matching parent profile, source and unit validation still work, but the +push-enabled app cannot be signed for a physical device. + +APNs and the gateway continue to carry only the constant opaque wake-up. The +extension fetches the message from the scoped relay, verifies message, sender +profile, and channel-metadata signatures, and uses a bounded App Group cache +for names and app-rendered avatar thumbnails. It never fetches an avatar URL; +missing, stale, or invalid enrichment falls back to the verified message with a +short sender pubkey, community subtitle, and no image. + ## Checks ```bash diff --git a/mobile/ios/BuzzPushKit/Package.swift b/mobile/ios/BuzzPushKit/Package.swift index ebfc644318e..5af3d0a6c46 100644 --- a/mobile/ios/BuzzPushKit/Package.swift +++ b/mobile/ios/BuzzPushKit/Package.swift @@ -17,7 +17,10 @@ let package = Package( ), .testTarget( name: "BuzzPushKitTests", - dependencies: ["BuzzPushKit"], + dependencies: [ + "BuzzPushKit", + .product(name: "P256K", package: "swift-secp256k1"), + ], resources: [.copy("Fixtures/app_attest_transcripts.json")] ), ] diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift new file mode 100644 index 00000000000..dac1a5ee563 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -0,0 +1,133 @@ +import Foundation + +/// Verified local values used to specialize an ordinary notification as communication. +public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { + public let senderDisplayName: String + public let senderIdentifier: String + public let senderAvatarPNG: Data? + public let messageBody: String + public let conversationIdentifier: String + public let conversationDisplayName: String? + + public init( + senderDisplayName: String, + senderIdentifier: String, + senderAvatarPNG: Data?, + messageBody: String, + conversationIdentifier: String, + conversationDisplayName: String? + ) { + self.senderDisplayName = senderDisplayName + self.senderIdentifier = senderIdentifier + self.senderAvatarPNG = senderAvatarPNG + self.messageBody = messageBody + self.conversationIdentifier = conversationIdentifier + self.conversationDisplayName = conversationDisplayName + } + + public init?(resolution: BuzzPushResolution) { + guard let target = resolution.navigationTarget, + let senderPubkey = resolution.senderPubkey, + !senderPubkey.isEmpty, + let conversationIdentifier = resolution.conversationIdentifier, + !conversationIdentifier.isEmpty + else { return nil } + self.init( + senderDisplayName: resolution.title, + senderIdentifier: BuzzPushPresentationIdentity.sender( + communityID: target.communityID, + pubkey: senderPubkey + ), + senderAvatarPNG: resolution.senderAvatarPNG, + messageBody: resolution.body, + conversationIdentifier: conversationIdentifier, + conversationDisplayName: resolution.conversationDisplayName + ) + } +} + +#if os(iOS) + import Intents + import UserNotifications + + /// Donates and applies Apple's supported Communication Notifications intent. + public final class BuzzCommunicationNotificationPresenter { + public typealias Donation = (INInteraction, @escaping (Error?) -> Void) -> Void + public typealias ContentUpdate = ( + UNMutableNotificationContent, + INSendMessageIntent + ) throws -> UNNotificationContent + + private let donate: Donation + private let updateContent: ContentUpdate + + public convenience init() { + self.init( + donate: { interaction, completion in + interaction.donate(completion: completion) + }, + updateContent: { content, intent in + try content.updating(from: intent) + } + ) + } + + public init( + donate: @escaping Donation, + updateContent: @escaping ContentUpdate + ) { + self.donate = donate + self.updateContent = updateContent + } + + public func present( + ordinaryContent: UNMutableNotificationContent, + resolution: BuzzPushResolution, + completion: @escaping (UNNotificationContent) -> Void + ) { + guard let descriptor = BuzzCommunicationNotificationDescriptor(resolution: resolution) else { + completion(ordinaryContent) + return + } + let intent = Self.makeIntent(descriptor) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + donate(interaction) { [updateContent] error in + guard error == nil, + let specialized = try? updateContent(ordinaryContent, intent) + else { + completion(ordinaryContent) + return + } + completion(specialized) + } + } + + public static func makeIntent( + _ descriptor: BuzzCommunicationNotificationDescriptor + ) -> INSendMessageIntent { + let sender = INPerson( + personHandle: INPersonHandle(value: descriptor.senderIdentifier, type: .unknown), + nameComponents: nil, + displayName: descriptor.senderDisplayName, + image: descriptor.senderAvatarPNG.map(INImage.init(imageData:)), + contactIdentifier: nil, + customIdentifier: descriptor.senderIdentifier, + isMe: false, + suggestionType: .none + ) + return INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: descriptor.messageBody, + speakableGroupName: descriptor.conversationDisplayName.map { + INSpeakableString(spokenPhrase: $0) + }, + conversationIdentifier: descriptor.conversationIdentifier, + serviceName: "Buzz", + sender: sender, + attachments: nil + ) + } + } +#endif diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 775a94f05ec..c222fe30d94 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -13,7 +13,10 @@ import Foundation /// The opaque gateway capability and binding metadata needed by a later lease publisher. public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public let relayOrigin: String + /// NIP-PL delegation key selected from the relay push descriptor. public let relayPubkey: String + /// Optional NIP-11 `self` key that verifies relay-authored NIP-29 metadata. + public let relayMetadataPubkey: String? /// Gateway installation authority. This is distinct from [installationId], /// which is the unlinkable per-relay-origin NIP-PL lease address. public let gatewayInstallationHandle: String? @@ -28,6 +31,7 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public init( relayOrigin: String, relayPubkey: String, + relayMetadataPubkey: String? = nil, gatewayInstallationHandle: String? = nil, installationId: String, endpointGrant: String, @@ -40,6 +44,7 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { precondition(generation > 0, "Endpoint grant generation must be positive") self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey + self.relayMetadataPubkey = relayMetadataPubkey self.gatewayInstallationHandle = gatewayInstallationHandle self.installationId = installationId self.endpointGrant = endpointGrant @@ -422,7 +427,8 @@ public final class BuzzDevPushEnrollmentDriver { ) async throws -> BuzzPushEndpointGrantRecord { precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") let relayOrigin = try Self.relayOrigin(relayURL) - let relayPubkey = try await fetchCurrentRelayPushPubkey(from: relayOrigin.url) + let relayKeys = try await fetchCurrentRelayKeys(from: relayOrigin.url) + let relayPubkey = relayKeys.pushPubkey let endpoint = Self.lowercaseHex(deviceToken) let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) let nowSeconds = Int64(now().timeIntervalSince1970) @@ -437,7 +443,24 @@ public final class BuzzDevPushEnrollmentDriver { current.endpointEpoch == Self.endpointEpoch, current.expiresAt > nowSeconds + 300 { - return current + guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { + return current + } + let refreshed = BuzzPushEndpointGrantRecord( + relayOrigin: current.relayOrigin, + relayPubkey: current.relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, + gatewayInstallationHandle: current.gatewayInstallationHandle, + installationId: current.installationId, + endpointGrant: current.endpointGrant, + endpointHash: current.endpointHash, + appProfile: current.appProfile, + endpointEpoch: current.endpointEpoch, + generation: current.generation, + expiresAt: current.expiresAt + ) + try store.save(refreshed) + return refreshed } // One gateway delegation is scoped to an installation and relay key, not @@ -453,6 +476,7 @@ public final class BuzzDevPushEnrollmentDriver { let record = BuzzPushEndpointGrantRecord( relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, installationId: try makeInstallationId(), endpointGrant: sharedGrant.endpointGrant, @@ -565,6 +589,7 @@ public final class BuzzDevPushEnrollmentDriver { let record = BuzzPushEndpointGrantRecord( relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, gatewayInstallationHandle: installationHandle, installationId: try storedForOrigin?.installationId ?? makeInstallationId(), endpointGrant: endpointGrant, @@ -666,7 +691,7 @@ public final class BuzzDevPushEnrollmentDriver { return response.endpointGrant } - private func fetchCurrentRelayPushPubkey(from relayOrigin: URL) async throws -> String { + private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys { var request = URLRequest(url: relayOrigin) request.httpMethod = "GET" request.setValue("application/nostr+json", forHTTPHeaderField: "Accept") @@ -679,10 +704,18 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.invalidRelayDescriptor } let current = document.push.keys.filter(\.current) - guard current.count == 1, Self.isLowercaseHexPubkey(current[0].pubkey) else { + guard current.count == 1, + Self.isLowercaseHexPubkey(current[0].pubkey) + else { throw BuzzDevPushEnrollmentError.invalidRelayDescriptor } - return current[0].pubkey + let metadataPubkey = document.relaySelf.flatMap { + Self.isLowercaseHexPubkey($0) ? $0 : nil + } + return RelayKeys( + pushPubkey: current[0].pubkey, + metadataPubkey: metadataPubkey + ) } private func post( @@ -869,5 +902,22 @@ private struct RelayInformation: Decodable { } let keys: [Key] } + let relaySelf: String? let push: Push + + enum CodingKeys: String, CodingKey { + case relaySelf = "self" + case push + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + push = try container.decode(Push.self, forKey: .push) + relaySelf = try? container.decode(String.self, forKey: .relaySelf) + } +} + +private struct RelayKeys { + let pushPubkey: String + let metadataPubkey: String? } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 36eb0d0fc43..6895245474e 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -11,19 +11,31 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable { public let subtitle: String? public let threadIdentifier: String? public let navigationTarget: BuzzPushNavigationTarget? + public let senderPubkey: String? + public let senderAvatarPNG: Data? + public let conversationIdentifier: String? + public let conversationDisplayName: String? public init( title: String, body: String, subtitle: String?, threadIdentifier: String?, - navigationTarget: BuzzPushNavigationTarget? = nil + navigationTarget: BuzzPushNavigationTarget? = nil, + senderPubkey: String? = nil, + senderAvatarPNG: Data? = nil, + conversationIdentifier: String? = nil, + conversationDisplayName: String? = nil ) { self.title = title self.body = body self.subtitle = subtitle self.threadIdentifier = threadIdentifier self.navigationTarget = navigationTarget + self.senderPubkey = senderPubkey + self.senderAvatarPNG = senderAvatarPNG + self.conversationIdentifier = conversationIdentifier + self.conversationDisplayName = conversationDisplayName } } @@ -34,19 +46,30 @@ public protocol BuzzPushNotificationResolving { /// Reads configured Buzz communities and resolves their newest unread event. public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + static let maximumPresentationResponseBytes = 128 * 1_024 + private let session: URLSession private let loadCommunitiesData: () -> Data? + private let loadPresentationCacheData: () -> Data? private let loadPrivateKey: (String) -> String? + private let now: () -> Date + private let presentationCacheLifetime: TimeInterval /// Creates a resolver around the notification extension's App Group and Keychain I/O. public init( session: URLSession, loadCommunitiesData: @escaping () -> Data?, - loadPrivateKey: @escaping (String) -> String? + loadPrivateKey: @escaping (String) -> String?, + loadPresentationCacheData: @escaping () -> Data? = { nil }, + now: @escaping () -> Date = Date.init, + presentationCacheLifetime: TimeInterval = BuzzPushPresentationCacheStore.freshnessLifetime ) { self.session = session self.loadCommunitiesData = loadCommunitiesData + self.loadPresentationCacheData = loadPresentationCacheData self.loadPrivateKey = loadPrivateKey + self.now = now + self.presentationCacheLifetime = presentationCacheLifetime } public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { @@ -61,7 +84,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } let group = DispatchGroup() let lock = NSLock() - var candidates: [(BuzzPushResolution, VerifiedNostrEvent)] = [] + var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = [] for community in communities { group.enter() query(community) { candidate in @@ -75,15 +98,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } group.notify(queue: .global(qos: .userInitiated)) { let newest = candidates.max { - $0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt + $0.0.createdAt == $1.0.createdAt ? $0.0.id > $1.0.id : $0.0.createdAt < $1.0.createdAt } - completion(newest?.0) + guard let newest else { + completion(nil) + return + } + self.resolvePresentation(event: newest.0, community: newest.1, completion: completion) } } private func query( _ community: PushLeaseCommunity, - completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void + completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void ) { guard let privateKey = loadPrivateKey(community.id), community.pubkey?.isEmpty == false else { completion(nil) @@ -122,47 +149,313 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { completion(nil) return } + let candidate = Self.newestMessage( + events: events.filter { event in + event.hasValidIDAndSignature() + && subscriptions.contains { subscription in + PushLeaseMatcher.matches(event: event, subscription: subscription) + } + }, + community: community + ) + completion(candidate.map { ($0, community) }) + }.resume() + } + + private func resolvePresentation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + completion: @escaping (BuzzPushResolution?) -> Void + ) { + let snapshot = BuzzPushPresentationCacheSnapshot.decode(loadPresentationCacheData()) + let relayOrigin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl) + let cachedProfile = relayOrigin.flatMap { + snapshot.profile( + communityID: community.id, + relayOrigin: $0, + pubkey: event.pubkey + ) + } + let channelID = Self.tagValue("h", in: event) + let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() + let cachedChannel = channelID.flatMap { channelID in + relayOrigin.flatMap { + snapshot.channel( + communityID: community.id, + relayOrigin: $0, + channelID: channelID + ) + } + }.flatMap { channel in + channel.relayMetadataPubkey == relayMetadataPubkey ? channel : nil + } + let timestamp = Int(now().timeIntervalSince1970) + let profileNeedsRefresh = Self.isStale( + cachedAt: cachedProfile?.cachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) + let channelNeedsRefresh = channelID != nil && Self.isStale( + cachedAt: cachedChannel?.cachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) + let fallback = Self.makeResolution( + event: event, + community: community, + profile: cachedProfile, + channel: cachedChannel + ) + guard profileNeedsRefresh || channelNeedsRefresh else { + completion(fallback) + return + } + + refreshPresentation( + event: event, + community: community, + refreshProfile: profileNeedsRefresh, + refreshChannel: channelNeedsRefresh + ) { refreshedProfileEvent, refreshedChannelEvent in + let profile = refreshedProfileEvent.flatMap { + guard BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedProfile?.eventCreatedAt, + existingID: cachedProfile?.eventID, + candidateCreatedAt: $0.createdAt, + candidateID: $0.id + ) else { return nil } + return Self.ephemeralProfile( + event: $0, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + cached: cachedProfile, + cachedAt: timestamp + ) + } ?? cachedProfile + let channel = refreshedChannelEvent.flatMap { + guard let relayMetadataPubkey else { return nil } + guard BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.eventCreatedAt, + existingID: cachedChannel?.eventID, + candidateCreatedAt: $0.createdAt, + candidateID: $0.id + ) else { return nil } + return Self.ephemeralChannel( + event: $0, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + relayMetadataPubkey: relayMetadataPubkey, + cachedAt: timestamp + ) + } ?? cachedChannel completion( - Self.decodeResolution( - events: events.filter { event in - event.hasValidIDAndSignature() - && subscriptions.contains { subscription in - PushLeaseMatcher.matches(event: event, subscription: subscription) - } - }, - community: community - )) + Self.makeResolution( + event: event, + community: community, + profile: profile, + channel: channel + ) ?? fallback + ) + } + } + + private func refreshPresentation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + refreshProfile: Bool, + refreshChannel: Bool, + completion: @escaping (VerifiedNostrEvent?, VerifiedNostrEvent?) -> Void + ) { + guard let privateKey = loadPrivateKey(community.id), + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL) + else { + completion(nil, nil) + return + } + let channelID = Self.tagValue("h", in: event) + let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() + var filters: [[String: Any]] = [] + if refreshProfile { + filters.append(["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1]) + } + if refreshChannel, let channelID, let relayMetadataPubkey { + filters.append([ + "kinds": [39_000], + "authors": [relayMetadataPubkey], + "#d": [channelID], + "limit": 1, + ]) + } + guard !filters.isEmpty, + let body = try? JSONSerialization.data(withJSONObject: filters) + else { + completion(nil, nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 1 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, + method: "POST", + body: body, + privateKeyHex: privateKey + ) else { + completion(nil, nil) + return + } + request.setValue(auth, forHTTPHeaderField: "Authorization") + session.downloadTask(with: request) { fileURL, response, _ in + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let fileURL, + let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= Self.maximumPresentationResponseBytes, + let data = try? Data(contentsOf: fileURL), + let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(nil, nil) + return + } + let verified = events.filter { $0.hasValidIDAndSignature() } + let profile = refreshProfile ? Self.newest(verified.filter { + $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() + }) : nil + let channel = refreshChannel ? channelID.flatMap { channelID in + Self.newest(verified.filter { + $0.kind == 39_000 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + completion(profile, channel) }.resume() } static func decodeResolution( events: [VerifiedNostrEvent], community: PushLeaseCommunity ) -> (BuzzPushResolution, VerifiedNostrEvent)? { + let event = newestMessage(events: events, community: community) + guard let event else { return nil } + guard let resolution = makeResolution( + event: event, + community: community, + profile: nil, + channel: nil + ) else { return nil } + return (resolution, event) + } + + private static func newestMessage( + events: [VerifiedNostrEvent], + community: PushLeaseCommunity + ) -> VerifiedNostrEvent? { guard let mine = community.pubkey?.lowercased() else { return nil } - let event = events.filter { + return events.filter { $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) }.sorted { $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt }.first - guard let event else { return nil } + } + + private static func makeResolution( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + profile: BuzzPushCachedProfile?, + channel: BuzzPushCachedChannel? + ) -> BuzzPushResolution? { let body = previewBody(event.content) guard !body.isEmpty else { return nil } - let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] - return ( - BuzzPushResolution( - title: shortPubkey(event.pubkey), body: body, subtitle: community.name, - threadIdentifier: channel ?? community.id, - navigationTarget: channel.map { - BuzzPushNavigationTarget( - eventID: event.id, - communityID: community.id, - channelID: $0 - ) - } - ), event + let channelID = tagValue("h", in: event) + let conversationIdentifier = channelID.map { + BuzzPushPresentationIdentity.conversation(communityID: community.id, channelID: $0) + } + return BuzzPushResolution( + title: profile?.displayName ?? shortPubkey(event.pubkey), + body: body, + subtitle: community.name, + threadIdentifier: conversationIdentifier ?? community.id, + navigationTarget: channelID.map { + BuzzPushNavigationTarget( + eventID: event.id, + communityID: community.id, + channelID: $0 + ) + }, + senderPubkey: event.pubkey.lowercased(), + senderAvatarPNG: profile?.avatarPNG, + conversationIdentifier: conversationIdentifier, + conversationDisplayName: channel?.displayName + ) + } + + private static func ephemeralProfile( + event: VerifiedNostrEvent, + communityID: String, + relayOrigin: String, + cached: BuzzPushCachedProfile?, + cachedAt: Int + ) -> BuzzPushCachedProfile { + let metadata = BuzzPushPresentationCacheStore.profileMetadata(event) + return BuzzPushCachedProfile( + communityID: communityID, + relayOrigin: relayOrigin, + pubkey: event.pubkey.lowercased(), + displayName: metadata.displayName, + pictureHash: metadata.pictureHash, + avatarPNG: cached?.pictureHash == metadata.pictureHash ? cached?.avatarPNG : nil, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + } + + private static func ephemeralChannel( + event: VerifiedNostrEvent, + communityID: String, + relayOrigin: String, + relayMetadataPubkey: String, + cachedAt: Int + ) -> BuzzPushCachedChannel? { + guard let channelID = tagValue("d", in: event), !channelID.isEmpty else { return nil } + return BuzzPushCachedChannel( + communityID: communityID, + relayOrigin: relayOrigin, + channelID: channelID, + relayMetadataPubkey: relayMetadataPubkey, + displayName: BuzzPushPresentationCacheStore.normalizedDisplayName( + tagValue("name", in: event) + ), + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt ) } + private static func newest(_ events: [VerifiedNostrEvent]) -> VerifiedNostrEvent? { + events.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.first + } + + private static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { + event.tags.first { $0.count >= 2 && $0[0] == name }?[1] + } + + private static func isStale( + cachedAt: Int?, + now: Int, + lifetime: TimeInterval + ) -> Bool { + guard let cachedAt else { return true } + return TimeInterval(max(0, now - cachedAt)) > lifetime + } + static func previewBody(_ content: String) -> String { var result = content.replacingOccurrences( of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift new file mode 100644 index 00000000000..3645913704c --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -0,0 +1,564 @@ +import CryptoKit +import Foundation + +/// A verified sender profile retained for notification presentation. +public struct BuzzPushCachedProfile: Codable, Equatable, Sendable { + public let communityID: String + public let relayOrigin: String + public let pubkey: String + public let displayName: String? + public let pictureHash: String? + public let avatarPNG: Data? + public let eventID: String + public let eventCreatedAt: Int + public let cachedAt: Int + + public init( + communityID: String, + relayOrigin: String, + pubkey: String, + displayName: String?, + pictureHash: String?, + avatarPNG: Data?, + eventID: String, + eventCreatedAt: Int, + cachedAt: Int + ) { + self.communityID = communityID + self.relayOrigin = relayOrigin + self.pubkey = pubkey + self.displayName = displayName + self.pictureHash = pictureHash + self.avatarPNG = avatarPNG + self.eventID = eventID + self.eventCreatedAt = eventCreatedAt + self.cachedAt = cachedAt + } +} + +/// Verified channel metadata retained for notification presentation. +public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { + public let communityID: String + public let relayOrigin: String + public let channelID: String + public let relayMetadataPubkey: String + public let displayName: String? + public let eventID: String + public let eventCreatedAt: Int + public let cachedAt: Int + + public init( + communityID: String, + relayOrigin: String, + channelID: String, + relayMetadataPubkey: String, + displayName: String?, + eventID: String, + eventCreatedAt: Int, + cachedAt: Int + ) { + self.communityID = communityID + self.relayOrigin = relayOrigin + self.channelID = channelID + self.relayMetadataPubkey = relayMetadataPubkey + self.displayName = displayName + self.eventID = eventID + self.eventCreatedAt = eventCreatedAt + self.cachedAt = cachedAt + } +} + +/// Atomic App Group snapshot shared by the app and its notification extension. +public struct BuzzPushPresentationCacheSnapshot: Codable, Equatable, Sendable { + public static let currentVersion = 1 + + public let version: Int + public var profiles: [BuzzPushCachedProfile] + public var channels: [BuzzPushCachedChannel] + + public init( + version: Int = currentVersion, + profiles: [BuzzPushCachedProfile] = [], + channels: [BuzzPushCachedChannel] = [] + ) { + self.version = version + self.profiles = profiles + self.channels = channels + } + + public static func decode(_ data: Data?) -> Self { + guard let data, + let snapshot = try? JSONDecoder().decode(Self.self, from: data), + snapshot.version == currentVersion + else { return Self() } + return snapshot + } + + public func profile( + communityID: String, + relayOrigin: String, + pubkey: String + ) -> BuzzPushCachedProfile? { + let normalizedPubkey = pubkey.lowercased() + return profiles.first { + $0.communityID == communityID && $0.relayOrigin == relayOrigin + && $0.pubkey == normalizedPubkey + } + } + + public func channel( + communityID: String, + relayOrigin: String, + channelID: String + ) -> BuzzPushCachedChannel? { + channels.first { + $0.communityID == communityID && $0.relayOrigin == relayOrigin + && $0.channelID == channelID + } + } +} + +/// One app-provided profile update, optionally carrying a sanitized local thumbnail. +public struct BuzzPushProfileCacheUpdate: Sendable { + public let event: VerifiedNostrEvent + public let avatarPNG: Data? + + public init(event: VerifiedNostrEvent, avatarPNG: Data? = nil) { + self.event = event + self.avatarPNG = avatarPNG + } +} + +/// Maintains the bounded presentation snapshot. The app is the sole writer. +public final class BuzzPushPresentationCacheStore: @unchecked Sendable { + public static let fileName = "push-presentation-cache.json" + public static let freshnessLifetime: TimeInterval = 24 * 60 * 60 + public static let maximumProfiles = 256 + public static let maximumChannels = 512 + public static let maximumAvatarBytes = 64 * 1024 + public static let maximumTotalAvatarBytes = 4 * 1024 * 1024 + public static let maximumSnapshotBytes = 8 * 1024 * 1024 + + private let fileURL: URL + private let now: () -> Date + private let lock = NSLock() + + public init(containerURL: URL, now: @escaping () -> Date = Date.init) { + fileURL = containerURL.appendingPathComponent(Self.fileName) + self.now = now + } + + /// Saves verified kind-0 events and returns the event IDs still needing thumbnails. + @discardableResult + public func updateProfiles( + communityID: String, + relayOrigin: String, + updates: [BuzzPushProfileCacheUpdate] + ) throws -> Set { + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin) + else { return [] } + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + let cachedAt = Int(now().timeIntervalSince1970) + var acceptedEventIDs = Set() + for update in updates { + let event = update.event + guard event.kind == 0, event.hasValidIDAndSignature() else { continue } + let pubkey = event.pubkey.lowercased() + guard Self.isHexPubkey(pubkey) else { continue } + + let metadata = Self.profileMetadata(event) + let index = snapshot.profiles.firstIndex { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.pubkey == pubkey + } + let existing = index.map { snapshot.profiles[$0] } + guard Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { continue } + + let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG) + let preservedAvatar = existing?.pictureHash == metadata.pictureHash + ? existing?.avatarPNG : nil + let entry = BuzzPushCachedProfile( + communityID: communityID, + relayOrigin: canonicalRelayOrigin, + pubkey: pubkey, + displayName: metadata.displayName, + pictureHash: metadata.pictureHash, + avatarPNG: metadata.pictureHash == nil ? nil : suppliedAvatar ?? preservedAvatar, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + if let index { + snapshot.profiles[index] = entry + } else { + snapshot.profiles.append(entry) + } + acceptedEventIDs.insert(event.id) + } + + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + return Set(snapshot.profiles.compactMap { profile in + guard profile.communityID == communityID, + profile.relayOrigin == canonicalRelayOrigin, + acceptedEventIDs.contains(profile.eventID), + profile.pictureHash != nil, + profile.avatarPNG == nil + else { return nil } + return profile.eventID + }) + } + + /// Saves signature-verified kind-39000 events for the selected community. + public func updateChannels( + communityID: String, + relayOrigin: String, + relayMetadataPubkey: String, + events: [VerifiedNostrEvent] + ) throws { + let normalizedRelayPubkey = relayMetadataPubkey.lowercased() + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + Self.isHexPubkey(normalizedRelayPubkey) + else { + return + } + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + let cachedAt = Int(now().timeIntervalSince1970) + for event in events { + guard event.kind == 39_000, event.hasValidIDAndSignature(), + event.pubkey.lowercased() == normalizedRelayPubkey, + let channelID = Self.tagValue("d", in: event), + Self.isBoundedOpaqueID(channelID) + else { continue } + let index = snapshot.channels.firstIndex { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.channelID == channelID + } + let existing = index.map { snapshot.channels[$0] } + guard Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { continue } + + let entry = BuzzPushCachedChannel( + communityID: communityID, + relayOrigin: canonicalRelayOrigin, + channelID: channelID, + relayMetadataPubkey: normalizedRelayPubkey, + displayName: Self.normalizedDisplayName(Self.tagValue("name", in: event)), + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + if let index { + snapshot.channels[index] = entry + } else { + snapshot.channels.append(entry) + } + } + + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + } + + /// Attaches an app-rendered thumbnail to every verified profile with this source digest. + @discardableResult + public func updateAvatar( + communityID: String, + relayOrigin: String, + sourceURL: String, + avatarPNG: Data + ) throws -> Bool { + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + let normalizedURL = Self.normalizedAvatarURL(sourceURL), + let normalizedPNG = Self.normalizedAvatarPNG(avatarPNG) + else { return false } + let pictureHash = VerifiedNostrEvent.hex( + SHA256.hash(data: Data(normalizedURL.utf8)) + ) + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + var changed = false + for index in snapshot.profiles.indices where + snapshot.profiles[index].communityID == communityID + && snapshot.profiles[index].relayOrigin == canonicalRelayOrigin + && snapshot.profiles[index].pictureHash == pictureHash + && snapshot.profiles[index].avatarPNG != normalizedPNG + { + let profile = snapshot.profiles[index] + snapshot.profiles[index] = BuzzPushCachedProfile( + communityID: profile.communityID, + relayOrigin: profile.relayOrigin, + pubkey: profile.pubkey, + displayName: profile.displayName, + pictureHash: profile.pictureHash, + avatarPNG: normalizedPNG, + eventID: profile.eventID, + eventCreatedAt: profile.eventCreatedAt, + cachedAt: profile.cachedAt + ) + changed = true + } + guard changed else { return false } + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + return true + } + + /// Removes metadata belonging to communities no longer present in the app. + public func retainCommunities(_ communityIDs: Set) throws { + lock.lock() + defer { lock.unlock() } + var snapshot = loadLocked() + snapshot.profiles.removeAll { !communityIDs.contains($0.communityID) } + snapshot.channels.removeAll { !communityIDs.contains($0.communityID) } + try writeLocked(snapshot) + } + + private func loadLocked() -> BuzzPushPresentationCacheSnapshot { + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= Self.maximumSnapshotBytes + else { return BuzzPushPresentationCacheSnapshot() } + return BuzzPushPresentationCacheSnapshot.decode(try? Data(contentsOf: fileURL)) + } + + private func writeLocked(_ snapshot: BuzzPushPresentationCacheSnapshot) throws { + let data = try Self.encodedBoundedSnapshot(snapshot) + try data.write(to: fileURL, options: [.atomic]) + #if os(iOS) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + #endif + } + + static func encodedBoundedSnapshot( + _ snapshot: BuzzPushPresentationCacheSnapshot + ) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + var bounded = snapshot + Self.enforceBounds(&bounded) + var data = try encoder.encode(bounded) + + if data.count > Self.maximumSnapshotBytes { + var estimatedExcess = data.count - Self.maximumSnapshotBytes + 1_024 + for index in bounded.profiles.indices.reversed() { + guard let avatar = bounded.profiles[index].avatarPNG else { continue } + estimatedExcess -= min(estimatedExcess, 4 * ((avatar.count + 2) / 3)) + bounded.profiles[index] = Self.removingAvatar(from: bounded.profiles[index]) + if estimatedExcess == 0 { break } + } + data = try encoder.encode(bounded) + } + + while data.count > Self.maximumSnapshotBytes, + !bounded.profiles.isEmpty || !bounded.channels.isEmpty + { + let entryCount = bounded.profiles.count + bounded.channels.count + let ratio = Double(Self.maximumSnapshotBytes) / Double(data.count) + let targetCount = max(0, min(entryCount - 1, Int(Double(entryCount) * ratio * 0.95))) + Self.removeOldestEntries(entryCount - targetCount, from: &bounded) + data = try encoder.encode(bounded) + } + return data + } + + static func profileMetadata( + _ event: VerifiedNostrEvent + ) -> (displayName: String?, pictureHash: String?) { + guard event.content.utf8.count <= 32 * 1024, + let data = event.content.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return (nil, nil) } + let displayName = normalizedDisplayName(object["display_name"] as? String) + ?? normalizedDisplayName(object["name"] as? String) + let pictureHash = normalizedAvatarURL(object["picture"] as? String).map { + VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8))) + } + return (displayName, pictureHash) + } + + static func normalizedDisplayName(_ value: String?) -> String? { + guard let value else { return nil } + let collapsed = value.precomposedStringWithCanonicalMapping + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + guard !collapsed.isEmpty else { return nil } + var bounded = String(collapsed.prefix(128)) + while bounded.utf8.count > 512, !bounded.isEmpty { + bounded.removeLast() + } + return bounded.isEmpty ? nil : bounded + } + + static func normalizedAvatarURL(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.utf8.count <= 2_048, + let components = URLComponents(string: trimmed), + components.user == nil, components.password == nil, + components.host?.isEmpty == false, + ["http", "https"].contains(components.scheme?.lowercased() ?? "") + else { return nil } + return components.url?.absoluteString + } + + public static func canonicalRelayOrigin(_ value: String) -> String? { + guard value.utf8.count <= 2_048, + var components = URLComponents(string: value), + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + switch components.scheme?.lowercased() { + case "wss": components.scheme = "https" + case "ws": components.scheme = "http" + case "https", "http": break + default: return nil + } + components.path = "" + guard let result = components.string, result.utf8.count <= 2_048 else { return nil } + return result + } + + static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { + event.tags.first { $0.count >= 2 && $0[0] == name }?[1] + } + + private static func isBoundedOpaqueID(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 1_024 + } + + private static func isHexPubkey(_ value: String) -> Bool { + value.count == 64 && VerifiedNostrEvent.hexBytes(value)?.count == 32 + } + + private static func normalizedAvatarPNG(_ data: Data?) -> Data? { + guard let data, !data.isEmpty, data.count <= maximumAvatarBytes, + data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + else { return nil } + return data + } + + static func shouldReplace( + existingCreatedAt: Int?, + existingID: String?, + candidateCreatedAt: Int, + candidateID: String + ) -> Bool { + guard let existingCreatedAt, let existingID else { return true } + return candidateCreatedAt > existingCreatedAt + || (candidateCreatedAt == existingCreatedAt && candidateID <= existingID) + } + + static func enforceBounds(_ snapshot: inout BuzzPushPresentationCacheSnapshot) { + snapshot.profiles = Array( + snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles) + ) + snapshot.channels = Array( + snapshot.channels.sorted(by: channelNewestFirst).prefix(maximumChannels) + ) + + var avatarBytes = snapshot.profiles.reduce(0) { $0 + ($1.avatarPNG?.count ?? 0) } + guard avatarBytes > maximumTotalAvatarBytes else { return } + for index in snapshot.profiles.indices.reversed() { + guard let avatar = snapshot.profiles[index].avatarPNG else { continue } + avatarBytes -= avatar.count + let profile = snapshot.profiles[index] + snapshot.profiles[index] = removingAvatar(from: profile) + if avatarBytes <= maximumTotalAvatarBytes { break } + } + } + + private static func removingAvatar( + from profile: BuzzPushCachedProfile + ) -> BuzzPushCachedProfile { + BuzzPushCachedProfile( + communityID: profile.communityID, + relayOrigin: profile.relayOrigin, + pubkey: profile.pubkey, + displayName: profile.displayName, + pictureHash: profile.pictureHash, + avatarPNG: nil, + eventID: profile.eventID, + eventCreatedAt: profile.eventCreatedAt, + cachedAt: profile.cachedAt + ) + } + + private static func removeOldestEntries( + _ count: Int, + from snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + for _ in 0.. Bool { + lhs.cachedAt == rhs.cachedAt ? lhs.eventID > rhs.eventID : lhs.cachedAt > rhs.cachedAt + } + + private static func channelNewestFirst( + _ lhs: BuzzPushCachedChannel, + _ rhs: BuzzPushCachedChannel + ) -> Bool { + lhs.cachedAt == rhs.cachedAt ? lhs.eventID > rhs.eventID : lhs.cachedAt > rhs.cachedAt + } +} + +/// Stable, privacy-preserving identifiers used only after the NSE resolves an event. +public enum BuzzPushPresentationIdentity { + public static func conversation(communityID: String, channelID: String) -> String { + scoped(namespace: "conversation", values: [communityID, channelID]) + } + + public static func sender(communityID: String, pubkey: String) -> String { + scoped(namespace: "sender", values: [communityID, pubkey.lowercased()]) + } + + private static func scoped(namespace: String, values: [String]) -> String { + let encoded = (try? JSONEncoder().encode([namespace] + values)) ?? Data() + return "buzz.\(namespace).\(VerifiedNostrEvent.hex(SHA256.hash(data: encoded)))" + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift index 2f3dfaa660c..7ac75721f01 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -18,6 +18,8 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { public let id: String public let name: String public let relayUrl: String + /// Relay NIP-11 `self` key used to verify NIP-29 channel metadata. + public let relayMetadataPubkey: String? public let pubkey: String? public let pushSubscriptionState: PushLeaseSubscriptionState @@ -25,12 +27,14 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { id: String, name: String, relayUrl: String, + relayMetadataPubkey: String? = nil, pubkey: String?, pushSubscriptionState: PushLeaseSubscriptionState ) { self.id = id self.name = name self.relayUrl = relayUrl + self.relayMetadataPubkey = relayMetadataPubkey self.pubkey = pubkey self.pushSubscriptionState = pushSubscriptionState } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index b263c8d72ec..821cd240024 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -49,6 +49,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { request, status: 200, json: [ + "self": Self.relayPubkey, "push": [ "keys": [ ["id": "current", "pubkey": Self.relayPubkey, "current": true] @@ -143,6 +144,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { BuzzPushEndpointGrantRecord( relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, installationId: Self.installationId, endpointGrant: "opaque-grant", @@ -167,7 +169,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { return Self.response( request, status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] ) case ("POST", "http://push.example/v1/installations/challenges"): challengeCount += 1 @@ -211,7 +216,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } #if DEBUG - func testDevelopmentAttestationMatchesGatewayBypassShape() async throws { + func testDevelopmentAttestationMatchesGatewayBypassShape() async throws { let entropy = Data(repeating: 0xAB, count: 32) let provider = BuzzDevAppAttestProvider(randomBytes: { entropy }) let prepared = try await provider.prepareAttestation() @@ -229,6 +234,17 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } #endif + func testLegacyGrantDecodesWithoutMetadataAuthority() throws { + let data = Data( + #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#.utf8 + ) + + let record = try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + } + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { let service = RecordingDCAppAttestService(isSupported: false) let provider = BuzzDCAppAttestProvider( @@ -464,6 +480,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let existing = BuzzPushEndpointGrantRecord( relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -482,7 +499,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { return Self.response( request, status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] ) } @@ -500,6 +520,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let existing = BuzzPushEndpointGrantRecord( relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, installationId: String(repeating: "f", count: 32), endpointGrant: "existing-grant", @@ -517,7 +538,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { return Self.response( request, status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] ) } @@ -541,6 +565,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let existing = BuzzPushEndpointGrantRecord( relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, installationId: String(repeating: "f", count: 32), endpointGrant: "first-relay-grant", @@ -559,7 +584,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { return Self.response( request, status: 200, - json: ["push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]]] + json: [ + "self": secondRelayPubkey, + "push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]], + ] ) case ("POST", "http://push.example/v1/installations/challenges"): return Self.response( @@ -607,6 +635,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let existing = BuzzPushEndpointGrantRecord( relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -631,7 +660,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { return Self.response( request, status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] ) case ("POST", "http://push.example/v1/installations/challenges"): challengeCount += 1 @@ -685,6 +717,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { request, status: 200, json: [ + "self": Self.relayPubkey, "push": [ "keys": [ ["pubkey": Self.relayPubkey, "current": true], @@ -704,6 +737,124 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 1) } + func testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws { + let pushPubkey = String(repeating: "b", count: 64) + let oldMetadataPubkey = String(repeating: "c", count: 64) + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: pushPubkey, + relayMetadataPubkey: oldMetadataPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": [ + "keys": [ + ["pubkey": pushPubkey, "current": true] + ] + ], + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, pushPubkey) + XCTAssertEqual(record.relayMetadataPubkey, Self.relayPubkey) + XCTAssertNotEqual(record.relayMetadataPubkey, oldMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "push": [ + "keys": [["pubkey": Self.relayPubkey, "current": true]] + ] + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": 42, + "push": [ + "keys": [["pubkey": Self.relayPubkey, "current": true]] + ], + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + func testFailsLoudlyOnUnexpectedGatewayStatus() async throws { let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in @@ -711,7 +862,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { return Self.response( request, status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] ) } return Self.response(request, status: 400, json: ["error": "invalid_request"]) diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 45e484c4378..817bca48614 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -1,4 +1,6 @@ +import CryptoKit import Foundation +import P256K import XCTest @testable import BuzzPushKit @@ -12,6 +14,8 @@ final class BuzzPushNotificationResolverTests: XCTestCase { private static let ownPubkey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" private static let now = Int(Date().timeIntervalSince1970) + private static let profilePrivateKey = String(repeating: "0", count: 63) + "2" + private static let relayPrivateKey = String(repeating: "0", count: 63) + "3" private static let gatewayBody = "Reconnect to your relay now" private static let channelID = "123e4567-e89b-42d3-a456-426614174000" @@ -145,7 +149,14 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.title, String(event.pubkey.prefix(8)) + "…") XCTAssertEqual(result.body, "Hello Buzz") XCTAssertEqual(result.subtitle, "Community") - XCTAssertEqual(result.threadIdentifier, Self.channelID) + XCTAssertEqual( + result.threadIdentifier, + BuzzPushPresentationIdentity.conversation( + communityID: "community-id", + channelID: Self.channelID + ) + ) + XCTAssertEqual(result.senderPubkey, event.pubkey) XCTAssertEqual( result.navigationTarget, BuzzPushNavigationTarget( @@ -156,6 +167,410 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) } + func testFreshVerifiedCacheResolvesSenderAvatarAndChannelWithoutRefresh() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Hello from Alice" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let avatar = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Alice", + pictureHash: "picture-hash", + avatarPNG: avatar, + eventID: "profile-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Alice") + XCTAssertEqual(result.senderAvatarPNG, avatar) + XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testStaleVerifiedCacheIsUsedWhileOneBoundedRefreshFails() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Stale cache still presents" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Stale Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: "profile-event", + eventCreatedAt: staleAt, + cachedAt: staleAt + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "Stale General", + eventID: "channel-event", + eventCreatedAt: staleAt, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + XCTAssertEqual(request.timeoutInterval, 1) + return Self.response(request, status: 503, data: Data()) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Stale Alice") + XCTAssertEqual(result.conversationDisplayName, "Stale General") + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Keep newer cached metadata" + ) + let olderProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now - 20, + kind: 0, + content: #"{"display_name":"Older Alice"}"# + ) + let olderChannel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now - 20, + kind: 39_000, + tags: [["d", Self.channelID], ["name", "Older General"]] + ) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Newer Cached Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: String(repeating: "f", count: 64), + eventCreatedAt: Self.now - 10, + cachedAt: staleAt + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: olderChannel.pubkey, + displayName: "Newer Cached General", + eventID: String(repeating: "f", count: 64), + eventCreatedAt: Self.now - 10, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([olderProfile, olderChannel]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: olderChannel.pubkey) + ]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Newer Cached Alice") + XCTAssertEqual(result.conversationDisplayName, "Newer Cached General") + } + + func testChannelOnlyRefreshIgnoresUnrequestedProfileEvent() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Ignore unrelated enrichment" + ) + let unexpectedProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now + 1, + kind: 0, + content: #"{"display_name":"Unexpected Alice"}"# + ) + let relayMetadataPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Cached Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: "cached-profile", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayMetadataPubkey, + displayName: "Stale General", + eventID: "cached-channel", + eventCreatedAt: Self.now, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([unexpectedProfile]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: relayMetadataPubkey) + ]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Cached Alice") + XCTAssertEqual(result.conversationDisplayName, "Stale General") + } + + func testMissingCacheRefreshesVerifiedProfileAndChannelTogether() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Fresh metadata" + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Fresh Alice"}"# + ) + let channel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_000, + tags: [["d", Self.channelID], ["name", "Fresh General"]] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([profile, channel]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: channel.pubkey) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Fresh Alice") + XCTAssertEqual(result.conversationDisplayName, "Fresh General") + XCTAssertNil(result.senderAvatarPNG) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Fallback content" + ) + let validProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Tampered"}"# + ) + let tamperedProfile = VerifiedNostrEvent( + id: validProfile.id, + pubkey: validProfile.pubkey, + createdAt: validProfile.createdAt, + kind: validProfile.kind, + tags: validProfile.tags, + content: #"{"display_name":"Mallory"}"#, + sig: validProfile.sig + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([tamperedProfile]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey)) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.subtitle, "Community") + XCTAssertEqual(result.body, "Fallback content") + } + + func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Bounded fallback" + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Must Not Be Used"}"# + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + var oversized = Data( + repeating: 0x20, + count: BuzzPushNotificationResolver.maximumPresentationResponseBytes + ) + oversized.append(try JSONEncoder().encode([profile])) + return Self.response(request, status: 200, data: oversized) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey)) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.body, "Bounded fallback") + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws { URLProtocolStub.handler = { request in XCTAssertEqual(request.url?.absoluteString, "https://relay.example/query") @@ -173,14 +588,18 @@ final class BuzzPushNotificationResolverTests: XCTestCase { private func makeResolver( communitiesData: Data?, - privateKeys: [String: String] = ["community-id": privateKey] + privateKeys: [String: String] = ["community-id": privateKey], + presentationCacheData: Data? = nil, + now: Date = Date() ) -> BuzzPushNotificationResolver { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [URLProtocolStub.self] return BuzzPushNotificationResolver( session: URLSession(configuration: configuration), loadCommunitiesData: { communitiesData }, - loadPrivateKey: { privateKeys[$0] } + loadPrivateKey: { privateKeys[$0] }, + loadPresentationCacheData: { presentationCacheData }, + now: { now } ) } @@ -199,12 +618,14 @@ final class BuzzPushNotificationResolverTests: XCTestCase { id: String = "community-id", name: String = "Community", relayUrl: String = "https://relay.example", + relayMetadataPubkey: String? = nil, pubkey: String? = ownPubkey ) -> PushLeaseCommunity { PushLeaseCommunity( id: id, name: name, relayUrl: relayUrl, + relayMetadataPubkey: relayMetadataPubkey, pubkey: pubkey, pushSubscriptionState: PushLeaseSubscriptionState( authority: "accepted", @@ -262,6 +683,44 @@ final class BuzzPushNotificationResolverTests: XCTestCase { )! return (response, data) } + + private static func pubkey(for privateKey: String) throws -> String { + let bytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + return VerifiedNostrEvent.hex(key.xonly.bytes) + } + + private static func signedEvent( + privateKey: String, + createdAt: Int, + kind: Int, + tags: [[String]] = [], + content: String = "" + ) throws -> VerifiedNostrEvent { + let privateKeyBytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + } } private final class URLProtocolStub: URLProtocol, @unchecked Sendable { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift new file mode 100644 index 00000000000..4342c2b8b1c --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -0,0 +1,426 @@ +import CryptoKit +import Foundation +import P256K +import Testing + +@testable import BuzzPushKit + +@Suite("Push presentation cache") +struct BuzzPushPresentationCacheTests { + private let profileKey = String(repeating: "0", count: 63) + "1" + private let relayKey = String(repeating: "0", count: 63) + "2" + private let otherRelayKey = String(repeating: "0", count: 63) + "3" + + @Test("Verified profile uses display_name, then name, and attaches a bounded local avatar") + func verifiedProfilePrecedenceAndAvatar() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore( + containerURL: directory, + now: { Date(timeIntervalSince1970: 1_700_000_100) } + ) + let event = try signedEvent( + privateKey: profileKey, + createdAt: 1_700_000_000, + kind: 0, + content: #"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"# + ) + + let needsAvatar = try store.updateProfiles( + communityID: "community-a", + relayOrigin: "wss://relay.example/", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + try store.updateProfiles( + communityID: "community-b", + relayOrigin: "wss://relay.example/", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + + #expect(needsAvatar == Set([event.id])) + var snapshot = try loadSnapshot(directory) + var cached = try #require( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.displayName == "Alice Example") + #expect(cached.avatarPNG == nil) + #expect( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://other.example", + pubkey: event.pubkey + ) == nil + ) + + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01]) + #expect( + try store.updateAvatar( + communityID: "community-a", + relayOrigin: "https://relay.example", + sourceURL: "https://images.example/alice.png", + avatarPNG: png + ) + ) + snapshot = try loadSnapshot(directory) + cached = try #require( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.avatarPNG == png) + #expect( + snapshot.profile( + communityID: "community-b", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + )?.avatarPNG == nil + ) + } + + @Test("Verified profile falls back from blank display_name to name") + func verifiedProfileNameFallback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let event = try signedEvent( + privateKey: profileKey, + kind: 0, + content: #"{"display_name":" ","name":"Alice"}"# + ) + + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + + let cached = try #require( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.displayName == "Alice") + } + + @Test("Malformed verified profile clears presentation while an unverified event is ignored") + func malformedAndUnverifiedProfileFallback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let named = try signedEvent( + privateKey: profileKey, + createdAt: 100, + kind: 0, + content: #"{"name":"Alice"}"# + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: named)] + ) + let malformed = try signedEvent( + privateKey: profileKey, + createdAt: 101, + kind: 0, + content: "not-json" + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: malformed)] + ) + let tampered = VerifiedNostrEvent( + id: malformed.id, + pubkey: malformed.pubkey, + createdAt: 102, + kind: 0, + tags: [], + content: #"{"display_name":"Mallory"}"#, + sig: malformed.sig + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: tampered)] + ) + + let cached = try #require( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: malformed.pubkey + ) + ) + #expect(cached.eventID == malformed.id) + #expect(cached.displayName == nil) + } + + @Test("Channel name requires the expected relay signer and accepts opaque IDs") + func channelAuthorityAndOpaqueID() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let opaqueChannelID = "channel/general:v5" + let verified = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", opaqueChannelID], ["name", " General Chat "]] + ) + let wrongSigner = try signedEvent( + privateKey: otherRelayKey, + createdAt: 101, + kind: 39_000, + tags: [["d", opaqueChannelID], ["name", "Impostor"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "wss://relay.example", + relayMetadataPubkey: relayPubkey, + events: [wrongSigner, verified] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: opaqueChannelID + ) + ) + #expect(cached.eventID == verified.id) + #expect(cached.displayName == "General Chat") + #expect(cached.relayMetadataPubkey == relayPubkey) + } + + @Test("Missing or malformed channel metadata never fabricates a name") + func malformedChannelMetadataFallback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let blankName = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", " \n "]] + ) + let missingChannelID = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_000, + tags: [["name", "Must not be used"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + events: [blankName, missingChannelID] + ) + + let snapshot = try loadSnapshot(directory) + let cached = try #require( + snapshot.channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.displayName == nil) + #expect(snapshot.channels.count == 1) + } + + @Test("Community removal prunes profile and channel state") + func communityPruning() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let profile = try signedEvent(privateKey: profileKey, kind: 0, content: #"{"name":"A"}"#) + let channel = try signedEvent( + privateKey: relayKey, + kind: 39_000, + tags: [["d", "opaque"], ["name", "General"]] + ) + try store.updateProfiles( + communityID: "removed", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: profile)] + ) + try store.updateChannels( + communityID: "removed", + relayOrigin: "https://relay.example", + relayMetadataPubkey: try pubkey(for: relayKey), + events: [channel] + ) + + try store.retainCommunities(["retained"]) + + let snapshot = try loadSnapshot(directory) + #expect(snapshot.profiles.isEmpty) + #expect(snapshot.channels.isEmpty) + } + + @Test("Cache deterministically evicts entries beyond its global bounds") + func boundedEviction() { + var snapshot = BuzzPushPresentationCacheSnapshot( + profiles: (0...BuzzPushPresentationCacheStore.maximumProfiles).map { index in + BuzzPushCachedProfile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: String(format: "%064x", index), + displayName: "Profile \(index)", + pictureHash: nil, + avatarPNG: nil, + eventID: String(format: "%064x", index), + eventCreatedAt: index, + cachedAt: index + ) + }, + channels: (0...BuzzPushPresentationCacheStore.maximumChannels).map { index in + BuzzPushCachedChannel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "channel-\(index)", + relayMetadataPubkey: String(repeating: "a", count: 64), + displayName: "Channel \(index)", + eventID: String(format: "%064x", index), + eventCreatedAt: index, + cachedAt: index + ) + } + ) + + BuzzPushPresentationCacheStore.enforceBounds(&snapshot) + + #expect(snapshot.profiles.count == BuzzPushPresentationCacheStore.maximumProfiles) + #expect(snapshot.channels.count == BuzzPushPresentationCacheStore.maximumChannels) + #expect(snapshot.profiles.contains { $0.cachedAt == 0 } == false) + #expect(snapshot.channels.contains { $0.cachedAt == 0 } == false) + } + + @Test("Encoded cache remains bounded with adversarial strings and maximum avatars") + func encodedCacheByteBound() throws { + let controlText = String(repeating: "\u{0001}", count: 1_024) + let avatar = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + + Data( + repeating: 0, + count: BuzzPushPresentationCacheStore.maximumAvatarBytes - 8 + ) + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: (0..<80).map { index in + BuzzPushCachedProfile( + communityID: controlText, + relayOrigin: "https://relay.example", + pubkey: String(format: "%064x", index), + displayName: controlText, + pictureHash: String(repeating: "a", count: 64), + avatarPNG: avatar, + eventID: String(format: "%064x", index), + eventCreatedAt: index, + cachedAt: index + ) + }, + channels: (0.. URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("buzz-push-cache-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func loadSnapshot(_ directory: URL) throws -> BuzzPushPresentationCacheSnapshot { + let data = try Data( + contentsOf: directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName) + ) + return try JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) + } + + private func pubkey(for privateKey: String) throws -> String { + let bytes = try #require(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + return VerifiedNostrEvent.hex(key.xonly.bytes) + } + + private func signedEvent( + privateKey: String, + createdAt: Int = 1_700_000_000, + kind: Int, + tags: [[String]] = [], + content: String = "" + ) throws -> VerifiedNostrEvent { + let privateKeyBytes = try #require(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + } +} diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 466fd8fa0f5..439a9dc6e76 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -15,8 +15,9 @@ BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 // dogfood builds opt in by including PushEnabled.xcconfig from the ignored // AppOverrides.xcconfig file. BUZZ_PUSH_ENABLED = NO +BUZZ_INFO_PLIST = Runner/Info.plist BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements -EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift +EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): a per-worktree bundle identifier diff --git a/mobile/ios/Flutter/PushEnabled.xcconfig b/mobile/ios/Flutter/PushEnabled.xcconfig index 79a72d2a614..c088a384a5d 100644 --- a/mobile/ios/Flutter/PushEnabled.xcconfig +++ b/mobile/ios/Flutter/PushEnabled.xcconfig @@ -1,6 +1,7 @@ // Complete internal iOS push capability overlay. This file is inert unless an // ignored AppOverrides.xcconfig explicitly includes it. BUZZ_PUSH_ENABLED = YES +BUZZ_INFO_PLIST = Runner/RunnerPush-Info.plist BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index c33c5a44217..29eee3fc778 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -13,6 +13,7 @@ BUZZ_DEVELOPMENT_TEAM = EYF346PHUG // App Store builds remain dormant until a later rollout. The internal build // pipeline explicitly includes PushEnabled.xcconfig from AppOverrides.xcconfig. BUZZ_PUSH_ENABLED = NO +BUZZ_INFO_PLIST = Runner/Info.plist BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements -EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift +EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 8d6a2d52981..644677c04f0 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -6,6 +6,7 @@ import UserNotifications final class NotificationService: UNNotificationServiceExtension { private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? + private let communicationPresenter = BuzzCommunicationNotificationPresenter() private lazy var resolver: BuzzPushNotificationResolving = { let appGroupIdentifier = Bundle.main.object( @@ -25,6 +26,13 @@ final class NotificationService: UNNotificationServiceExtension { communityID: communityID, keychainAccessGroup: keychainAccessGroup ) + }, + loadPresentationCacheData: { + Self.loadAppGroupData( + fileName: BuzzPushPresentationCacheStore.fileName, + appGroupIdentifier: appGroupIdentifier, + maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes + ) } ) }() @@ -59,6 +67,14 @@ final class NotificationService: UNNotificationServiceExtension { userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue content.userInfo = userInfo } + self.bestAttemptContent = content + self.communicationPresenter.present( + ordinaryContent: content, + resolution: resolution + ) { [weak self] specializedContent in + self?.finish(specializedContent) + } + return } self.finish(content) } @@ -98,11 +114,29 @@ final class NotificationService: UNNotificationServiceExtension { } private static func loadCommunitiesData(appGroupIdentifier: String?) -> Data? { + loadAppGroupData( + fileName: "push-communities.json", + appGroupIdentifier: appGroupIdentifier + ) + } + + private static func loadAppGroupData( + fileName: String, + appGroupIdentifier: String?, + maximumBytes: Int? = nil + ) -> Data? { guard let appGroupIdentifier, let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupIdentifier ) else { return nil } - return try? Data(contentsOf: container.appendingPathComponent("push-communities.json")) + let fileURL = container.appendingPathComponent(fileName) + if let maximumBytes { + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= maximumBytes + else { return nil } + } + return try? Data(contentsOf: fileURL) } } diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index d4a4ca60d8e..09ec7a94dfa 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; BZZ00000000000000000023 /* PushNativeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000024 /* PushNativeState.swift */; }; BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000027 /* PushEndpointGrantStore.swift */; }; + BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */; }; + BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -79,6 +81,7 @@ BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = ""; }; BZZ00000000000000000028 /* RunnerPush.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = RunnerPush.entitlements; sourceTree = ""; }; + BZZ0000000000000000002D /* RunnerPush-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "RunnerPush-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -105,6 +108,8 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = ""; }; BZZ00000000000000000027 /* PushEndpointGrantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEndpointGrantStore.swift; sourceTree = ""; }; + BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPresentationCacheBridge.swift; sourceTree = ""; }; + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzCommunicationNotificationTests.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -153,6 +158,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, 331C80A0294A618700263BE5 /* Fixtures */, ); path = RunnerTests; @@ -224,11 +230,13 @@ 97C147021CF9000F007C117D /* Info.plist */, BZZ0000000000000000000A /* Runner.entitlements */, BZZ00000000000000000028 /* RunnerPush.entitlements */, + BZZ0000000000000000002D /* RunnerPush-Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, BZZ00000000000000000024 /* PushNativeState.swift */, BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, + BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, @@ -518,6 +526,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -528,6 +537,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, BZZ00000000000000000023 /* PushNativeState.swift in Sources */, BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, + BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, @@ -651,7 +661,7 @@ DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; - INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -840,7 +850,7 @@ DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; - INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -864,7 +874,7 @@ DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; - INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index f605694b618..f8e8c50b8a3 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -22,6 +22,9 @@ import UIKit private var appGroupIdentifier: String? { Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String } + private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge( + appGroupIdentifier: appGroupIdentifier + ) #endif private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? @@ -278,6 +281,9 @@ import UIKit _ call: FlutterMethodCall, result: @escaping FlutterResult ) { + if pushPresentationCacheBridge.handle(call, result: result) { + return + } switch call.method { case "requestAuthorization": requestPushAuthorization(result: result) @@ -461,10 +467,42 @@ import UIKit domain: "BuzzPush", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) } + // Channel-name enrichment is optional presentation state. A damaged or + // unavailable grant cache must not block the core NSE snapshot/key update. + let grants = (try? endpointGrantStore.records()) ?? [] + let enriched = communities.map { community -> [String: Any] in + var community = community + guard let relayURL = community["relayUrl"] as? String, + let relayMetadataPubkey = Self.pushRelayMetadataPubkey( + relayURL: relayURL, + grants: grants + ) + else { return community } + community["relayMetadataPubkey"] = relayMetadataPubkey + return community + } let data = try JSONSerialization.data( - withJSONObject: ["communities": communities], options: [.sortedKeys]) + withJSONObject: ["communities": enriched], options: [.sortedKeys]) let destination = container.appendingPathComponent("push-communities.json") try data.write(to: destination, options: [.atomic]) + pushPresentationCacheBridge.retainCommunities( + Set(enriched.compactMap { $0["id"] as? String }) + ) + } + + static func pushRelayMetadataPubkey( + relayURL: String, + grants: [BuzzPushEndpointGrantRecord] + ) -> String? { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { + return nil + } + return grants.filter { + $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile + && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + }.max { + $0.generation < $1.generation + }?.relayMetadataPubkey } #endif diff --git a/mobile/ios/Runner/PushPresentationCacheBridge.swift b/mobile/ios/Runner/PushPresentationCacheBridge.swift new file mode 100644 index 00000000000..5e7f9cd38b4 --- /dev/null +++ b/mobile/ios/Runner/PushPresentationCacheBridge.swift @@ -0,0 +1,194 @@ +#if BUZZ_PUSH_ENABLED + import BuzzPushKit + import Flutter + import Foundation + +final class BuzzPushPresentationCacheBridge { + private let appGroupIdentifier: String? + private let queue = DispatchQueue( + label: "xyz.block.buzz.push-presentation-cache", + qos: .utility + ) + private lazy var store: BuzzPushPresentationCacheStore? = { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { return nil } + return BuzzPushPresentationCacheStore(containerURL: container) + }() + + init(appGroupIdentifier: String?) { + self.appGroupIdentifier = appGroupIdentifier + } + + @discardableResult + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool { + switch call.method { + case "cachePresentationProfiles": + cacheProfiles(call.arguments, result: result) + case "cachePresentationChannels": + cacheChannels(call.arguments, result: result) + case "cachePresentationAvatar": + cacheAvatar(call.arguments, result: result) + default: + return false + } + return true + } + + func retainCommunities(_ communityIDs: Set) { + queue.async { [weak self] in + try? self?.store?.retainCommunities(communityIDs) + } + } + + private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let rawEvents = arguments["events"] as? [[String: Any]] + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected communityId and profile events.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID) else { + Self.complete(result, value: nil) + return + } + let events = try decodeEvents(rawEvents) + try store?.updateProfiles( + communityID: communityID, + relayOrigin: community.relayUrl, + updates: events.map { BuzzPushProfileCacheUpdate(event: $0) } + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "profile_cache_failed", + message: "Unable to cache push sender profiles.", + details: error.localizedDescription + ) + ) + } + } + } + + private func cacheChannels(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let rawEvents = arguments["events"] as? [[String: Any]] + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected communityId and channel events.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID), + let relayMetadataPubkey = community.relayMetadataPubkey + else { + Self.complete(result, value: nil) + return + } + try store?.updateChannels( + communityID: communityID, + relayOrigin: community.relayUrl, + relayMetadataPubkey: relayMetadataPubkey, + events: try decodeEvents(rawEvents) + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "channel_cache_failed", + message: "Unable to cache push channel metadata.", + details: error.localizedDescription + ) + ) + } + } + } + + private func cacheAvatar(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let sourceURL = arguments["sourceUrl"] as? String, + let avatar = arguments["png"] as? FlutterStandardTypedData + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected an avatar source and PNG thumbnail.", + details: nil + ) + ) + return + } + let avatarData = avatar.data + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID) else { + Self.complete(result, value: false) + return + } + let updated = try store?.updateAvatar( + communityID: communityID, + relayOrigin: community.relayUrl, + sourceURL: sourceURL, + avatarPNG: avatarData + ) ?? false + Self.complete(result, value: updated) + } catch { + Self.complete( + result, + value: FlutterError( + code: "avatar_cache_failed", + message: "Unable to cache a push sender avatar.", + details: error.localizedDescription + ) + ) + } + } + } + + private func community(id: String) -> PushLeaseCommunity? { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ), + let data = try? Data( + contentsOf: container.appendingPathComponent("push-communities.json") + ), + let snapshot = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + else { return nil } + return snapshot.communities.first { $0.id == id } + } + + private func decodeEvents(_ rawEvents: [[String: Any]]) throws -> [VerifiedNostrEvent] { + let data = try JSONSerialization.data(withJSONObject: rawEvents) + return try JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + } + + private static func complete(_ result: @escaping FlutterResult, value: Any?) { + DispatchQueue.main.async { + result(value) + } + } +} +#endif diff --git a/mobile/ios/Runner/RunnerPush-Info.plist b/mobile/ios/Runner/RunnerPush-Info.plist new file mode 100644 index 00000000000..998cc20a3d5 --- /dev/null +++ b/mobile/ios/Runner/RunnerPush-Info.plist @@ -0,0 +1,98 @@ + + + + + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(APP_DISPLAY_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Buzz + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.buzz.deeplink + CFBundleURLSchemes + + buzz + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSFaceIDUsageDescription + Buzz uses Face ID to confirm sensitive identity transfers. + NSCameraUsageDescription + Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. + NSPhotoLibraryUsageDescription + Buzz needs photo library access so you can attach images to messages. + NSPhotoLibraryAddUsageDescription + Buzz needs permission to save images to your photo library. + NSUserActivityTypes + + INSendMessageIntent + + PHPhotoLibraryPreventAutomaticLimitedAccessAlert + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/mobile/ios/Runner/RunnerPush.entitlements b/mobile/ios/Runner/RunnerPush.entitlements index 8b77cecac88..7fca08a0f35 100644 --- a/mobile/ios/Runner/RunnerPush.entitlements +++ b/mobile/ios/Runner/RunnerPush.entitlements @@ -6,6 +6,8 @@ $(BUZZ_IOS_PUSH_ENVIRONMENT) com.apple.developer.devicecheck.appattest-environment $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.developer.usernotifications.communication + com.apple.security.application-groups $(BUZZ_APP_GROUP_IDENTIFIER) diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift new file mode 100644 index 00000000000..a9b5e3df1c5 --- /dev/null +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -0,0 +1,182 @@ +import BuzzPushKit +import Intents +import UserNotifications +import XCTest + +@testable import Buzz + +final class BuzzCommunicationNotificationTests: XCTestCase { + func testIntentUsesVerifiedSenderAvatarAndGroupName() throws { + let resolution = communicationResolution( + displayName: "Alice", + groupName: "General", + avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) + ) + let descriptor = try XCTUnwrap( + BuzzCommunicationNotificationDescriptor(resolution: resolution) + ) + + let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) + + XCTAssertEqual(intent.sender?.displayName, "Alice") + XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier) + XCTAssertNotNil(intent.sender?.image) + XCTAssertEqual(intent.content, "Hello Buzz") + XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General") + XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier) + XCTAssertNil(intent.recipients) + } + + func testPresentationFallsBackWhenDonationFails() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + ordinary.body = "Hello Buzz" + var updateCalled = false + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in + completion(NSError(domain: "test", code: 1)) + }, + updateContent: { content, _ in + updateCalled = true + return content + } + ) + let completed = expectation(description: "ordinary fallback returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "Alice") + XCTAssertEqual(content.body, "Hello Buzz") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + XCTAssertFalse(updateCalled) + } + + func testPresentationDonatesBeforeSpecializing() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + var order: [String] = [] + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in + order.append("donate") + completion(nil) + }, + updateContent: { _, _ in + order.append("update") + let specialized = UNMutableNotificationContent() + specialized.title = "specialized" + return specialized + } + ) + let completed = expectation(description: "specialized content returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "specialized") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + XCTAssertEqual(order, ["donate", "update"]) + } + + func testPresentationFallsBackWhenSpecializationFails() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + ordinary.body = "Hello Buzz" + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in completion(nil) }, + updateContent: { _, _ in throw NSError(domain: "test", code: 2) } + ) + let completed = expectation(description: "ordinary fallback returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "Alice") + XCTAssertEqual(content.body, "Hello Buzz") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + } + + private func communicationResolution( + displayName: String = "Alice", + groupName: String? = "General", + avatarPNG: Data? = nil + ) -> BuzzPushResolution { + let communityID = "community-id" + let channelID = "channel/general:v5" + return BuzzPushResolution( + title: displayName, + body: "Hello Buzz", + subtitle: "Community", + threadIdentifier: BuzzPushPresentationIdentity.conversation( + communityID: communityID, + channelID: channelID + ), + navigationTarget: BuzzPushNavigationTarget( + eventID: "message-id", + communityID: communityID, + channelID: channelID + ), + senderPubkey: String(repeating: "a", count: 64), + senderAvatarPNG: avatarPNG, + conversationIdentifier: BuzzPushPresentationIdentity.conversation( + communityID: communityID, + channelID: channelID + ), + conversationDisplayName: groupName + ) + } +} + +final class BuzzPushSnapshotEnrichmentTests: XCTestCase { + func testMetadataAuthorityUsesCurrentAppProfileForMatchingRelay() { + let correctProfile = grant( + appProfile: BuzzDevPushEnrollmentDriver.appProfile, + generation: 2, + metadataPubkey: String(repeating: "a", count: 64) + ) + let wrongProfile = grant( + appProfile: "buzz-ios-app-store", + generation: 99, + metadataPubkey: String(repeating: "b", count: 64) + ) + + XCTAssertEqual( + AppDelegate.pushRelayMetadataPubkey( + relayURL: "wss://relay.example/", + grants: [wrongProfile, correctProfile] + ), + correctProfile.relayMetadataPubkey + ) + } + + private func grant( + appProfile: String, + generation: Int64, + metadataPubkey: String + ) -> BuzzPushEndpointGrantRecord { + BuzzPushEndpointGrantRecord( + relayOrigin: "https://relay.example", + relayPubkey: String(repeating: "c", count: 64), + relayMetadataPubkey: metadataPubkey, + installationId: "installation", + endpointGrant: "opaque-grant", + endpointHash: String(repeating: "d", count: 64), + appProfile: appProfile, + endpointEpoch: 1, + generation: generation, + expiresAt: 1_900_000_000 + ) + } +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 2f8dddf2d92..fedf213932e 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -5,6 +5,8 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/community/community_provider.dart'; +import '../../shared/push/push_presentation_cache.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; @@ -139,6 +141,7 @@ class ChannelsNotifier extends AsyncNotifier> { }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); + final communityID = ref.read(activeCommunityProvider).value?.id; _loadThreadInterestStores(myPk); final session = ref.read(relaySessionProvider.notifier); @@ -190,11 +193,17 @@ class ChannelsNotifier extends AsyncNotifier> { final id = event.getTagValue('d'); if (id == null) continue; final existing = latestMetaPerId[id]; - if (existing == null || event.createdAt > existing.createdAt) { + if (existing == null || + event.createdAt > existing.createdAt || + (event.createdAt == existing.createdAt && + event.id.compareTo(existing.id) < 0)) { latestMetaPerId[id] = event; } } final dedupedMetas = latestMetaPerId.values; + if (communityID != null) { + unawaited(cacheBuzzPushChannelEvents(communityID, dedupedMetas)); + } // Resolve DM participant display names. Relay stores DM channels with // literal name="DM"; pure-Nostr architecture pushes name resolution to diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 4354cc2b164..4ca1e92b15e 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../community/community_provider.dart'; import '../crypto/nip_oa.dart'; +import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; import 'user_profile.dart'; @@ -56,6 +58,7 @@ class UserCacheNotifier extends Notifier> { final pubkeys = _pending.toList(); _pending.clear(); + final communityID = ref.read(activeCommunityProvider).value?.id; try { final session = ref.read(relaySessionProvider.notifier); @@ -78,6 +81,9 @@ class UserCacheNotifier extends Notifier> { } state = updated; + if (communityID != null) { + unawaited(cacheBuzzPushProfileEvents(communityID, events)); + } } catch (_) { // Silently fail — we'll just show pubkeys. } diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 4d8596bfeea..bf22c551395 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -137,7 +137,12 @@ class BuzzPushBootstrap extends HookConsumerWidget { ? null : buzzPushSubscriptionsFingerprint(state.accepted!); final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); - final grant = await enrollBuzzPush(config.wsUrl, Env.pushGatewayUrl); + final grant = await enrollBuzzPush( + config.wsUrl, + Env.pushGatewayUrl, + communitiesForSnapshotRefresh: + ref.read(communityListProvider).value ?? [community], + ); if (state.authority == BuzzPushLeaseSubscriptionAuthority.accepted && acceptedFingerprint == desiredFingerprint && state.acceptedGrantGeneration == grant.generation && diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 4e3fc6f887f..8341921371c 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -128,10 +128,15 @@ Future> readBuzzPushEndpointGrants() async { } } +/// Enrolls the endpoint and optionally rewrites the NSE snapshot afterward. +/// +/// The rewrite propagates NIP-11 `self` rotations even when the opaque grant +/// and accepted relay lease remain reusable and their generations do not move. Future enrollBuzzPush( String relayUrl, - String gatewayUrl, -) async { + String gatewayUrl, { + List? communitiesForSnapshotRefresh, +}) async { final raw = await _channel.invokeMapMethod('enrollPush', { 'relayUrl': relayUrl, 'gatewayUrl': gatewayUrl, @@ -141,6 +146,14 @@ Future enrollBuzzPush( } final grant = BuzzPushEndpointGrant.fromMap(raw); await readBuzzPushEndpointGrants(); + if (communitiesForSnapshotRefresh != null) { + try { + await registerBuzzPushCommunitySnapshot(communitiesForSnapshotRefresh); + pushCommunitySnapshotError.value = null; + } catch (error, stackTrace) { + reportPushCommunitySnapshotError(error, stackTrace); + } + } return grant; } diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart new file mode 100644 index 00000000000..fd8a0b5965b --- /dev/null +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/nostr_models.dart'; +import 'push_capability.dart'; + +const _pushPresentationChannel = MethodChannel('buzz/push'); +const _maximumAvatarSourceBytes = 512 * 1024; +const _maximumAvatarPNGBytes = 64 * 1024; +Future _avatarEncodeTail = Future.value(); + +/// The latest best-effort App Group presentation-cache failure. +final pushPresentationCacheError = ValueNotifier(null); + +/// Revalidates a relay event before it crosses into the native cache writer. +bool isVerifiedPushPresentationEvent(NostrEvent event) { + try { + nostr.Event( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ); + return true; + } catch (_) { + return false; + } +} + +/// Exports raw verified kind-0 events. Native code verifies them again before storage. +Future cacheBuzzPushProfileEvents( + String communityID, + Iterable events, +) async { + if (!buzzPushCapabilityEnabled || + defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty) { + return; + } + final verified = events + .where( + (event) => event.kind == 0 && isVerifiedPushPresentationEvent(event), + ) + .toList(); + if (verified.isEmpty) return; + await _invokeBestEffort('cachePresentationProfiles', { + 'communityId': communityID, + 'events': [for (final event in verified) event.toJson()], + }); +} + +/// Exports raw verified kind-39000 events for relay-authority validation and storage. +Future cacheBuzzPushChannelEvents( + String communityID, + Iterable events, +) async { + if (!buzzPushCapabilityEnabled || + defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty) { + return; + } + final verified = events + .where( + (event) => + event.kind == 39000 && isVerifiedPushPresentationEvent(event), + ) + .toList(); + if (verified.isEmpty) return; + await _invokeBestEffort('cachePresentationChannels', { + 'communityId': communityID, + 'events': [for (final event in verified) event.toJson()], + }); +} + +/// Reuses bytes already fetched for a visible foreground avatar. +/// +/// This never starts network I/O. Oversized, malformed, or unsupported images +/// are ignored, and notification delivery remains independent of the cache. +Future cacheBuzzPushAvatarFromLoadedBytes( + String communityID, + String sourceURL, + Uint8List sourceBytes, +) async { + if (!buzzPushCapabilityEnabled || + defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty || + sourceBytes.isEmpty || + sourceBytes.length > _maximumAvatarSourceBytes || + !_isRemoteImageURL(sourceURL)) { + return; + } + final previous = _avatarEncodeTail; + final release = Completer(); + _avatarEncodeTail = release.future; + await previous; + try { + final png = await _boundedAvatarPNG(sourceBytes); + if (png == null) return; + await _invokeBestEffort('cachePresentationAvatar', { + 'communityId': communityID, + 'sourceUrl': sourceURL, + 'png': png, + }); + } finally { + release.complete(); + } +} + +Future _invokeBestEffort( + String method, + Map arguments, +) async { + try { + await _pushPresentationChannel.invokeMethod(method, arguments); + pushPresentationCacheError.value = null; + } on MissingPluginException { + // Push-free builds and non-Runner embeddings intentionally omit the bridge. + } catch (error, stackTrace) { + pushPresentationCacheError.value = error.toString(); + debugPrint('Push presentation cache update failed: $error'); + debugPrintStack(stackTrace: stackTrace); + } +} + +bool _isRemoteImageURL(String value) { + final uri = Uri.tryParse(value.trim()); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty && + uri.userInfo.isEmpty; +} + +Future _boundedAvatarPNG(Uint8List sourceBytes) async { + for (final size in const [128, 96, 64, 48]) { + ui.Codec? codec; + ui.Image? image; + try { + codec = await ui.instantiateImageCodec( + sourceBytes, + targetWidth: size, + targetHeight: size, + allowUpscaling: false, + ); + final frame = await codec.getNextFrame(); + image = frame.image; + final data = await image.toByteData(format: ui.ImageByteFormat.png); + if (data == null) continue; + final png = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + if (png.isNotEmpty && png.length <= _maximumAvatarPNGBytes) return png; + } catch (_) { + return null; + } finally { + image?.dispose(); + codec?.dispose(); + } + } + return null; +} diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 14187df081c..0ab5b5aa204 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -38,6 +38,10 @@ class MediaImageProvider extends ImageProvider { final double scale; final MediaGetAuthService auth; + /// Optional observer for bytes already fetched by the foreground image path. + /// Excluded from equality because it is a side effect, not cache identity. + final ValueChanged? onBytesLoaded; + /// Excluded from equality: transport, not identity. final http.Client client; @@ -45,6 +49,7 @@ class MediaImageProvider extends ImageProvider { required this.url, required this.auth, required this.client, + this.onBytesLoaded, this.scale = 1.0, }); @@ -109,6 +114,7 @@ class MediaImageProvider extends ImageProvider { _cooldownUntil[url] = debugNow().add(_defaultCooldown); throw NetworkImageLoadException(statusCode: 200, uri: uri); } + onBytesLoaded?.call(bytes); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); return decode(buffer); } catch (_) { @@ -181,6 +187,7 @@ class MediaImage extends ConsumerWidget { final FilterQuality filterQuality; final double? decodeWidth; final bool boundDecodeToLayout; + final ValueChanged? onBytesLoaded; const MediaImage({ super.key, @@ -194,6 +201,7 @@ class MediaImage extends ConsumerWidget { this.filterQuality = FilterQuality.medium, this.decodeWidth, this.boundDecodeToLayout = true, + this.onBytesLoaded, }); @override @@ -202,6 +210,7 @@ class MediaImage extends ConsumerWidget { url: url, auth: ref.watch(mediaGetAuthServiceProvider), client: ref.watch(mediaHttpClientProvider), + onBytesLoaded: onBytesLoaded, ); if (decodeWidth != null) { diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 7b40739c4c7..70e34d06d6c 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -1,10 +1,14 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../animated_avatar.dart'; +import '../community/community_provider.dart'; +import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -50,7 +54,7 @@ class AvatarImage extends StatelessWidget { } /// Image content for avatar surfaces whose shape is supplied by their parent. -class AvatarImageContent extends StatefulWidget { +class AvatarImageContent extends ConsumerStatefulWidget { final String? imageUrl; final Widget fallback; final BoxFit fit; @@ -63,10 +67,10 @@ class AvatarImageContent extends StatefulWidget { }); @override - State createState() => _AvatarImageContentState(); + ConsumerState createState() => _AvatarImageContentState(); } -class _AvatarImageContentState extends State { +class _AvatarImageContentState extends ConsumerState { late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); @override @@ -80,6 +84,7 @@ class _AvatarImageContentState extends State { @override Widget build(BuildContext context) { final centeredFallback = Center(child: widget.fallback); + final communityID = ref.watch(activeCommunityProvider).value?.id; return switch (_source) { _EmojiAvatarSource(:final emoji, :final color) => ColoredBox( @@ -111,6 +116,11 @@ class _AvatarImageContentState extends State { _NetworkAvatarSource(:final url) => MediaImage( url: url, fit: widget.fit, + onBytesLoaded: communityID == null + ? null + : (bytes) => unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes), + ), errorBuilder: (_, _, _) => centeredFallback, ), null => centeredFallback, diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 730e011d6e5..448f5b747ae 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/relay/relay_provider.dart'; @@ -18,6 +19,7 @@ void main() { pushAuthorizationGranted.value = null; pushEndpointGrants.value = const []; pushEndpointGrantError.value = null; + pushCommunitySnapshotError.value = null; pendingPushNotificationLink.value = null; installBuzzPushMethodHandler(); }); @@ -85,6 +87,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; final methods = []; final enrollmentArguments = []; + final snapshotArguments = []; final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; messenger.setMockMethodCallHandler(_channel, (call) async { @@ -96,6 +99,10 @@ void main() { if (call.method == 'endpointGrants') { return [_grantMap('new-grant')]; } + if (call.method == 'saveCommunitySnapshot') { + snapshotArguments.add(call.arguments); + return null; + } fail('Unexpected method ${call.method}'); }); @@ -106,6 +113,15 @@ void main() { final secondGrant = await enrollBuzzPush( 'wss://relay.example/', 'https://gateway-two.example/', + communitiesForSnapshotRefresh: [ + Community( + id: 'community-id', + name: 'Community', + relayUrl: 'wss://relay.example/', + pubkey: 'd' * 64, + addedAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ], ); expect(firstGrant.endpointGrant, 'new-grant'); @@ -125,6 +141,24 @@ void main() { 'endpointGrants', 'enrollPush', 'endpointGrants', + 'saveCommunitySnapshot', + ]); + expect(snapshotArguments, [ + { + 'communities': [ + { + 'id': 'community-id', + 'name': 'Community', + 'relayUrl': 'wss://relay.example/', + 'pubkey': 'd' * 64, + 'pushSubscriptionState': { + 'authority': 'desired', + 'desired': [], + }, + }, + ], + 'signingKeys': {}, + }, ]); expect(pushEndpointGrants.value.single.endpointGrant, 'new-grant'); }, diff --git a/mobile/test/shared/push/push_presentation_cache_test.dart b/mobile/test/shared/push/push_presentation_cache_test.dart new file mode 100644 index 00000000000..8954d70cf96 --- /dev/null +++ b/mobile/test/shared/push/push_presentation_cache_test.dart @@ -0,0 +1,80 @@ +import 'package:buzz/shared/push/push_presentation_cache.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const secretKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + + test('accepts a valid signed profile event', () { + final signed = nostr.Event.from( + kind: 0, + content: '{"display_name":"Alice"}', + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test('accepts opaque channel IDs in a valid signed metadata event', () { + final signed = nostr.Event.from( + kind: 39000, + content: '', + tags: const [ + ['d', 'channel/general:v5'], + ['name', 'General'], + ], + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test('rejects changed content and malformed signatures', () { + final signed = nostr.Event.from( + kind: 0, + content: '{"name":"Alice"}', + secretKey: secretKey, + createdAt: 1700000000, + ); + final event = NostrEvent.fromJson(signed.toMap()); + + expect( + isVerifiedPushPresentationEvent( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: '{"name":"Mallory"}', + sig: event.sig, + ), + ), + isFalse, + ); + expect( + isVerifiedPushPresentationEvent( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: '00', + ), + ), + isFalse, + ); + }); +} From 7bdefdf7a645ed1be3df20490b9af9548a027b60 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 21 Aug 2026 21:18:15 -0700 Subject: [PATCH 05/27] fix(db): renumber push message migration Signed-off-by: Tom Brow --- crates/buzz-db/src/migration.rs | 6 +++--- ...2_push_message_kinds.sql => 0033_push_message_kinds.sql} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename migrations/{0032_push_message_kinds.sql => 0033_push_message_kinds.sql} (100%) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 6a34102aab6..c584f00e125 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -641,7 +641,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 32); + assert_eq!(migrations.len(), 33); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1110,8 +1110,8 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations[31].version, 32); - let sql = migrations[31].sql.as_str(); + assert_eq!(migrations[32].version, 33); + let sql = migrations[32].sql.as_str(); assert!(sql.contains("CREATE OR REPLACE FUNCTION enqueue_push_match_job")); assert!(sql.contains("NEW.kind IN (9, 40002, 45001, 45003)")); assert!(!sql.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); diff --git a/migrations/0032_push_message_kinds.sql b/migrations/0033_push_message_kinds.sql similarity index 100% rename from migrations/0032_push_message_kinds.sql rename to migrations/0033_push_message_kinds.sql From daeb59877b075c2344a7c86c94a25c0a4ba828bf Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 21 Aug 2026 21:20:57 -0700 Subject: [PATCH 06/27] fix(push): align fresh database message matching Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-db/src/migration.rs | 4 ++++ crates/buzz-relay/src/handlers/push_lease.rs | 2 +- schema/schema.sql | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index c584f00e125..2afc4180102 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -1115,6 +1115,10 @@ mod tests { assert!(sql.contains("CREATE OR REPLACE FUNCTION enqueue_push_match_job")); assert!(sql.contains("NEW.kind IN (9, 40002, 45001, 45003)")); assert!(!sql.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); + + let desired_schema = include_str!("../../../schema/schema.sql"); + assert!(desired_schema.contains("NEW.kind IN (9, 40002, 45001, 45003)")); + assert!(!desired_schema.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); } #[test] diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index f858611dff0..d3e0188dbbb 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -709,7 +709,7 @@ mod tests { .collect::>() .join(", "); let predicate = format!("NEW.kind IN ({kinds})"); - let migration = include_str!("../../../../migrations/0032_push_message_kinds.sql"); + let migration = include_str!("../../../../migrations/0033_push_message_kinds.sql"); assert!( migration.contains(&predicate), "migration trigger must use PUSH_KINDS exactly: {predicate}" diff --git a/schema/schema.sql b/schema/schema.sql index 6e14e6be1bf..66208bdc0f5 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -920,7 +920,7 @@ BEGIN -- Keep this allowlist identical to the relay's validated NIP-PL descriptor. -- Centralizing it on the events table covers every durable producer, -- including internal paths that bypass live dispatch. - IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN + IF NEW.kind IN (9, 40002, 45001, 45003) THEN PERFORM pg_advisory_xact_lock_shared( hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0)); IF EXISTS ( From ed34cf4a18070b7854aef9c9df94cce891587aee Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 21 Aug 2026 21:31:09 -0700 Subject: [PATCH 07/27] test(mobile): cover push presentation bridge gating Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- scripts/test-mobile-worktree-overrides.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index d3472410f69..d9f0625a8a0 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -207,7 +207,7 @@ for config in "$debug_xcconfig" "$release_xcconfig"; do '^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements$' \ "$(basename "$config") uses the push-free Runner entitlements" assert_xcconfig_value "$config" \ - '^EXCLUDED_SOURCE_FILE_NAMES = NotificationService\.appex PushNativeState\.swift PushEndpointGrantStore\.swift$' \ + '^EXCLUDED_SOURCE_FILE_NAMES = NotificationService\.appex PushNativeState\.swift PushEndpointGrantStore\.swift PushPresentationCacheBridge\.swift$' \ "$(basename "$config") excludes native push sources and extension product" done From bf76a5300b9215152f702c204ed90c8b241aeb57 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 21 Aug 2026 23:18:55 -0700 Subject: [PATCH 08/27] feat(mobile): add rich communication push context Signed-off-by: Tom Brow --- .../BuzzCommunicationNotification.swift | 21 +- .../BuzzPushNotificationResolver.swift | 170 +++++++++-- .../BuzzPushPresentationCache.swift | 212 ++++++++++++- .../BuzzPushConversationResolverTests.swift | 266 +++++++++++++++++ .../BuzzPushNotificationResolverTests.swift | 135 ++++++--- .../BuzzPushPresentationCacheTests.swift | 279 +++++++++++++++++- mobile/ios/Runner/AppDelegate.swift | 33 ++- mobile/ios/Runner/PushNativeState.swift | 39 +++ .../Runner/PushPresentationCacheBridge.swift | 13 +- .../BuzzCommunicationNotificationTests.swift | 102 ++++++- .../features/channels/channels_provider.dart | 30 +- .../shared/push/push_presentation_cache.dart | 114 ++++++- .../push/push_presentation_cache_test.dart | 68 +++++ 13 files changed, 1355 insertions(+), 127 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift index dac1a5ee563..5946d5a92c2 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -8,6 +8,8 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { public let messageBody: String public let conversationIdentifier: String public let conversationDisplayName: String? + /// Verified recipients represented by the incoming message, excluding its sender. + public let recipientCount: Int public init( senderDisplayName: String, @@ -15,7 +17,8 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { senderAvatarPNG: Data?, messageBody: String, conversationIdentifier: String, - conversationDisplayName: String? + conversationDisplayName: String?, + recipientCount: Int ) { self.senderDisplayName = senderDisplayName self.senderIdentifier = senderIdentifier @@ -23,6 +26,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { self.messageBody = messageBody self.conversationIdentifier = conversationIdentifier self.conversationDisplayName = conversationDisplayName + self.recipientCount = recipientCount } public init?(resolution: BuzzPushResolution) { @@ -30,7 +34,9 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { let senderPubkey = resolution.senderPubkey, !senderPubkey.isEmpty, let conversationIdentifier = resolution.conversationIdentifier, - !conversationIdentifier.isEmpty + !conversationIdentifier.isEmpty, + let recipientCount = resolution.conversationRecipientCount, + recipientCount > 0 else { return nil } self.init( senderDisplayName: resolution.title, @@ -41,7 +47,8 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { senderAvatarPNG: resolution.senderAvatarPNG, messageBody: resolution.body, conversationIdentifier: conversationIdentifier, - conversationDisplayName: resolution.conversationDisplayName + conversationDisplayName: resolution.conversationDisplayName, + recipientCount: recipientCount ) } } @@ -116,7 +123,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { isMe: false, suggestionType: .none ) - return INSendMessageIntent( + let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: descriptor.messageBody, @@ -128,6 +135,12 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { sender: sender, attachments: nil ) + if descriptor.conversationDisplayName != nil { + let donationMetadata = INSendMessageIntentDonationMetadata() + donationMetadata.recipientCount = descriptor.recipientCount + intent.donationMetadata = donationMetadata + } + return intent } } #endif diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 6895245474e..6cd76ecf740 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -15,6 +15,8 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable { public let senderAvatarPNG: Data? public let conversationIdentifier: String? public let conversationDisplayName: String? + /// Exact verified recipient count for Communication Notifications specialization. + public let conversationRecipientCount: Int? public init( title: String, @@ -25,7 +27,8 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable { senderPubkey: String? = nil, senderAvatarPNG: Data? = nil, conversationIdentifier: String? = nil, - conversationDisplayName: String? = nil + conversationDisplayName: String? = nil, + conversationRecipientCount: Int? = nil ) { self.title = title self.body = body @@ -36,6 +39,7 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable { self.senderAvatarPNG = senderAvatarPNG self.conversationIdentifier = conversationIdentifier self.conversationDisplayName = conversationDisplayName + self.conversationRecipientCount = conversationRecipientCount } } @@ -195,10 +199,26 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { now: timestamp, lifetime: presentationCacheLifetime ) - let channelNeedsRefresh = channelID != nil && Self.isStale( - cachedAt: cachedChannel?.cachedAt, - now: timestamp, - lifetime: presentationCacheLifetime + let membershipNeedsRefresh: Bool = { + guard let cachedChannel else { return true } + if Self.isStale( + cachedAt: cachedChannel.membershipCachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) { + return true + } + guard let memberCount = cachedChannel.memberCount else { return true } + guard memberCount <= BuzzPushPresentationCacheStore.maximumMembersPerChannel + else { return false } + return cachedChannel.memberDigests?.count != memberCount + }() + let channelNeedsRefresh = channelID != nil && ( + Self.isStale( + cachedAt: cachedChannel?.cachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) || membershipNeedsRefresh ) let fallback = Self.makeResolution( event: event, @@ -216,7 +236,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { community: community, refreshProfile: profileNeedsRefresh, refreshChannel: channelNeedsRefresh - ) { refreshedProfileEvent, refreshedChannelEvent in + ) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in let profile = refreshedProfileEvent.flatMap { guard BuzzPushPresentationCacheStore.shouldReplace( existingCreatedAt: cachedProfile?.eventCreatedAt, @@ -232,19 +252,32 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { cachedAt: timestamp ) } ?? cachedProfile - let channel = refreshedChannelEvent.flatMap { - guard let relayMetadataPubkey else { return nil } + let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in guard BuzzPushPresentationCacheStore.shouldReplace( existingCreatedAt: cachedChannel?.eventCreatedAt, existingID: cachedChannel?.eventID, - candidateCreatedAt: $0.createdAt, - candidateID: $0.id + candidateCreatedAt: event.createdAt, + candidateID: event.id ) else { return nil } - return Self.ephemeralChannel( - event: $0, + return event + } + let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in + guard BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.membershipEventCreatedAt, + existingID: cachedChannel?.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { return nil } + return event + } + let channel = relayMetadataPubkey.flatMap { + Self.ephemeralChannel( + metadataEvent: newerChannelEvent, + membershipEvent: newerMembershipEvent, + cached: cachedChannel, communityID: community.id, relayOrigin: relayOrigin ?? community.relayUrl, - relayMetadataPubkey: relayMetadataPubkey, + relayMetadataPubkey: $0, cachedAt: timestamp ) } ?? cachedChannel @@ -264,13 +297,15 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { community: PushLeaseCommunity, refreshProfile: Bool, refreshChannel: Bool, - completion: @escaping (VerifiedNostrEvent?, VerifiedNostrEvent?) -> Void + completion: @escaping ( + VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? + ) -> Void ) { guard let privateKey = loadPrivateKey(community.id), let relayURL = community.relayURL, let url = URL(string: "/query", relativeTo: relayURL) else { - completion(nil, nil) + completion(nil, nil, nil) return } let channelID = Self.tagValue("h", in: event) @@ -286,11 +321,17 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { "#d": [channelID], "limit": 1, ]) + filters.append([ + "kinds": [39_002], + "authors": [relayMetadataPubkey], + "#d": [channelID], + "limit": 1, + ]) } guard !filters.isEmpty, let body = try? JSONSerialization.data(withJSONObject: filters) else { - completion(nil, nil) + completion(nil, nil, nil) return } @@ -305,7 +346,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { body: body, privateKeyHex: privateKey ) else { - completion(nil, nil) + completion(nil, nil, nil) return } request.setValue(auth, forHTTPHeaderField: "Authorization") @@ -319,7 +360,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let data = try? Data(contentsOf: fileURL), let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) else { - completion(nil, nil) + completion(nil, nil, nil) return } let verified = events.filter { $0.hasValidIDAndSignature() } @@ -333,7 +374,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { && Self.tagValue("d", in: $0) == channelID }) } : nil - completion(profile, channel) + let membership = refreshChannel ? channelID.flatMap { channelID in + Self.newest(verified.filter { + $0.kind == 39_002 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + completion(profile, channel, membership) }.resume() } @@ -375,6 +423,9 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let conversationIdentifier = channelID.map { BuzzPushPresentationIdentity.conversation(communityID: community.id, channelID: $0) } + let conversation = channel.flatMap { + communicationConversation(event: event, community: community, channel: $0) + } return BuzzPushResolution( title: profile?.displayName ?? shortPubkey(event.pubkey), body: body, @@ -390,7 +441,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { senderPubkey: event.pubkey.lowercased(), senderAvatarPNG: profile?.avatarPNG, conversationIdentifier: conversationIdentifier, - conversationDisplayName: channel?.displayName + conversationDisplayName: conversation?.displayName, + conversationRecipientCount: conversation?.recipientCount ) } @@ -416,27 +468,89 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } private static func ephemeralChannel( - event: VerifiedNostrEvent, + metadataEvent: VerifiedNostrEvent?, + membershipEvent: VerifiedNostrEvent?, + cached: BuzzPushCachedChannel?, communityID: String, relayOrigin: String, relayMetadataPubkey: String, cachedAt: Int ) -> BuzzPushCachedChannel? { - guard let channelID = tagValue("d", in: event), !channelID.isEmpty else { return nil } + guard metadataEvent != nil || cached != nil else { return nil } + let channelID = metadataEvent.flatMap { tagValue("d", in: $0) } ?? cached?.channelID + guard let channelID, !channelID.isEmpty, + let eventID = metadataEvent?.id ?? cached?.eventID, + let eventCreatedAt = metadataEvent?.createdAt ?? cached?.eventCreatedAt, + let metadataCachedAt = metadataEvent == nil ? cached?.cachedAt : cachedAt + else { return nil } + let membership = membershipEvent.flatMap { + BuzzPushPresentationCacheStore.normalizedChannelMembership( + $0, + communityID: communityID, + channelID: channelID + ) + } + let acceptedMembershipEvent = membership == nil ? nil : membershipEvent return BuzzPushCachedChannel( communityID: communityID, relayOrigin: relayOrigin, channelID: channelID, relayMetadataPubkey: relayMetadataPubkey, - displayName: BuzzPushPresentationCacheStore.normalizedDisplayName( - tagValue("name", in: event) - ), - eventID: event.id, - eventCreatedAt: event.createdAt, - cachedAt: cachedAt + displayName: metadataEvent.map { + BuzzPushPresentationCacheStore.normalizedDisplayName(tagValue("name", in: $0)) + } ?? cached?.displayName, + channelType: metadataEvent.map { + BuzzPushPresentationCacheStore.normalizedChannelType(tagValue("t", in: $0)) + } ?? cached?.channelType, + memberCount: membership?.count ?? cached?.memberCount, + memberDigests: membership?.digests ?? cached?.memberDigests, + membershipEventID: acceptedMembershipEvent?.id ?? cached?.membershipEventID, + membershipEventCreatedAt: acceptedMembershipEvent?.createdAt + ?? cached?.membershipEventCreatedAt, + membershipCachedAt: acceptedMembershipEvent == nil + ? cached?.membershipCachedAt : cachedAt, + eventID: eventID, + eventCreatedAt: eventCreatedAt, + cachedAt: metadataCachedAt ) } + private static func communicationConversation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + channel: BuzzPushCachedChannel + ) -> (displayName: String?, recipientCount: Int)? { + guard let currentUser = community.pubkey?.lowercased(), + let memberCount = channel.memberCount, + let memberDigests = channel.memberDigests, + memberDigests.count == memberCount + else { return nil } + let currentUserDigest = BuzzPushPresentationIdentity.channelMember( + communityID: community.id, + channelID: channel.channelID, + pubkey: currentUser + ) + guard memberDigests.contains(currentUserDigest) else { return nil } + let senderDigest = BuzzPushPresentationIdentity.channelMember( + communityID: community.id, + channelID: channel.channelID, + pubkey: event.pubkey + ) + let senderIsMember = memberDigests.contains(senderDigest) + let recipientCount = memberCount - (senderIsMember ? 1 : 0) + guard recipientCount > 0 else { return nil } + + if channel.channelType == "dm" { + guard memberCount == 2, senderIsMember else { return nil } + return (nil, recipientCount) + } + guard let channelType = channel.channelType, + ["stream", "forum"].contains(channelType), + let displayName = channel.displayName + else { return nil } + return (displayName, recipientCount) + } + private static func newest(_ events: [VerifiedNostrEvent]) -> VerifiedNostrEvent? { events.sorted { $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift index 3645913704c..df0a7360d9f 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -43,6 +43,18 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { public let channelID: String public let relayMetadataPubkey: String public let displayName: String? + /// Relay-verified Buzz channel type, when recognized. + public let channelType: String? + /// Exact unique-member count when bounded, or a value above the bound when oversized. + public let memberCount: Int? + /// Complete community-and-channel-scoped member digests, when within bounds. + public let memberDigests: [String]? + /// Event ID that established the cached membership snapshot. + public let membershipEventID: String? + /// Creation time of the cached membership replacement event. + public let membershipEventCreatedAt: Int? + /// Device time when the membership snapshot was cached. + public let membershipCachedAt: Int? public let eventID: String public let eventCreatedAt: Int public let cachedAt: Int @@ -53,6 +65,12 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { channelID: String, relayMetadataPubkey: String, displayName: String?, + channelType: String? = nil, + memberCount: Int? = nil, + memberDigests: [String]? = nil, + membershipEventID: String? = nil, + membershipEventCreatedAt: Int? = nil, + membershipCachedAt: Int? = nil, eventID: String, eventCreatedAt: Int, cachedAt: Int @@ -62,6 +80,12 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { self.channelID = channelID self.relayMetadataPubkey = relayMetadataPubkey self.displayName = displayName + self.channelType = channelType + self.memberCount = memberCount + self.memberDigests = memberDigests + self.membershipEventID = membershipEventID + self.membershipEventCreatedAt = membershipEventCreatedAt + self.membershipCachedAt = membershipCachedAt self.eventID = eventID self.eventCreatedAt = eventCreatedAt self.cachedAt = cachedAt @@ -135,6 +159,8 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { public static let freshnessLifetime: TimeInterval = 24 * 60 * 60 public static let maximumProfiles = 256 public static let maximumChannels = 512 + public static let maximumMembersPerChannel = 512 + public static let maximumTotalMemberDigests = 8_192 public static let maximumAvatarBytes = 64 * 1024 public static let maximumTotalAvatarBytes = 4 * 1024 * 1024 public static let maximumSnapshotBytes = 8 * 1024 * 1024 @@ -156,7 +182,8 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { updates: [BuzzPushProfileCacheUpdate] ) throws -> Set { guard Self.isBoundedOpaqueID(communityID), - let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin) + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + updates.count <= Self.maximumProfiles else { return [] } lock.lock() defer { lock.unlock() } @@ -218,17 +245,20 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { }) } - /// Saves signature-verified kind-39000 events for the selected community. + /// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots. public func updateChannels( communityID: String, relayOrigin: String, relayMetadataPubkey: String, - events: [VerifiedNostrEvent] + metadataEvents: [VerifiedNostrEvent], + membershipEvents: [VerifiedNostrEvent] ) throws { let normalizedRelayPubkey = relayMetadataPubkey.lowercased() guard Self.isBoundedOpaqueID(communityID), let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), - Self.isHexPubkey(normalizedRelayPubkey) + Self.isHexPubkey(normalizedRelayPubkey), + metadataEvents.count <= Self.maximumChannels, + membershipEvents.count <= Self.maximumChannels else { return } @@ -237,7 +267,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { var snapshot = loadLocked() let cachedAt = Int(now().timeIntervalSince1970) - for event in events { + for event in metadataEvents { guard event.kind == 39_000, event.hasValidIDAndSignature(), event.pubkey.lowercased() == normalizedRelayPubkey, let channelID = Self.tagValue("d", in: event), @@ -248,7 +278,8 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { && $0.channelID == channelID } let existing = index.map { snapshot.channels[$0] } - guard Self.shouldReplace( + let hasCurrentAuthority = existing?.relayMetadataPubkey == normalizedRelayPubkey + guard !hasCurrentAuthority || Self.shouldReplace( existingCreatedAt: existing?.eventCreatedAt, existingID: existing?.eventID, candidateCreatedAt: event.createdAt, @@ -261,6 +292,13 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { channelID: channelID, relayMetadataPubkey: normalizedRelayPubkey, displayName: Self.normalizedDisplayName(Self.tagValue("name", in: event)), + channelType: Self.normalizedChannelType(Self.tagValue("t", in: event)), + memberCount: hasCurrentAuthority ? existing?.memberCount : nil, + memberDigests: hasCurrentAuthority ? existing?.memberDigests : nil, + membershipEventID: hasCurrentAuthority ? existing?.membershipEventID : nil, + membershipEventCreatedAt: hasCurrentAuthority + ? existing?.membershipEventCreatedAt : nil, + membershipCachedAt: hasCurrentAuthority ? existing?.membershipCachedAt : nil, eventID: event.id, eventCreatedAt: event.createdAt, cachedAt: cachedAt @@ -271,6 +309,50 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { snapshot.channels.append(entry) } } + Self.enforceChannelCountBound(&snapshot) + + for event in membershipEvents { + guard event.kind == 39_002, event.hasValidIDAndSignature(), + event.pubkey.lowercased() == normalizedRelayPubkey, + let channelID = Self.tagValue("d", in: event), + Self.isBoundedOpaqueID(channelID), + let membership = Self.normalizedChannelMembership( + event, + communityID: communityID, + channelID: channelID + ), + let index = snapshot.channels.firstIndex(where: { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.channelID == channelID + && $0.relayMetadataPubkey == normalizedRelayPubkey + }) + else { continue } + let existing = snapshot.channels[index] + guard Self.shouldReplace( + existingCreatedAt: existing.membershipEventCreatedAt, + existingID: existing.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { continue } + + snapshot.channels[index] = BuzzPushCachedChannel( + communityID: existing.communityID, + relayOrigin: existing.relayOrigin, + channelID: existing.channelID, + relayMetadataPubkey: existing.relayMetadataPubkey, + displayName: existing.displayName, + channelType: existing.channelType, + memberCount: membership.count, + memberDigests: membership.digests, + membershipEventID: event.id, + membershipEventCreatedAt: event.createdAt, + membershipCachedAt: cachedAt, + eventID: existing.eventID, + eventCreatedAt: existing.eventCreatedAt, + cachedAt: existing.cachedAt + ) + Self.enforceMemberDigestBound(&snapshot) + } Self.enforceBounds(&snapshot) try writeLocked(snapshot) @@ -372,6 +454,15 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { data = try encoder.encode(bounded) } + if data.count > Self.maximumSnapshotBytes { + for index in bounded.channels.indices.reversed() { + guard bounded.channels[index].memberDigests != nil else { continue } + bounded.channels[index] = Self.removingMemberDigests(from: bounded.channels[index]) + data = try encoder.encode(bounded) + if data.count <= Self.maximumSnapshotBytes { break } + } + } + while data.count > Self.maximumSnapshotBytes, !bounded.profiles.isEmpty || !bounded.channels.isEmpty { @@ -413,6 +504,40 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { return bounded.isEmpty ? nil : bounded } + static func normalizedChannelType(_ value: String?) -> String? { + guard let value, ["stream", "forum", "dm"].contains(value) else { return nil } + return value + } + + static func normalizedChannelMembership( + _ event: VerifiedNostrEvent, + communityID: String, + channelID: String + ) -> (count: Int, digests: [String]?)? { + var pubkeys = Set() + var exceededMemberBound = false + for tag in event.tags where tag.first == "p" { + guard tag.count >= 2 else { return nil } + let pubkey = tag[1].lowercased() + guard isHexPubkey(pubkey) else { return nil } + if !exceededMemberBound { + pubkeys.insert(pubkey) + exceededMemberBound = pubkeys.count > maximumMembersPerChannel + } + } + let digests = exceededMemberBound ? nil : pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: communityID, + channelID: channelID, + pubkey: $0 + ) + }.sorted() + return ( + exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count, + digests + ) + } + static func normalizedAvatarURL(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -480,9 +605,8 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { snapshot.profiles = Array( snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles) ) - snapshot.channels = Array( - snapshot.channels.sorted(by: channelNewestFirst).prefix(maximumChannels) - ) + enforceChannelCountBound(&snapshot) + enforceMemberDigestBound(&snapshot) var avatarBytes = snapshot.profiles.reduce(0) { $0 + ($1.avatarPNG?.count ?? 0) } guard avatarBytes > maximumTotalAvatarBytes else { return } @@ -495,6 +619,30 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { } } + private static func enforceChannelCountBound( + _ snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + snapshot.channels = Array( + snapshot.channels.sorted(by: channelNewestFirst).prefix(maximumChannels) + ) + } + + private static func enforceMemberDigestBound( + _ snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + snapshot.channels.sort(by: channelNewestFirst) + var memberDigestCount = snapshot.channels.reduce(0) { + $0 + ($1.memberDigests?.count ?? 0) + } + guard memberDigestCount > maximumTotalMemberDigests else { return } + for index in snapshot.channels.indices.reversed() { + guard let memberDigests = snapshot.channels[index].memberDigests else { continue } + memberDigestCount -= memberDigests.count + snapshot.channels[index] = removingMemberDigests(from: snapshot.channels[index]) + if memberDigestCount <= maximumTotalMemberDigests { break } + } + } + private static func removingAvatar( from profile: BuzzPushCachedProfile ) -> BuzzPushCachedProfile { @@ -511,6 +659,27 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { ) } + private static func removingMemberDigests( + from channel: BuzzPushCachedChannel + ) -> BuzzPushCachedChannel { + BuzzPushCachedChannel( + communityID: channel.communityID, + relayOrigin: channel.relayOrigin, + channelID: channel.channelID, + relayMetadataPubkey: channel.relayMetadataPubkey, + displayName: channel.displayName, + channelType: channel.channelType, + memberCount: channel.memberCount, + memberDigests: nil, + membershipEventID: channel.membershipEventID, + membershipEventCreatedAt: channel.membershipEventCreatedAt, + membershipCachedAt: channel.membershipCachedAt, + eventID: channel.eventID, + eventCreatedAt: channel.eventCreatedAt, + cachedAt: channel.cachedAt + ) + } + private static func removeOldestEntries( _ count: Int, from snapshot: inout BuzzPushPresentationCacheSnapshot @@ -521,8 +690,9 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { case (.some, nil): snapshot.profiles.removeLast() case (nil, .some): snapshot.channels.removeLast() case (let profile?, let channel?): - if profile.cachedAt < channel.cachedAt - || (profile.cachedAt == channel.cachedAt && profile.eventID <= channel.eventID) + let channelCachedAt = channelLastCachedAt(channel) + if profile.cachedAt < channelCachedAt + || (profile.cachedAt == channelCachedAt && profile.eventID <= channel.eventID) { snapshot.profiles.removeLast() } else { @@ -543,7 +713,13 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { _ lhs: BuzzPushCachedChannel, _ rhs: BuzzPushCachedChannel ) -> Bool { - lhs.cachedAt == rhs.cachedAt ? lhs.eventID > rhs.eventID : lhs.cachedAt > rhs.cachedAt + let lhsCachedAt = channelLastCachedAt(lhs) + let rhsCachedAt = channelLastCachedAt(rhs) + return lhsCachedAt == rhsCachedAt ? lhs.eventID > rhs.eventID : lhsCachedAt > rhsCachedAt + } + + private static func channelLastCachedAt(_ channel: BuzzPushCachedChannel) -> Int { + max(channel.cachedAt, channel.membershipCachedAt ?? 0) } } @@ -557,6 +733,18 @@ public enum BuzzPushPresentationIdentity { scoped(namespace: "sender", values: [communityID, pubkey.lowercased()]) } + /// Returns a stable, channel-scoped digest used for exact local membership checks. + public static func channelMember( + communityID: String, + channelID: String, + pubkey: String + ) -> String { + scoped( + namespace: "channel-member", + values: [communityID, channelID, pubkey.lowercased()] + ) + } + private static func scoped(namespace: String, values: [String]) -> String { let encoded = (try? JSONEncoder().encode([namespace] + values)) ?? Data() return "buzz.\(namespace).\(VerifiedNostrEvent.hex(SHA256.hash(data: encoded)))" diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift new file mode 100644 index 00000000000..9bf3b85f50b --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift @@ -0,0 +1,266 @@ +import Foundation +import XCTest + +@testable import BuzzPushKit + +extension BuzzPushNotificationResolverTests { + func testOpenChannelOutsiderUsesEveryVerifiedMemberAsARecipient() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Hello from an open-channel guest" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, relayPubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationRecipientCount, 2) + } + + func testMissingCurrentUserInVerifiedRosterUsesOrdinaryPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Do not fabricate a group" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 1, + memberDigests: Self.memberDigests([message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertNil(result.conversationRecipientCount) + XCTAssertEqual(result.subtitle, "Community") + } + + func testFreshEvictedMembershipDigestsTriggerOneBoundedRefresh() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Refresh an evicted roster" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: nil, + membershipEventID: "evicted-membership", + membershipEventCreatedAt: Self.now - 1, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + let refreshedMembership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_002, + tags: [ + ["d", Self.channelID], + ["p", Self.ownPubkey], + ["p", message.pubkey], + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + XCTAssertEqual(request.timeoutInterval, 1) + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([refreshedMembership]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testTwoPersonDMUsesDirectCommunicationPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Direct message" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "DM", + channelType: "dm", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.conversationRecipientCount, 1) + } + + func testGroupDMWithoutVerifiedDisplayLabelUsesOrdinaryPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Group direct message" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "DM", + channelType: "dm", + memberCount: 3, + memberDigests: Self.memberDigests([ + Self.ownPubkey, + message.pubkey, + relayPubkey, + ]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertNil(result.conversationRecipientCount) + XCTAssertEqual(result.subtitle, "Community") + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 817bca48614..c91ef665e49 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -10,14 +10,14 @@ import XCTest #endif final class BuzzPushNotificationResolverTests: XCTestCase { - private static let privateKey = String(repeating: "0", count: 63) + "1" - private static let ownPubkey = + static let privateKey = String(repeating: "0", count: 63) + "1" + static let ownPubkey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - private static let now = Int(Date().timeIntervalSince1970) - private static let profilePrivateKey = String(repeating: "0", count: 63) + "2" - private static let relayPrivateKey = String(repeating: "0", count: 63) + "3" - private static let gatewayBody = "Reconnect to your relay now" - private static let channelID = "123e4567-e89b-42d3-a456-426614174000" + static let now = Int(Date().timeIntervalSince1970) + static let profilePrivateKey = String(repeating: "0", count: 63) + "2" + static let relayPrivateKey = String(repeating: "0", count: 63) + "3" + static let gatewayBody = "Reconnect to your relay now" + static let channelID = "123e4567-e89b-42d3-a456-426614174000" override func setUp() { super.setUp() @@ -198,6 +198,12 @@ final class BuzzPushNotificationResolverTests: XCTestCase { channelID: Self.channelID, relayMetadataPubkey: relayPubkey, displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, eventID: "channel-event", eventCreatedAt: Self.now, cachedAt: Self.now @@ -221,6 +227,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.title, "Alice") XCTAssertEqual(result.senderAvatarPNG, avatar) XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertEqual(URLProtocolStub.requests.count, 1) } @@ -255,6 +262,12 @@ final class BuzzPushNotificationResolverTests: XCTestCase { channelID: Self.channelID, relayMetadataPubkey: relayPubkey, displayName: "Stale General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: staleAt, + membershipCachedAt: staleAt, eventID: "channel-event", eventCreatedAt: staleAt, cachedAt: staleAt @@ -281,6 +294,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.title, "Stale Alice") XCTAssertEqual(result.conversationDisplayName, "Stale General") + XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertEqual(URLProtocolStub.requests.count, 2) } @@ -326,6 +340,12 @@ final class BuzzPushNotificationResolverTests: XCTestCase { channelID: Self.channelID, relayMetadataPubkey: olderChannel.pubkey, displayName: "Newer Cached General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "cached-membership", + membershipEventCreatedAt: Self.now - 10, + membershipCachedAt: staleAt, eventID: String(repeating: "f", count: 64), eventCreatedAt: Self.now - 10, cachedAt: staleAt @@ -396,6 +416,12 @@ final class BuzzPushNotificationResolverTests: XCTestCase { channelID: Self.channelID, relayMetadataPubkey: relayMetadataPubkey, displayName: "Stale General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "cached-membership", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, eventID: "cached-channel", eventCreatedAt: Self.now, cachedAt: staleAt @@ -447,7 +473,17 @@ final class BuzzPushNotificationResolverTests: XCTestCase { privateKey: Self.relayPrivateKey, createdAt: Self.now, kind: 39_000, - tags: [["d", Self.channelID], ["name", "Fresh General"]] + tags: [["d", Self.channelID], ["name", "Fresh General"], ["t", "stream"]] + ) + let membership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_002, + tags: [ + ["d", Self.channelID], + ["p", Self.ownPubkey], + ["p", message.pubkey], + ] ) URLProtocolStub.handler = { request in if URLProtocolStub.requests.count == 1 { @@ -456,7 +492,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { return Self.response( request, status: 200, - data: try JSONEncoder().encode([profile, channel]) + data: try JSONEncoder().encode([profile, channel, membership]) ) } @@ -473,6 +509,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.title, "Fresh Alice") XCTAssertEqual(result.conversationDisplayName, "Fresh General") + XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertNil(result.senderAvatarPNG) XCTAssertEqual(URLProtocolStub.requests.count, 2) } @@ -586,7 +623,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 1) } - private func makeResolver( + func makeResolver( communitiesData: Data?, privateKeys: [String: String] = ["community-id": privateKey], presentationCacheData: Data? = nil, @@ -603,7 +640,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) } - private func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? { + func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? { let completed = expectation(description: "resolver completed") var result: BuzzPushResolution? resolver.resolve { @@ -614,7 +651,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { return result } - private func community( + func community( id: String = "community-id", name: String = "Community", relayUrl: String = "https://relay.example", @@ -643,7 +680,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) } - private func snapshotData(_ communities: [PushLeaseCommunity]) throws -> Data { + func snapshotData(_ communities: [PushLeaseCommunity]) throws -> Data { try JSONEncoder().encode(PushLeaseSnapshot(communities: communities)) } @@ -670,7 +707,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { {"kind":9,"created_at":1785551670,"tags":[["h","123e4567-e89b-42d3-a456-426614174000"]],"content":" Hello [Buzz](https://buzz.block.xyz) ","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","id":"233ccf24ec7c94808f9ef08b0c986b6df1bc3843ff72a9f8d016e2a77c77429b","sig":"d39dcd413839b872ed75a979b2c1542247fde636709966905c9e424e227a43897dc67b71ec84178a3faad0634f9bcdf0b48a56ebac84a2ac6e58124b8b6476e6"} """# - private static func response( + static func response( _ request: URLRequest, status: Int, data: Data @@ -684,13 +721,23 @@ final class BuzzPushNotificationResolverTests: XCTestCase { return (response, data) } - private static func pubkey(for privateKey: String) throws -> String { + static func pubkey(for privateKey: String) throws -> String { let bytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey)) let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) return VerifiedNostrEvent.hex(key.xonly.bytes) } - private static func signedEvent( + static func memberDigests(_ pubkeys: [String]) -> [String] { + pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: "community-id", + channelID: channelID, + pubkey: $0 + ) + }.sorted() + } + + static func signedEvent( privateKey: String, createdAt: Int, kind: Int, @@ -721,37 +768,37 @@ final class BuzzPushNotificationResolverTests: XCTestCase { sig: VerifiedNostrEvent.hex(signature.dataRepresentation) ) } -} -private final class URLProtocolStub: URLProtocol, @unchecked Sendable { - static let lock = NSLock() - static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? - static var requests: [URLRequest] = [] - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - Self.lock.lock() - Self.requests.append(request) - let handler = Self.handler - Self.lock.unlock() - do { - let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) + final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } } - } - override func stopLoading() {} + override func stopLoading() {} - static func reset() { - lock.lock() - handler = nil - requests = [] - lock.unlock() + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index 4342c2b8b1c..ee005ed74dc 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -174,7 +174,7 @@ struct BuzzPushPresentationCacheTests { privateKey: relayKey, createdAt: 100, kind: 39_000, - tags: [["d", opaqueChannelID], ["name", " General Chat "]] + tags: [["d", opaqueChannelID], ["name", " General Chat "], ["t", "stream"]] ) let wrongSigner = try signedEvent( privateKey: otherRelayKey, @@ -187,7 +187,8 @@ struct BuzzPushPresentationCacheTests { communityID: "community-a", relayOrigin: "wss://relay.example", relayMetadataPubkey: relayPubkey, - events: [wrongSigner, verified] + metadataEvents: [wrongSigner, verified], + membershipEvents: [] ) let cached = try #require( @@ -199,9 +200,277 @@ struct BuzzPushPresentationCacheTests { ) #expect(cached.eventID == verified.id) #expect(cached.displayName == "General Chat") + #expect(cached.channelType == "stream") #expect(cached.relayMetadataPubkey == relayPubkey) } + @Test("Channel membership requires the same relay authority and keeps exact scoped digests") + func channelMembershipAuthorityAndOrdering() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let firstMember = try pubkey(for: profileKey) + let secondMember = try pubkey(for: otherRelayKey) + let metadata = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + let newestMembership = try signedEvent( + privateKey: relayKey, + createdAt: 102, + kind: 39_002, + tags: [ + ["d", "opaque-channel"], + ["p", firstMember], + ["p", secondMember], + ["p", secondMember], + ] + ) + let olderMembership = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", firstMember]] + ) + let wrongSigner = try signedEvent( + privateKey: otherRelayKey, + createdAt: 103, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", firstMember]] + ) + let malformed = try signedEvent( + privateKey: relayKey, + createdAt: 104, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", "not-a-pubkey"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [metadata], + membershipEvents: [wrongSigner, malformed, newestMembership, olderMembership] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.memberCount == 2) + #expect(cached.membershipEventID == newestMembership.id) + #expect( + cached.memberDigests == [firstMember, secondMember].map { + BuzzPushPresentationIdentity.channelMember( + communityID: "community-a", + channelID: "opaque-channel", + pubkey: $0 + ) + }.sorted() + ) + } + + @Test("Channel authority rotation clears membership signed by the old authority") + func channelAuthorityRotationClearsMembership() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let rotatedRelayPubkey = try pubkey(for: otherRelayKey) + let member = try pubkey(for: profileKey) + let metadata = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + let membership = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", member]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [metadata], + membershipEvents: [membership] + ) + + let rotatedMetadata = try signedEvent( + privateKey: otherRelayKey, + createdAt: 50, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: rotatedRelayPubkey, + metadataEvents: [rotatedMetadata], + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.relayMetadataPubkey == rotatedRelayPubkey) + #expect(cached.eventID == rotatedMetadata.id) + #expect(cached.memberCount == nil) + #expect(cached.memberDigests == nil) + #expect(cached.membershipEventID == nil) + } + + @Test("Oversized channel batches are ignored before cache mutation") + func oversizedChannelBatchIsIgnored() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let initial = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "Initial"], ["t", "stream"]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [initial], + membershipEvents: [] + ) + let replacement = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "Replacement"], ["t", "stream"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: Array( + repeating: replacement, + count: BuzzPushPresentationCacheStore.maximumChannels + 1 + ), + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.eventID == initial.id) + #expect(cached.displayName == "Initial") + } + + @Test("Global member-digest bound drops oldest complete rosters first") + func globalMembershipDigestBound() throws { + let membersPerChannel = BuzzPushPresentationCacheStore.maximumMembersPerChannel + let channelCount = + BuzzPushPresentationCacheStore.maximumTotalMemberDigests / membersPerChannel + 1 + let digests = (0.. Void ) { - if response.actionIdentifier == UNNotificationDefaultActionIdentifier, - let target = BuzzPushNavigationTarget.decodeIfPresent( - from: response.notification.request.content.userInfo - ) - { - pushNavigationBuffer.record(target) - deliverPushNavigationTarget(target) - } + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: response.actionIdentifier, + userInfo: response.notification.request.content.userInfo, + onTarget: { target in + pushNavigationBuffer.record(target) + deliverPushNavigationTarget(target) + }, + forwardToFlutter: { pluginCompletion in + self.forwardPushNotificationResponseToFlutter( + center, + response: response, + completion: pluginCompletion + ) + }, + completion: completionHandler + ) + } + + private func forwardPushNotificationResponseToFlutter( + _ center: UNUserNotificationCenter, + response: UNNotificationResponse, + completion: @escaping () -> Void + ) { super.userNotificationCenter( center, didReceive: response, - withCompletionHandler: completionHandler + withCompletionHandler: completion ) } diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift index cbcb161110f..1b216855661 100644 --- a/mobile/ios/Runner/PushNativeState.swift +++ b/mobile/ios/Runner/PushNativeState.swift @@ -1,6 +1,45 @@ #if BUZZ_PUSH_ENABLED + import BuzzPushKit import Foundation import Security + import UserNotifications + +final class BuzzOneShotCompletion { + private let lock = NSLock() + private var completion: (() -> Void)? + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + func call() { + lock.lock() + let completion = completion + self.completion = nil + lock.unlock() + completion?() + } +} + +enum BuzzPushNotificationResponseCoordinator { + static func handle( + actionIdentifier: String, + userInfo: [AnyHashable: Any], + onTarget: (BuzzPushNavigationTarget) -> Void, + forwardToFlutter: (@escaping () -> Void) -> Void, + completion: @escaping () -> Void + ) { + let completionGate = BuzzOneShotCompletion(completion) + defer { completionGate.call() } + + if actionIdentifier == UNNotificationDefaultActionIdentifier, + let target = BuzzPushNavigationTarget.decodeIfPresent(from: userInfo) + { + onTarget(target) + } + forwardToFlutter { completionGate.call() } + } +} enum BuzzPushKeychain { static let service = "buzz.push.nse.signing" diff --git a/mobile/ios/Runner/PushPresentationCacheBridge.swift b/mobile/ios/Runner/PushPresentationCacheBridge.swift index 5e7f9cd38b4..b7493491326 100644 --- a/mobile/ios/Runner/PushPresentationCacheBridge.swift +++ b/mobile/ios/Runner/PushPresentationCacheBridge.swift @@ -46,7 +46,8 @@ final class BuzzPushPresentationCacheBridge { private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { guard let arguments = rawArguments as? [String: Any], let communityID = arguments["communityId"] as? String, - let rawEvents = arguments["events"] as? [[String: Any]] + let rawEvents = arguments["events"] as? [[String: Any]], + rawEvents.count <= BuzzPushPresentationCacheStore.maximumProfiles else { result( FlutterError( @@ -86,12 +87,15 @@ final class BuzzPushPresentationCacheBridge { private func cacheChannels(_ rawArguments: Any?, result: @escaping FlutterResult) { guard let arguments = rawArguments as? [String: Any], let communityID = arguments["communityId"] as? String, - let rawEvents = arguments["events"] as? [[String: Any]] + let rawMetadataEvents = arguments["metadataEvents"] as? [[String: Any]], + let rawMembershipEvents = arguments["membershipEvents"] as? [[String: Any]], + rawMetadataEvents.count <= BuzzPushPresentationCacheStore.maximumChannels, + rawMembershipEvents.count <= BuzzPushPresentationCacheStore.maximumChannels else { result( FlutterError( code: "invalid_arguments", - message: "Expected communityId and channel events.", + message: "Expected communityId, channel metadata, and membership events.", details: nil ) ) @@ -109,7 +113,8 @@ final class BuzzPushPresentationCacheBridge { communityID: communityID, relayOrigin: community.relayUrl, relayMetadataPubkey: relayMetadataPubkey, - events: try decodeEvents(rawEvents) + metadataEvents: try decodeEvents(rawMetadataEvents), + membershipEvents: try decodeEvents(rawMembershipEvents) ) Self.complete(result, value: nil) } catch { diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index a9b5e3df1c5..098067cc90d 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -25,6 +25,37 @@ final class BuzzCommunicationNotificationTests: XCTestCase { XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General") XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier) XCTAssertNil(intent.recipients) + XCTAssertEqual(descriptor.recipientCount, 1) + XCTAssertEqual( + (intent.donationMetadata as? INSendMessageIntentDonationMetadata)?.recipientCount, + 1 + ) + } + + func testDirectMessageRetainsSenderAvatarWithoutGroupMetadata() throws { + let resolution = communicationResolution( + displayName: "Alice", + groupName: nil, + avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) + ) + let descriptor = try XCTUnwrap( + BuzzCommunicationNotificationDescriptor(resolution: resolution) + ) + + let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) + + XCTAssertEqual(intent.sender?.displayName, "Alice") + XCTAssertNotNil(intent.sender?.image) + XCTAssertNil(intent.speakableGroupName) + XCTAssertNil(intent.donationMetadata) + } + + func testMissingVerifiedRecipientCountUsesOrdinaryPresentation() { + XCTAssertNil( + BuzzCommunicationNotificationDescriptor( + resolution: communicationResolution(recipientCount: nil) + ) + ) } func testPresentationFallsBackWhenDonationFails() { @@ -111,7 +142,8 @@ final class BuzzCommunicationNotificationTests: XCTestCase { private func communicationResolution( displayName: String = "Alice", groupName: String? = "General", - avatarPNG: Data? = nil + avatarPNG: Data? = nil, + recipientCount: Int? = 1 ) -> BuzzPushResolution { let communityID = "community-id" let channelID = "channel/general:v5" @@ -134,7 +166,8 @@ final class BuzzCommunicationNotificationTests: XCTestCase { communityID: communityID, channelID: channelID ), - conversationDisplayName: groupName + conversationDisplayName: groupName, + conversationRecipientCount: recipientCount ) } } @@ -180,3 +213,68 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { ) } } + +final class BuzzPushNotificationResponseTests: XCTestCase { + func testValidDefaultActionRoutesAndCompletesExactlyOnce() { + let target = BuzzPushNavigationTarget( + eventID: "message-id", + communityID: "community-id", + channelID: "opaque-channel-id" + ) + var routedTargets: [BuzzPushNavigationTarget] = [] + var forwarded = 0 + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { pluginCompletion in + forwarded += 1 + pluginCompletion() + pluginCompletion() + }, + completion: { completions += 1 } + ) + + XCTAssertEqual(routedTargets, [target]) + XCTAssertEqual(forwarded, 1) + XCTAssertEqual(completions, 1) + } + + func testMalformedTargetFallsBackToOneCompletion() { + var routedTargets: [BuzzPushNavigationTarget] = [] + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: [BuzzPushNavigationTarget.userInfoKey: ["event_id": ""]], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { _ in }, + completion: { completions += 1 } + ) + + XCTAssertTrue(routedTargets.isEmpty) + XCTAssertEqual(completions, 1) + } + + func testNonDefaultActionIgnoresLateDuplicatePluginCompletion() throws { + var routedTargets: [BuzzPushNavigationTarget] = [] + var pluginCompletion: (() -> Void)? + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: "buzz.reply", + userInfo: [:], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { pluginCompletion = $0 }, + completion: { completions += 1 } + ) + let capturedCompletion = try XCTUnwrap(pluginCompletion) + capturedCompletion() + capturedCompletion() + + XCTAssertTrue(routedTargets.isEmpty) + XCTAssertEqual(completions, 1) + } +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index fedf213932e..980d8b571b4 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -200,10 +200,7 @@ class ChannelsNotifier extends AsyncNotifier> { latestMetaPerId[id] = event; } } - final dedupedMetas = latestMetaPerId.values; - if (communityID != null) { - unawaited(cacheBuzzPushChannelEvents(communityID, dedupedMetas)); - } + final dedupedMetas = latestMetaPerId.values.toList(); // Resolve DM participant display names. Relay stores DM channels with // literal name="DM"; pure-Nostr architecture pushes name resolution to @@ -263,8 +260,31 @@ class ChannelsNotifier extends AsyncNotifier> { ), ); if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); + final latestMembershipPerId = {}; + for (final event in [...memberships, ...memberEvents]) { + if (event.kind != 39002) continue; + final id = event.getTagValue('d'); + if (id == null) continue; + final existing = latestMembershipPerId[id]; + if (existing == null || + event.createdAt > existing.createdAt || + (event.createdAt == existing.createdAt && + event.id.compareTo(existing.id) < 0)) { + latestMembershipPerId[id] = event; + } + } + final dedupedMemberships = latestMembershipPerId.values.toList(); + if (communityID != null) { + unawaited( + cacheBuzzPushChannelEvents( + communityID, + dedupedMetas, + dedupedMemberships, + ), + ); + } final memberCounts = {}; - for (final event in memberEvents) { + for (final event in dedupedMemberships) { final chId = event.getTagValue('d'); if (chId == null) continue; final pTags = {}; diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index fd8a0b5965b..e42d94b8299 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -9,6 +9,9 @@ import '../relay/nostr_models.dart'; import 'push_capability.dart'; const _pushPresentationChannel = MethodChannel('buzz/push'); +// Keep these bridge payload bounds aligned with BuzzPushPresentationCacheStore. +const _maximumPresentationProfiles = 256; +const _maximumPresentationChannels = 512; const _maximumAvatarSourceBytes = 512 * 1024; const _maximumAvatarPNGBytes = 64 * 1024; Future _avatarEncodeTail = Future.value(); @@ -44,11 +47,12 @@ Future cacheBuzzPushProfileEvents( communityID.isEmpty) { return; } - final verified = events - .where( - (event) => event.kind == 0 && isVerifiedPushPresentationEvent(event), - ) - .toList(); + final verified = _boundedNewestEvents( + events, + kind: 0, + maximum: _maximumPresentationProfiles, + scope: (event) => event.pubkey.toLowerCase(), + ).values.toList(); if (verified.isEmpty) return; await _invokeBestEffort('cachePresentationProfiles', { 'communityId': communityID, @@ -56,29 +60,109 @@ Future cacheBuzzPushProfileEvents( }); } -/// Exports raw verified kind-39000 events for relay-authority validation and storage. +/// Exports verified channel metadata and membership for native authority checks. Future cacheBuzzPushChannelEvents( String communityID, - Iterable events, + Iterable metadataEvents, + Iterable membershipEvents, ) async { if (!buzzPushCapabilityEnabled || defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } - final verified = events - .where( - (event) => - event.kind == 39000 && isVerifiedPushPresentationEvent(event), - ) - .toList(); - if (verified.isEmpty) return; + final batch = selectBoundedPushChannelEvents( + metadataEvents, + membershipEvents, + ); + final verifiedMetadata = batch.metadata; + final verifiedMembership = batch.membership; + if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; await _invokeBestEffort('cachePresentationChannels', { 'communityId': communityID, - 'events': [for (final event in verified) event.toJson()], + 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], + 'membershipEvents': [ + for (final event in verifiedMembership) event.toJson(), + ], }); } +/// Selects a bounded, paired set of verified channel metadata and membership events. +@visibleForTesting +({List metadata, List membership}) +selectBoundedPushChannelEvents( + Iterable metadataEvents, + Iterable membershipEvents, { + @visibleForTesting int maximumChannels = _maximumPresentationChannels, +}) { + final verifiedMembershipByChannel = _boundedNewestEvents( + membershipEvents, + kind: 39002, + maximum: maximumChannels, + scope: (event) => event.getTagValue('d'), + ); + final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); + final verifiedMetadataByChannel = _boundedNewestEvents( + metadataEvents, + kind: 39000, + maximum: maximumChannels, + scope: (event) => event.getTagValue('d'), + allowedScopes: selectedChannelIDs.isEmpty ? null : selectedChannelIDs, + ); + if (selectedChannelIDs.isEmpty) { + selectedChannelIDs.addAll(verifiedMetadataByChannel.keys); + } + final verifiedMetadata = [ + for (final entry in verifiedMetadataByChannel.entries) + if (selectedChannelIDs.contains(entry.key)) entry.value, + ]; + final verifiedMembership = [ + for (final entry in verifiedMembershipByChannel.entries) + if (selectedChannelIDs.contains(entry.key)) entry.value, + ]; + return (metadata: verifiedMetadata, membership: verifiedMembership); +} + +Map _boundedNewestEvents( + Iterable events, { + required int kind, + required int maximum, + required String? Function(NostrEvent event) scope, + Set? allowedScopes, +}) { + final selected = {}; + if (maximum <= 0) return selected; + for (final event in events) { + if (event.kind != kind || !isVerifiedPushPresentationEvent(event)) continue; + final key = scope(event); + if (key == null || key.isEmpty) continue; + if (allowedScopes != null && !allowedScopes.contains(key)) continue; + final existing = selected[key]; + if (existing != null) { + if (_isNewerEvent(event, existing)) selected[key] = event; + continue; + } + if (selected.length < maximum) { + selected[key] = event; + continue; + } + final oldest = selected.entries.reduce( + (left, right) => _isNewerEvent(left.value, right.value) ? right : left, + ); + if (_isNewerEvent(event, oldest.value)) { + selected + ..remove(oldest.key) + ..[key] = event; + } + } + return selected; +} + +bool _isNewerEvent(NostrEvent candidate, NostrEvent existing) => + candidate.createdAt > existing.createdAt || + (candidate.createdAt == existing.createdAt && + candidate.id.compareTo(existing.id) < 0); + /// Reuses bytes already fetched for a visible foreground avatar. /// /// This never starts network I/O. Oversized, malformed, or unsupported images diff --git a/mobile/test/shared/push/push_presentation_cache_test.dart b/mobile/test/shared/push/push_presentation_cache_test.dart index 8954d70cf96..21ec3c8c6cc 100644 --- a/mobile/test/shared/push/push_presentation_cache_test.dart +++ b/mobile/test/shared/push/push_presentation_cache_test.dart @@ -39,6 +39,74 @@ void main() { ); }); + test('accepts a valid signed channel membership snapshot', () { + final signed = nostr.Event.from( + kind: 39002, + content: '', + tags: const [ + ['d', 'channel/general:v5'], + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'member', + ], + ], + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test( + 'bounded channel selection keeps metadata paired with selected rosters', + () { + NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { + final signed = nostr.Event.from( + kind: kind, + content: '', + tags: [ + ['d', channelID], + if (kind == 39002) + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ], + ], + secretKey: secretKey, + createdAt: createdAt, + ); + return NostrEvent.fromJson(signed.toMap()); + } + + final batch = selectBoundedPushChannelEvents( + [ + signedChannelEvent(39000, 'channel-0', 100), + signedChannelEvent(39000, 'channel-1', 300), + signedChannelEvent(39000, 'channel-2', 200), + ], + [ + signedChannelEvent(39002, 'channel-0', 300), + signedChannelEvent(39002, 'channel-1', 200), + signedChannelEvent(39002, 'channel-2', 100), + ], + maximumChannels: 2, + ); + + expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + }, + ); + test('rejects changed content and malformed signatures', () { final signed = nostr.Event.from( kind: 0, From 2f5f8d89c66e5d8276e08e0bb85b799292f900af Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 11:35:20 -0700 Subject: [PATCH 09/27] refactor(mobile): activate push from relay capability Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- docs/push-gateway-deployment.md | 35 +- mobile/README.md | 40 +- mobile/ios/Flutter/Debug.xcconfig | 19 +- mobile/ios/Flutter/PushEnabled.xcconfig | 16 - mobile/ios/Flutter/Release.xcconfig | 12 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 16 +- mobile/ios/Runner/AppDelegate.swift | 596 +++++++++--------- mobile/ios/Runner/Info.plist | 4 + .../ios/Runner/PushEndpointGrantStore.swift | 180 +++--- mobile/ios/Runner/PushNativeState.swift | 15 +- .../Runner/PushPresentationCacheBridge.swift | 21 +- mobile/ios/Runner/Runner.entitlements | 17 +- mobile/ios/Runner/RunnerPush-Info.plist | 98 --- mobile/ios/Runner/RunnerPush.entitlements | 20 - mobile/lib/app.dart | 4 +- mobile/lib/main.dart | 9 +- .../shared/community/community_provider.dart | 2 - mobile/lib/shared/push/push_bootstrap.dart | 48 +- mobile/lib/shared/push/push_capability.dart | 6 - .../shared/push/push_presentation_cache.dart | 12 +- .../push/push_relay_capability_provider.dart | 61 ++ .../push_relay_capability_provider_test.dart | 73 +++ scripts/test-ios-pbxproj-semantics.py | 6 +- scripts/test-mobile-worktree-overrides.sh | 79 +-- 24 files changed, 675 insertions(+), 714 deletions(-) delete mode 100644 mobile/ios/Flutter/PushEnabled.xcconfig delete mode 100644 mobile/ios/Runner/RunnerPush-Info.plist delete mode 100644 mobile/ios/Runner/RunnerPush.entitlements delete mode 100644 mobile/lib/shared/push/push_capability.dart create mode 100644 mobile/lib/shared/push/push_relay_capability_provider.dart create mode 100644 mobile/test/shared/push/push_relay_capability_provider_test.dart diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 8a2593c7773..736fdd2bce8 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -118,17 +118,18 @@ capability—not a raw APNs token—into the encrypted relay lease. ## Internal dogfood evaluation and rollback -The MVP is ready to enable only when all of these are true: the internal iOS -artifact was built with `mobile/ios/Flutter/PushEnabled.xcconfig`; the canonical -gateway has the dogfood profile enabled with its server-owned App Attest app ID, -APNs topic, production certificate identity, and production APNs environment; -and only the selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`. -The App Store profile remains configured but dormant, and ordinary/App Store -iOS builds continue to omit the push capability and notification extension. +The MVP is ready to enable only when the canonical gateway has the dogfood +profile enabled with its server-owned App Attest app ID, APNs topic, production +certificate identity, and production APNs environment, and only the selected +internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS artifact +contains the native push bridge and Notification Service Extension, but the +client remains inactive until its current authenticated relay advertises a +fully valid NIP-11 `nip-pl` descriptor. The App Store gateway profile remains +configured but dormant. Local physical-device development may instead use the normal -`xyz.block.buzz.mobile` development identity with the push overlay and sandbox -entitlements. Its local gateway must enable only the closed App Store profile, +`xyz.block.buzz.mobile` development identity with sandbox entitlements. Its +local gateway must enable only the closed App Store profile, configured with that profile's server-owned App Attest application ID, APNs topic, sandbox certificate, and sandbox environment. This is a development integration proof, not dogfood release validation, and does not authorize @@ -143,13 +144,12 @@ APNs delivery, fetched and signature-verified notification content, and exact-message tap routing against the canonical gateway and a push-enabled internal relay before widening the internal evaluation. -Before that first push-enabled candidate, the private dogfood builder must -include `PushEnabled.xcconfig` from its generated `AppOverrides.xcconfig`. Its -manual signing and export configuration must also map separate distribution +Before that first candidate, the private dogfood builder's manual signing and +export configuration must map separate distribution profiles for both `xyz.block.buzz.dogfood.mobile` and `xyz.block.buzz.dogfood.mobile.NotificationService`; an app-only profile does -not provision the extension. The App Store builder must continue omitting the -overlay and extension profile until that rollout is separately approved. +not provision the extension. App Store rollout remains off through relay and +gateway deployment configuration until separately approved. Before enabling rich message presentation, enable Apple's Communication Notifications capability on the parent dogfood App ID and regenerate its app provisioning profile. The extension profile does not need that capability. @@ -171,10 +171,9 @@ is designed. Rollback does not require deleting credentials or mutating existing leases. Set `BUZZ_PUSH_ENABLED=false` on the enabled relays to stop advertisement, lease acceptance, matching, workers, and new gateway traffic; disable the dogfood -gateway profile if the gateway itself is unhealthy; and ship the next internal -build without the push overlay if client behavior must be removed. Existing -leases and gateway authorities then expire naturally. Do not enable the App -Store build capability or profile as part of this internal evaluation. +gateway profile if the gateway itself is unhealthy. Existing leases and gateway +authorities then expire naturally. Do not enable the App Store gateway profile +as part of this internal evaluation. ## Helm production inputs diff --git a/mobile/README.md b/mobile/README.md index bfc796c74f8..2bdd7ad7a99 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -68,36 +68,24 @@ connected Android emulators, run `just mobile-clean` (add `--dry-run` via `./scripts/mobile-worktree-clean.sh --dry-run` to preview). Production installs are never touched. -### Internal iOS push capability +### iOS push capability -iOS push is a compile/build capability and defaults off. A normal Debug, -Profile, Release, or App Store build excludes the native push bridge sources, -uses push-free Runner entitlements, and neither builds nor embeds the -Notification Service Extension. Dart also compiles out permission requests, -APNs registration, gateway enrollment/delegation, and relay lease behavior. +Every iOS artifact builds and embeds the Notification Service Extension and +native push bridge. Runtime activation is fail-closed and scoped to the current +relay: Buzz requests notification permission, registers with APNs, enrolls with +the gateway, and publishes a lease only after authenticated connectivity and a +fully valid NIP-11 `nip-pl` push descriptor. An absent, malformed, or +unreachable descriptor leaves push inactive without partial enrollment. -For an authorized internal dogfood build only, create the gitignored -`mobile/ios/Flutter/AppOverrides.xcconfig` with this single include: - -```xcconfig -#include "PushEnabled.xcconfig" -``` - -The tracked overlay selects `xyz.block.buzz.dogfood.mobile`, production App -Attest/APNs entitlements, the internal development team, the push-capable -Runner entitlements, the native bridge, and the extension. CI may equivalently -inject that same include into its ephemeral `AppOverrides.xcconfig`; it must not -edit a tracked base configuration. Relay rollout is independent and remains off -unless its deployment sets `BUZZ_PUSH_ENABLED=true`. See +Relay rollout remains an explicit deployment opt-in. Only deployments with +`BUZZ_PUSH_ENABLED=true` advertise the descriptor and process push. See `docs/push-gateway-deployment.md` for the canonical gateway profile contract, manual physical-device proof, measurements, and rollback procedure. -For local physical-device development, enable the same capability while -overriding the dogfood identity back to the normal mobile development identity -and sandbox environments: +For local physical-device development, override the identity and sandbox +environments in the gitignored `mobile/ios/Flutter/AppOverrides.xcconfig`: ```xcconfig -#include "PushEnabled.xcconfig" BUNDLE_IDENTIFIER = xyz.block.buzz.mobile BUZZ_DEVELOPMENT_TEAM = EYF346PHUG BUZZ_IOS_PUSH_ENVIRONMENT = development @@ -112,14 +100,14 @@ artifact or enable the App Store profile in production. Validate dogfood APNs end to end by cutting an internal release, waiting for it to reach Mobile Releases/Comp Portal, and installing that signed artifact on a physical device. -Push-enabled parent app identifiers also require Apple's Communication +Parent app identifiers require Apple's Communication Notifications capability and a regenerated app provisioning profile. The Notification Service Extension profile does not require that capability. Enable it on the personal development App ID for local rich-presentation validation. Enabling it on the Block dogfood and eventual App Store App IDs is a release follow-up and is not performed by this repository change. Without a -matching parent profile, source and unit validation still work, but the -push-enabled app cannot be signed for a physical device. +matching parent profile, source and unit validation still work, but the app +cannot be signed for a physical device. APNs and the gateway continue to carry only the constant opaque wake-up. The extension fetches the message from the scoped relay, verifies message, sender diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 439a9dc6e76..8660e4e3ed0 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -10,14 +10,12 @@ BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile APP_DISPLAY_NAME = Buzz BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 -// Push is a build capability, not a runtime rollout switch. Normal Debug -// builds compile out the bridge and exclude the extension product. Internal -// dogfood builds opt in by including PushEnabled.xcconfig from the ignored -// AppOverrides.xcconfig file. -BUZZ_PUSH_ENABLED = NO -BUZZ_INFO_PLIST = Runner/Info.plist -BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements -EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift +// Push support is always present in the iOS artifact. The current relay's +// fully validated NIP-11 descriptor is the runtime activation authority. +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): a per-worktree bundle identifier @@ -26,7 +24,6 @@ EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift Pus #include? "WorktreeOverrides.xcconfig" // Developer app-specific overrides are included last: xcconfig -// later-include-wins is per variable, so a personal BUNDLE_IDENTIFIER for -// device signing beats the worktree default while unset variables still -// fall through to the worktree values. +// later-include-wins is per variable, so a personal signing identity beats the +// worktree default while unset variables still fall through to tracked values. #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/Flutter/PushEnabled.xcconfig b/mobile/ios/Flutter/PushEnabled.xcconfig deleted file mode 100644 index c088a384a5d..00000000000 --- a/mobile/ios/Flutter/PushEnabled.xcconfig +++ /dev/null @@ -1,16 +0,0 @@ -// Complete internal iOS push capability overlay. This file is inert unless an -// ignored AppOverrides.xcconfig explicitly includes it. -BUZZ_PUSH_ENABLED = YES -BUZZ_INFO_PLIST = Runner/RunnerPush-Info.plist -BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile -BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 -BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) -BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) -BUZZ_IOS_PUSH_ENVIRONMENT = production -BUZZ_APP_ATTEST_ENVIRONMENT = production -BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush.entitlements -SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) BUZZ_PUSH_ENABLED -EXCLUDED_SOURCE_FILE_NAMES = - -// BUZZ_PUSH_ENABLED=true, base64-encoded for Flutter's DART_DEFINES setting. -DART_DEFINES = $(inherited),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ== diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index 29eee3fc778..cdacb9e89bc 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -10,10 +10,10 @@ CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY = iPhone Developer BUZZ_DEVELOPMENT_TEAM = EYF346PHUG -// App Store builds remain dormant until a later rollout. The internal build -// pipeline explicitly includes PushEnabled.xcconfig from AppOverrides.xcconfig. -BUZZ_PUSH_ENABLED = NO -BUZZ_INFO_PLIST = Runner/Info.plist -BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements -EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift +// Push support is always present in the iOS artifact. Relay advertisement is +// the rollout authority, so a relay without a valid descriptor remains inert. +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = production +BUZZ_APP_ATTEST_ENVIRONMENT = production #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 09ec7a94dfa..e8a284b0d6a 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -80,8 +80,6 @@ BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = ""; }; - BZZ00000000000000000028 /* RunnerPush.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = RunnerPush.entitlements; sourceTree = ""; }; - BZZ0000000000000000002D /* RunnerPush-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "RunnerPush-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -229,8 +227,6 @@ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, BZZ0000000000000000000A /* Runner.entitlements */, - BZZ00000000000000000028 /* RunnerPush.entitlements */, - BZZ0000000000000000002D /* RunnerPush-Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -660,8 +656,8 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; - CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; - INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -849,8 +845,8 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; - CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; - INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -873,8 +869,8 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; - CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; - INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 9779b032c4c..f33cad45b4c 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,31 +1,26 @@ import AVFoundation +import BuzzPushKit import Flutter import UIKit - -#if BUZZ_PUSH_ENABLED - import BuzzPushKit - import UserNotifications -#endif +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? - #if BUZZ_PUSH_ENABLED - private var pushChannel: FlutterMethodChannel? - private let apnsRegistrationBuffer = APNsRegistrationBuffer() - private let pushNavigationBuffer = BuzzPushNavigationBuffer() - private var apnsDeviceToken: Data? - private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore( - accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String - ) - private var enrollmentTask: Task? - private var appGroupIdentifier: String? { - Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String - } - private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge( - appGroupIdentifier: appGroupIdentifier - ) - #endif + private var pushChannel: FlutterMethodChannel? + private let apnsRegistrationBuffer = APNsRegistrationBuffer() + private let pushNavigationBuffer = BuzzPushNavigationBuffer() + private var apnsDeviceToken: Data? + private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore( + accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + ) + private var enrollmentTask: Task? + private var appGroupIdentifier: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String + } + private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge( + appGroupIdentifier: appGroupIdentifier + ) private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? @@ -37,9 +32,7 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - #if BUZZ_PUSH_ENABLED - UNUserNotificationCenter.current().delegate = self - #endif + UNUserNotificationCenter.current().delegate = self return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -53,18 +46,16 @@ import UIKit mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } - #if BUZZ_PUSH_ENABLED - pushChannel = FlutterMethodChannel( - name: "buzz/push", - binaryMessenger: messenger - ) - pushChannel?.setMethodCallHandler { [weak self] call, result in - self?.handlePushMethodCall(call, result: result) - } - apnsRegistrationBuffer.attach { [weak self] update in - self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments) - } - #endif + pushChannel = FlutterMethodChannel( + name: "buzz/push", + binaryMessenger: messenger + ) + pushChannel?.setMethodCallHandler { [weak self] call, result in + self?.handlePushMethodCall(call, result: result) + } + apnsRegistrationBuffer.attach { [weak self] update in + self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments) + } qrScannerChannel = FlutterMethodChannel( name: "buzz/qr_scanner", binaryMessenger: messenger @@ -169,7 +160,8 @@ import UIKit if #available(iOS 16.0, *), let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeMessageActionSurface" - ) { + ) + { nativeMessageActionsRegistrar.register( NativeMessageActionSurfaceFactory(messenger: messenger), withId: "buzz/native_message_action_surface" @@ -238,297 +230,295 @@ import UIKit .safeAreaInsets.top ?? 0 } - #if BUZZ_PUSH_ENABLED - override func application( - _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data - ) { - super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) - apnsDeviceToken = deviceToken - apnsRegistrationBuffer.recordToken(deviceToken) - } + override func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + apnsDeviceToken = deviceToken + apnsRegistrationBuffer.recordToken(deviceToken) + } - override func application( - _ application: UIApplication, - didFailToRegisterForRemoteNotificationsWithError error: Error - ) { - super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) - apnsRegistrationBuffer.recordError(error.localizedDescription) - } + override func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) + apnsRegistrationBuffer.recordError(error.localizedDescription) + } - override func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - BuzzPushNotificationResponseCoordinator.handle( - actionIdentifier: response.actionIdentifier, - userInfo: response.notification.request.content.userInfo, - onTarget: { target in - pushNavigationBuffer.record(target) - deliverPushNavigationTarget(target) - }, - forwardToFlutter: { pluginCompletion in - self.forwardPushNotificationResponseToFlutter( - center, - response: response, - completion: pluginCompletion - ) - }, - completion: completionHandler - ) - } + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: response.actionIdentifier, + userInfo: response.notification.request.content.userInfo, + onTarget: { target in + pushNavigationBuffer.record(target) + deliverPushNavigationTarget(target) + }, + forwardToFlutter: { pluginCompletion in + self.forwardPushNotificationResponseToFlutter( + center, + response: response, + completion: pluginCompletion + ) + }, + completion: completionHandler + ) + } - private func forwardPushNotificationResponseToFlutter( - _ center: UNUserNotificationCenter, - response: UNNotificationResponse, - completion: @escaping () -> Void - ) { - super.userNotificationCenter( - center, - didReceive: response, - withCompletionHandler: completion - ) - } + private func forwardPushNotificationResponseToFlutter( + _ center: UNUserNotificationCenter, + response: UNNotificationResponse, + completion: @escaping () -> Void + ) { + super.userNotificationCenter( + center, + didReceive: response, + withCompletionHandler: completion + ) + } - private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) { - pushChannel?.invokeMethod( - "notificationOpened", - arguments: target.flutterArguments - ) { [weak self] result in - guard result as? String == "handled" else { return } - self?.pushNavigationBuffer.remove(ifMatching: target) - } + private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) { + pushChannel?.invokeMethod( + "notificationOpened", + arguments: target.flutterArguments + ) { [weak self] result in + guard result as? String == "handled" else { return } + self?.pushNavigationBuffer.remove(ifMatching: target) } + } - private func handlePushMethodCall( - _ call: FlutterMethodCall, - result: @escaping FlutterResult - ) { - if pushPresentationCacheBridge.handle(call, result: result) { + private func handlePushMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + if pushPresentationCacheBridge.handle(call, result: result) { + return + } + switch call.method { + case "requestAuthorization": + requestPushAuthorization(result: result) + case "takePendingNotificationResponse": + result(pushNavigationBuffer.take()?.flutterArguments) + case "saveCommunitySnapshot": + guard let arguments = call.arguments as? [String: Any], + let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String] + else { + result( + FlutterError( + code: "invalid_arguments", message: "Expected communities array.", details: nil)) return } - switch call.method { - case "requestAuthorization": - requestPushAuthorization(result: result) - case "takePendingNotificationResponse": - result(pushNavigationBuffer.take()?.flutterArguments) - case "saveCommunitySnapshot": - guard let arguments = call.arguments as? [String: Any], - let communities = arguments["communities"] as? [[String: Any]], - let signingKeys = arguments["signingKeys"] as? [String: String] - else { - result( - FlutterError( - code: "invalid_arguments", message: "Expected communities array.", details: nil)) - return - } - do { - try savePushCommunitySnapshot(communities) - try BuzzPushKeychain.replace( - signingKeys: signingKeys, - accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") - as? String + do { + try savePushCommunitySnapshot(communities) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") + as? String + ) + result(nil) + } catch { + result( + FlutterError( + code: "save_failed", message: "Unable to save push community credentials.", + details: error.localizedDescription)) + } + case "endpointGrants": + do { + result(try endpointGrantStore.records().map(\.flutterArguments)) + } catch { + result( + FlutterError( + code: "endpoint_grant_read_failed", + message: "Unable to read persisted push endpoint grants.", + details: error.localizedDescription ) - result(nil) - } catch { - result( - FlutterError( - code: "save_failed", message: "Unable to save push community credentials.", - details: error.localizedDescription)) - } - case "endpointGrants": - do { - result(try endpointGrantStore.records().map(\.flutterArguments)) - } catch { + ) + } + case "enrollPush": + handleDevPushEnrollment(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func requestPushAuthorization(result: @escaping FlutterResult) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + granted, error in + DispatchQueue.main.async { + if let error { result( FlutterError( - code: "endpoint_grant_read_failed", - message: "Unable to read persisted push endpoint grants.", + code: "notification_authorization_failed", + message: "Unable to request notification authorization.", details: error.localizedDescription ) ) + return } - case "enrollPush": - handleDevPushEnrollment(call, result: result) - default: - result(FlutterMethodNotImplemented) - } - } - - private func requestPushAuthorization(result: @escaping FlutterResult) { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { - granted, error in - DispatchQueue.main.async { - if let error { - result( - FlutterError( - code: "notification_authorization_failed", - message: "Unable to request notification authorization.", - details: error.localizedDescription - ) - ) - return - } - guard granted else { - result(false) - return - } - UIApplication.shared.registerForRemoteNotifications() - result(true) + guard granted else { + result(false) + return } + UIApplication.shared.registerForRemoteNotifications() + result(true) } } + } - private func handleDevPushEnrollment( - _ call: FlutterMethodCall, - result: @escaping FlutterResult - ) { - guard enrollmentTask == nil else { - result( - FlutterError( - code: "enrollment_in_progress", - message: "Development push enrollment is already running.", - details: nil - ) + private func handleDevPushEnrollment( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard enrollmentTask == nil else { + result( + FlutterError( + code: "enrollment_in_progress", + message: "Development push enrollment is already running.", + details: nil ) - return - } - guard let deviceToken = apnsDeviceToken else { - result( - FlutterError( - code: "missing_apns_token", - message: "APNs has not supplied a device token.", - details: nil - ) + ) + return + } + guard let deviceToken = apnsDeviceToken else { + result( + FlutterError( + code: "missing_apns_token", + message: "APNs has not supplied a device token.", + details: nil ) - return - } - guard !deviceToken.isEmpty else { - result( - FlutterError( - code: "invalid_apns_token", - message: "APNs supplied an empty device token.", - details: nil - ) + ) + return + } + guard !deviceToken.isEmpty else { + result( + FlutterError( + code: "invalid_apns_token", + message: "APNs supplied an empty device token.", + details: nil ) - return - } - guard let arguments = call.arguments as? [String: Any], - let relayText = arguments["relayUrl"] as? String, - let relayURL = URL(string: relayText), - let gatewayText = arguments["gatewayUrl"] as? String, - let gatewayURL = URL(string: gatewayText) - else { - result( - FlutterError( - code: "invalid_arguments", - message: "Development push enrollment requires relayUrl and gatewayUrl.", - details: nil - ) + ) + return + } + guard let arguments = call.arguments as? [String: Any], + let relayText = arguments["relayUrl"] as? String, + let relayURL = URL(string: relayText), + let gatewayText = arguments["gatewayUrl"] as? String, + let gatewayURL = URL(string: gatewayText) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Development push enrollment requires relayUrl and gatewayUrl.", + details: nil ) - return - } + ) + return + } - do { - let driver: BuzzDevPushEnrollmentDriver - #if DEBUG - driver = try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: gatewayURL, - store: endpointGrantStore - ) - #else - driver = try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: gatewayURL, - store: endpointGrantStore, - appAttestKeychainAccessGroup: Bundle.main.object( - forInfoDictionaryKey: "BuzzKeychainAccessGroup" - ) as? String + do { + let driver: BuzzDevPushEnrollmentDriver + #if DEBUG + driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore + ) + #else + driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + ) + #endif + enrollmentTask = Task { [weak self] in + defer { self?.enrollmentTask = nil } + do { + let record = try await driver.enroll( + deviceToken: deviceToken, + relayURL: relayURL ) - #endif - enrollmentTask = Task { [weak self] in - defer { self?.enrollmentTask = nil } - do { - let record = try await driver.enroll( - deviceToken: deviceToken, - relayURL: relayURL - ) - await MainActor.run { result(record.flutterArguments) } - } catch { - await MainActor.run { - result( - FlutterError( - code: "dev_enrollment_failed", - message: "Development push enrollment failed.", - details: error.localizedDescription - ) + await MainActor.run { result(record.flutterArguments) } + } catch { + await MainActor.run { + result( + FlutterError( + code: "dev_enrollment_failed", + message: "Development push enrollment failed.", + details: error.localizedDescription ) - } + ) } } - } catch { - result( - FlutterError( - code: "dev_enrollment_configuration_failed", - message: "Development push enrollment is not configured.", - details: error.localizedDescription - ) - ) } + } catch { + result( + FlutterError( + code: "dev_enrollment_configuration_failed", + message: "Development push enrollment is not configured.", + details: error.localizedDescription + ) + ) } + } - private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { - guard let appGroupIdentifier else { - throw NSError( - domain: "BuzzPush", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) - } - guard - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier) - else { - throw NSError( - domain: "BuzzPush", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) - } - // Channel-name enrichment is optional presentation state. A damaged or - // unavailable grant cache must not block the core NSE snapshot/key update. - let grants = (try? endpointGrantStore.records()) ?? [] - let enriched = communities.map { community -> [String: Any] in - var community = community - guard let relayURL = community["relayUrl"] as? String, - let relayMetadataPubkey = Self.pushRelayMetadataPubkey( - relayURL: relayURL, - grants: grants - ) - else { return community } - community["relayMetadataPubkey"] = relayMetadataPubkey - return community - } - let data = try JSONSerialization.data( - withJSONObject: ["communities": enriched], options: [.sortedKeys]) - let destination = container.appendingPathComponent("push-communities.json") - try data.write(to: destination, options: [.atomic]) - pushPresentationCacheBridge.retainCommunities( - Set(enriched.compactMap { $0["id"] as? String }) - ) + private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { + guard let appGroupIdentifier else { + throw NSError( + domain: "BuzzPush", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) } + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + else { + throw NSError( + domain: "BuzzPush", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) + } + // Channel-name enrichment is optional presentation state. A damaged or + // unavailable grant cache must not block the core NSE snapshot/key update. + let grants = (try? endpointGrantStore.records()) ?? [] + let enriched = communities.map { community -> [String: Any] in + var community = community + guard let relayURL = community["relayUrl"] as? String, + let relayMetadataPubkey = Self.pushRelayMetadataPubkey( + relayURL: relayURL, + grants: grants + ) + else { return community } + community["relayMetadataPubkey"] = relayMetadataPubkey + return community + } + let data = try JSONSerialization.data( + withJSONObject: ["communities": enriched], options: [.sortedKeys]) + let destination = container.appendingPathComponent("push-communities.json") + try data.write(to: destination, options: [.atomic]) + pushPresentationCacheBridge.retainCommunities( + Set(enriched.compactMap { $0["id"] as? String }) + ) + } - static func pushRelayMetadataPubkey( - relayURL: String, - grants: [BuzzPushEndpointGrantRecord] - ) -> String? { - guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { - return nil - } - return grants.filter { - $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile - && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin - }.max { - $0.generation < $1.generation - }?.relayMetadataPubkey + static func pushRelayMetadataPubkey( + relayURL: String, + grants: [BuzzPushEndpointGrantRecord] + ) -> String? { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { + return nil } - #endif + return grants.filter { + $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile + && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + }.max { + $0.generation < $1.generation + }?.relayMetadataPubkey + } private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, @@ -969,14 +959,12 @@ import UIKit } } -#if BUZZ_PUSH_ENABLED - extension BuzzPushNavigationTarget { - fileprivate var flutterArguments: [String: String] { - [ - "eventId": eventID, - "communityId": communityID, - "channelId": channelID, - ] - } +extension BuzzPushNavigationTarget { + fileprivate var flutterArguments: [String: String] { + [ + "eventId": eventID, + "communityId": communityID, + "channelId": channelID, + ] } -#endif +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index baf248cfa5b..998cc20a3d5 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,6 +55,10 @@ Buzz needs photo library access so you can attach images to messages. NSPhotoLibraryAddUsageDescription Buzz needs permission to save images to your photo library. + NSUserActivityTypes + + INSendMessageIntent + PHPhotoLibraryPreventAutomaticLimitedAccessAlert UIApplicationSceneManifest diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index ca4c6ace787..9b9c5554567 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -1,108 +1,106 @@ -#if BUZZ_PUSH_ENABLED - import BuzzPushKit - import Foundation - import Security +import BuzzPushKit +import Foundation +import Security - /// Keychain-backed endpoint grant storage. The opaque grant is never written to - /// UserDefaults or logs. Dart can read the closed record through the push bridge. - final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { - private static let service = "buzz.push.endpoint-grants" - private static let account = "v1" +/// Keychain-backed endpoint grant storage. The opaque grant is never written to +/// UserDefaults or logs. Dart can read the closed record through the push bridge. +final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { + private static let service = "buzz.push.endpoint-grants" + private static let account = "v1" - private let accessGroup: String? + private let accessGroup: String? - init(accessGroup: String?) { - self.accessGroup = accessGroup - } + init(accessGroup: String?) { + self.accessGroup = accessGroup + } - func records() throws -> [BuzzPushEndpointGrantRecord] { - var query = baseQuery() - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return [] } - guard status == errSecSuccess, let data = result as? Data else { - throw keychainError(status, operation: "read") - } - do { - return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data) - } catch { - throw NSError( - domain: "BuzzPushEndpointGrantStore", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"] - ) - } + func records() throws -> [BuzzPushEndpointGrantRecord] { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read") } - - func save(_ record: BuzzPushEndpointGrantRecord) throws { - var all = try records() - all.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile - } - all.append(record) - try replace(all) + do { + return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data) + } catch { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"] + ) } + } - private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws { - let data = try JSONEncoder().encode(records) - let updateStatus = SecItemUpdate( - baseQuery() as CFDictionary, - [kSecValueData as String: data] as CFDictionary - ) - if updateStatus == errSecSuccess { return } - guard updateStatus == errSecItemNotFound else { - throw keychainError(updateStatus, operation: "update") - } + func save(_ record: BuzzPushEndpointGrantRecord) throws { + var all = try records() + all.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + all.append(record) + try replace(all) + } - var add = baseQuery() - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let addStatus = SecItemAdd(add as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw keychainError(addStatus, operation: "add") - } + private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws { + let data = try JSONEncoder().encode(records) + let updateStatus = SecItemUpdate( + baseQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") } - private func baseQuery() -> [String: Any] { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: Self.service, - kSecAttrAccount as String: Self.account, - ] - if let accessGroup, !accessGroup.isEmpty { - query[kSecAttrAccessGroup as String] = accessGroup - } - return query + var add = baseQuery() + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") } + } - private func keychainError(_ status: OSStatus, operation: String) -> Error { - NSError( - domain: NSOSStatusErrorDomain, - code: Int(status), - userInfo: [ - NSLocalizedDescriptionKey: - "Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" - ] - ) + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup } + return query } - extension BuzzPushEndpointGrantRecord { - var flutterArguments: [String: Any] { - let arguments: [String: Any] = [ - "relayOrigin": relayOrigin, - "relayPubkey": relayPubkey, - "installationId": installationId, - "endpointGrant": endpointGrant, - "endpointHash": endpointHash, - "appProfile": appProfile, - "endpointEpoch": endpointEpoch, - "generation": generation, - "expiresAt": expiresAt, + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" ] - return arguments - } + ) + } +} + +extension BuzzPushEndpointGrantRecord { + var flutterArguments: [String: Any] { + let arguments: [String: Any] = [ + "relayOrigin": relayOrigin, + "relayPubkey": relayPubkey, + "installationId": installationId, + "endpointGrant": endpointGrant, + "endpointHash": endpointHash, + "appProfile": appProfile, + "endpointEpoch": endpointEpoch, + "generation": generation, + "expiresAt": expiresAt, + ] + return arguments } -#endif +} diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift index 1b216855661..6253f656ce4 100644 --- a/mobile/ios/Runner/PushNativeState.swift +++ b/mobile/ios/Runner/PushNativeState.swift @@ -1,8 +1,7 @@ -#if BUZZ_PUSH_ENABLED - import BuzzPushKit - import Foundation - import Security - import UserNotifications +import BuzzPushKit +import Foundation +import Security +import UserNotifications final class BuzzOneShotCompletion { private let lock = NSLock() @@ -56,7 +55,10 @@ enum BuzzPushKeychain { SecItemDelete(baseQuery(accessGroup: accessGroup) as CFDictionary) throw NSError( domain: NSOSStatusErrorDomain, code: Int(status), - userInfo: [NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) ?? "Keychain write failed" as CFString] + userInfo: [ + NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) + ?? "Keychain write failed" as CFString + ] ) } query.removeValue(forKey: kSecValueData as String) @@ -76,4 +78,3 @@ enum BuzzPushKeychain { return query } } -#endif diff --git a/mobile/ios/Runner/PushPresentationCacheBridge.swift b/mobile/ios/Runner/PushPresentationCacheBridge.swift index b7493491326..51894fcc793 100644 --- a/mobile/ios/Runner/PushPresentationCacheBridge.swift +++ b/mobile/ios/Runner/PushPresentationCacheBridge.swift @@ -1,7 +1,6 @@ -#if BUZZ_PUSH_ENABLED - import BuzzPushKit - import Flutter - import Foundation +import BuzzPushKit +import Flutter +import Foundation final class BuzzPushPresentationCacheBridge { private let appGroupIdentifier: String? @@ -152,12 +151,13 @@ final class BuzzPushPresentationCacheBridge { Self.complete(result, value: false) return } - let updated = try store?.updateAvatar( - communityID: communityID, - relayOrigin: community.relayUrl, - sourceURL: sourceURL, - avatarPNG: avatarData - ) ?? false + let updated = + try store?.updateAvatar( + communityID: communityID, + relayOrigin: community.relayUrl, + sourceURL: sourceURL, + avatarPNG: avatarData + ) ?? false Self.complete(result, value: updated) } catch { Self.complete( @@ -196,4 +196,3 @@ final class BuzzPushPresentationCacheBridge { } } } -#endif diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 0c67376ebac..7fca08a0f35 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -1,5 +1,20 @@ - + + aps-environment + $(BUZZ_IOS_PUSH_ENVIRONMENT) + com.apple.developer.devicecheck.appattest-environment + $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.developer.usernotifications.communication + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + diff --git a/mobile/ios/Runner/RunnerPush-Info.plist b/mobile/ios/Runner/RunnerPush-Info.plist deleted file mode 100644 index 998cc20a3d5..00000000000 --- a/mobile/ios/Runner/RunnerPush-Info.plist +++ /dev/null @@ -1,98 +0,0 @@ - - - - - BuzzAppGroupIdentifier - $(BUZZ_APP_GROUP_IDENTIFIER) - BuzzKeychainAccessGroup - $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - $(APP_DISPLAY_NAME) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Buzz - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLName - com.buzz.deeplink - CFBundleURLSchemes - - buzz - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - FlutterDeepLinkingEnabled - - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSFaceIDUsageDescription - Buzz uses Face ID to confirm sensitive identity transfers. - NSCameraUsageDescription - Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. - NSPhotoLibraryUsageDescription - Buzz needs photo library access so you can attach images to messages. - NSPhotoLibraryAddUsageDescription - Buzz needs permission to save images to your photo library. - NSUserActivityTypes - - INSendMessageIntent - - PHPhotoLibraryPreventAutomaticLimitedAccessAlert - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/mobile/ios/Runner/RunnerPush.entitlements b/mobile/ios/Runner/RunnerPush.entitlements deleted file mode 100644 index 7fca08a0f35..00000000000 --- a/mobile/ios/Runner/RunnerPush.entitlements +++ /dev/null @@ -1,20 +0,0 @@ - - - - - aps-environment - $(BUZZ_IOS_PUSH_ENVIRONMENT) - com.apple.developer.devicecheck.appattest-environment - $(BUZZ_APP_ATTEST_ENVIRONMENT) - com.apple.developer.usernotifications.communication - - com.apple.security.application-groups - - $(BUZZ_APP_GROUP_IDENTIFIER) - - keychain-access-groups - - $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) - - - diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index a968302b3e1..fef4f317fb9 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -20,7 +20,7 @@ import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; import 'shared/push/push_subscription_provider.dart'; -import 'shared/push/push_capability.dart'; +import 'shared/push/push_relay_capability_provider.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; @@ -89,7 +89,7 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); - if (buzzPushCapabilityEnabled) { + if (ref.watch(currentRelayPushDescriptorProvider).value != null) { ref.watch(pushSubscriptionSyncProvider); } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index fb8ce50aebb..a793a5aa6aa 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,7 +4,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'shared/push/push_bootstrap.dart'; -import 'shared/push/push_capability.dart'; import 'shared/push/push_bridge.dart'; import 'shared/theme/theme_provider.dart'; @@ -12,10 +11,8 @@ void main() => runBuzzApp(const App()); Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); - if (buzzPushCapabilityEnabled) { - installBuzzPushMethodHandler(); - await syncPendingBuzzPushNotificationResponse(); - } + installBuzzPushMethodHandler(); + await syncPendingBuzzPushNotificationResponse(); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); @@ -23,7 +20,7 @@ Future runBuzzApp(Widget app) async { runApp( ProviderScope( overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: buzzPushCapabilityEnabled ? BuzzPushBootstrap(child: app) : app, + child: BuzzPushBootstrap(child: app), ), ); } diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index ceb2545cbab..d37ff45a630 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -4,7 +4,6 @@ import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; import '../push/dev_push_lease.dart'; import '../push/push_bridge.dart'; -import '../push/push_capability.dart'; import '../push/push_subscription.dart'; import '../relay/signed_event_relay.dart'; import 'community.dart'; @@ -39,7 +38,6 @@ final communityPushLeaseDeactivatorProvider = }); Future _deactivateCommunityPushLease(Community community) async { - if (!buzzPushCapabilityEnabled) return; final state = community.pushSubscriptionState; final acceptedGeneration = state.acceptedGeneration; final installationId = state.acceptedInstallationId; diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index bf22c551395..9b1d0a5fdd8 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -11,6 +11,7 @@ import '../relay/relay_session.dart'; import '../relay/signed_event_relay.dart'; import 'dev_push_lease.dart'; import 'push_bridge.dart'; +import 'push_relay_capability_provider.dart'; import 'push_subscription.dart'; /// Starts the push lifecycle only after authenticated relay connectivity and a @@ -30,27 +31,40 @@ class BuzzPushBootstrap extends HookConsumerWidget { final config = ref.watch(relayConfigProvider); final community = ref.watch(activeCommunityProvider).value; final memberPubkey = ref.watch(myPubkeyProvider); + final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; - useEffect(() { - if (!_ready(session, config, community, memberPubkey)) return null; - final attempt = '${community!.id}|${config.baseUrl}'; - if (authorizationAttempt.value == attempt) return null; - authorizationAttempt.value = attempt; - unawaited( - _authorize(config.baseUrl).catchError((Object error, StackTrace stack) { - authorizationAttempt.value = null; - debugPrint('Push authorization bootstrap failed: $error'); - debugPrintStack(stackTrace: stack); - }), - ); - return null; - }, [session.status, config.baseUrl, community?.id, memberPubkey]); + useEffect( + () { + if (!_ready(session, config, community, memberPubkey) || + descriptor == null) { + return null; + } + final attempt = '${community!.id}|${config.baseUrl}'; + if (authorizationAttempt.value == attempt) return null; + authorizationAttempt.value = attempt; + unawaited(() async { + try { + await requestBuzzPushAuthorizationIfCapable( + descriptor, + requestAuthorization: requestBuzzPushAuthorization, + ); + } catch (error, stack) { + authorizationAttempt.value = null; + debugPrint('Push authorization bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [session.status, config.baseUrl, community?.id, memberPubkey, descriptor], + ); final token = apnsDeviceToken.value; final authorized = pushAuthorizationGranted.value; useEffect( () { if (!_ready(session, config, community, memberPubkey) || + descriptor == null || authorized != true || token == null) { return null; @@ -97,6 +111,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { community?.id, community?.pushSubscriptionState, memberPubkey, + descriptor, authorized, token, ], @@ -118,11 +133,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { memberPubkey != null && memberPubkey.isNotEmpty; - static Future _authorize(String relayBaseUrl) async { - await fetchBuzzPushLeaseDescriptor(relayBaseUrl); - await requestBuzzPushAuthorization(); - } - static Future _publish( WidgetRef ref, RelayConfig config, diff --git a/mobile/lib/shared/push/push_capability.dart b/mobile/lib/shared/push/push_capability.dart deleted file mode 100644 index 55aafb85165..00000000000 --- a/mobile/lib/shared/push/push_capability.dart +++ /dev/null @@ -1,6 +0,0 @@ -/// Compile-time iOS push capability. It is false for every normal build and is -/// injected as a Dart define only by the tracked PushEnabled.xcconfig overlay. -const buzzPushCapabilityEnabled = bool.fromEnvironment( - 'BUZZ_PUSH_ENABLED', - defaultValue: false, -); diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index e42d94b8299..5465c633a37 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -6,7 +6,6 @@ import 'package:flutter/services.dart'; import 'package:nostr/nostr.dart' as nostr; import '../relay/nostr_models.dart'; -import 'push_capability.dart'; const _pushPresentationChannel = MethodChannel('buzz/push'); // Keep these bridge payload bounds aligned with BuzzPushPresentationCacheStore. @@ -42,9 +41,7 @@ Future cacheBuzzPushProfileEvents( String communityID, Iterable events, ) async { - if (!buzzPushCapabilityEnabled || - defaultTargetPlatform != TargetPlatform.iOS || - communityID.isEmpty) { + if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } final verified = _boundedNewestEvents( @@ -66,9 +63,7 @@ Future cacheBuzzPushChannelEvents( Iterable metadataEvents, Iterable membershipEvents, ) async { - if (!buzzPushCapabilityEnabled || - defaultTargetPlatform != TargetPlatform.iOS || - communityID.isEmpty) { + if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } final batch = selectBoundedPushChannelEvents( @@ -172,8 +167,7 @@ Future cacheBuzzPushAvatarFromLoadedBytes( String sourceURL, Uint8List sourceBytes, ) async { - if (!buzzPushCapabilityEnabled || - defaultTargetPlatform != TargetPlatform.iOS || + if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty || sourceBytes.isEmpty || sourceBytes.length > _maximumAvatarSourceBytes || diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart new file mode 100644 index 00000000000..8e5e42d2eb3 --- /dev/null +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import 'dev_push_lease.dart'; + +typedef BuzzPushDescriptorFetcher = + Future Function(String relayBaseUrl); + +final buzzPushDescriptorFetcherProvider = Provider( + (ref) => fetchBuzzPushLeaseDescriptor, +); + +/// The fully validated push capability advertised by the current relay. +/// +/// Discovery fails closed. An absent, malformed, or unreachable NIP-11 push +/// descriptor is represented as no capability, so no notification permission, +/// APNs registration, gateway enrollment, or relay lease can begin. +final currentRelayPushDescriptorProvider = + FutureProvider.autoDispose((ref) async { + final session = ref.watch(relaySessionProvider); + final config = ref.watch(relayConfigProvider); + final community = ref.watch(activeCommunityProvider).value; + final memberPubkey = ref.watch(myPubkeyProvider); + if (session.status != SessionStatus.connected || + community == null || + config.nsec == null || + config.nsec!.isEmpty || + memberPubkey == null || + memberPubkey.isEmpty) { + return null; + } + + return discoverBuzzPushRelayCapability( + config.baseUrl, + fetchDescriptor: ref.read(buzzPushDescriptorFetcherProvider), + ); + }); + +Future discoverBuzzPushRelayCapability( + String relayBaseUrl, { + required BuzzPushDescriptorFetcher fetchDescriptor, +}) async { + try { + return await fetchDescriptor(relayBaseUrl); + } catch (error, stackTrace) { + debugPrint('Current relay does not advertise valid push: $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } +} + +Future requestBuzzPushAuthorizationIfCapable( + BuzzPushLeaseDescriptor? descriptor, { + required Future Function() requestAuthorization, +}) async { + if (descriptor == null) return false; + return requestAuthorization(); +} diff --git a/mobile/test/shared/push/push_relay_capability_provider_test.dart b/mobile/test/shared/push/push_relay_capability_provider_test.dart new file mode 100644 index 00000000000..696911fcdb5 --- /dev/null +++ b/mobile/test/shared/push/push_relay_capability_provider_test.dart @@ -0,0 +1,73 @@ +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('valid capability permits notification authorization', () async { + var requests = 0; + + final granted = await requestBuzzPushAuthorizationIfCapable( + _descriptor, + requestAuthorization: () async { + requests += 1; + return true; + }, + ); + + expect(granted, isTrue); + expect(requests, 1); + }); + + test('missing or invalid capability cannot start authorization', () async { + var requests = 0; + + final granted = await requestBuzzPushAuthorizationIfCapable( + null, + requestAuthorization: () async { + requests += 1; + return true; + }, + ); + + expect(granted, isFalse); + expect(requests, 0); + }); + + for (final failure in [ + const FormatException('malformed descriptor'), + StateError('relay unreachable'), + ]) { + test('$failure keeps capability inactive without authorization', () async { + final descriptor = await discoverBuzzPushRelayCapability( + 'https://relay.example', + fetchDescriptor: (_) async => throw failure, + ); + var requests = 0; + + final granted = await requestBuzzPushAuthorizationIfCapable( + descriptor, + requestAuthorization: () async { + requests += 1; + return true; + }, + ); + + expect(descriptor, isNull); + expect(granted, isFalse); + expect(requests, 0); + }); + } +} + +const _descriptor = BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'relay-v1', + executorPubkey: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); diff --git a/scripts/test-ios-pbxproj-semantics.py b/scripts/test-ios-pbxproj-semantics.py index 5bb07bcc333..e2b2015904c 100755 --- a/scripts/test-ios-pbxproj-semantics.py +++ b/scripts/test-ios-pbxproj-semantics.py @@ -13,9 +13,9 @@ NotificationService Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService NotificationService Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService NotificationService Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService -Runner Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) $(BUZZ_CODE_SIGN_ENTITLEMENTS) $(BUNDLE_IDENTIFIER) -Runner Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) $(BUZZ_CODE_SIGN_ENTITLEMENTS) $(BUNDLE_IDENTIFIER) -Runner Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) $(BUZZ_CODE_SIGN_ENTITLEMENTS) $(BUNDLE_IDENTIFIER) +Runner Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) +Runner Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) +Runner Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) RunnerTests Debug Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests RunnerTests Profile Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests RunnerTests Release Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index d9f0625a8a0..56b09f41fe9 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -123,10 +123,8 @@ grep -q '^applicationIdSuffix=\.w_2fast$' "$wt2/mobile/android/worktree.properti # ── Tracked build files: overrides are debug-only, release stays production ── debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig" release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig" -push_xcconfig="$repo_root/mobile/ios/Flutter/PushEnabled.xcconfig" pbxproj="$repo_root/mobile/ios/Runner.xcodeproj/project.pbxproj" runner_entitlements="$repo_root/mobile/ios/Runner/Runner.entitlements" -runner_push_entitlements="$repo_root/mobile/ios/Runner/RunnerPush.entitlements" gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" @@ -201,44 +199,25 @@ assert_single_xcconfig_declaration() { } for config in "$debug_xcconfig" "$release_xcconfig"; do - assert_xcconfig_value "$config" '^BUZZ_PUSH_ENABLED = NO$' \ - "$(basename "$config") defaults push capability off" assert_xcconfig_value "$config" \ - '^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements$' \ - "$(basename "$config") uses the push-free Runner entitlements" + '^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \ + "$(basename "$config") derives the push App Group from the bundle" assert_xcconfig_value "$config" \ - '^EXCLUDED_SOURCE_FILE_NAMES = NotificationService\.appex PushNativeState\.swift PushEndpointGrantStore\.swift PushPresentationCacheBridge\.swift$' \ - "$(basename "$config") excludes native push sources and extension product" + '^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \ + "$(basename "$config") derives the push Keychain group from the bundle" done - -assert_xcconfig_value "$push_xcconfig" '^BUZZ_PUSH_ENABLED = YES$' \ - "PushEnabled explicitly enables the capability" -assert_xcconfig_value "$push_xcconfig" \ - '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' \ - "PushEnabled selects the internal dogfood bundle" -assert_xcconfig_value "$push_xcconfig" \ +assert_xcconfig_value "$debug_xcconfig" \ + '^BUZZ_IOS_PUSH_ENVIRONMENT = development$' \ + "Debug uses sandbox APNs" +assert_xcconfig_value "$debug_xcconfig" \ + '^BUZZ_APP_ATTEST_ENVIRONMENT = development$' \ + "Debug uses development App Attest" +assert_xcconfig_value "$release_xcconfig" \ '^BUZZ_IOS_PUSH_ENVIRONMENT = production$' \ - "PushEnabled uses production APNs transport for distribution" -assert_xcconfig_value "$push_xcconfig" \ + "Release uses production APNs" +assert_xcconfig_value "$release_xcconfig" \ '^BUZZ_APP_ATTEST_ENVIRONMENT = production$' \ - "PushEnabled uses production App Attest" -assert_xcconfig_value "$push_xcconfig" \ - '^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \ - "PushEnabled derives the App Group from the dogfood bundle" -assert_xcconfig_value "$push_xcconfig" \ - '^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \ - "PushEnabled derives the Keychain access group from the dogfood bundle" -assert_xcconfig_value "$push_xcconfig" \ - '^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush\.entitlements$' \ - "PushEnabled selects push-capable Runner entitlements" -assert_xcconfig_value "$push_xcconfig" \ - '^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) BUZZ_PUSH_ENABLED$' \ - "PushEnabled compiles the native push bridge" -assert_xcconfig_value "$push_xcconfig" '^EXCLUDED_SOURCE_FILE_NAMES =$' \ - "PushEnabled restores the extension product and native push sources" -assert_xcconfig_value "$push_xcconfig" \ - '^DART_DEFINES = \$\(inherited\),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ==$' \ - "PushEnabled compiles the Dart push bootstrap" + "Release uses production App Attest" assert_xcconfig_value "$release_xcconfig" \ '^CODE_SIGN_STYLE = Automatic$' \ "Release code signing style is declared as automatic" @@ -246,15 +225,19 @@ assert_xcconfig_value "$release_xcconfig" \ '^CODE_SIGN_IDENTITY = iPhone Developer$' \ "Release code signing identity is declared as iPhone Developer" -for key in BUNDLE_IDENTIFIER BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES; do +for key in BUNDLE_IDENTIFIER; do assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" done for key in BUZZ_KEYCHAIN_ACCESS_GROUP BUZZ_IOS_PUSH_ENVIRONMENT BUZZ_APP_ATTEST_ENVIRONMENT BUZZ_APP_GROUP_IDENTIFIER; do + assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" + assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" +done + +for key in BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES DART_DEFINES; do assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" assert_xcconfig_declaration_count "$release_xcconfig" "$key" 0 "Release" - assert_single_xcconfig_declaration "$push_xcconfig" "$key" "PushEnabled" done for key in CODE_SIGN_STYLE CODE_SIGN_IDENTITY; do @@ -295,8 +278,8 @@ else fi grep -q 'aps-environment' "$runner_entitlements" \ - && fail "push-free Runner entitlements must not contain aps-environment" \ - || pass "push-free Runner entitlements omit aps-environment" + && pass "Runner entitlements always include APNs support" \ + || fail "Runner entitlements must include aps-environment" # Split the retired identifiers so the regression test does not match itself. retired_bundle_id='com.buzz.buzz'"Mobile" @@ -305,14 +288,14 @@ if git -C "$repo_root" grep -q -F "$retired_bundle_id"; then else pass "tracked files do not retain the retired iOS bundle identifier" fi -grep -q 'com.apple.developer.devicecheck.appattest-environment' "$runner_push_entitlements" \ - && pass "push-enabled Runner uses the App Attest entitlement key accepted by Apple" \ - || fail "push-enabled Runner must use com.apple.developer.devicecheck.appattest-environment" +grep -q 'com.apple.developer.devicecheck.appattest-environment' "$runner_entitlements" \ + && pass "Runner uses the App Attest entitlement key accepted by Apple" \ + || fail "Runner must use com.apple.developer.devicecheck.appattest-environment" retired_entitlement_key='com.apple.developer.app-attest.'"environment" -if grep -q "$retired_entitlement_key" "$runner_push_entitlements"; then - fail "push-enabled Runner must not retain the invalid App Attest entitlement key" +if grep -q "$retired_entitlement_key" "$runner_entitlements"; then + fail "Runner must not retain the invalid App Attest entitlement key" else - pass "push-enabled Runner omits the invalid App Attest entitlement key" + pass "Runner omits the invalid App Attest entitlement key" fi duplicate_pbx_object_ids=$(awk ' @@ -507,9 +490,9 @@ expected_signing_map=$(printf '%s\n' \ 'NotificationService Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ 'NotificationService Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ 'NotificationService Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ - 'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \ - 'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \ - 'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"') + 'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \ + 'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \ + 'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements') if [[ "$signing_map" == "$expected_signing_map" ]]; then pass "Runner and NotificationService signing settings match each build configuration" else From 1b0563e57f3089614e7c9ed6a1483b3589a19efd Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 11:45:16 -0700 Subject: [PATCH 10/27] refactor(mobile): split channel member snapshots Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../channels/channel_member_snapshots.dart | 37 +++++++++++ .../features/channels/channels_provider.dart | 64 ++----------------- .../shared/push/push_presentation_cache.dart | 6 +- 3 files changed, 48 insertions(+), 59 deletions(-) create mode 100644 mobile/lib/features/channels/channel_member_snapshots.dart diff --git a/mobile/lib/features/channels/channel_member_snapshots.dart b/mobile/lib/features/channels/channel_member_snapshots.dart new file mode 100644 index 00000000000..33f266ba6b9 --- /dev/null +++ b/mobile/lib/features/channels/channel_member_snapshots.dart @@ -0,0 +1,37 @@ +part of 'channels_provider.dart'; + +extension on ChannelsNotifier { + void _cacheMemberSnapshots( + Iterable events, { + bool replaceAll = false, + }) { + final latestByChannelId = {}; + for (final event in events) { + final channelId = event.getTagValue('d'); + if (channelId == null) continue; + final current = latestByChannelId[channelId]; + if (current == null || event.createdAt > current.createdAt) { + latestByChannelId[channelId] = event; + } + } + + final snapshots = replaceAll + ? >{} + : Map>.of(_memberSnapshotsByChannelId); + snapshots.addAll({ + for (final entry in latestByChannelId.entries) + entry.key: List.unmodifiable([ + for (final member in membersFromEvent(entry.value)) + ChannelMember( + pubkey: member.pubkey, + role: member.role, + joinedAt: DateTime.fromMillisecondsSinceEpoch( + entry.value.createdAt * 1000, + isUtc: true, + ), + ), + ]), + }); + _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); + } +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 44599116fb3..10f4d136df4 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -22,6 +22,7 @@ import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; part 'channel_directory.dart'; +part 'channel_member_snapshots.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; @@ -281,29 +282,12 @@ class ChannelsNotifier extends AsyncNotifier> { // Use the membership snapshots already fetched above for both Huddle // linkage validation and member-count hydration. if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); - final latestMembershipPerId = {}; - for (final event in [...memberships, ...memberEvents]) { - if (event.kind != 39002) continue; - final id = event.getTagValue('d'); - if (id == null) continue; - final existing = latestMembershipPerId[id]; - if (existing == null || - event.createdAt > existing.createdAt || - (event.createdAt == existing.createdAt && - event.id.compareTo(existing.id) < 0)) { - latestMembershipPerId[id] = event; - } - } - final dedupedMemberships = latestMembershipPerId.values.toList(); - if (communityID != null) { - unawaited( - cacheBuzzPushChannelEvents( - communityID, - dedupedMetas, - dedupedMemberships, - ), - ); - } + unawaited( + cacheBuzzPushChannelEvents(communityID, dedupedMetas, [ + ...memberships, + ...memberEvents, + ]), + ); final memberCounts = _memberCountsByChannelId(memberEvents); for (var i = 0; i < channels.length; i++) { final count = memberCounts[channels[i].id]; @@ -411,40 +395,6 @@ class ChannelsNotifier extends AsyncNotifier> { return channels; } - void _cacheMemberSnapshots( - Iterable events, { - bool replaceAll = false, - }) { - final latestByChannelId = {}; - for (final event in events) { - final channelId = event.getTagValue('d'); - if (channelId == null) continue; - final current = latestByChannelId[channelId]; - if (current == null || event.createdAt > current.createdAt) { - latestByChannelId[channelId] = event; - } - } - - final snapshots = replaceAll - ? >{} - : Map>.of(_memberSnapshotsByChannelId); - snapshots.addAll({ - for (final entry in latestByChannelId.entries) - entry.key: List.unmodifiable([ - for (final member in membersFromEvent(entry.value)) - ChannelMember( - pubkey: member.pubkey, - role: member.role, - joinedAt: DateTime.fromMillisecondsSinceEpoch( - entry.value.createdAt * 1000, - isUtc: true, - ), - ), - ]), - }); - _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); - } - /// Fetches each channel's independent latest-message window in one HTTP /// bridge request. The relay preserves NIP-01 per-filter limits while /// executing the filters with bounded concurrency, avoiding an unbounded diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index 5465c633a37..a833d33819d 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -59,11 +59,13 @@ Future cacheBuzzPushProfileEvents( /// Exports verified channel metadata and membership for native authority checks. Future cacheBuzzPushChannelEvents( - String communityID, + String? communityID, Iterable metadataEvents, Iterable membershipEvents, ) async { - if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { + if (defaultTargetPlatform != TargetPlatform.iOS || + communityID == null || + communityID.isEmpty) { return; } final batch = selectBoundedPushChannelEvents( From 9b1a06224130f0a52da60caf745e537254ca8fae Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 11:47:36 -0700 Subject: [PATCH 11/27] fix(mobile): guard push profile cache disposal Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/profile/user_cache_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index dd8afce66b0..9893257b900 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -68,9 +68,9 @@ class UserCacheNotifier extends Notifier> { final pubkeys = _pending.toList(); _pending.clear(); - final communityID = ref.read(activeCommunityProvider).value?.id; try { + final communityID = ref.read(activeCommunityProvider).value?.id; final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.profilesBatch(pubkeys), From 7d6efa9fb51bd9c0103240cf291c30e126c93dcf Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 14:02:32 -0700 Subject: [PATCH 12/27] fix(mobile): show avatars in channel push notifications Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzPushKit/BuzzCommunicationNotification.swift | 10 +++++++++- .../BuzzPushKit/BuzzPushNotificationResolver.swift | 2 +- .../BuzzPushConversationResolverTests.swift | 4 ++-- .../BuzzPushNotificationResolverTests.swift | 10 +++++----- .../BuzzCommunicationNotificationTests.swift | 2 ++ 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift index 5946d5a92c2..60f4d9e5110 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -113,11 +113,12 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { public static func makeIntent( _ descriptor: BuzzCommunicationNotificationDescriptor ) -> INSendMessageIntent { + let senderAvatar = descriptor.senderAvatarPNG.map(INImage.init(imageData:)) let sender = INPerson( personHandle: INPersonHandle(value: descriptor.senderIdentifier, type: .unknown), nameComponents: nil, displayName: descriptor.senderDisplayName, - image: descriptor.senderAvatarPNG.map(INImage.init(imageData:)), + image: senderAvatar, contactIdentifier: nil, customIdentifier: descriptor.senderIdentifier, isMe: false, @@ -139,6 +140,13 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { let donationMetadata = INSendMessageIntentDonationMetadata() donationMetadata.recipientCount = descriptor.recipientCount intent.donationMetadata = donationMetadata + if let senderAvatar { + // Communication Notifications render a group conversation's image + // from the speakable-group parameter rather than INPerson.image. + // Buzz channels do not have a separate avatar, so use the verified + // sender thumbnail for the visible incoming-message avatar. + intent.setImage(senderAvatar, forParameterNamed: \.speakableGroupName) + } } return intent } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 6cd76ecf740..908d4048357 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -548,7 +548,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ["stream", "forum"].contains(channelType), let displayName = channel.displayName else { return nil } - return (displayName, recipientCount) + return (displayName.hasPrefix("#") ? displayName : "#\(displayName)", recipientCount) } private static func newest(_ events: [VerifiedNostrEvent]) -> VerifiedNostrEvent? { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift index 9bf3b85f50b..57ff7475359 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift @@ -47,7 +47,7 @@ extension BuzzPushNotificationResolverTests { ) ) - XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationDisplayName, "#General") XCTAssertEqual(result.conversationRecipientCount, 2) } @@ -160,7 +160,7 @@ extension BuzzPushNotificationResolverTests { ) ) - XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationDisplayName, "#General") XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertEqual(URLProtocolStub.requests.count, 2) } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index c91ef665e49..e8e858f176c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -226,7 +226,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.title, "Alice") XCTAssertEqual(result.senderAvatarPNG, avatar) - XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationDisplayName, "#General") XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertEqual(URLProtocolStub.requests.count, 1) } @@ -293,7 +293,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) XCTAssertEqual(result.title, "Stale Alice") - XCTAssertEqual(result.conversationDisplayName, "Stale General") + XCTAssertEqual(result.conversationDisplayName, "#Stale General") XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertEqual(URLProtocolStub.requests.count, 2) } @@ -376,7 +376,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) XCTAssertEqual(result.title, "Newer Cached Alice") - XCTAssertEqual(result.conversationDisplayName, "Newer Cached General") + XCTAssertEqual(result.conversationDisplayName, "#Newer Cached General") } func testChannelOnlyRefreshIgnoresUnrequestedProfileEvent() throws { @@ -452,7 +452,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) XCTAssertEqual(result.title, "Cached Alice") - XCTAssertEqual(result.conversationDisplayName, "Stale General") + XCTAssertEqual(result.conversationDisplayName, "#Stale General") } func testMissingCacheRefreshesVerifiedProfileAndChannelTogether() throws { @@ -508,7 +508,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) XCTAssertEqual(result.title, "Fresh Alice") - XCTAssertEqual(result.conversationDisplayName, "Fresh General") + XCTAssertEqual(result.conversationDisplayName, "#Fresh General") XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertNil(result.senderAvatarPNG) XCTAssertEqual(URLProtocolStub.requests.count, 2) diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 098067cc90d..8d8f1373df9 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -21,6 +21,7 @@ final class BuzzCommunicationNotificationTests: XCTestCase { XCTAssertEqual(intent.sender?.displayName, "Alice") XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier) XCTAssertNotNil(intent.sender?.image) + XCTAssertNotNil(intent.image(forParameterNamed: \.speakableGroupName)) XCTAssertEqual(intent.content, "Hello Buzz") XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General") XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier) @@ -46,6 +47,7 @@ final class BuzzCommunicationNotificationTests: XCTestCase { XCTAssertEqual(intent.sender?.displayName, "Alice") XCTAssertNotNil(intent.sender?.image) + XCTAssertNil(intent.image(forParameterNamed: \.speakableGroupName)) XCTAssertNil(intent.speakableGroupName) XCTAssertNil(intent.donationMetadata) } From b89c13af1e797be6e8e03af96a129f4cb4f30105 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 14:02:51 -0700 Subject: [PATCH 13/27] chore(mobile): format merged sources Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/main.dart | 2 +- mobile/test/features/channels/deep_link_dispatcher_test.dart | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index acb45ec1a7b..8f360a3db91 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -24,7 +24,7 @@ Future runBuzzApp(Widget app) async { savedPrefsProvider.overrideWithValue(prefs), inviteJoinRecoveryProvider.overrideWith( (ref) => - (scope) => buildMobileInviteJoinRecovery(ref, scope), + (scope) => buildMobileInviteJoinRecovery(ref, scope), ), ], child: BuzzPushBootstrap(child: app), diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 7a04723d368..8cf1d5091a7 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -731,6 +731,7 @@ class _FailedStarterRecoveryInviteJoinNotifier extends InviteJoinNotifier { ); } } + class _CountingCommunityStorage extends CommunityStorage { int loadCalls = 0; From 39bf80b0626f5f028e674c1a7762b7ab3dfead53 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 14:35:42 -0700 Subject: [PATCH 14/27] fix(mobile): resolve inline push profile avatars Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzPushNotificationResolver.swift | 180 ++++++++++-------- .../BuzzPushPresentationCache.swift | 112 +++++++---- .../BuzzPushConversationResolverTests.swift | 2 +- .../BuzzPushNotificationResolverTests.swift | 55 +++++- .../BuzzPushPresentationCacheTests.swift | 84 ++++++-- .../shared/push/push_presentation_cache.dart | 16 +- mobile/lib/shared/widgets/avatar_image.dart | 35 +++- .../shared/widgets/avatar_image_test.dart | 10 + 8 files changed, 354 insertions(+), 140 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 908d4048357..f4113292dc4 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -50,7 +50,10 @@ public protocol BuzzPushNotificationResolving { /// Reads configured Buzz communities and resolves their newest unread event. public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { - static let maximumPresentationResponseBytes = 128 * 1_024 + // Verified profiles may carry bounded inline raster avatars. Keep the refresh + // response capped, but large enough to recover the sender name from those + // otherwise valid kind-0 events when the app cache has not been populated. + static let maximumPresentationResponseBytes = 256 * 1_024 private let session: URLSession private let loadCommunitiesData: () -> Data? @@ -213,13 +216,13 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { else { return false } return cachedChannel.memberDigests?.count != memberCount }() - let channelNeedsRefresh = channelID != nil && ( - Self.isStale( + let channelNeedsRefresh = + channelID != nil + && (Self.isStale( cachedAt: cachedChannel?.cachedAt, now: timestamp, lifetime: presentationCacheLifetime - ) || membershipNeedsRefresh - ) + ) || membershipNeedsRefresh) let fallback = Self.makeResolution( event: event, community: community, @@ -237,50 +240,58 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { refreshProfile: profileNeedsRefresh, refreshChannel: channelNeedsRefresh ) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in - let profile = refreshedProfileEvent.flatMap { - guard BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedProfile?.eventCreatedAt, - existingID: cachedProfile?.eventID, - candidateCreatedAt: $0.createdAt, - candidateID: $0.id - ) else { return nil } - return Self.ephemeralProfile( - event: $0, - communityID: community.id, - relayOrigin: relayOrigin ?? community.relayUrl, - cached: cachedProfile, - cachedAt: timestamp - ) - } ?? cachedProfile + let profile = + refreshedProfileEvent.flatMap { + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedProfile?.eventCreatedAt, + existingID: cachedProfile?.eventID, + candidateCreatedAt: $0.createdAt, + candidateID: $0.id + ) + else { return nil } + return Self.ephemeralProfile( + event: $0, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + cached: cachedProfile, + cachedAt: timestamp + ) + } ?? cachedProfile let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in - guard BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedChannel?.eventCreatedAt, - existingID: cachedChannel?.eventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { return nil } + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.eventCreatedAt, + existingID: cachedChannel?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { return nil } return event } let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in - guard BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedChannel?.membershipEventCreatedAt, - existingID: cachedChannel?.membershipEventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { return nil } + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.membershipEventCreatedAt, + existingID: cachedChannel?.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { return nil } return event } - let channel = relayMetadataPubkey.flatMap { - Self.ephemeralChannel( - metadataEvent: newerChannelEvent, - membershipEvent: newerMembershipEvent, - cached: cachedChannel, - communityID: community.id, - relayOrigin: relayOrigin ?? community.relayUrl, - relayMetadataPubkey: $0, - cachedAt: timestamp - ) - } ?? cachedChannel + let channel = + relayMetadataPubkey.flatMap { + Self.ephemeralChannel( + metadataEvent: newerChannelEvent, + membershipEvent: newerMembershipEvent, + cached: cachedChannel, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + relayMetadataPubkey: $0, + cachedAt: timestamp + ) + } ?? cachedChannel completion( Self.makeResolution( event: event, @@ -297,9 +308,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { community: PushLeaseCommunity, refreshProfile: Bool, refreshChannel: Bool, - completion: @escaping ( - VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? - ) -> Void + completion: + @escaping ( + VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? + ) -> Void ) { guard let privateKey = loadPrivateKey(community.id), let relayURL = community.relayURL, @@ -338,14 +350,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = body - request.timeoutInterval = 1 + // A verified kind-0 profile may include a bounded inline raster avatar. + // Railway can take longer than one second to return that larger response, + // while three seconds remains a small fraction of the NSE execution budget. + request.timeoutInterval = 3 request.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard let auth = try? NostrHTTPAuth.authorizationHeader( - url: url, - method: "POST", - body: body, - privateKeyHex: privateKey - ) else { + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, + method: "POST", + body: body, + privateKeyHex: privateKey + ) + else { completion(nil, nil, nil) return } @@ -364,23 +381,32 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { return } let verified = events.filter { $0.hasValidIDAndSignature() } - let profile = refreshProfile ? Self.newest(verified.filter { - $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() - }) : nil - let channel = refreshChannel ? channelID.flatMap { channelID in - Self.newest(verified.filter { - $0.kind == 39_000 - && $0.pubkey.lowercased() == relayMetadataPubkey - && Self.tagValue("d", in: $0) == channelID - }) - } : nil - let membership = refreshChannel ? channelID.flatMap { channelID in - Self.newest(verified.filter { - $0.kind == 39_002 - && $0.pubkey.lowercased() == relayMetadataPubkey - && Self.tagValue("d", in: $0) == channelID - }) - } : nil + let profile = + refreshProfile + ? Self.newest( + verified.filter { + $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() + }) : nil + let channel = + refreshChannel + ? channelID.flatMap { channelID in + Self.newest( + verified.filter { + $0.kind == 39_000 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + let membership = + refreshChannel + ? channelID.flatMap { channelID in + Self.newest( + verified.filter { + $0.kind == 39_002 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil completion(profile, channel, membership) }.resume() } @@ -390,12 +416,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ) -> (BuzzPushResolution, VerifiedNostrEvent)? { let event = newestMessage(events: events, community: community) guard let event else { return nil } - guard let resolution = makeResolution( - event: event, - community: community, - profile: nil, - channel: nil - ) else { return nil } + guard + let resolution = makeResolution( + event: event, + community: community, + profile: nil, + channel: nil + ) + else { return nil } return (resolution, event) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift index df0a7360d9f..56eb7703a1c 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -164,6 +164,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { public static let maximumAvatarBytes = 64 * 1024 public static let maximumTotalAvatarBytes = 4 * 1024 * 1024 public static let maximumSnapshotBytes = 8 * 1024 * 1024 + static let maximumProfileMetadataBytes = 256 * 1024 private let fileURL: URL private let now: () -> Date @@ -203,15 +204,18 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { && $0.pubkey == pubkey } let existing = index.map { snapshot.profiles[$0] } - guard Self.shouldReplace( - existingCreatedAt: existing?.eventCreatedAt, - existingID: existing?.eventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { continue } + guard + Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG) - let preservedAvatar = existing?.pictureHash == metadata.pictureHash + let preservedAvatar = + existing?.pictureHash == metadata.pictureHash ? existing?.avatarPNG : nil let entry = BuzzPushCachedProfile( communityID: communityID, @@ -234,15 +238,16 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { Self.enforceBounds(&snapshot) try writeLocked(snapshot) - return Set(snapshot.profiles.compactMap { profile in - guard profile.communityID == communityID, - profile.relayOrigin == canonicalRelayOrigin, - acceptedEventIDs.contains(profile.eventID), - profile.pictureHash != nil, - profile.avatarPNG == nil - else { return nil } - return profile.eventID - }) + return Set( + snapshot.profiles.compactMap { profile in + guard profile.communityID == communityID, + profile.relayOrigin == canonicalRelayOrigin, + acceptedEventIDs.contains(profile.eventID), + profile.pictureHash != nil, + profile.avatarPNG == nil + else { return nil } + return profile.eventID + }) } /// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots. @@ -279,12 +284,15 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { } let existing = index.map { snapshot.channels[$0] } let hasCurrentAuthority = existing?.relayMetadataPubkey == normalizedRelayPubkey - guard !hasCurrentAuthority || Self.shouldReplace( - existingCreatedAt: existing?.eventCreatedAt, - existingID: existing?.eventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { continue } + guard + !hasCurrentAuthority + || Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } let entry = BuzzPushCachedChannel( communityID: communityID, @@ -328,12 +336,14 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { }) else { continue } let existing = snapshot.channels[index] - guard Self.shouldReplace( - existingCreatedAt: existing.membershipEventCreatedAt, - existingID: existing.membershipEventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { continue } + guard + Self.shouldReplace( + existingCreatedAt: existing.membershipEventCreatedAt, + existingID: existing.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } snapshot.channels[index] = BuzzPushCachedChannel( communityID: existing.communityID, @@ -379,11 +389,12 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { var snapshot = loadLocked() var changed = false - for index in snapshot.profiles.indices where + for index in snapshot.profiles.indices + where snapshot.profiles[index].communityID == communityID - && snapshot.profiles[index].relayOrigin == canonicalRelayOrigin - && snapshot.profiles[index].pictureHash == pictureHash - && snapshot.profiles[index].avatarPNG != normalizedPNG + && snapshot.profiles[index].relayOrigin == canonicalRelayOrigin + && snapshot.profiles[index].pictureHash == pictureHash + && snapshot.profiles[index].avatarPNG != normalizedPNG { let profile = snapshot.profiles[index] snapshot.profiles[index] = BuzzPushCachedProfile( @@ -478,11 +489,12 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { static func profileMetadata( _ event: VerifiedNostrEvent ) -> (displayName: String?, pictureHash: String?) { - guard event.content.utf8.count <= 32 * 1024, + guard event.content.utf8.count <= maximumProfileMetadataBytes, let data = event.content.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (nil, nil) } - let displayName = normalizedDisplayName(object["display_name"] as? String) + let displayName = + normalizedDisplayName(object["display_name"] as? String) ?? normalizedDisplayName(object["name"] as? String) let pictureHash = normalizedAvatarURL(object["picture"] as? String).map { VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8))) @@ -525,13 +537,16 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { exceededMemberBound = pubkeys.count > maximumMembersPerChannel } } - let digests = exceededMemberBound ? nil : pubkeys.map { - BuzzPushPresentationIdentity.channelMember( - communityID: communityID, - channelID: channelID, - pubkey: $0 - ) - }.sorted() + let digests = + exceededMemberBound + ? nil + : pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: communityID, + channelID: channelID, + pubkey: $0 + ) + }.sorted() return ( exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count, digests @@ -541,7 +556,20 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { static func normalizedAvatarURL(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed.utf8.count <= 2_048, + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("data:image/") { + guard trimmed.utf8.count <= maximumProfileMetadataBytes, + let separator = trimmed.firstIndex(of: ","), + separator < trimmed.index(before: trimmed.endIndex) + else { return nil } + let metadata = trimmed[.. cacheBuzzPushAvatarFromLoadedBytes( communityID.isEmpty || sourceBytes.isEmpty || sourceBytes.length > _maximumAvatarSourceBytes || - !_isRemoteImageURL(sourceURL)) { + !isCacheablePushAvatarSource(sourceURL)) { return; } final previous = _avatarEncodeTail; @@ -209,7 +209,19 @@ Future _invokeBestEffort( } } -bool _isRemoteImageURL(String value) { +@visibleForTesting +bool isCacheablePushAvatarSource(String value) { + final trimmed = value.trim(); + if (trimmed.startsWith('data:image/')) { + try { + final data = UriData.parse(trimmed); + return data.mimeType.startsWith('image/') && + data.mimeType != 'image/svg+xml' && + data.contentAsBytes().isNotEmpty; + } on FormatException { + return false; + } + } final uri = Uri.tryParse(value.trim()); return uri != null && (uri.scheme == 'http' || uri.scheme == 'https') && diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 5165c2a383c..b869bf8fbc4 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -74,6 +74,7 @@ class AvatarImageContent extends ConsumerStatefulWidget { class _AvatarImageContentState extends ConsumerState { late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); + String? _scheduledPushAvatar; @override void didUpdateWidget(AvatarImageContent oldWidget) { @@ -110,10 +111,11 @@ class _AvatarImageContentState extends ConsumerState { placeholderBuilder: (_) => centeredFallback, errorBuilder: (_, _, _) => centeredFallback, ), - _RasterDataAvatarSource(:final bytes) => Image.memory( - bytes, - fit: widget.fit, - errorBuilder: (_, _, _) => centeredFallback, + _RasterDataAvatarSource(:final bytes) => _rasterImage( + communityID: communityID, + sourceURL: widget.imageUrl, + bytes: bytes, + fallback: centeredFallback, ), _NetworkAvatarSource(:final url) => MediaImage( url: url, @@ -128,6 +130,31 @@ class _AvatarImageContentState extends ConsumerState { null => centeredFallback, }; } + + Widget _rasterImage({ + required String? communityID, + required String? sourceURL, + required Uint8List bytes, + required Widget fallback, + }) { + if (communityID != null && sourceURL != null) { + final identity = '$communityID\u0000$sourceURL'; + if (_scheduledPushAvatar != identity) { + _scheduledPushAvatar = identity; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _scheduledPushAvatar != identity) return; + unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes), + ); + }); + } + } + return Image.memory( + bytes, + fit: widget.fit, + errorBuilder: (_, _, _) => fallback, + ); + } } sealed class _AvatarSource { diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 4631cf58dab..821de63a480 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/push/push_presentation_cache.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,15 @@ void main() { ), ); + test('accepts bounded raster data avatars for the push cache', () { + expect(isCacheablePushAvatarSource('data:image/png;base64,AA=='), isTrue); + expect( + isCacheablePushAvatarSource('data:image/svg+xml;base64,AA=='), + isFalse, + ); + expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse); + }); + testWidgets('renders raccoon percent-encoded SVG data avatar', ( tester, ) async { From 35044e8558c15e483c020e06913f80e506aa8145 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 18:56:08 -0700 Subject: [PATCH 15/27] fix(mobile): decouple push enrollment from permission Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/README.md | 10 ++-- mobile/ios/Runner/AppDelegate.swift | 36 +++++------- mobile/lib/shared/push/push_bootstrap.dart | 18 +++--- mobile/lib/shared/push/push_bridge.dart | 16 +++-- .../push/push_relay_capability_provider.dart | 8 +-- mobile/test/shared/push/push_bridge_test.dart | 23 ++++---- .../push_relay_capability_provider_test.dart | 58 +++++++++---------- 7 files changed, 80 insertions(+), 89 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 170c785a0c4..c108dcece25 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -97,10 +97,12 @@ installs are never touched. Every iOS artifact builds and embeds the Notification Service Extension and native push bridge. Runtime activation is fail-closed and scoped to the current -relay: Buzz requests notification permission, registers with APNs, enrolls with -the gateway, and publishes a lease only after authenticated connectivity and a -fully valid NIP-11 `nip-pl` push descriptor. An absent, malformed, or -unreachable descriptor leaves push inactive without partial enrollment. +relay. After authenticated connectivity and a fully valid NIP-11 `nip-pl` push +descriptor, Buzz independently requests display permission and registers with +APNs. Display denial or request failure does not gate the device token, gateway +enrollment, or lease publication, so a later user opt-in can display pushes +without rebuilding transport authority. An absent, malformed, or unreachable +descriptor leaves push inactive without partial enrollment. Relay rollout remains an explicit deployment opt-in. Only deployments with `BUZZ_PUSH_ENABLED=true` advertise the descriptor and process push. See diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 84bee09c506..1da32bca4ea 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -3,6 +3,7 @@ import BuzzPushKit import Flutter import UIKit import UserNotifications +import os.log @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -329,8 +330,8 @@ import UserNotifications return } switch call.method { - case "requestAuthorization": - requestPushAuthorization(result: result) + case "startRegistration": + startPushRegistration(result: result) case "takePendingNotificationResponse": result(pushNavigationBuffer.take()?.flutterArguments) case "saveCommunitySnapshot": @@ -376,28 +377,21 @@ import UserNotifications } } - private func requestPushAuthorization(result: @escaping FlutterResult) { + private func startPushRegistration(result: @escaping FlutterResult) { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { - granted, error in - DispatchQueue.main.async { - if let error { - result( - FlutterError( - code: "notification_authorization_failed", - message: "Unable to request notification authorization.", - details: error.localizedDescription - ) - ) - return - } - guard granted else { - result(false) - return - } - UIApplication.shared.registerForRemoteNotifications() - result(true) + _, error in + if let error { + os_log( + "Buzz notification authorization request failed: %{public}@", + type: .error, + error.localizedDescription + ) } } + // APNs token registration is independent from display authorization. A + // denied or failed prompt must not prevent gateway enrollment and leases. + UIApplication.shared.registerForRemoteNotifications() + result(nil) } private func handleDevPushEnrollment( diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 9b1d0a5fdd8..0652ad8fa43 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -24,8 +24,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); - useListenable(pushAuthorizationGranted); - final authorizationAttempt = useRef(null); + final registrationAttempt = useRef(null); final publicationAttempt = useRef(null); final session = ref.watch(relaySessionProvider); final config = ref.watch(relayConfigProvider); @@ -40,17 +39,17 @@ class BuzzPushBootstrap extends HookConsumerWidget { return null; } final attempt = '${community!.id}|${config.baseUrl}'; - if (authorizationAttempt.value == attempt) return null; - authorizationAttempt.value = attempt; + if (registrationAttempt.value == attempt) return null; + registrationAttempt.value = attempt; unawaited(() async { try { - await requestBuzzPushAuthorizationIfCapable( + await startBuzzPushRegistrationIfCapable( descriptor, - requestAuthorization: requestBuzzPushAuthorization, + startRegistration: startBuzzPushRegistration, ); } catch (error, stack) { - authorizationAttempt.value = null; - debugPrint('Push authorization bootstrap failed: $error'); + registrationAttempt.value = null; + debugPrint('Push registration bootstrap failed: $error'); debugPrintStack(stackTrace: stack); } }()); @@ -60,12 +59,10 @@ class BuzzPushBootstrap extends HookConsumerWidget { ); final token = apnsDeviceToken.value; - final authorized = pushAuthorizationGranted.value; useEffect( () { if (!_ready(session, config, community, memberPubkey) || descriptor == null || - authorized != true || token == null) { return null; } @@ -112,7 +109,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { community?.pushSubscriptionState, memberPubkey, descriptor, - authorized, token, ], ); diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 8341921371c..db647aefd9c 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -13,7 +13,6 @@ const _channel = MethodChannel('buzz/push'); /// the Flutter method channel attaches. final apnsDeviceToken = ValueNotifier(null); final apnsRegistrationError = ValueNotifier(null); -final pushAuthorizationGranted = ValueNotifier(null); final pushEndpointGrants = ValueNotifier>([]); final pushEndpointGrantError = ValueNotifier(null); @@ -59,16 +58,15 @@ Future syncPendingBuzzPushNotificationResponse() async { } } -Future requestBuzzPushAuthorization() async { - if (defaultTargetPlatform != TargetPlatform.iOS) return false; +/// Starts the independent iOS notification-authorization and APNs-registration +/// requests. Display authorization is intentionally not returned or persisted: +/// APNs registration and enrollment remain valid while display is denied. +Future startBuzzPushRegistration() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; try { - final granted = - await _channel.invokeMethod('requestAuthorization') ?? false; - pushAuthorizationGranted.value = granted; - return granted; + await _channel.invokeMethod('startRegistration'); } on MissingPluginException { - pushAuthorizationGranted.value = false; - return false; + // Flutter tests and non-Runner embeddings do not install the native bridge. } } diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart index 8e5e42d2eb3..f2e8eebd8be 100644 --- a/mobile/lib/shared/push/push_relay_capability_provider.dart +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -52,10 +52,10 @@ Future discoverBuzzPushRelayCapability( } } -Future requestBuzzPushAuthorizationIfCapable( +Future startBuzzPushRegistrationIfCapable( BuzzPushLeaseDescriptor? descriptor, { - required Future Function() requestAuthorization, + required Future Function() startRegistration, }) async { - if (descriptor == null) return false; - return requestAuthorization(); + if (descriptor == null) return; + await startRegistration(); } diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 448f5b747ae..c54a86cdd1d 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -16,7 +16,6 @@ void main() { setUp(() { apnsDeviceToken.value = null; apnsRegistrationError.value = null; - pushAuthorizationGranted.value = null; pushEndpointGrants.value = const []; pushEndpointGrantError.value = null; pushCommunitySnapshotError.value = null; @@ -44,17 +43,19 @@ void main() { expect(apnsRegistrationError.value, isNull); }); - test('records denied authorization so enrollment stays blocked', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_channel, (call) async { - expect(call.method, 'requestAuthorization'); - return false; - }); + test( + 'starts native permission and APNs registration without a result gate', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'startRegistration'); + return null; + }); - expect(await requestBuzzPushAuthorization(), isFalse); - expect(pushAuthorizationGranted.value, isFalse); - }); + await startBuzzPushRegistration(); + }, + ); test('reads and exposes persisted endpoint grants on iOS', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; diff --git a/mobile/test/shared/push/push_relay_capability_provider_test.dart b/mobile/test/shared/push/push_relay_capability_provider_test.dart index 696911fcdb5..f6cc5874bac 100644 --- a/mobile/test/shared/push/push_relay_capability_provider_test.dart +++ b/mobile/test/shared/push/push_relay_capability_provider_test.dart @@ -3,57 +3,57 @@ import 'package:buzz/shared/push/push_relay_capability_provider.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('valid capability permits notification authorization', () async { - var requests = 0; + test( + 'valid capability starts independent permission and APNs registration', + () async { + var requests = 0; - final granted = await requestBuzzPushAuthorizationIfCapable( - _descriptor, - requestAuthorization: () async { - requests += 1; - return true; - }, - ); + await startBuzzPushRegistrationIfCapable( + _descriptor, + startRegistration: () async { + requests += 1; + }, + ); - expect(granted, isTrue); - expect(requests, 1); - }); + expect(requests, 1); + }, + ); - test('missing or invalid capability cannot start authorization', () async { - var requests = 0; + test( + 'missing capability cannot start permission or APNs registration', + () async { + var requests = 0; - final granted = await requestBuzzPushAuthorizationIfCapable( - null, - requestAuthorization: () async { - requests += 1; - return true; - }, - ); + await startBuzzPushRegistrationIfCapable( + null, + startRegistration: () async { + requests += 1; + }, + ); - expect(granted, isFalse); - expect(requests, 0); - }); + expect(requests, 0); + }, + ); for (final failure in [ const FormatException('malformed descriptor'), StateError('relay unreachable'), ]) { - test('$failure keeps capability inactive without authorization', () async { + test('$failure keeps capability inactive without registration', () async { final descriptor = await discoverBuzzPushRelayCapability( 'https://relay.example', fetchDescriptor: (_) async => throw failure, ); var requests = 0; - final granted = await requestBuzzPushAuthorizationIfCapable( + await startBuzzPushRegistrationIfCapable( descriptor, - requestAuthorization: () async { + startRegistration: () async { requests += 1; - return true; }, ); expect(descriptor, isNull); - expect(granted, isFalse); expect(requests, 0); }); } From aca13021d67f1cb7b30a9864975d36ae1fbf5859 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 19:05:01 -0700 Subject: [PATCH 16/27] refactor(mobile): remove duplicate lease self-validation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/push/dev_push_lease.dart | 96 ------------------- .../test/shared/push/dev_push_lease_test.dart | 40 -------- 2 files changed, 136 deletions(-) diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 2a41c3220d5..9108c104b0c 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -367,11 +367,6 @@ Future publishBuzzDevPushLease({ for (final subscription in subscriptions) subscription.toJson(), ], }; - validateBuzzPushLeasePlaintext( - plaintextMap, - maxEndpointLength: descriptor.maxEndpointLength, - maxStringLength: descriptor.maxStringLength, - ); final plaintext = jsonEncode(plaintextMap); if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { throw const FormatException('lease plaintext exceeds the advertised limit'); @@ -469,7 +464,6 @@ Future publishBuzzPushLeaseTombstone({ 'generation': generation, 'active': false, }; - validateBuzzPushLeaseTombstonePlaintext(plaintextMap); final plaintext = jsonEncode(plaintextMap); if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { throw const FormatException('lease plaintext exceeds the advertised limit'); @@ -521,96 +515,6 @@ Future publishBuzzPushLeaseTombstoneThroughRelay({ now: now, ); -void validateBuzzPushLeaseTombstonePlaintext(Map plaintext) { - _requireExactKeys( - plaintext, - required: const {'v', 'origin', 'generation', 'active'}, - allowed: const {'v', 'origin', 'generation', 'active'}, - name: 'lease tombstone plaintext', - ); - if (plaintext['v'] != 1 || plaintext['active'] != false) { - throw const FormatException('lease tombstone must be inactive v1'); - } - _canonicalOrigin(plaintext['origin']); - final generation = _positiveInt(plaintext['generation'], name: 'generation'); - if (generation > _maxSafeJsonInteger) { - throw const FormatException( - 'generation exceeds the safe JSON integer range', - ); - } -} - -void validateBuzzPushLeasePlaintext( - Map plaintext, { - int maxEndpointLength = 4096, - int maxStringLength = 512, -}) { - _requireExactKeys( - plaintext, - required: const { - 'v', - 'origin', - 'app_profile', - 'transport', - 'endpoint', - 'generation', - 'active', - 'subscriptions', - }, - allowed: const { - 'v', - 'origin', - 'app_profile', - 'transport', - 'endpoint', - 'generation', - 'active', - 'subscriptions', - }, - name: 'lease plaintext', - ); - if (plaintext['v'] != 1) { - throw const FormatException('lease version must be 1'); - } - final origin = _canonicalOrigin(plaintext['origin']); - _checkStringLength(origin, maxStringLength, name: 'origin'); - if (plaintext['app_profile'] != buzzDevPushAppProfile) { - throw const FormatException('lease app_profile must be buzz-ios-dogfood'); - } - if (plaintext['transport'] != buzzPushTransport) { - throw const FormatException('lease transport must be apns'); - } - _checkStringLength( - buzzDevPushAppProfile, - maxStringLength, - name: 'app_profile', - ); - _checkStringLength(buzzPushTransport, maxStringLength, name: 'transport'); - final endpoint = _nonEmptyString(plaintext['endpoint'], name: 'endpoint'); - _checkStringLength(endpoint, maxEndpointLength, name: 'endpoint'); - final generation = _positiveInt(plaintext['generation'], name: 'generation'); - if (generation > _maxSafeJsonInteger) { - throw const FormatException( - 'generation exceeds the safe JSON integer range', - ); - } - if (plaintext['active'] != true) { - throw const FormatException('push lease must be active'); - } - - final subscriptions = _mapList( - plaintext['subscriptions'], - name: 'subscriptions', - ); - if (subscriptions.isEmpty || - subscriptions.length > buzzPushMaxSubscriptions) { - throw const FormatException('push lease subscription count is invalid'); - } - for (final subscription in subscriptions) { - BuzzPushSubscription.fromJson(subscription); - } -} - void _validateGrant( BuzzPushEndpointGrant grant, BuzzPushLeaseDescriptor descriptor, diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index 6da2dd2116a..e174941f74a 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -166,46 +166,6 @@ void main() { }, ); - test('strict plaintext validator accepts message kinds only', () { - final valid = { - 'v': 1, - 'origin': 'wss://tenant.example:8443', - 'app_profile': 'buzz-ios-dogfood', - 'transport': 'apns', - 'endpoint': 'opaque-grant', - 'generation': 1, - 'active': true, - 'subscriptions': [ - { - 'filter': { - 'kinds': [7], - '#p': [signer.public], - }, - 'class': 'default', - }, - ], - }; - - expect( - () => validateBuzzPushLeasePlaintext(valid), - throwsA( - isA().having( - (error) => error.message, - 'message', - 'Push filter contains invalid kinds.', - ), - ), - ); - - ((valid['subscriptions'] as List).single['filter'] as Map)['kinds'] = [ - 9, - 40002, - 45001, - 45003, - ]; - expect(() => validateBuzzPushLeasePlaintext(valid), returnsNormally); - }); - test('propagates relay rejection instead of accepting locally', () async { await expectLater( publishBuzzDevPushLease( From 3b3e536bd2951a9f9c8ba5aad5dfdf33892bbcc6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 20:25:40 -0700 Subject: [PATCH 17/27] refactor(push): simplify internal notification stack Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .github/workflows/ci.yml | 5 - crates/buzz-push-gateway/Cargo.toml | 4 - .../migrations/0002_application_profiles.sql | 2 +- crates/buzz-push-gateway/src/app_attest.rs | 125 ------ .../src/app_attest_policy.rs | 123 ------ crates/buzz-push-gateway/src/config.rs | 358 +--------------- .../buzz-push-gateway/src/dev_app_attest.rs | 168 -------- crates/buzz-push-gateway/src/http.rs | 81 ++-- crates/buzz-push-gateway/src/lib.rs | 3 - crates/buzz-push-gateway/src/main.rs | 54 +-- crates/buzz-push-gateway/src/model.rs | 2 - crates/buzz-push-gateway/src/postgres.rs | 3 +- .../fixtures/app-attest-generator/Cargo.lock | 341 --------------- .../fixtures/app-attest-generator/Cargo.toml | 14 - .../fixtures/app-attest-generator/README.md | 18 - .../fixtures/app-attest-generator/src/main.rs | 404 ------------------ .../tests/fixtures/app-attest-good.json | 4 - .../fixtures/app-attest-wrong-aaguid.json | 4 - .../tests/fixtures/app-attest-wrong-root.json | 4 - crates/buzz-relay/src/handlers/push_lease.rs | 55 +-- crates/buzz-relay/src/nip11.rs | 8 +- crates/buzz-relay/src/push_runtime.rs | 10 +- .../templates/deployment.yaml | 14 - .../charts/buzz-push-gateway/tests/render.sh | 3 +- .../buzz-push-gateway/values.schema.json | 9 +- deploy/charts/buzz-push-gateway/values.yaml | 7 - docs/nips/NIP-PL.md | 54 +-- docs/push-gateway-deployment.md | 27 +- .../BuzzDevPushEnrollmentDriver.swift | 58 --- .../BuzzPushNotificationResolver.swift | 11 +- .../BuzzPushPresentationCache.swift | 30 +- .../Sources/BuzzPushKit/PushLease.swift | 74 +--- .../BuzzDevPushEnrollmentDriverTests.swift | 19 - .../BuzzPushNotificationResolverTests.swift | 11 +- .../BuzzPushPresentationCacheTests.swift | 25 +- .../BuzzPushKitTests/PushLeaseTests.swift | 43 +- .../NotificationService.swift | 15 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 8 +- mobile/ios/Runner/AppDelegate.swift | 109 +---- ...eBridge.swift => PushSnapshotBridge.swift} | 107 ++++- .../BuzzCommunicationNotificationTests.swift | 4 +- .../channels/deep_link_dispatcher.dart | 99 ++--- .../shared/community/community_provider.dart | 19 +- .../deeplink/pending_deep_link_provider.dart | 32 ++ mobile/lib/shared/push/dev_push_lease.dart | 14 +- mobile/lib/shared/push/push_bootstrap.dart | 24 +- mobile/lib/shared/push/push_bridge.dart | 5 +- .../shared/push/push_presentation_cache.dart | 64 +-- mobile/lib/shared/push/push_snapshot.dart | 31 +- mobile/lib/shared/push/push_subscription.dart | 59 +-- .../channels/deep_link_dispatcher_test.dart | 20 + .../test/shared/push/dev_push_lease_test.dart | 13 +- mobile/test/shared/push/push_bridge_test.dart | 10 +- .../push/push_presentation_cache_test.dart | 80 ++-- .../test/shared/push/push_snapshot_test.dart | 11 +- .../shared/push/push_subscription_test.dart | 22 +- scripts/run-tests.sh | 3 - scripts/test-ios-pbxproj-semantics.py | 87 ---- scripts/test-mobile-worktree-overrides.sh | 344 --------------- 59 files changed, 487 insertions(+), 2868 deletions(-) delete mode 100644 crates/buzz-push-gateway/src/app_attest_policy.rs delete mode 100644 crates/buzz-push-gateway/src/dev_app_attest.rs delete mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock delete mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml delete mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md delete mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs rename mobile/ios/Runner/{PushPresentationCacheBridge.swift => PushSnapshotBridge.swift} (61%) delete mode 100755 scripts/test-ios-pbxproj-semantics.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a5f3bc304b..af8e2abb085 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,6 @@ jobs: - 'pnpm-lock.yaml' mobile: - 'mobile/**' - - 'crates/buzz-push-gateway/tests/vectors/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' @@ -72,7 +71,6 @@ jobs: - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' - 'scripts/test-mobile-worktree-overrides.sh' - - 'scripts/test-ios-pbxproj-semantics.py' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract @@ -934,9 +932,6 @@ jobs: run: swift build -c release --package-path mobile/ios/BuzzPushKit - name: Test run: swift test --package-path mobile/ios/BuzzPushKit - - name: Validate iOS project semantics - run: python3 scripts/test-ios-pbxproj-semantics.py - security: name: Security runs-on: ubuntu-latest diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index c5a5368f3c8..06376c02dc5 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -7,10 +7,6 @@ rust-version.workspace = true license.workspace = true repository.workspace = true -[features] -default = [] -dev-app-attest-bypass = [] - [lib] name = "buzz_push_gateway" path = "src/lib.rs" diff --git a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql index 45be402dc07..b661f9c3de2 100644 --- a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql +++ b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql @@ -15,4 +15,4 @@ ALTER TABLE push_gateway_installations DROP CONSTRAINT push_gateway_installations_app_profile_check; ALTER TABLE push_gateway_installations ADD CONSTRAINT push_gateway_installations_app_profile_check - CHECK (app_profile IN ('buzz-ios-dogfood', 'buzz-ios-app-store')); + CHECK (app_profile = 'buzz-ios-dogfood'); diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index 302e2dd5add..b8f86a35e39 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -45,14 +45,6 @@ impl AppAttestVerifier { apple_root_cert_pem, }) } - #[cfg(all(test, feature = "dev-app-attest-bypass"))] - pub(crate) fn for_policy_test() -> Self { - Self { - app_id: "policy-test".to_owned(), - apple_root_cert_pem: Vec::new(), - } - } - /// `client_data` is the exact canonical enrollment transcript represented by /// the challenge string passed to `attestKey`; callers must include every /// authority-bearing enrollment field in it. @@ -151,7 +143,6 @@ fn assertion_counter(cbor: &[u8]) -> Result { mod tests { use super::*; use appattest::error::AppAttestError as DependencyAppAttestError; - use chrono::{Duration, NaiveDateTime, Utc}; use serde::Deserialize; const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json"); @@ -165,13 +156,9 @@ mod tests { #[derive(Deserialize)] struct Fixture { description: String, - generator: String, - generated_at: String, - regeneration_command: String, app_id: String, challenge: String, aaguid: String, - leaf_not_after: String, attestation_b64: String, key_id_b64: String, root_cert_pem: String, @@ -180,11 +167,6 @@ mod tests { fn fixture(json: &str) -> Fixture { let fixture: Fixture = serde_json::from_str(json).expect("valid App Attest fixture JSON"); assert!(!fixture.description.is_empty()); - assert_eq!( - fixture.generator, - "crates/buzz-push-gateway/tests/fixtures/app-attest-generator" - ); - assert!(!fixture.generated_at.is_empty()); fixture } @@ -365,111 +347,4 @@ mod tests { .is_err() ); } - - #[test] - fn fixture_leaf_certificate_is_valid_for_at_least_thirty_days() { - let fixture = fixture(GOOD_FIXTURE_JSON); - let leaf_certificate = fixture_leaf_certificate(&fixture); - let not_after = certificate_not_after(&leaf_certificate); - assert_eq!( - not_after.format("%b %e %H:%M:%S %Y GMT").to_string(), - fixture.leaf_not_after - ); - assert!( - not_after > Utc::now() + Duration::days(30), - "App Attest fixture expires within 30 days; regenerate with: {}", - fixture.regeneration_command - ); - } - - fn fixture_leaf_certificate(fixture: &Fixture) -> Vec { - let cbor = STANDARD - .decode(&fixture.attestation_b64) - .expect("fixture attestation is base64"); - let mut decoder = minicbor::Decoder::new(&cbor); - let root_entries = decoder - .map() - .expect("attestation root is a map") - .expect("attestation root map has a fixed length"); - for _ in 0..root_entries { - let key = decoder.str().expect("attestation root key is text"); - if key != "attStmt" { - decoder.skip().expect("skip non-attStmt value"); - continue; - } - let statement_entries = decoder - .map() - .expect("attStmt is a map") - .expect("attStmt map has a fixed length"); - for _ in 0..statement_entries { - let key = decoder.str().expect("attStmt key is text"); - if key != "x5c" { - decoder.skip().expect("skip non-x5c value"); - continue; - } - assert!( - decoder - .array() - .expect("x5c is an array") - .expect("x5c has a fixed length") - >= 2 - ); - return decoder - .bytes() - .expect("x5c leaf certificate is bytes") - .to_vec(); - } - } - panic!("fixture attestation has no x5c leaf certificate"); - } - - fn certificate_not_after(certificate: &[u8]) -> chrono::DateTime { - let (tag, certificate, _) = der_tlv(certificate); - assert_eq!(tag, 0x30, "certificate must be a DER sequence"); - let (tag, tbs_certificate, _) = der_tlv(certificate); - assert_eq!(tag, 0x30, "TBSCertificate must be a DER sequence"); - - let mut fields = tbs_certificate; - if fields.first() == Some(&0xa0) { - fields = der_tlv(fields).2; - } - for _ in 0..3 { - fields = der_tlv(fields).2; - } - let (tag, validity, _) = der_tlv(fields); - assert_eq!(tag, 0x30, "certificate validity must be a DER sequence"); - let (_, _, after_not_before) = der_tlv(validity); - let (time_tag, not_after, _) = der_tlv(after_not_before); - let not_after = std::str::from_utf8(not_after).expect("notAfter is ASCII"); - let format = match time_tag { - 0x17 => "%y%m%d%H%M%SZ", - 0x18 => "%Y%m%d%H%M%SZ", - _ => panic!("unexpected DER time tag {time_tag:#x}"), - }; - NaiveDateTime::parse_from_str(not_after, format) - .expect("valid DER notAfter timestamp") - .and_utc() - } - - fn der_tlv(input: &[u8]) -> (u8, &[u8], &[u8]) { - let tag = *input.first().expect("DER TLV has a tag"); - let first_length = *input.get(1).expect("DER TLV has a length"); - let (length, length_bytes) = if first_length & 0x80 == 0 { - (first_length as usize, 1) - } else { - let byte_count = (first_length & 0x7f) as usize; - assert!( - byte_count > 0 && byte_count <= std::mem::size_of::(), - "supported DER long-form length" - ); - let length = input[2..2 + byte_count] - .iter() - .fold(0_usize, |length, byte| (length << 8) | *byte as usize); - (length, 1 + byte_count) - }; - let value_start = 1 + length_bytes; - let value_end = value_start + length; - assert!(value_end <= input.len(), "DER TLV length is in bounds"); - (tag, &input[value_start..value_end], &input[value_end..]) - } } diff --git a/crates/buzz-push-gateway/src/app_attest_policy.rs b/crates/buzz-push-gateway/src/app_attest_policy.rs deleted file mode 100644 index 77c724d9e60..00000000000 --- a/crates/buzz-push-gateway/src/app_attest_policy.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Selects the production Apple verifier or the feature-gated development stub. - -use crate::app_attest::{ - AppAttestError, AppAttestVerifier, VerifiedAssertion, VerifiedAttestation, -}; -#[cfg(feature = "dev-app-attest-bypass")] -#[derive(Clone)] -pub struct DevelopmentAppAttestPolicy { - _private: (), -} - -#[cfg(feature = "dev-app-attest-bypass")] -pub struct DevelopmentAppAttestBypass { - _private: (), -} - -#[cfg(feature = "dev-app-attest-bypass")] -impl DevelopmentAppAttestBypass { - pub(crate) fn enabled() -> Self { - Self { _private: () } - } -} - -#[cfg_attr( - not(feature = "dev-app-attest-bypass"), - doc = r#" -The development policy is structurally unavailable in default builds: - -```compile_fail -use buzz_push_gateway::app_attest_policy::AppAttestPolicy; - -let _ = AppAttestPolicy::Development; -``` -"# -)] -#[derive(Clone)] -pub enum AppAttestPolicy { - Apple(AppAttestVerifier), - #[cfg(feature = "dev-app-attest-bypass")] - Development(DevelopmentAppAttestPolicy), -} - -impl AppAttestPolicy { - pub fn apple(verifier: AppAttestVerifier) -> Self { - Self::Apple(verifier) - } - - #[cfg(feature = "dev-app-attest-bypass")] - pub fn from_config( - bypass: Option, - apple: AppAttestVerifier, - ) -> Self { - if bypass.is_some() { - tracing::warn!( - "DEVELOPMENT APP ATTEST BYPASS ACTIVE; Apple attestation and assertion verification are disabled" - ); - Self::development() - } else { - Self::apple(apple) - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - fn development() -> Self { - Self::Development(DevelopmentAppAttestPolicy { _private: () }) - } - - pub fn verify_attestation( - &self, - attestation_b64: &str, - key_id_b64: &str, - client_data: &[u8], - ) -> Result { - match self { - Self::Apple(verifier) => { - verifier.verify_attestation(attestation_b64, key_id_b64, client_data) - } - #[cfg(feature = "dev-app-attest-bypass")] - Self::Development(_) => { - crate::dev_app_attest::verify_attestation(attestation_b64, key_id_b64, client_data) - } - } - } - - pub fn verify_assertion( - &self, - assertion_b64: &str, - client_data: &[u8], - public_key: &[u8], - previous_counter: u32, - challenge: &str, - stored_challenge: &str, - ) -> Result { - match self { - Self::Apple(verifier) => verifier.verify_assertion( - assertion_b64, - client_data, - public_key, - previous_counter, - challenge, - stored_challenge, - ), - #[cfg(feature = "dev-app-attest-bypass")] - Self::Development(_) => crate::dev_app_attest::verify_assertion( - assertion_b64, - client_data, - public_key, - previous_counter, - challenge, - stored_challenge, - ), - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn development_bypass_stays_a_non_default_feature() { - assert!(include_str!("../Cargo.toml") - .contains("[features]\ndefault = []\ndev-app-attest-bypass = []")); - } -} diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index 8e4d5512436..f8485a628de 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -1,11 +1,5 @@ -#[cfg(feature = "dev-app-attest-bypass")] -use crate::app_attest_policy::DevelopmentAppAttestBypass; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use std::{ - collections::{HashMap, HashSet}, - net::SocketAddr, - path::PathBuf, -}; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf}; use thiserror::Error; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -16,9 +10,8 @@ pub enum ApnsEnvironment { #[derive(Debug, Clone)] pub struct AppProfileConfig { - pub enabled: bool, pub app_attest_app_id: String, - pub apns_cert_path: Option, + pub apns_cert_path: PathBuf, pub apns_topic: String, pub apns_environment: ApnsEnvironment, } @@ -38,14 +31,10 @@ pub struct Config { pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - /// Closed server-owned registry. Both known application identities are - /// present even when one is dormant; only enabled entries carry a required - /// APNs identity and can enroll or deliver. - pub profiles: HashMap, + /// Server-owned dogfood application identity and APNs transport. + pub profile: AppProfileConfig, pub database_url: String, pub app_attest_root_cert_path: PathBuf, - #[cfg(feature = "dev-app-attest-bypass")] - pub dev_app_attest_bypass: bool, /// Ordered current key first, followed by decrypt-only predecessors. pub grant_keys: Vec, /// Independent token-custody keyring. These keys MUST NOT be reused for @@ -93,31 +82,11 @@ fn parse_keyring( Ok(keys) } -fn parse_profile( - e: &HashMap, - prefix: &'static str, - enabled: bool, -) -> Result { - let app_id_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", - _ => unreachable!("closed profile prefix"), - }; - let cert_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH", - _ => unreachable!("closed profile prefix"), - }; - let topic_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_TOPIC", - _ => unreachable!("closed profile prefix"), - }; - let environment_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT", - _ => unreachable!("closed profile prefix"), - }; +fn parse_profile(e: &HashMap) -> Result { + let app_id_key = "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID"; + let cert_key = "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH"; + let topic_key = "BUZZ_PUSH_DOGFOOD_APNS_TOPIC"; + let environment_key = "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT"; let required = |key: &'static str| { e.get(key) .map(String::as_str) @@ -126,18 +95,13 @@ fn parse_profile( }; let app_attest_app_id = required(app_id_key)?.to_owned(); let apns_topic = required(topic_key)?.to_owned(); - let apns_cert_path = match e.get(cert_key).filter(|value| !value.is_empty()) { - Some(path) => Some(PathBuf::from(path)), - None if enabled => return Err(ConfigError::Missing(cert_key)), - None => None, - }; + let apns_cert_path = PathBuf::from(required(cert_key)?); let apns_environment = match e.get(environment_key).map(String::as_str) { None | Some("production") => ApnsEnvironment::Production, Some("sandbox") => ApnsEnvironment::Sandbox, Some(_) => return Err(ConfigError::Invalid(environment_key)), }; Ok(AppProfileConfig { - enabled, app_attest_app_id, apns_cert_path, apns_topic, @@ -146,12 +110,6 @@ fn parse_profile( } impl Config { - #[cfg(feature = "dev-app-attest-bypass")] - pub fn dev_app_attest_bypass(&self) -> Option { - self.dev_app_attest_bypass - .then(DevelopmentAppAttestBypass::enabled) - } - pub fn from_env() -> Result { Self::from_map(&std::env::vars().collect()) } @@ -217,35 +175,7 @@ impl Config { bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS", 10, 86_400)?; let endpoint_quota_max_deliveries = bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES", 10, 10_000)?; - let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")? - .split(',') - .map(|profile| match profile { - "buzz-ios-dogfood" => Ok(crate::model::AppProfile::BuzzIosDogfood), - "buzz-ios-app-store" => Ok(crate::model::AppProfile::BuzzIosAppStore), - _ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")), - }) - .collect::, _>>()?; - if enabled_profiles.is_empty() { - return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); - } - let profiles = HashMap::from([ - ( - crate::model::AppProfile::BuzzIosDogfood, - parse_profile( - e, - "DOGFOOD", - enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood), - )?, - ), - ( - crate::model::AppProfile::BuzzIosAppStore, - parse_profile( - e, - "APP_STORE", - enabled_profiles.contains(&crate::model::AppProfile::BuzzIosAppStore), - )?, - ), - ]); + let profile = parse_profile(e)?; let bind_addr = e .get("BUZZ_PUSH_BIND_ADDR") .map(String::as_str) @@ -258,33 +188,6 @@ impl Config { .unwrap_or("0.0.0.0:8081") .parse::() .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?; - let dev_app_attest_bypass_requested = - match e.get("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS").map(String::as_str) { - None | Some("0") => false, - Some("1") => true, - Some(_) => return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")), - }; - #[cfg(not(feature = "dev-app-attest-bypass"))] - if dev_app_attest_bypass_requested { - return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); - } - #[cfg(feature = "dev-app-attest-bypass")] - let dev_app_attest_bypass = dev_app_attest_bypass_requested; - #[cfg(feature = "dev-app-attest-bypass")] - if dev_app_attest_bypass - && (!bind_addr.ip().is_loopback() || !health_addr.ip().is_loopback()) - { - return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); - } - #[cfg(feature = "dev-app-attest-bypass")] - if dev_app_attest_bypass - && (enabled_profiles.len() != 1 - || !enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood) - || profiles[&crate::model::AppProfile::BuzzIosDogfood].apns_environment - != ApnsEnvironment::Sandbox) - { - return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); - } Ok(Self { bind_addr, health_addr, @@ -293,11 +196,9 @@ impl Config { max_installation_lifetime_seconds, endpoint_quota_window_seconds, endpoint_quota_max_deliveries, - profiles, + profile, database_url: req(e, "DATABASE_URL")?.to_owned(), app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), - #[cfg(feature = "dev-app-attest-bypass")] - dev_app_attest_bypass, grant_keys, token_keys, }) @@ -307,11 +208,6 @@ impl Config { #[cfg(test)] mod tests { use super::*; - #[cfg(feature = "dev-app-attest-bypass")] - use crate::app_attest_policy::AppAttestPolicy; - #[cfg(feature = "dev-app-attest-bypass")] - use sha2::Digest as _; - fn base() -> HashMap { HashMap::from([ ( @@ -338,10 +234,6 @@ mod tests { "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(), "2592000".into(), ), - ( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ), ( "DATABASE_URL".into(), "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 @@ -350,10 +242,6 @@ mod tests { "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID".into(), "TEAM.xyz.block.buzz.dogfood.mobile".into(), ), - ( - "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID".into(), - "TEAM.xyz.block.buzz.mobile".into(), - ), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), "/apple-root.pem".into(), @@ -370,36 +258,24 @@ mod tests { "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), "production".into(), ), - ( - "BUZZ_PUSH_APP_STORE_APNS_TOPIC".into(), - "xyz.block.buzz.mobile".into(), - ), ("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()), ("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()), ]) } #[test] - fn enabled_profile_requires_its_certificate_and_all_profiles_have_server_owned_identity() { + fn dogfood_profile_requires_server_owned_identity_and_certificate() { let config = Config::from_map(&base()).unwrap(); - let dogfood = &config.profiles[&crate::model::AppProfile::BuzzIosDogfood]; - assert!(dogfood.enabled); assert_eq!( - dogfood.apns_cert_path, - Some(PathBuf::from("/dogfood-identity.pem")) + config.profile.apns_cert_path, + PathBuf::from("/dogfood-identity.pem") ); - assert_eq!(dogfood.apns_topic, "xyz.block.buzz.dogfood.mobile"); - let app_store = &config.profiles[&crate::model::AppProfile::BuzzIosAppStore]; - assert!(!app_store.enabled); - assert_eq!(app_store.apns_cert_path, None); - assert_eq!(app_store.apns_topic, "xyz.block.buzz.mobile"); + assert_eq!(config.profile.apns_topic, "xyz.block.buzz.dogfood.mobile"); for variable in [ "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", - "BUZZ_PUSH_APP_STORE_APNS_TOPIC", - "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", ] { let mut env = base(); env.remove(variable); @@ -432,7 +308,6 @@ mod tests { ), ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), - ("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), ("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS", "0"), @@ -466,207 +341,6 @@ mod tests { assert_eq!(config.health_addr, "0.0.0.0:8081".parse().unwrap()); } - #[test] - fn dogfood_and_app_store_profiles_parse_together_without_bypass() { - let mut env = base(); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood,buzz-ios-app-store".into(), - ); - env.insert( - "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), - "/app-store-identity.pem".into(), - ); - - let config = Config::from_map(&env).unwrap(); - assert_eq!(config.profiles.len(), 2); - assert!(config.profiles[&crate::model::AppProfile::BuzzIosDogfood].enabled); - assert!(config.profiles[&crate::model::AppProfile::BuzzIosAppStore].enabled); - } - - #[test] - fn dev_app_attest_bypass_flag_is_strict_and_feature_gated() { - let absent = Config::from_map(&base()).unwrap(); - #[cfg(feature = "dev-app-attest-bypass")] - assert!(!absent.dev_app_attest_bypass); - #[cfg(not(feature = "dev-app-attest-bypass"))] - let _ = absent; - - let mut disabled = base(); - disabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "0".into()); - let disabled = Config::from_map(&disabled).unwrap(); - #[cfg(feature = "dev-app-attest-bypass")] - assert!(!disabled.dev_app_attest_bypass); - #[cfg(not(feature = "dev-app-attest-bypass"))] - let _ = disabled; - - for value in ["", "false", "true", "TRUE", "yes", " 1"] { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), value.into()); - assert!( - matches!( - Config::from_map(&env), - Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) - ), - "accepted non-canonical value {value:?}" - ); - } - - #[cfg(not(feature = "dev-app-attest-bypass"))] - { - let mut enabled = base(); - enabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - assert!(matches!( - Config::from_map(&enabled), - Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) - )); - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_requires_both_loopback_listeners() { - for (key, value) in [ - ("BUZZ_PUSH_BIND_ADDR", "0.0.0.0:8080"), - ("BUZZ_PUSH_HEALTH_ADDR", "[::]:8081"), - ] { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - env.insert(key.into(), value.into()); - assert!(Config::from_map(&env).is_err(), "accepted {key}={value}"); - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn non_loopback_bind_is_rejected_before_loopback_equivalent_is_accepted() { - let mut non_loopback = base(); - non_loopback.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - non_loopback.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - non_loopback.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "0.0.0.0:8080".into()); - assert!(Config::from_map(&non_loopback).is_err()); - - non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()); - assert!( - Config::from_map(&non_loopback) - .unwrap() - .dev_app_attest_bypass - ); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_requires_sandbox_dogfood_as_the_only_profile() { - for profiles in ["buzz-ios-app-store", "buzz-ios-dogfood,buzz-ios-app-store"] { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert("BUZZ_PUSH_ENABLED_PROFILES".into(), profiles.into()); - assert!( - Config::from_map(&env).is_err(), - "accepted profiles {profiles}" - ); - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_accepts_explicit_one_for_loopback_sandbox() { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn second_profile_is_rejected_before_sandbox_only_is_accepted() { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood,buzz-ios-app-store".into(), - ); - env.insert( - "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), - "/app-store-identity.pem".into(), - ); - assert!(Config::from_map(&env).is_err()); - - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_selects_development_policy_from_validated_config() { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - let config = Config::from_map(&env).unwrap(); - let apple = crate::app_attest::AppAttestVerifier::for_policy_test(); - - let policy = AppAttestPolicy::from_config(config.dev_app_attest_bypass(), apple); - - assert!(matches!(policy, AppAttestPolicy::Development(_))); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn bypass_unset_keeps_sentinel_on_the_apple_verifier() { - let config = Config::from_map(&base()).unwrap(); - let policy = AppAttestPolicy::from_config( - config.dev_app_attest_bypass(), - crate::app_attest::AppAttestVerifier::for_policy_test(), - ); - let mut sentinel = b"buzz-dev-app-attest-v1:".to_vec(); - sentinel.extend_from_slice(&[1; 32]); - let key_id = sha2::Sha256::digest(&sentinel); - - assert!(policy - .verify_attestation( - &STANDARD.encode(sentinel), - &STANDARD.encode(key_id), - b"canonical enrollment transcript", - ) - .is_err()); - } - #[test] fn malformed_or_empty_keyrings_fail_startup() { for (variable, value) in [ diff --git a/crates/buzz-push-gateway/src/dev_app_attest.rs b/crates/buzz-push-gateway/src/dev_app_attest.rs deleted file mode 100644 index 038d8b524d7..00000000000 --- a/crates/buzz-push-gateway/src/dev_app_attest.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Explicit development-only App Attest sentinel verification. -//! -//! This module is absent unless the non-default `dev-app-attest-bypass` Cargo -//! feature is enabled. Runtime configuration adds a second, independent gate. - -use crate::app_attest::{AppAttestError, VerifiedAssertion, VerifiedAttestation}; -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use sha2::{Digest, Sha256}; - -const ATTESTATION_PREFIX: &[u8] = b"buzz-dev-app-attest-v1:"; -const ASSERTION_SENTINEL: &[u8] = b"buzz-dev-app-assertion-v1"; -const PUBLIC_KEY_PREFIX: &[u8] = b"buzz-dev-app-attest-public-key-v1:"; -const NONCE_BYTES: usize = 32; - -pub fn assertion_sentinel() -> String { - STANDARD.encode(ASSERTION_SENTINEL) -} - -pub fn verify_attestation( - attestation_b64: &str, - key_id_b64: &str, - client_data: &[u8], -) -> Result { - let attestation = STANDARD - .decode(attestation_b64) - .map_err(|_| AppAttestError::Invalid)?; - let supplied_key_id = STANDARD - .decode(key_id_b64) - .map_err(|_| AppAttestError::Invalid)?; - let expected_key_id = Sha256::digest(&attestation); - if !attestation.starts_with(ATTESTATION_PREFIX) - || attestation.len() != ATTESTATION_PREFIX.len() + NONCE_BYTES - || supplied_key_id.as_slice() != expected_key_id.as_slice() - || client_data.is_empty() - { - return Err(AppAttestError::Invalid); - } - let mut public_key = PUBLIC_KEY_PREFIX.to_vec(); - public_key.extend_from_slice(&expected_key_id); - Ok(VerifiedAttestation { - key_id: expected_key_id.to_vec(), - public_key, - }) -} - -pub fn verify_assertion( - assertion_b64: &str, - client_data: &[u8], - public_key: &[u8], - previous_counter: u32, - challenge: &str, - stored_challenge: &str, -) -> Result { - let assertion = STANDARD - .decode(assertion_b64) - .map_err(|_| AppAttestError::Invalid)?; - if assertion != ASSERTION_SENTINEL - || client_data.is_empty() - || !public_key.starts_with(PUBLIC_KEY_PREFIX) - || public_key.len() != PUBLIC_KEY_PREFIX.len() + 32 - || challenge.is_empty() - || challenge != stored_challenge - { - return Err(AppAttestError::Invalid); - } - Ok(VerifiedAssertion { - counter: previous_counter - .checked_add(1) - .ok_or(AppAttestError::Invalid)?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn attestation(nonce: u8) -> (String, String) { - let mut sentinel = ATTESTATION_PREFIX.to_vec(); - sentinel.extend_from_slice(&[nonce; NONCE_BYTES]); - let key_id = Sha256::digest(&sentinel); - (STANDARD.encode(sentinel), STANDARD.encode(key_id)) - } - - #[test] - fn versioned_attestation_derives_a_unique_bound_key_id() { - let (attestation_a, key_id_a) = attestation(1); - let verified_a = verify_attestation( - &attestation_a, - &key_id_a, - b"canonical enrollment transcript", - ) - .unwrap(); - assert_eq!(verified_a.key_id.len(), 32); - assert!(verified_a.public_key.starts_with(PUBLIC_KEY_PREFIX)); - - let (attestation_b, key_id_b) = attestation(2); - let verified_b = verify_attestation( - &attestation_b, - &key_id_b, - b"canonical enrollment transcript", - ) - .unwrap(); - assert_ne!(verified_a.key_id, verified_b.key_id); - - for (attestation, key, transcript) in [ - ("bad".to_owned(), key_id_a.clone(), b"transcript".as_slice()), - (attestation_a.clone(), key_id_b, b"transcript"), - (attestation_a, key_id_a, b""), - ] { - assert!(verify_attestation(&attestation, &key, transcript).is_err()); - } - } - - #[test] - fn bad_attestation_sentinel_is_rejected_before_good_sentinel_is_accepted() { - let (good_attestation, key_id) = attestation(1); - let bad_attestation = STANDARD.encode(b"buzz-dev-app-attest-v1:bad"); - - assert!(verify_attestation(&bad_attestation, &key_id, b"transcript").is_err()); - assert!(verify_attestation(&good_attestation, &key_id, b"transcript").is_ok()); - } - - #[test] - fn exact_assertion_sentinel_and_stored_dev_marker_advance_counter() { - let (attestation, key_id) = attestation(1); - let public_key = verify_attestation(&attestation, &key_id, b"transcript") - .unwrap() - .public_key; - let verified = verify_assertion( - &assertion_sentinel(), - b"canonical assertion transcript", - &public_key, - 7, - "challenge", - "challenge", - ) - .unwrap(); - assert_eq!(verified.counter, 8); - - assert!(verify_assertion( - "bad", - b"canonical assertion transcript", - &public_key, - 7, - "challenge", - "challenge", - ) - .is_err()); - assert!(verify_assertion( - &assertion_sentinel(), - b"canonical assertion transcript", - b"not-the-development-marker", - 7, - "challenge", - "challenge", - ) - .is_err()); - assert!(verify_assertion( - &assertion_sentinel(), - b"canonical assertion transcript", - &public_key, - u32::MAX, - "challenge", - "challenge", - ) - .is_err()); - } -} diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 4033825d369..b300dd8637f 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -1,7 +1,7 @@ //! Stateful installation, delegation, delivery, and health APIs. use crate::{ apns::{DeliveryAttempt, DeliveryOutcome, PushTransport}, - app_attest_policy::AppAttestPolicy, + app_attest::AppAttestVerifier, authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, @@ -23,7 +23,6 @@ use nostr::{ Event, JsonUtil, Timestamp, }; use std::{ - collections::HashMap, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -35,15 +34,8 @@ use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; #[derive(Clone)] pub struct ProfileRuntime { - /// Both values are absent for a registered but dormant profile. - pub app_attest: Option>, - pub transport: Option>, -} - -impl ProfileRuntime { - fn enabled(&self) -> bool { - self.app_attest.is_some() && self.transport.is_some() - } + pub app_attest: Arc, + pub transport: Arc, } #[derive(Clone)] @@ -51,10 +43,9 @@ pub struct AppState { pub grant_keyring: Arc, pub authority: Arc, pub token_keyring: Arc, - /// Closed server-owned app identity registry. Client profile selectors - /// choose only a candidate; App Attest verifies the configured application - /// ID before the profile is persisted as installation authority. - pub profiles: Arc>, + /// Server-owned dogfood application identity and APNs transport. The wire + /// profile selector is fixed and App Attest verifies the configured app ID. + pub profile: Arc, pub delivery_url: url::Url, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, @@ -177,10 +168,9 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; - let profile = match s.profiles.get(&r.app_profile) { - Some(profile) if profile.enabled() => profile, - _ => return error(StatusCode::BAD_REQUEST, "invalid_request"), - }; + if r.app_profile != AppProfile::BuzzIosDogfood { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } if r.v != WIRE_VERSION || r.endpoint_epoch != 1 || r.expires_at <= now @@ -207,14 +197,15 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; - let verified = match profile.app_attest.as_ref().and_then(|policy| { - policy + let verified = + match s + .profile + .app_attest .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) - .ok() - }) { - Some(value) => value, - None => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), - }; + { + Ok(value) => value, + Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), + }; if let Err(e) = s .authority .consume_challenge(r.challenge_id, challenge, now) @@ -269,14 +260,14 @@ async fn verify_installation_assertion( .installation(installation_id, now) .await .map_err(authority_error)?; - let app_attest = s - .profiles - .get(&installation.profile) - .and_then(|profile| profile.app_attest.as_ref()) - .ok_or_else(|| error(StatusCode::NOT_FOUND, "not_authorized"))?; + if installation.profile != AppProfile::BuzzIosDogfood { + return Err(error(StatusCode::NOT_FOUND, "not_authorized")); + } let transcript = transcript(domain, signed) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; - let verified = app_attest + let verified = s + .profile + .app_attest .verify_assertion( assertion, transcript.as_bytes(), @@ -655,23 +646,15 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> .await; return error(StatusCode::NOT_FOUND, "invalid_grant"); } - let profile = permit.authority.profile; - let transport = match s - .profiles - .get(&profile) - .and_then(|runtime| runtime.transport.as_ref()) - .cloned() - { - Some(transport) => transport, - None => { - crate::metrics::record_delivery_error("profile_disabled"); - let _ = s - .authority - .finish_delivery(permit, DeliveryDisposition::Retryable) - .await; - return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); - } - }; + if permit.authority.profile != AppProfile::BuzzIosDogfood { + crate::metrics::record_delivery_error("profile_disabled"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await; + return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); + } + let transport = Arc::clone(&s.profile.transport); let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { Ok(token) => hex::encode(token), Err(_) => { diff --git a/crates/buzz-push-gateway/src/lib.rs b/crates/buzz-push-gateway/src/lib.rs index 59fa2ca09bd..563d725db99 100644 --- a/crates/buzz-push-gateway/src/lib.rs +++ b/crates/buzz-push-gateway/src/lib.rs @@ -1,11 +1,8 @@ //! Stateful, capability-gated APNs last hop for NIP-PL. pub mod apns; pub mod app_attest; -pub mod app_attest_policy; pub mod authority; pub mod config; -#[cfg(feature = "dev-app-attest-bypass")] -pub mod dev_app_attest; pub mod grant; pub mod http; pub mod metrics; diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index a2613dfb374..db35b251104 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -1,7 +1,6 @@ use buzz_push_gateway::{ apns::ApnsTransport, app_attest::AppAttestVerifier, - app_attest_policy::AppAttestPolicy, authority::AuthorityStore, config::Config, grant::{GrantKey, GrantKeyring}, @@ -11,7 +10,6 @@ use buzz_push_gateway::{ AppState, }; use std::{ - collections::HashMap, fs, sync::{ atomic::{AtomicBool, Ordering}, @@ -38,40 +36,22 @@ async fn main() -> Result<(), Box> { let c = Config::from_env()?; let metrics_handle = buzz_push_gateway::metrics::install()?; let app_attest_root = fs::read(&c.app_attest_root_cert_path)?; - let mut profiles = HashMap::new(); - for (profile, configured) in &c.profiles { - let runtime = if configured.enabled { - let cert_path = configured.apns_cert_path.as_ref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "enabled push profile has no APNs identity path", - ) - })?; - let transport = Arc::new(ApnsTransport::certificate( - &fs::read(cert_path)?, - configured.apns_topic.clone(), - configured.apns_environment, - )?); - let apple = AppAttestVerifier::new( - configured.app_attest_app_id.clone(), - app_attest_root.clone(), - )?; - #[cfg(feature = "dev-app-attest-bypass")] - let policy = AppAttestPolicy::from_config(c.dev_app_attest_bypass(), apple); - #[cfg(not(feature = "dev-app-attest-bypass"))] - let policy = AppAttestPolicy::apple(apple); - buzz_push_gateway::http::ProfileRuntime { - app_attest: Some(Arc::new(policy)), - transport: Some(transport), - } - } else { - buzz_push_gateway::http::ProfileRuntime { - app_attest: None, - transport: None, - } - }; - profiles.insert(*profile, runtime); - } + let configured = &c.profile; + let profile = { + let transport = Arc::new(ApnsTransport::certificate( + &fs::read(&configured.apns_cert_path)?, + configured.apns_topic.clone(), + configured.apns_environment, + )?); + let apple = AppAttestVerifier::new( + configured.app_attest_app_id.clone(), + app_attest_root.clone(), + )?; + buzz_push_gateway::http::ProfileRuntime { + app_attest: Arc::new(apple), + transport, + } + }; let grant_keyring = GrantKeyring::new( c.grant_keys .iter() @@ -114,7 +94,7 @@ async fn main() -> Result<(), Box> { grant_keyring: Arc::new(grant_keyring), authority, token_keyring: Arc::new(token_keyring), - profiles: Arc::new(profiles), + profile: Arc::new(profile), delivery_url: c.public_delivery_url, max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 574d1d6b9c2..bcb5cc947d3 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -13,13 +13,11 @@ pub const WIRE_VERSION: u8 = 1; #[serde(rename_all = "kebab-case")] pub enum AppProfile { BuzzIosDogfood, - BuzzIosAppStore, } impl AppProfile { pub const fn as_str(self) -> &'static str { match self { Self::BuzzIosDogfood => "buzz-ios-dogfood", - Self::BuzzIosAppStore => "buzz-ios-app-store", } } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index a3ea82c8230..8492ac12c57 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -64,7 +64,6 @@ fn ts(v: DateTime) -> i64 { fn profile(v: &str) -> Result { match v { "buzz-ios-dogfood" => Ok(AppProfile::BuzzIosDogfood), - "buzz-ios-app-store" => Ok(AppProfile::BuzzIosAppStore), _ => Err(AuthorityError::Unavailable), } } @@ -409,7 +408,7 @@ mod tests { use super::*; use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials #[tokio::test] #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock deleted file mode 100644 index d29012deecf..00000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock +++ /dev/null @@ -1,341 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "buzz-app-attest-fixture-generator" -version = "0.1.0" -dependencies = [ - "base64", - "byteorder", - "ciborium", - "openssl", - "sha2", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml deleted file mode 100644 index 2ffc6d243f7..00000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "buzz-app-attest-fixture-generator" -version = "0.1.0" -edition = "2021" -publish = false - -[workspace] - -[dependencies] -base64 = "0.22" -byteorder = "1.5" -ciborium = "0.2" -openssl = "0.10" -sha2 = "0.10" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md deleted file mode 100644 index cf498bf1d68..00000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# App Attest verifier fixtures - -This standalone crate generates synthetic App Attest fixtures for the gateway's strict verifier tests. It has its own `[workspace]`, does not belong to the repository workspace dependency graph, and must never depend on `appattest`. That separation prevents the dependency's `testing` feature from being unified into the gateway test build, where it would permit the development AAGUID. - -The generator owns the full root, intermediate, and credential certificate chain. It writes Apple's nonce extension OID `1.2.840.113635.100.8.2` and App Attest EKU `1.2.840.113635.100.4.24` directly. Generator correctness is therefore load-bearing. The strict verifier's good-fixture acceptance test is the encoding oracle. Any generator change must pass the full App Attest test floor, and a fixture rejected by the shipped verifier must never be made green by loosening a test. - -`apple-app-attestation-root.pem` is the production pin-control fixture downloaded from [Apple's certificate authority](https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem). Its exact PEM-file SHA-256 must remain `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. - -Regenerate from the repository root: - -```bash -cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- \ - --output-dir crates/buzz-push-gateway/tests/fixtures \ - --good-aaguid appattest \ - --wrong-aaguid appattestdevelop -``` - -The command rewrites `app-attest-good.json`, `app-attest-wrong-aaguid.json`, and `app-attest-wrong-root.json`. Review all fixture changes and run the complete gateway package test suite afterward. diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs deleted file mode 100644 index fe367fbdaaf..00000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs +++ /dev/null @@ -1,404 +0,0 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; - -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use byteorder::{BigEndian, ByteOrder}; -use ciborium::{cbor, Value}; -use openssl::{ - asn1::{Asn1Integer, Asn1Object, Asn1OctetString, Asn1Time}, - bn::{BigNum, MsbOption}, - ec::{EcGroup, EcKey, PointConversionForm}, - hash::MessageDigest, - nid::Nid, - pkey::{PKey, Private}, - x509::{ - extension::{BasicConstraints, ExtendedKeyUsage, KeyUsage}, - X509Builder, X509Extension, X509Name, X509NameBuilder, X509, - }, -}; -use sha2::{Digest, Sha256}; - -const APP_ID: &str = "TEAMID.xyz.buzz.mobile"; -const CHALLENGE: &str = "buzz-app-attest-strict-verifier-fixture"; -const CERT_VALIDITY_DAYS: u32 = 7_305; -const REGENERATION_COMMAND: &str = "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop"; - -struct Args { - output_dir: PathBuf, - good_aaguid: String, - wrong_aaguid: String, -} - -struct CertificateAuthority { - root_cert: X509, - intermediate_cert: X509, - intermediate_key: PKey, -} - -struct Fixture { - attestation_b64: String, - key_id_b64: String, - root_cert_pem: String, - leaf_not_after: String, -} - -fn main() { - let args = parse_args(); - validate_aaguid(&args.good_aaguid); - validate_aaguid(&args.wrong_aaguid); - fs::create_dir_all(&args.output_dir).expect("create fixture output directory"); - - let primary_ca = CertificateAuthority::generate("Buzz App Attest Fixture Root"); - let good = build_fixture(&primary_ca, &args.good_aaguid); - let wrong_aaguid = build_fixture(&primary_ca, &args.wrong_aaguid); - - let unrelated_ca = CertificateAuthority::generate("Unrelated App Attest Fixture Root"); - let wrong_root = build_fixture(&unrelated_ca, &args.good_aaguid); - - write_fixture( - &args.output_dir, - "app-attest-good.json", - "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", - &args.good_aaguid, - &good, - ); - write_fixture( - &args.output_dir, - "app-attest-wrong-aaguid.json", - "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", - &args.wrong_aaguid, - &wrong_aaguid, - ); - write_fixture( - &args.output_dir, - "app-attest-wrong-root.json", - "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", - &args.good_aaguid, - &wrong_root, - ); -} - -fn parse_args() -> Args { - let mut output_dir = None; - let mut good_aaguid = None; - let mut wrong_aaguid = None; - let mut args = env::args().skip(1); - while let Some(arg) = args.next() { - let value = args - .next() - .unwrap_or_else(|| panic!("missing value for {arg}")); - match arg.as_str() { - "--output-dir" => output_dir = Some(PathBuf::from(value)), - "--good-aaguid" => good_aaguid = Some(value), - "--wrong-aaguid" => wrong_aaguid = Some(value), - _ => panic!("unknown argument {arg}"), - } - } - Args { - output_dir: output_dir.expect("--output-dir is required"), - good_aaguid: good_aaguid.expect("--good-aaguid is required"), - wrong_aaguid: wrong_aaguid.expect("--wrong-aaguid is required"), - } -} - -fn validate_aaguid(aaguid: &str) { - assert!( - !aaguid.is_empty() && aaguid.len() <= 16 && aaguid.is_ascii(), - "AAGUID must be 1 to 16 ASCII bytes" - ); -} - -impl CertificateAuthority { - fn generate(root_common_name: &str) -> Self { - let root_key = p384_key(); - let root_name = name(root_common_name); - let root_cert = certificate( - &root_name, - &root_name, - &root_key, - &root_key, - MessageDigest::sha384(), - true, - None, - ); - - let intermediate_key = p256_key(); - let intermediate_name = name("Buzz App Attest Fixture Intermediate"); - let intermediate_cert = certificate( - &intermediate_name, - root_cert.subject_name(), - &intermediate_key, - &root_key, - MessageDigest::sha384(), - true, - None, - ); - - Self { - root_cert, - intermediate_cert, - intermediate_key, - } - } -} - -fn build_fixture(ca: &CertificateAuthority, aaguid_value: &str) -> Fixture { - let device_key = p256_key(); - let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("P-256 group"); - let ec_key = device_key.ec_key().expect("device EC key"); - let mut context = openssl::bn::BigNumContext::new().expect("bignum context"); - let public_key = ec_key - .public_key() - .to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut context) - .expect("serialize device public key"); - let key_id = Sha256::digest(&public_key); - let key_id_b64 = STANDARD.encode(key_id); - - let mut aaguid = [0_u8; 16]; - aaguid[..aaguid_value.len()].copy_from_slice(aaguid_value.as_bytes()); - let mut auth_data = Vec::with_capacity(87); - auth_data.extend_from_slice(&Sha256::digest(APP_ID.as_bytes())); - auth_data.push(0x41); - auth_data.extend_from_slice(&[0_u8; 4]); - auth_data.extend_from_slice(&aaguid); - let mut credential_length = [0_u8; 2]; - BigEndian::write_u16(&mut credential_length, key_id.len() as u16); - auth_data.extend_from_slice(&credential_length); - auth_data.extend_from_slice(&key_id); - - let mut nonce_input = auth_data.clone(); - nonce_input.extend_from_slice(&Sha256::digest(CHALLENGE.as_bytes())); - let nonce = Sha256::digest(nonce_input); - - let leaf_name = name("Buzz App Attest Fixture Credential"); - let leaf_cert = certificate( - &leaf_name, - ca.intermediate_cert.subject_name(), - &device_key, - &ca.intermediate_key, - MessageDigest::sha256(), - false, - Some(&nonce), - ); - let leaf_der = leaf_cert.to_der().expect("encode credential certificate"); - let intermediate_der = ca - .intermediate_cert - .to_der() - .expect("encode intermediate certificate"); - let value = cbor!({ - "fmt" => "apple-appattest", - "attStmt" => { - "x5c" => [ - Value::Bytes(leaf_der), - Value::Bytes(intermediate_der) - ], - "receipt" => Value::Bytes(Vec::new()) - }, - "authData" => Value::Bytes(auth_data) - }) - .expect("build attestation CBOR value"); - let mut cbor = Vec::new(); - ciborium::into_writer(&value, &mut cbor).expect("encode attestation CBOR"); - - Fixture { - attestation_b64: STANDARD.encode(cbor), - key_id_b64, - root_cert_pem: String::from_utf8( - ca.root_cert.to_pem().expect("encode root certificate PEM"), - ) - .expect("root PEM is UTF-8"), - leaf_not_after: leaf_cert.not_after().to_string(), - } -} - -fn certificate( - subject: &X509Name, - issuer: &openssl::x509::X509NameRef, - subject_key: &PKey, - issuer_key: &PKey, - signature_digest: MessageDigest, - is_ca: bool, - nonce: Option<&[u8]>, -) -> X509 { - let mut builder = X509Builder::new().expect("create certificate builder"); - builder.set_version(2).expect("set X.509 version"); - builder - .set_serial_number(&serial_number()) - .expect("set certificate serial"); - builder - .set_subject_name(subject) - .expect("set certificate subject"); - builder - .set_issuer_name(issuer) - .expect("set certificate issuer"); - builder - .set_pubkey(subject_key) - .expect("set certificate public key"); - builder - .set_not_before(&Asn1Time::days_from_now(0).expect("build notBefore")) - .expect("set notBefore"); - builder - .set_not_after(&Asn1Time::days_from_now(CERT_VALIDITY_DAYS).expect("build long notAfter")) - .expect("set notAfter"); - - if is_ca { - builder - .append_extension( - BasicConstraints::new() - .critical() - .ca() - .build() - .expect("build CA constraints"), - ) - .expect("append CA constraints"); - builder - .append_extension( - KeyUsage::new() - .critical() - .key_cert_sign() - .crl_sign() - .build() - .expect("build CA key usage"), - ) - .expect("append CA key usage"); - } else { - builder - .append_extension( - BasicConstraints::new() - .critical() - .build() - .expect("build leaf constraints"), - ) - .expect("append leaf constraints"); - builder - .append_extension( - ExtendedKeyUsage::new() - .other("1.2.840.113635.100.4.24") - .build() - .expect("build App Attest EKU"), - ) - .expect("append App Attest EKU"); - - let nonce = nonce.expect("credential certificate nonce"); - assert_eq!(nonce.len(), 32, "App Attest nonce must be 32 bytes"); - let mut extension_value = Vec::with_capacity(38); - extension_value.extend_from_slice(&[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]); - extension_value.extend_from_slice(nonce); - let oid = Asn1Object::from_str("1.2.840.113635.100.8.2") - .expect("parse App Attest nonce extension OID"); - let octets = Asn1OctetString::new_from_bytes(&extension_value) - .expect("encode App Attest nonce extension"); - builder - .append_extension( - X509Extension::new_from_der(&oid, false, &octets) - .expect("build App Attest nonce extension"), - ) - .expect("append App Attest nonce extension"); - } - - builder - .sign(issuer_key, signature_digest) - .expect("sign certificate"); - builder.build() -} - -fn p256_key() -> PKey { - ec_key(Nid::X9_62_PRIME256V1) -} - -fn p384_key() -> PKey { - ec_key(Nid::SECP384R1) -} - -fn ec_key(curve: Nid) -> PKey { - let group = EcGroup::from_curve_name(curve).expect("EC group"); - PKey::from_ec_key(EcKey::generate(&group).expect("generate EC key")).expect("wrap EC key") -} - -fn name(common_name: &str) -> X509Name { - let mut builder = X509NameBuilder::new().expect("create X.509 name builder"); - builder - .append_entry_by_text("CN", common_name) - .expect("set common name"); - builder - .append_entry_by_text("O", "Buzz") - .expect("set organization"); - builder.build() -} - -fn serial_number() -> Asn1Integer { - let mut number = BigNum::new().expect("create certificate serial"); - number - .rand(128, MsbOption::MAYBE_ZERO, false) - .expect("generate certificate serial"); - Asn1Integer::from_bn(&number).expect("convert certificate serial") -} - -fn write_fixture( - output_dir: &Path, - file_name: &str, - description: &str, - aaguid: &str, - fixture: &Fixture, -) { - let generated_at = generation_date(); - let json = format!( - concat!( - "{{\n", - " \"description\": \"{}\",\n", - " \"generator\": \"crates/buzz-push-gateway/tests/fixtures/app-attest-generator\",\n", - " \"generated_at\": \"{}\",\n", - " \"regeneration_command\": \"{}\",\n", - " \"app_id\": \"{}\",\n", - " \"challenge\": \"{}\",\n", - " \"aaguid\": \"{}\",\n", - " \"leaf_not_after\": \"{}\",\n", - " \"attestation_b64\": \"{}\",\n", - " \"key_id_b64\": \"{}\",\n", - " \"root_cert_pem\": \"{}\"\n", - "}}\n" - ), - json_escape(description), - generated_at, - json_escape(REGENERATION_COMMAND), - APP_ID, - CHALLENGE, - aaguid, - json_escape(&fixture.leaf_not_after), - fixture.attestation_b64, - fixture.key_id_b64, - json_escape(&fixture.root_cert_pem), - ); - fs::write(output_dir.join(file_name), json).expect("write fixture JSON"); -} - -fn generation_date() -> String { - let output = std::process::Command::new("date") - .args(["-u", "+%Y-%m-%d"]) - .output() - .expect("run date for fixture metadata"); - assert!(output.status.success(), "date command failed"); - String::from_utf8(output.stdout) - .expect("date output is UTF-8") - .trim() - .to_owned() -} - -fn json_escape(value: &str) -> String { - value - .chars() - .flat_map(|character| match character { - '\\' => "\\\\".chars().collect::>(), - '"' => "\\\"".chars().collect(), - '\n' => "\\n".chars().collect(), - '\r' => "\\r".chars().collect(), - '\t' => "\\t".chars().collect(), - value if value.is_control() => { - panic!("unsupported control character in fixture metadata") - } - value => vec![value], - }) - .collect() -} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json index 5c076ba951d..3bdff5ccdce 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json @@ -1,12 +1,8 @@ { "description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", - "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", - "generated_at": "2026-08-02", - "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", "app_id": "TEAMID.xyz.buzz.mobile", "challenge": "buzz-app-attest-strict-verifier-fixture", "aaguid": "appattest", - "leaf_not_after": "Aug 2 05:27:02 2046 GMT", "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdgwggHUMIIBeqADAgECAhEA1hkzMVx4LIlx2Z04+dq+DjAKBggqhkjOPQQDAjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA8MSswKQYDVQQDDCJCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBDcmVkZW50aWFsMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnUoIjO//gCI6cvjfmAw62OnnngGVoId2q7MQYG//94tXIX2tIZChUe1y/spRzJqxLo0JNm7d9QKdoVuLNBpfaNbMFkwDAYDVR0TAQH/BAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCDi01h8mHF6AJkdlwJoO7ieXb9TDEttdsV48n1Jd57tIDAKBggqhkjOPQQDAgNIADBFAiEAmyNVz7oG03YWXBP55xcqJ1xrwv7INxQmSKjr/lrrXKwCIEGS9+8qhYxQfZa1q/jcegDlNxphatVVqx5j8cQbjNU2WQG6MIIBtjCCATugAwIBAgIQJj6YcsuecIX6zF/ZFQ6wzDAKBggqhkjOPQQDAzA2MSUwIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowPjEtMCsGA1UEAwwkQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgSW50ZXJtZWRpYXRlMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfGKED5L/Nh0lvKRJAllDU01J6pZhqYBV/a7HRTphUIkIhW0Jc/Q2BplGB+vrMgUG+QX9eG8k7VvRZjov/m7gbaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaQAwZgIxAONcQ0m5yYfK4ILWwnRWAZjhQg/ZrwiRY3VEBzkAc082FXwp0mqMjXwicSt/ibULFgIxAKFayHKDgusCMjLMPkoIYbOI2jnR+TY8Vftq89b33qLQ2EebRB1PGDld2mvVY01OU2dyZWNlaXB0QGhhdXRoRGF0YVhXH5nFfKMZs8qsLEqZv4n7atEJxvG0oHWjDbycL/O5tJlBAAAAAGFwcGF0dGVzdAAAAAAAAAAAIOtFw/nPMzM0gQAeS/gQ1R2aF7oMMjXIx08QJN8q0cuk", "key_id_b64": "60XD+c8zMzSBAB5L+BDVHZoXugwyNcjHTxAk3yrRy6Q=", "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json index 4b8a5737b7c..56d1260bead 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json @@ -1,12 +1,8 @@ { "description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", - "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", - "generated_at": "2026-08-02", - "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", "app_id": "TEAMID.xyz.buzz.mobile", "challenge": "buzz-app-attest-strict-verifier-fixture", "aaguid": "appattestdevelop", - "leaf_not_after": "Aug 2 05:27:02 2046 GMT", "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhAXdDyYByLYxE4WftXjOFC1MAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ8ZGvDc7xMJINZw6mLHRU6xr1kFY+vn+PRZYIMypdlYb99U/l8VCK9zWQt+xXSEAyNvzdcZiom5N/fKuAI5xh/o1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEILEfYIC8xsY+hZnqOrQF1PpWR3VioqnjjQwo5/YmtAwRMAoGCCqGSM49BAMCA0gAMEUCIQCpOzhfo94xcJ0ojQki6wxpOdORPsNwXtZz+eByIhtwlwIgPr71d/DiOaQ3Jd9jDaiCFrzozcR5owB0kaKRzvFuBv1ZAbowggG2MIIBO6ADAgECAhAmPphyy55whfrMX9kVDrDMMAoGCCqGSM49BAMDMDYxJTAjBgNVBAMMHEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8YoQPkv82HSW8pEkCWUNTTUnqlmGpgFX9rsdFOmFQiQiFbQlz9DYGmUYH6+syBQb5Bf14byTtW9FmOi/+buBtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA41xDSbnJh8rggtbCdFYBmOFCD9mvCJFjdUQHOQBzTzYVfCnSaoyNfCJxK3+JtQsWAjEAoVrIcoOC6wIyMsw+Sghhs4jaOdH5NjxV+2rz1vfeotDYR5tEHU8YOV3aa9VjTU5TZ3JlY2VpcHRAaGF1dGhEYXRhWFcfmcV8oxmzyqwsSpm/iftq0QnG8bSgdaMNvJwv87m0mUEAAAAAYXBwYXR0ZXN0ZGV2ZWxvcAAg6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", "key_id_b64": "6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json index c709c118e44..7129b63939e 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json @@ -1,12 +1,8 @@ { "description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", - "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", - "generated_at": "2026-08-02", - "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", "app_id": "TEAMID.xyz.buzz.mobile", "challenge": "buzz-app-attest-strict-verifier-fixture", "aaguid": "appattest", - "leaf_not_after": "Aug 2 05:27:02 2046 GMT", "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhBGe4kbr8X3vBBmRW24fEPWMAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3NUd9f8Ma88b5fiKPmvgL0akkZfv3Q5v2jJMGVQ+pDY2ZFkZTQnzTfAPydFBFtVQE9HpPLlx22e/8eixSUFdLo1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEIF7PDSiNaYyhbJlVGsubqOBUPUSS4sT5PJ0Ri8mGDRjSMAoGCCqGSM49BAMCA0gAMEUCIQCSjdrbcQurd+avRl+OcRIZPusoJBNVGLun3Rda9tJ5NwIgOFEcGxdOZi3atz7Nwzwe409oVcu4GdXOVo9N86pOu8dZAb8wggG7MIIBQaADAgECAhEAx1cRnQUJhJKCUll92sLeGDAKBggqhkjOPQQDAzA7MSowKAYDVQQDDCFVbnJlbGF0ZWQgQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASz7MX/nc9MaCmjSQ3f+L8SCsgNdFEcDyZ7FxREEPu4bGUujA+P5exSwDuA8L64WrznNITC1J8sZ98VZ/tTNWFtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA8ABjGCavBGyl6FgO9u58hV/xzRnhdlFiTUPiN/XCvmfxDkOyYwzLk06/k4JmdqfCAjBADKJsa+9138UAMZgU8iYWTOY+FO96DHsdC+8H9vBoLBE/DxzQHsX2Wd/DEbggUGJncmVjZWlwdEBoYXV0aERhdGFYVx+ZxXyjGbPKrCxKmb+J+2rRCcbxtKB1ow28nC/zubSZQQAAAABhcHBhdHRlc3QAAAAAAAAAACCvcDv+nttQP9RSSwBycpsL+NiE13xuEsfU7iKqeRaTsQ==", "key_id_b64": "r3A7/p7bUD/UUksAcnKbC/jYhNd8bhLH1O4iqnkWk7E=", "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB1DCCAVugAwIBAgIRALE3l3fzQ4wPjIL/IjBs02IwCgYIKoZIzj0EAwMwOzEq\nMCgGA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYD\nVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowOzEqMCgG\nA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQK\nDARCdXp6MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEPa8SWuDIcjNVDwTXlTQnWbKj\n5Vt8TCiGGH0CiSJajPOlevvjHBEYuVHf7bFYa5N/7OzXQ3qkZomCyizJ6nc5tBEN\nGL3rkz7vZjb9J3QPfixkBwyUHFHmx1WJ84fgAYcDoyMwITAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAMO0cvuHHJSqWj\n4DxJorq8LH7VH9ILTGjcZmz91rLlO7w4oDqiewFQE+GVFl9boekCMBhaa0a/WiW2\nyf2j5d04SOkXREM1NkbHsd1yH1jqSOCuj6PU3Z6zDSSXy1z3HjQIBg==\n-----END CERTIFICATE-----\n" diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index d3e0188dbbb..bf5783d0730 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -16,7 +16,6 @@ use sha2::Digest as _; /// Generic Nostr notes and non-message workflow/agent events are deliberately /// excluded from the dogfood MVP. pub(crate) const PUSH_KINDS: &[u64] = &[9, 40_002, 45_001, 45_003]; -pub(crate) const URGENT_KINDS: &[u64] = &[]; /// NIP-PL addressable push-lease event kind. pub const KIND_PUSH_LEASE: u32 = 30_350; @@ -71,7 +70,6 @@ pub struct LeaseLimits<'a> { pub app_profiles: &'a [AppProfile<'a>], pub supported_classes: &'a [&'a str], pub push_kinds: &'a [u64], - pub urgent_kinds: &'a [u64], pub max_subscriptions: usize, pub max_kinds: usize, pub max_authors: usize, @@ -248,14 +246,13 @@ fn validate_subscription(sub: &Subscription, limits: &LeaseLimits<'_>) -> Result if !limits.supported_classes.contains(&sub.class.as_str()) { return Err("class not supported".into()); } - validate_filter(&sub.filter, limits, true, &sub.class)?; + validate_filter(&sub.filter, limits, true)?; if sub.ignore.len() > limits.max_ignore { return Err("ignore quota exceeded".into()); } for filter in &sub.ignore { - // Ignore filters can only subtract from an already-positive match, so - // urgent-kind confinement belongs solely to the positive filter. - validate_filter(filter, limits, false, "")?; + // Ignore filters can only subtract from an already-positive match. + validate_filter(filter, limits, false)?; } if sub.suppress.as_ref().is_some_and(|s| s.p_tags_max == 0) { return Err("p_tags_max must be positive".into()); @@ -267,7 +264,6 @@ fn validate_filter( filter: &Map, limits: &LeaseLimits<'_>, require_narrowing: bool, - class: &str, ) -> Result<(), String> { const ALLOWED: &[&str] = &["kinds", "authors", "#p", "#h", "#e"]; if let Some(key) = filter.keys().find(|key| !ALLOWED.contains(&key.as_str())) { @@ -285,10 +281,6 @@ fn validate_filter( if kinds.iter().any(|kind| !limits.push_kinds.contains(kind)) { return Err("kind not push-eligible".into()); } - if class == "urgent" && kinds.iter().any(|kind| !limits.urgent_kinds.contains(kind)) { - return Err("class not permitted for kind".into()); - } - let authors = optional_string_array(filter, "authors", limits.max_authors)?; let p = optional_string_array(filter, "#p", limits.max_tag_values)?; let h = optional_string_array(filter, "#h", limits.max_h)?; @@ -499,19 +491,12 @@ pub async fn accept( let limits = LeaseLimits { expected_origin: &origin, author_hex: &author_hex, - app_profiles: &[ - AppProfile { - id: "buzz-ios-dogfood", - transport: "apns", - }, - AppProfile { - id: "buzz-ios-app-store", - transport: "apns", - }, - ], - supported_classes: &["silent", "default", "time_sensitive"], + app_profiles: &[AppProfile { + id: "buzz-ios-dogfood", + transport: "apns", + }], + supported_classes: &["default"], push_kinds: PUSH_KINDS, - urgent_kinds: URGENT_KINDS, max_subscriptions: 16, max_kinds: 16, max_authors: 20, @@ -579,14 +564,8 @@ pub async fn accept( .map_err(|_| AcceptError::Internal("lease persistence failed".to_string())) } -fn class_rank(class: &str) -> u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } fn canonical_origin(relay_url: &str, host: &str) -> Result { @@ -687,9 +666,8 @@ mod tests { id: "p", transport: "apns", }], - supported_classes: &["default", "urgent"], - push_kinds: &[9, 46010], - urgent_kinds: &[46010], + supported_classes: &["default"], + push_kinds: &[9], max_subscriptions: 4, max_kinds: 4, max_authors: 4, @@ -766,13 +744,4 @@ mod tests { assert!(canonical_origin("https://relay.example", "tenant.example").is_err()); assert!(canonical_origin("wss://relay.example", "").is_err()); } - - #[test] - fn urgent_is_limited_by_event_kind() { - let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"urgent"}]}"##, 4096).unwrap(); - assert_eq!( - validate_plaintext(&body, &limits()).unwrap_err(), - "class not permitted for kind" - ); - } } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 3a780db787d..c2b6c868abb 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -206,14 +206,10 @@ fn push_descriptor( "pubkey": relay_keypair.public_key().to_hex(), "current": true }], - "app_profiles": [ - {"id": "buzz-ios-dogfood", "transport": "apns"}, - {"id": "buzz-ios-app-store", "transport": "apns"} - ], + "app_profiles": [{"id": "buzz-ios-dogfood", "transport": "apns"}], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, - "urgent_kinds": crate::handlers::push_lease::URGENT_KINDS, "h_grammar": "uuid-v4-lowercase", - "class_support": {"apns": ["silent", "default", "time_sensitive"]}, + "class_support": {"apns": ["default"]}, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 2b16e05b34b..246997aac22 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -682,14 +682,8 @@ fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } #[cfg(test)] diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 588a394b051..20ce7567270 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -31,18 +31,11 @@ spec: - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } - - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } - { name: BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID, value: {{ .Values.profiles.dogfood.appAttestAppId | quote }} } - { name: BUZZ_PUSH_DOGFOOD_APNS_TOPIC, value: {{ .Values.profiles.dogfood.apnsTopic | quote }} } - { name: BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT, value: {{ .Values.profiles.dogfood.apnsEnvironment | quote }} } - { name: BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH, value: /run/buzz/apns-dogfood/identity.pem } - - { name: BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID, value: {{ .Values.profiles.appStore.appAttestAppId | quote }} } - - { name: BUZZ_PUSH_APP_STORE_APNS_TOPIC, value: {{ .Values.profiles.appStore.apnsTopic | quote }} } - - { name: BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT, value: {{ .Values.profiles.appStore.apnsEnvironment | quote }} } - {{- with .Values.profiles.appStore.apnsCert }} - - { name: BUZZ_PUSH_APP_STORE_APNS_CERT_PATH, value: /run/buzz/apns-app-store/identity.pem } - {{- end }} {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} - name: {{ $name }} valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } @@ -50,9 +43,6 @@ spec: volumeMounts: - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } - { name: apns-dogfood, mountPath: /run/buzz/apns-dogfood, readOnly: true } - {{- with .Values.profiles.appStore.apnsCert }} - - { name: apns-app-store, mountPath: /run/buzz/apns-app-store, readOnly: true } - {{- end }} livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 } startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 } @@ -62,10 +52,6 @@ spec: secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] } - name: apns-dogfood secret: { secretName: {{ .Values.profiles.dogfood.apnsCert.secretName }}, defaultMode: 0400, items: [{ key: {{ .Values.profiles.dogfood.apnsCert.secretKey }}, path: identity.pem }] } - {{- with .Values.profiles.appStore.apnsCert }} - - name: apns-app-store - secret: { secretName: {{ .secretName }}, defaultMode: 0400, items: [{ key: {{ .secretKey }}, path: identity.pem }] } - {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 43a8b360cef..250955c5fc2 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -54,11 +54,10 @@ env_names = d.dig("spec", "template", "spec", "containers", 0, "env") required = Set.new(%w[ DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID - BUZZ_PUSH_APP_STORE_APNS_TOPIC BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS ]) assert!(required.subset?(env_names)) -assert!(!env_names.include?("BUZZ_PUSH_APP_STORE_APNS_CERT_PATH")) +assert!(!env_names.any? { |name| name.include?("APP_STORE") }) apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) assert!(d.dig("spec", "replicas") >= 2) diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index bef5d17a338..29eafa22c8d 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -23,15 +23,11 @@ "type": "object", "additionalProperties": false, "required": [ - "dogfood", - "appStore" + "dogfood" ], "properties": { "dogfood": { "$ref": "#/$defs/enabledProfile" - }, - "appStore": { - "$ref": "#/$defs/dormantProfile" } } }, @@ -286,9 +282,6 @@ } ] }, - "dormantProfile": { - "$ref": "#/$defs/profileBase" - }, "apnsCert": { "type": "object", "additionalProperties": false, diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 5e5c770623b..1f1e90cbb08 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -20,7 +20,6 @@ migration: limits: {cpu: 250m, memory: 128Mi} publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns maxGrantLifetimeSeconds: 2592000 -enabledProfiles: buzz-ios-dogfood profiles: dogfood: # Production MUST override this with the exact Apple TEAMID.bundle-id. @@ -30,12 +29,6 @@ profiles: apnsCert: secretName: buzz-push-gateway secretKey: dogfood-apns-identity.pem - appStore: - # Registered server-side now, but dormant and uncredentialed until its - # profile is explicitly added to enabledProfiles in a later rollout. - appAttestAppId: TEAMID.xyz.block.buzz.mobile - apnsTopic: xyz.block.buzz.mobile - apnsEnvironment: production appAttestRoot: secretName: buzz-push-gateway secretKey: app-attest-root.pem diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index df6428e7cdc..bba5fb5c48a 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -17,7 +17,7 @@ Push Leases ## Abstract -This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs, FCM, optionally UnifiedPush) when the filter matches. +This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs or FCM) when the filter matches. The push payload is a **wake signal** authored entirely by the configured transport service: a fixed reconnect instruction, never relay-supplied bytes, event ids, event content, URLs, ciphertext, or extensible custom data. On wake, the client reconnects and fetches authoritative events over normal `REQ`. Push delivery is lossy and best-effort — duplicates and omissions are both possible; the relay remains the single source of truth. Platform transports are execution profiles for the lease, not the protocol's content plane. @@ -51,8 +51,8 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d - **origin**: the canonical origin identifier the descriptor advertises for a relay/community; the tenant key (see Acceptance and Origin Binding). - **wake signal**: the fixed, transport-authored reconnect payload defined in Wake Delivery. It contains no relay-supplied application data. - **subscription**: one `{filter, class, ignore?, suppress?}` entry inside a lease. -- **priority class**: one of `silent`, `default`, `time_sensitive`, `urgent`. -- **transport profile**: the APNs/FCM/UnifiedPush-specific execution rules for a lease. +- **priority class**: `default` in this profile. +- **transport profile**: the APNs/FCM-specific execution rules for a lease. ## The Lease Event @@ -73,7 +73,7 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d } ``` -- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, UnifiedPush endpoint, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. +- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. - `expiration` (NIP-40) is REQUIRED and MUST satisfy `now − allowed_skew < expiration ≤ now + max_lease_ttl` at acceptance (`invalid: lease ttl too long` / `invalid: lease already expired`; `max_lease_ttl` descriptor-advertised, default 30 days; RECOMMENDED `allowed_skew` 15 minutes). The executor MUST stop matching once it passes. Inactive (tombstone) replacements carry a public `expiration` under the same bound; it dates the tombstone, not any matching. Expiry is the self-healing backstop for every abuse and leak below. - `exec` names the descriptor encryption key the content was produced for (see Executor Discovery). - Public tags are exactly one `d`, one `expiration`, one `exec`, and at most one `alt`, each with exactly one value; duplicated tags, extra tags, or extra tag values MUST be rejected. The executor MUST reject a lease carrying filter, kind, author, endpoint, or platform data in public tags. @@ -87,12 +87,12 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d "v": 1, "origin": "", // tenant binding, verified — never routed on "app_profile": "com.example.app/ios", // selects transport credentials - "transport": "apns", // "apns" | "fcm" | "unifiedpush" - "endpoint": "", // APNs token / FCM token / UP URL + "transport": "apns", // "apns" | "fcm" + "endpoint": "", // APNs or FCM token/capability "generation": 3, // strictly increasing per lease address "active": true, // false = revocation tombstone "subscriptions": [ - { "filter": { "kinds": [9], "#p": [""] }, "class": "time_sensitive" }, + { "filter": { "kinds": [9], "#p": [""] }, "class": "default" }, { "filter": { "kinds": [9], "#h": [""] }, "class": "default", "ignore": [ { "kinds": [9], "authors": [""], "#h": [""] } ], "suppress": { "p_tags_max": 20 } } @@ -146,16 +146,9 @@ Each subscription carries exactly one `class`: | Class | Meaning | APNs `interruption-level` | Android importance | |---|---|---|---| -| `silent` | Sync-only wake, no alert | not user-visible; see APNs profile | `IMPORTANCE_MIN` | | `default` | Standard notification | `active` | `IMPORTANCE_DEFAULT` | -| `time_sensitive` | Breaks through Focus/DND within OS policy | `time-sensitive` | `IMPORTANCE_HIGH` | -| `urgent` | Reserved: approval gates | `critical` if entitled, else `time-sensitive` | `IMPORTANCE_HIGH` + full-screen intent where policy allows | -Classes are strictly ordered: `silent` < `default` < `time_sensitive` < `urgent`. When one deduplicated wake covers matches from multiple subscriptions or leases targeting the same endpoint (see Coalescing), the wake's effective class is the highest eligible class among those matches. The descriptor's `class_support` is authoritative: a lease naming a class unsupported for its transport MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. - -The executor MUST restrict `urgent` to the descriptor-advertised allow-list of approval-request kinds whose eligibility is decidable from the public event envelope (`invalid: class not permitted for kind`). Urgent DMs are explicitly out of scope for v1: gift-wrapped DM content is opaque to the executor, so no privacy-safe urgency marker exists yet; a future revision may add one. - -`silent` remains a matching preference only. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. +The descriptor's `class_support` is authoritative: a lease naming an unsupported class MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. Clients MUST NOT register any lease or subscription as a side effect of joining a channel or surface — absent explicit user opt-in the notifiable set is empty. @@ -176,10 +169,8 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in "app_profiles": [ { "id": "com.example.app/ios", "transport": "apns" }, { "id": "com.example.app/android", "transport": "fcm" } ], "push_kinds": [9, 1059, 40007, 46010, 7], - "urgent_kinds": [46010], "h_grammar": "uuid-v4-lowercase", - "class_support": { "apns": ["silent","default","time_sensitive","urgent"], - "fcm": ["silent","default","time_sensitive","urgent"] }, + "class_support": { "apns": ["default"], "fcm": ["default"] }, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, @@ -192,9 +183,9 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in } ``` -A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; `urgent_kinds ⊆ push_kinds`; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. +A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. -The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. Executors MUST NOT dereference a client-supplied `endpoint` URL except as the selected transport profile explicitly defines (UnifiedPush is the only profile whose endpoint is a URL, and it is validated per that profile before use). +The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. Leases MUST be author-only reads, as specified in Acceptance and Origin Binding, following the NIP-ER access pattern. @@ -230,10 +221,6 @@ The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"bo A future FCM profile MUST define one gateway-owned constant data message with identical noninterference semantics. Until that constant and its wire tests are registered, FCM is not a conforming v1 public-gateway profile. -### UnifiedPush (optional) - -UnifiedPush is not a conforming public-gateway profile in v1 because arbitrary distributor endpoints and message bodies do not meet the fixed-payload authority boundary. A future profile requires a separately registered constant body and hostile-endpoint analysis. - ## Lease and Key Lifecycle A lease is identified by `(author, kind, d)`. A replacement supersedes the prior lease at the same address only by passing the full acceptance sequence, including winning both NIP-01 addressable ordering and the strictly-increasing generation watermark (check 8). Any rejected replacement — stale by either ordering, or invalid for any other reason — MUST leave the stored event, effective push state, and watermark unchanged. @@ -264,15 +251,12 @@ This section registers the public last-hop profile served at `https://push.buzz. ### Registered values and lease mapping -The registered `app_profile` values are `buzz-ios-dogfood` and -`buzz-ios-app-store`. They identify closed Buzz application identities, not APNs -transport environments. The canonical gateway owns the mapping from each -profile to one exact App Attest application identifier, APNs topic, -certificate-backed connection pool, and APNs environment. A client profile -selector chooses only a candidate entry; enrollment succeeds only when App -Attest cryptographically verifies the configured application identifier. A -gateway deployment MUST enable only profiles whose full mapping is configured -consistently, and MUST NOT accept an APNs topic from a client. The APNs token +The registered `app_profile` value is `buzz-ios-dogfood`. It identifies the +closed Buzz dogfood application identity, not an APNs transport environment. +The canonical gateway owns its exact App Attest application identifier, APNs +topic, certificate-backed connection pool, and APNs environment. Enrollment +succeeds only when App Attest cryptographically verifies the configured +application identifier. The gateway MUST NOT accept an APNs topic from a client. The APNs token registered with the gateway is called the **installation endpoint** and never leaves gateway custody after enrollment. @@ -449,6 +433,6 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - `kind:30350`: push lease (addressable) - `exec` tag: executor encryption-key identifier for `kind:30350` - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery -- Classes: `silent`, `default`, `time_sensitive`, `urgent` +- Classes: `default` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-dogfood`, `buzz-ios-app-store`; wire version `1` +- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 736fdd2bce8..a9c4b019d48 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -16,25 +16,20 @@ | `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | -| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated closed application profiles: `buzz-ios-dogfood` and/or `buzz-ios-app-store`. Dogfood is the only enabled MVP profile. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`) for each closed profile. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_TOPIC` | Server-owned APNs topic for each profile. Never accepted from a client. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_ENVIRONMENT` | `production` or `sandbox`, selected per profile by deployment configuration. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_CERT_PATH` | Read-only certificate/private-key PEM for an enabled profile. A dormant profile may omit it. | +| `BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`). | +| `BUZZ_PUSH_DOGFOOD_APNS_TOPIC` | Server-owned APNs topic. Never accepted from a client. | +| `BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT` | `production` or `sandbox`, selected by deployment configuration. | +| `BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH` | Read-only certificate/private-key PEM. | | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | -The single canonical `push.buzz.xyz` deployment owns a closed profile registry -for both dogfood (`xyz.block.buzz.dogfood.mobile`) and App Store -(`xyz.block.buzz.mobile`) application identities. Enrollment's profile selector -only chooses a candidate entry: the corresponding App Attest verifier must -cryptographically validate that entry's configured application ID before the -profile is stored with the installation. Assertions and delivery subsequently -select App Attest policy, APNs topic, certificate-backed connection pool, and -environment from that stored profile. No client request or relay grant can -supply or override an APNs topic. The MVP enables and credentials dogfood only; -the App Store entry remains registered but dormant until a later rollout. +The canonical `push.buzz.xyz` MVP serves the dogfood application identity +(`xyz.block.buzz.dogfood.mobile`). App Attest must cryptographically validate +the configured application ID before enrollment. Assertions and delivery use +the server-owned APNs topic, certificate-backed connection pool, and +environment. No client request or relay grant can supply or override an APNs +topic. Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS` (default `10`, max `86400`) and `BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES` (default `10`, max `10000`). These are Buzz policy hypotheses, not Apple-published limits; tune under load while retaining a hard ceiling. @@ -73,7 +68,7 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe | Alert | Fires when | Severity | Action | |---|---|---|---| -| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | The enabled profile's APNs certificate/topic/environment is unhealthy. Check the matching `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_*` configuration. No endpoints are being invalidated. | +| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | The APNs certificate/topic/environment is unhealthy. Check `BUZZ_PUSH_DOGFOOD_APNS_*` configuration. No endpoints are being invalidated. | | `PushGatewayAdmissionUnavailable` | any admission `unavailable` for 5m | critical | PostgreSQL authority store is unreachable. Check DB connectivity and the pod's `postgresEgressCidrs` NetworkPolicy. | | `PushGatewayReadinessAuthorityFailing` | readiness `authority` failures for 5m | warning | Replicas are being pulled from the Service on DB check failure. Fix DB health before capacity drops below the PodDisruptionBudget. | | `PushGatewayReaperFailing` | reaper failed ≥2 times within 30m (runs every 5m) | warning | Expired reservations aren't being swept, growing the bounded-until-expiry window. Check DB write availability. | diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index c222fe30d94..be6ce3b97b5 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -121,46 +121,6 @@ private enum BuzzSecureRandom { } } -#if DEBUG - struct BuzzDevAppAttestProvider: BuzzDevAppAttesting { - private static let attestationPrefix = Data("buzz-dev-app-attest-v1:".utf8) - private static let assertionBytes = Data("buzz-dev-app-assertion-v1".utf8) - - let randomBytes: () throws -> Data - - init( - randomBytes: @escaping () throws -> Data = { - try BuzzSecureRandom.bytes(count: 32) - } - ) { - self.randomBytes = randomBytes - } - - func prepareAttestation() async throws -> BuzzDevAttestation { - let entropy = try randomBytes() - precondition(entropy.count == 32, "Development attestation entropy must be exactly 32 bytes") - let bytes = Self.attestationPrefix + entropy - return BuzzDevAttestation( - keyId: Data(SHA256.hash(data: bytes)).base64EncodedString(), - attestation: bytes.base64EncodedString() - ) - } - - func attestation( - _ prepared: BuzzDevAttestation, - clientData: Data - ) async throws -> BuzzDevAttestation { - precondition(!clientData.isEmpty, "Enrollment client data must not be empty") - return prepared - } - - func assertion(clientData: Data) async throws -> String { - precondition(!clientData.isEmpty, "Delegation client data must not be empty") - return Self.assertionBytes.base64EncodedString() - } - } -#endif - private enum BuzzAppAttestKeyId { static func isValid(_ keyId: String) -> Bool { guard !keyId.isEmpty, @@ -374,24 +334,6 @@ public final class BuzzDevPushEnrollmentDriver { ) } - #if DEBUG - public convenience init( - gatewayBaseURL: URL, - store: BuzzPushEndpointGrantStore, - session: URLSession = .shared - ) throws { - try self.init( - gatewayBaseURL: gatewayBaseURL, - store: store, - session: session, - appAttest: BuzzDevAppAttestProvider(), - now: Date.init, - lifetimeSeconds: 2_592_000, - installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } - ) - } - #endif - init( gatewayBaseURL: URL, store: BuzzPushEndpointGrantStore, diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index f4113292dc4..47661e81300 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -83,7 +83,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let communities = loadCommunities().filter { $0.pubkey?.isEmpty == false && loadPrivateKey($0.id) != nil - && (try? $0.pushSubscriptionState.authoritativeSubscriptions().isEmpty == false) == true + && !$0.policies.isEmpty } guard !communities.isEmpty else { completion(nil) @@ -124,12 +124,11 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { return } guard - let subscriptions = try? community.pushSubscriptionState.authoritativeSubscriptions(), - !subscriptions.isEmpty, + !community.policies.isEmpty, let relayURL = community.relayURL, let url = URL(string: "/query", relativeTo: relayURL), let body = try? JSONSerialization.data( - withJSONObject: subscriptions.map { $0.filter.queryFilter(since: nil, limit: 10) } + withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) } ) else { completion(nil) @@ -159,8 +158,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let candidate = Self.newestMessage( events: events.filter { event in event.hasValidIDAndSignature() - && subscriptions.contains { subscription in - PushLeaseMatcher.matches(event: event, subscription: subscription) + && community.policies.contains { policy in + PushLeaseMatcher.matches(event: event, policy: policy) } }, community: community diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift index 56eb7703a1c..f4a27e8a9f0 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -97,15 +97,18 @@ public struct BuzzPushPresentationCacheSnapshot: Codable, Equatable, Sendable { public static let currentVersion = 1 public let version: Int + public var communities: [PushLeaseCommunity] public var profiles: [BuzzPushCachedProfile] public var channels: [BuzzPushCachedChannel] public init( version: Int = currentVersion, + communities: [PushLeaseCommunity] = [], profiles: [BuzzPushCachedProfile] = [], channels: [BuzzPushCachedChannel] = [] ) { self.version = version + self.communities = communities self.profiles = profiles self.channels = channels } @@ -155,10 +158,11 @@ public struct BuzzPushProfileCacheUpdate: Sendable { /// Maintains the bounded presentation snapshot. The app is the sole writer. public final class BuzzPushPresentationCacheStore: @unchecked Sendable { - public static let fileName = "push-presentation-cache.json" + public static let fileName = "push-snapshot.json" public static let freshnessLifetime: TimeInterval = 24 * 60 * 60 public static let maximumProfiles = 256 public static let maximumChannels = 512 + public static let maximumCommunities = 64 public static let maximumMembersPerChannel = 512 public static let maximumTotalMemberDigests = 8_192 public static let maximumAvatarBytes = 64 * 1024 @@ -175,6 +179,19 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { self.now = now } + /// Replaces the app's flattened, relay-accepted community query policy. + public func replaceCommunities(_ communities: [PushLeaseCommunity]) throws { + guard communities.count <= Self.maximumCommunities else { return } + lock.lock() + defer { lock.unlock() } + var snapshot = loadLocked() + snapshot.communities = communities + let retained = Set(communities.map(\.id)) + snapshot.profiles.removeAll { !retained.contains($0.communityID) } + snapshot.channels.removeAll { !retained.contains($0.communityID) } + try writeLocked(snapshot) + } + /// Saves verified kind-0 events and returns the event IDs still needing thumbnails. @discardableResult public func updateProfiles( @@ -416,16 +433,6 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { return true } - /// Removes metadata belonging to communities no longer present in the app. - public func retainCommunities(_ communityIDs: Set) throws { - lock.lock() - defer { lock.unlock() } - var snapshot = loadLocked() - snapshot.profiles.removeAll { !communityIDs.contains($0.communityID) } - snapshot.channels.removeAll { !communityIDs.contains($0.communityID) } - try writeLocked(snapshot) - } - private func loadLocked() -> BuzzPushPresentationCacheSnapshot { guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), let fileSize = values.fileSize, @@ -630,6 +637,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { } static func enforceBounds(_ snapshot: inout BuzzPushPresentationCacheSnapshot) { + snapshot.communities = Array(snapshot.communities.prefix(maximumCommunities)) snapshot.profiles = Array( snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles) ) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift index 7ac75721f01..cb7eb791082 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -1,11 +1,5 @@ import Foundation -public enum PushLeaseError: Error, Equatable { - case unsupportedAuthority(String) - case acceptedAuthorityMissingSubscriptions - case emptySubscriptions -} - public struct PushLeaseSnapshot: Codable, Equatable, Sendable { public let communities: [PushLeaseCommunity] @@ -21,7 +15,7 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { /// Relay NIP-11 `self` key used to verify NIP-29 channel metadata. public let relayMetadataPubkey: String? public let pubkey: String? - public let pushSubscriptionState: PushLeaseSubscriptionState + public let policies: [PushResolutionPolicy] public init( id: String, @@ -29,80 +23,28 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { relayUrl: String, relayMetadataPubkey: String? = nil, pubkey: String?, - pushSubscriptionState: PushLeaseSubscriptionState + policies: [PushResolutionPolicy] ) { self.id = id self.name = name self.relayUrl = relayUrl self.relayMetadataPubkey = relayMetadataPubkey self.pubkey = pubkey - self.pushSubscriptionState = pushSubscriptionState + self.policies = policies } } -public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable { - public enum Authority: String, Codable, Sendable { - case desired - case accepted - } - - public let authority: String - public let desired: [PushLeaseSubscription] - public let accepted: [PushLeaseSubscription]? - - public init( - authority: String, - desired: [PushLeaseSubscription], - accepted: [PushLeaseSubscription]? = nil - ) { - self.authority = authority - self.desired = desired - self.accepted = accepted - } - - /// The app persists accepted authority only after the relay acknowledges the - /// corresponding lease. Until then the desired policy is used for snapshots. - public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] { - let subscriptions: [PushLeaseSubscription] - switch authority { - case Authority.desired.rawValue: - subscriptions = desired - case Authority.accepted.rawValue: - guard let accepted else { - throw PushLeaseError.acceptedAuthorityMissingSubscriptions - } - subscriptions = accepted - default: - throw PushLeaseError.unsupportedAuthority(authority) - } - guard !subscriptions.isEmpty else { - throw PushLeaseError.emptySubscriptions - } - return subscriptions - } -} - -public struct PushLeaseSubscription: Codable, Equatable, Sendable { +public struct PushResolutionPolicy: Codable, Equatable, Sendable { public let filter: PushLeaseFilter - public let notificationClass: String public let ignore: [PushLeaseFilter] public let suppress: PushLeaseSuppression? - enum CodingKeys: String, CodingKey { - case filter - case notificationClass = "class" - case ignore - case suppress - } - public init( filter: PushLeaseFilter, - notificationClass: String, ignore: [PushLeaseFilter] = [], suppress: PushLeaseSuppression? = nil ) { self.filter = filter - self.notificationClass = notificationClass self.ignore = ignore self.suppress = suppress } @@ -172,11 +114,11 @@ public struct PushLeaseFilter: Codable, Equatable, Sendable { public enum PushLeaseMatcher { public static func matches( event: VerifiedNostrEvent, - subscription: PushLeaseSubscription + policy: PushResolutionPolicy ) -> Bool { - guard subscription.filter.matches(event) else { return false } - if subscription.ignore.contains(where: { $0.matches(event) }) { return false } - if let maximum = subscription.suppress?.pTagsMax, + guard policy.filter.matches(event) else { return false } + if policy.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = policy.suppress?.pTagsMax, event.tagCount(named: "p") > maximum { return false diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 821cd240024..5e19fa76619 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -215,25 +215,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") } - #if DEBUG - func testDevelopmentAttestationMatchesGatewayBypassShape() async throws { - let entropy = Data(repeating: 0xAB, count: 32) - let provider = BuzzDevAppAttestProvider(randomBytes: { entropy }) - let prepared = try await provider.prepareAttestation() - let bytes = try XCTUnwrap(Data(base64Encoded: prepared.attestation)) - XCTAssertEqual( - bytes, - Data("buzz-dev-app-attest-v1:".utf8) + entropy - ) - XCTAssertEqual( - prepared.keyId, - Data(SHA256.hash(data: bytes)).base64EncodedString() - ) - let assertion = try await provider.assertion(clientData: Data("transcript".utf8)) - XCTAssertEqual(assertion, Self.assertion) - } - #endif - func testLegacyGrantDecodesWithoutMetadataAuthority() throws { let data = Data( #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#.utf8 diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 907795d3f30..b25e23595f6 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -717,19 +717,14 @@ final class BuzzPushNotificationResolverTests: XCTestCase { relayUrl: relayUrl, relayMetadataPubkey: relayMetadataPubkey, pubkey: pubkey, - pushSubscriptionState: PushLeaseSubscriptionState( - authority: "accepted", - desired: [], - accepted: [ - PushLeaseSubscription( + policies: [ + PushResolutionPolicy( filter: PushLeaseFilter( kinds: [9, 40002, 45001, 45003], hTags: [Self.channelID] - ), - notificationClass: "default" + ) ) ] - ) ) } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index 87187f37c55..ed592c576f6 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -512,20 +512,6 @@ struct BuzzPushPresentationCacheTests { #expect(cached.membershipEventID == membership.id) } - @Test("Legacy cache decodes with channel membership unavailable") - func legacyCacheCompatibility() throws { - let legacy = try #require( - #"{"version":1,"profiles":[],"channels":[{"communityID":"community-a","relayOrigin":"https://relay.example","channelID":"opaque","relayMetadataPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","displayName":"General","eventID":"event","eventCreatedAt":1,"cachedAt":2}]}"# - .data(using: .utf8) - ) - - let channel = try #require(BuzzPushPresentationCacheSnapshot.decode(legacy).channels.first) - - #expect(channel.displayName == "General") - #expect(channel.memberCount == nil) - #expect(channel.memberDigests == nil) - } - @Test("Missing or malformed channel metadata never fabricates a name") func malformedChannelMetadataFallback() throws { let directory = try temporaryDirectory() @@ -589,9 +575,18 @@ struct BuzzPushPresentationCacheTests { membershipEvents: [] ) - try store.retainCommunities(["retained"]) + try store.replaceCommunities([ + PushLeaseCommunity( + id: "retained", + name: "Retained", + relayUrl: "https://relay.example", + pubkey: nil, + policies: [] + ) + ]) let snapshot = try loadSnapshot(directory) + #expect(snapshot.communities.map(\.id) == ["retained"]) #expect(snapshot.profiles.isEmpty) #expect(snapshot.channels.isEmpty) } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift index 3c8190d3934..096737d5359 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -6,28 +6,6 @@ final class PushLeaseTests: XCTestCase { private let mine = String(repeating: "a", count: 64) private let other = String(repeating: "b", count: 64) - func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws { - let subscription = PushLeaseSubscription( - filter: PushLeaseFilter(kinds: [9], pTags: [mine]), - notificationClass: "default" - ) - XCTAssertEqual( - try PushLeaseSubscriptionState( - authority: "desired", - desired: [subscription] - ).authoritativeSubscriptions(), - [subscription] - ) - XCTAssertThrowsError( - try PushLeaseSubscriptionState( - authority: "accepted", - desired: [subscription] - ).authoritativeSubscriptions() - ) { error in - XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions) - } - } - func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() { let filter = PushLeaseFilter( kinds: [7, 1059], @@ -47,46 +25,43 @@ final class PushLeaseTests: XCTestCase { func testPushEligibleKindAbsentFromOldConstantMatchesLease() { let event = makeEvent(kind: 1059, tags: [["p", mine]]) - let subscription = PushLeaseSubscription( - filter: PushLeaseFilter(kinds: [1059], pTags: [mine]), - notificationClass: "default" + let policy = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]) ) - XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription)) + XCTAssertTrue(PushLeaseMatcher.matches(event: event, policy: policy)) } func testIgnoreAndHellthreadSuppressionRejectCandidates() { let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) - let ignoreSubscription = PushLeaseSubscription( + let ignorePolicy = PushResolutionPolicy( filter: PushLeaseFilter(kinds: [9], pTags: [mine]), - notificationClass: "default", ignore: [PushLeaseFilter(kinds: [9], authors: [other])] ) XCTAssertFalse( - PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription) + PushLeaseMatcher.matches(event: ignored, policy: ignorePolicy) ) let hellthread = makeEvent( kind: 9, tags: (0..<21).map { ["p", String(format: "%064x", $0)] } ) - let suppressed = PushLeaseSubscription( + let suppressed = PushResolutionPolicy( filter: PushLeaseFilter(kinds: [9], authors: [other]), - notificationClass: "default", suppress: PushLeaseSuppression(pTagsMax: 20) ) - XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed)) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, policy: suppressed)) } func testDecodesSnapshotContractFromDartShape() throws { let json = """ - {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]} + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","policies":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}]} """ let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) XCTAssertEqual(snapshot.communities.count, 1) XCTAssertEqual( - try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count, + snapshot.communities[0].policies.count, 1 ) } diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 644677c04f0..44965720a9f 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -19,7 +19,7 @@ final class NotificationService: UNNotificationServiceExtension { return BuzzPushNotificationResolver( session: .shared, loadCommunitiesData: { - Self.loadCommunitiesData(appGroupIdentifier: appGroupIdentifier) + Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) }, loadPrivateKey: { communityID in Self.loadPrivateKey( @@ -28,11 +28,7 @@ final class NotificationService: UNNotificationServiceExtension { ) }, loadPresentationCacheData: { - Self.loadAppGroupData( - fileName: BuzzPushPresentationCacheStore.fileName, - appGroupIdentifier: appGroupIdentifier, - maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes - ) + Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) } ) }() @@ -113,10 +109,11 @@ final class NotificationService: UNNotificationServiceExtension { return String(data: data, encoding: .utf8) } - private static func loadCommunitiesData(appGroupIdentifier: String?) -> Data? { + private static func loadPushSnapshotData(appGroupIdentifier: String?) -> Data? { loadAppGroupData( - fileName: "push-communities.json", - appGroupIdentifier: appGroupIdentifier + fileName: BuzzPushPresentationCacheStore.fileName, + appGroupIdentifier: appGroupIdentifier, + maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes ) } diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 023dade3fda..29ce66de691 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -36,7 +36,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; BZZ00000000000000000023 /* PushNativeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000024 /* PushNativeState.swift */; }; BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000027 /* PushEndpointGrantStore.swift */; }; - BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */; }; + BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushSnapshotBridge.swift */; }; BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -114,7 +114,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = ""; }; BZZ00000000000000000027 /* PushEndpointGrantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEndpointGrantStore.swift; sourceTree = ""; }; - BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPresentationCacheBridge.swift; sourceTree = ""; }; + BZZ0000000000000000002A /* PushSnapshotBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSnapshotBridge.swift; sourceTree = ""; }; BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzCommunicationNotificationTests.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -240,7 +240,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, BZZ00000000000000000024 /* PushNativeState.swift */, BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, - BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */, + BZZ0000000000000000002A /* PushSnapshotBridge.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, @@ -563,7 +563,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, BZZ00000000000000000023 /* PushNativeState.swift in Sources */, BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, - BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */, + BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 1da32bca4ea..b3f89333996 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -19,8 +19,13 @@ import os.log private var appGroupIdentifier: String? { Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String } - private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge( - appGroupIdentifier: appGroupIdentifier + private var pushKeychainAccessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + } + private lazy var pushSnapshotBridge = BuzzPushSnapshotBridge( + appGroupIdentifier: appGroupIdentifier, + endpointGrantStore: endpointGrantStore, + keychainAccessGroup: pushKeychainAccessGroup ) private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? @@ -326,7 +331,7 @@ import os.log _ call: FlutterMethodCall, result: @escaping FlutterResult ) { - if pushPresentationCacheBridge.handle(call, result: result) { + if pushSnapshotBridge.handle(call, result: result) { return } switch call.method { @@ -334,30 +339,6 @@ import os.log startPushRegistration(result: result) case "takePendingNotificationResponse": result(pushNavigationBuffer.take()?.flutterArguments) - case "saveCommunitySnapshot": - guard let arguments = call.arguments as? [String: Any], - let communities = arguments["communities"] as? [[String: Any]], - let signingKeys = arguments["signingKeys"] as? [String: String] - else { - result( - FlutterError( - code: "invalid_arguments", message: "Expected communities array.", details: nil)) - return - } - do { - try savePushCommunitySnapshot(communities) - try BuzzPushKeychain.replace( - signingKeys: signingKeys, - accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") - as? String - ) - result(nil) - } catch { - result( - FlutterError( - code: "save_failed", message: "Unable to save push community credentials.", - details: error.localizedDescription)) - } case "endpointGrants": do { result(try endpointGrantStore.records().map(\.flutterArguments)) @@ -445,21 +426,13 @@ import os.log } do { - let driver: BuzzDevPushEnrollmentDriver - #if DEBUG - driver = try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: gatewayURL, - store: endpointGrantStore - ) - #else - driver = try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: gatewayURL, - store: endpointGrantStore, - appAttestKeychainAccessGroup: Bundle.main.object( - forInfoDictionaryKey: "BuzzKeychainAccessGroup" - ) as? String - ) - #endif + let driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + ) enrollmentTask = Task { [weak self] in defer { self?.enrollmentTask = nil } do { @@ -491,58 +464,6 @@ import os.log } } - private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { - guard let appGroupIdentifier else { - throw NSError( - domain: "BuzzPush", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) - } - guard - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier) - else { - throw NSError( - domain: "BuzzPush", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) - } - // Channel-name enrichment is optional presentation state. A damaged or - // unavailable grant cache must not block the core NSE snapshot/key update. - let grants = (try? endpointGrantStore.records()) ?? [] - let enriched = communities.map { community -> [String: Any] in - var community = community - guard let relayURL = community["relayUrl"] as? String, - let relayMetadataPubkey = Self.pushRelayMetadataPubkey( - relayURL: relayURL, - grants: grants - ) - else { return community } - community["relayMetadataPubkey"] = relayMetadataPubkey - return community - } - let data = try JSONSerialization.data( - withJSONObject: ["communities": enriched], options: [.sortedKeys]) - let destination = container.appendingPathComponent("push-communities.json") - try data.write(to: destination, options: [.atomic]) - pushPresentationCacheBridge.retainCommunities( - Set(enriched.compactMap { $0["id"] as? String }) - ) - } - - static func pushRelayMetadataPubkey( - relayURL: String, - grants: [BuzzPushEndpointGrantRecord] - ) -> String? { - guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { - return nil - } - return grants.filter { - $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile - && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin - }.max { - $0.generation < $1.generation - }?.relayMetadataPubkey - } - private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/ios/Runner/PushPresentationCacheBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift similarity index 61% rename from mobile/ios/Runner/PushPresentationCacheBridge.swift rename to mobile/ios/Runner/PushSnapshotBridge.swift index 51894fcc793..5c7bfb5fac0 100644 --- a/mobile/ios/Runner/PushPresentationCacheBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -2,10 +2,12 @@ import BuzzPushKit import Flutter import Foundation -final class BuzzPushPresentationCacheBridge { +final class BuzzPushSnapshotBridge { private let appGroupIdentifier: String? + private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore + private let keychainAccessGroup: String? private let queue = DispatchQueue( - label: "xyz.block.buzz.push-presentation-cache", + label: "xyz.block.buzz.push-snapshot", qos: .utility ) private lazy var store: BuzzPushPresentationCacheStore? = { @@ -17,31 +19,104 @@ final class BuzzPushPresentationCacheBridge { return BuzzPushPresentationCacheStore(containerURL: container) }() - init(appGroupIdentifier: String?) { + init( + appGroupIdentifier: String?, + endpointGrantStore: BuzzPushEndpointGrantKeychainStore, + keychainAccessGroup: String? + ) { self.appGroupIdentifier = appGroupIdentifier + self.endpointGrantStore = endpointGrantStore + self.keychainAccessGroup = keychainAccessGroup } @discardableResult func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool { - switch call.method { - case "cachePresentationProfiles": - cacheProfiles(call.arguments, result: result) - case "cachePresentationChannels": - cacheChannels(call.arguments, result: result) - case "cachePresentationAvatar": - cacheAvatar(call.arguments, result: result) - default: + guard call.method == "syncPushSnapshot", + let arguments = call.arguments as? [String: Any], + let section = arguments["section"] as? String + else { return false } + switch section { + case "communities": syncCommunities(arguments, result: result) + case "profiles": cacheProfiles(arguments, result: result) + case "channels": cacheChannels(arguments, result: result) + case "avatar": cacheAvatar(arguments, result: result) + default: return false + } return true } - func retainCommunities(_ communityIDs: Set) { + private func syncCommunities(_ arguments: [String: Any], result: @escaping FlutterResult) { + guard let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String], + communities.count <= BuzzPushPresentationCacheStore.maximumCommunities + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected bounded communities and signing keys.", + details: nil + ) + ) + return + } queue.async { [weak self] in - try? self?.store?.retainCommunities(communityIDs) + do { + guard let self, let store else { + Self.complete(result, value: nil) + return + } + // Relay-metadata enrichment is optional presentation state. A damaged + // grant cache must not block the core NSE snapshot and key update. + let grants = (try? endpointGrantStore.records()) ?? [] + let enriched = communities.map { community -> [String: Any] in + var community = community + guard let relayURL = community["relayUrl"] as? String, + let relayMetadataPubkey = Self.relayMetadataPubkey( + relayURL: relayURL, + grants: grants + ) + else { return community } + community["relayMetadataPubkey"] = relayMetadataPubkey + return community + } + let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys]) + let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data) + try store.replaceCommunities(decoded) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: keychainAccessGroup + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "snapshot_sync_failed", + message: "Unable to sync push community state.", + details: error.localizedDescription + ) + ) + } } } + static func relayMetadataPubkey( + relayURL: String, + grants: [BuzzPushEndpointGrantRecord] + ) -> String? { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { + return nil + } + return grants.filter { + $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile + && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + }.max { + $0.generation < $1.generation + }?.relayMetadataPubkey + } + private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { guard let arguments = rawArguments as? [String: Any], let communityID = arguments["communityId"] as? String, @@ -177,10 +252,8 @@ final class BuzzPushPresentationCacheBridge { let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupIdentifier ), - let data = try? Data( - contentsOf: container.appendingPathComponent("push-communities.json") - ), - let snapshot = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + let data = try? Data(contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)), + let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) else { return nil } return snapshot.communities.first { $0.id == id } } diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 8d8f1373df9..156aa615210 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -182,13 +182,13 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { metadataPubkey: String(repeating: "a", count: 64) ) let wrongProfile = grant( - appProfile: "buzz-ios-app-store", + appProfile: "other-profile", generation: 99, metadataPubkey: String(repeating: "b", count: 64) ) XCTAssertEqual( - AppDelegate.pushRelayMetadataPubkey( + BuzzPushSnapshotBridge.relayMetadataPubkey( relayURL: "wss://relay.example/", grants: [wrongProfile, correctProfile] ), diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 3d2061ff137..9d3c0fdc8a4 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -5,8 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; -import '../../shared/community/community.dart'; -import '../../shared/community/community_provider.dart'; import '../invites/invite_join_provider.dart'; import '../invites/invite_join_sheet.dart'; import 'channel.dart'; @@ -41,7 +39,6 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget { class _DeepLinkDispatcherState extends ConsumerState { bool _preparingInvite = false; - String? _switchingCommunityId; @override void initState() { @@ -62,12 +59,6 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.listen>>(channelsProvider, (_, _) { _maybeDispatch(ref.read(pendingDeepLinkProvider)); }); - ref.listen>(activeCommunityProvider, (_, _) { - _maybeDispatch(ref.read(pendingDeepLinkProvider)); - }); - ref.listen>>(communityListProvider, (_, _) { - _maybeDispatch(ref.read(pendingDeepLinkProvider)); - }); } return widget.child; @@ -83,8 +74,43 @@ class _DeepLinkDispatcherState extends ConsumerState { !widget.dispatchMessageLinks) { return; } - if (link is MessageDeepLink && !_prepareNotificationCommunity(link)) return; + if (link is MessageDeepLink) { + unawaited(_dispatchNotificationLink(link)); + return; + } + + _dispatchNavigableLink(link); + } + Future _dispatchNotificationLink(MessageDeepLink link) async { + final preparation = await ref + .read(pendingDeepLinkProvider.notifier) + .prepareCommunity(link); + if (!mounted || ref.read(pendingDeepLinkProvider) != link) return; + switch (preparation) { + case DeepLinkCommunityPreparation.ready: + _dispatchNavigableLink(link); + case DeepLinkCommunityPreparation.switched: + // The community-scoped app subtree remounts and consumes the parked + // target after its channels load. + return; + case DeepLinkCommunityPreparation.unavailable: + ref.read(pendingDeepLinkProvider.notifier).consume(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Notification community is no longer available'), + ), + ); + case DeepLinkCommunityPreparation.failed: + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not open the notification community'), + ), + ); + } + } + + void _dispatchNavigableLink(BuzzDeepLink link) { final channelId = switch (link) { MessageDeepLink(:final channelId) => channelId, ChannelDeepLink(:final channelId) => channelId, @@ -131,59 +157,6 @@ class _DeepLinkDispatcherState extends ConsumerState { ); } - bool _prepareNotificationCommunity(MessageDeepLink link) { - final communityId = link.communityId; - if (communityId == null) return true; - - final communities = ref.read(communityListProvider).asData?.value; - if (communities == null) return false; - if (!communities.any((community) => community.id == communityId)) { - ref.read(pendingDeepLinkProvider.notifier).consume(); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Notification community is no longer available'), - ), - ); - return false; - } - - final activeCommunity = ref.read(activeCommunityProvider).asData?.value; - if (activeCommunity?.id == communityId) return true; - _switchCommunity(communityId); - return false; - } - - void _switchCommunity(String communityId) { - if (_switchingCommunityId != null) return; - _switchingCommunityId = communityId; - Future.microtask(() async { - var switched = false; - try { - await ref - .read(communityListProvider.notifier) - .switchCommunity(communityId); - switched = true; - } catch (error) { - debugPrint( - 'notification-routing: failed to switch to community ' - '$communityId: $error', - ); - if (mounted) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Could not open the notification community'), - ), - ); - } - } finally { - _switchingCommunityId = null; - if (mounted && switched) { - _maybeDispatch(ref.read(pendingDeepLinkProvider)); - } - } - }); - } - void _maybeDispatchInvite(InviteDeepLink link) { if (_preparingInvite) return; _preparingInvite = true; diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 76a73a20f92..d853e60d4e6 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -101,18 +101,23 @@ final communityPushLeaseDeactivatorProvider = Future _deactivateCommunityPushLease(Community community) async { final state = community.pushSubscriptionState; final acceptedGeneration = state.acceptedGeneration; - final installationId = state.acceptedInstallationId; final nsec = community.nsec; - if (acceptedGeneration == null || - installationId == null || - nsec == null || - nsec.isEmpty) { + if (acceptedGeneration == null || nsec == null || nsec.isEmpty) { return; } try { final decoded = nostr.Nip19.decode(payload: nsec); final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final installationId = (await readBuzzPushEndpointGrants()) + .where( + (grant) => + grant.relayOrigin == descriptor.origin && + grant.appProfile == buzzDevPushAppProfile, + ) + .map((grant) => grant.installationId) + .firstOrNull; + if (installationId == null) return; final uri = Uri.parse(community.relayUrl); final httpScheme = switch (uri.scheme) { 'wss' => 'https', @@ -320,8 +325,6 @@ class CommunityListNotifier extends AsyncNotifier> { String id, { required List subscriptions, required int generation, - required int grantGeneration, - required String installationId, }) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); @@ -333,8 +336,6 @@ class CommunityListNotifier extends AsyncNotifier> { pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, generation: generation, - grantGeneration: grantGeneration, - installationId: installationId, ), ); await storage.save(updated); diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 0f8c052c282..70f1a6a3b6f 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -6,8 +6,11 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; +import '../community/community_provider.dart'; import '../push/push_bridge.dart'; +enum DeepLinkCommunityPreparation { ready, switched, unavailable, failed } + /// Holds supported deep links until they can be dispatched. /// /// Links are queued in arrival order. Navigation cannot always happen the @@ -64,6 +67,35 @@ class PendingDeepLinkNotifier extends Notifier { state = _waiting.isEmpty ? null : _waiting.removeFirst(); } + /// Selects the device-local community carried by a structured push target. + /// Ordinary shared deep links have no community ID and remain unchanged. + Future prepareCommunity( + BuzzDeepLink link, + ) async { + if (link is! MessageDeepLink || link.communityId == null) { + return DeepLinkCommunityPreparation.ready; + } + final communityId = link.communityId!; + try { + final communities = await ref.read(communityListProvider.future); + if (!communities.any((community) => community.id == communityId)) { + return DeepLinkCommunityPreparation.unavailable; + } + final active = await ref.read(activeCommunityProvider.future); + if (active?.id == communityId) return DeepLinkCommunityPreparation.ready; + await ref + .read(communityListProvider.notifier) + .switchCommunity(communityId); + return DeepLinkCommunityPreparation.switched; + } catch (error) { + debugPrint( + 'notification-routing: failed to switch to community ' + '$communityId: $error', + ); + return DeepLinkCommunityPreparation.failed; + } + } + void _enqueue(BuzzDeepLink link) { if (state == null) { state = link; diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 9108c104b0c..a999ae2903d 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -87,7 +87,6 @@ class BuzzPushLeaseDescriptor { 'keys', 'app_profiles', 'push_kinds', - 'urgent_kinds', 'h_grammar', 'class_support', 'limitation', @@ -97,7 +96,6 @@ class BuzzPushLeaseDescriptor { 'keys', 'app_profiles', 'push_kinds', - 'urgent_kinds', 'h_grammar', 'class_support', 'limitation', @@ -170,16 +168,6 @@ class BuzzPushLeaseDescriptor { 'NIP-11 does not advertise every Buzz message kind for push', ); } - final urgentKinds = _intList( - push['urgent_kinds'], - name: 'urgent_kinds', - allowEmpty: true, - ); - if (urgentKinds.any((kind) => !pushKinds.contains(kind))) { - throw const FormatException( - 'urgent_kinds must be a subset of push_kinds', - ); - } final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); if (hGrammar != 'uuid-v4-lowercase') { throw const FormatException('Unsupported push h_grammar'); @@ -193,7 +181,7 @@ class BuzzPushLeaseDescriptor { classSupport[buzzPushTransport], name: 'class_support.apns', ); - const knownClasses = {'silent', 'default', 'time_sensitive', 'urgent'}; + const knownClasses = {'default'}; if (supportedClasses.any((value) => !knownClasses.contains(value))) { throw const FormatException('class_support contains an unknown class'); } diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0652ad8fa43..ce4efb29671 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -68,21 +68,11 @@ class BuzzPushBootstrap extends HookConsumerWidget { } final state = community!.pushSubscriptionState; if (state.desired.isEmpty) return null; - final desiredFingerprint = buzzPushSubscriptionsFingerprint( - state.desired, - ); - final acceptedFingerprint = state.accepted == null - ? '-' - : buzzPushSubscriptionsFingerprint(state.accepted!); final attempt = [ community.id, config.baseUrl, token, - desiredFingerprint, - acceptedFingerprint, - state.acceptedGeneration, - state.acceptedGrantGeneration, - state.acceptedInstallationId, + buzzPushSubscriptionsFingerprint(state.desired), ].join('|'); if (publicationAttempt.value == attempt) return null; publicationAttempt.value = attempt; @@ -138,10 +128,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { ) async { final state = community.pushSubscriptionState; final desired = state.desired; - final desiredFingerprint = buzzPushSubscriptionsFingerprint(desired); - final acceptedFingerprint = state.accepted == null - ? null - : buzzPushSubscriptionsFingerprint(state.accepted!); final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); final grant = await enrollBuzzPush( config.wsUrl, @@ -149,12 +135,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { communitiesForSnapshotRefresh: ref.read(communityListProvider).value ?? [community], ); - if (state.authority == BuzzPushLeaseSubscriptionAuthority.accepted && - acceptedFingerprint == desiredFingerprint && - state.acceptedGrantGeneration == grant.generation && - state.acceptedInstallationId == grant.installationId) { - return; - } // Relay lease replacement and gateway delegation are independent state // machines. Subscription changes advance only the kind-30350 generation; // the opaque grant remains reusable until its own authority changes. @@ -175,8 +155,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { community.id, subscriptions: desired, generation: leaseGeneration, - grantGeneration: grant.generation, - installationId: grant.installationId, ); } } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index db647aefd9c..513971fcf62 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -187,7 +187,7 @@ Future registerBuzzPushCommunitySnapshot( name: community.name, relayUrl: community.relayUrl, pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), - pushSubscriptionState: community.pushSubscriptionState, + subscriptions: community.pushSubscriptionState.authoritative, ), ]; final signingKeys = {}; @@ -205,7 +205,8 @@ Future registerBuzzPushCommunitySnapshot( // Native storage is fail-closed; malformed keys are never exported. } } - await _channel.invokeMethod('saveCommunitySnapshot', { + await _channel.invokeMethod('syncPushSnapshot', { + 'section': 'communities', 'communities': [for (final snapshot in snapshots) snapshot.toJson()], 'signingKeys': signingKeys, }); diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index 50c1795da4f..82cc78a89d2 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -8,9 +8,6 @@ import 'package:nostr/nostr.dart' as nostr; import '../relay/nostr_models.dart'; const _pushPresentationChannel = MethodChannel('buzz/push'); -// Keep these bridge payload bounds aligned with BuzzPushPresentationCacheStore. -const _maximumPresentationProfiles = 256; -const _maximumPresentationChannels = 512; const _maximumAvatarSourceBytes = 512 * 1024; const _maximumAvatarPNGBytes = 64 * 1024; Future _avatarEncodeTail = Future.value(); @@ -44,14 +41,14 @@ Future cacheBuzzPushProfileEvents( if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } - final verified = _boundedNewestEvents( + final verified = _newestVerifiedEvents( events, kind: 0, - maximum: _maximumPresentationProfiles, scope: (event) => event.pubkey.toLowerCase(), ).values.toList(); if (verified.isEmpty) return; - await _invokeBestEffort('cachePresentationProfiles', { + await _invokeBestEffort({ + 'section': 'profiles', 'communityId': communityID, 'events': [for (final event in verified) event.toJson()], }); @@ -68,14 +65,12 @@ Future cacheBuzzPushChannelEvents( communityID.isEmpty) { return; } - final batch = selectBoundedPushChannelEvents( - metadataEvents, - membershipEvents, - ); + final batch = selectPushChannelEvents(metadataEvents, membershipEvents); final verifiedMetadata = batch.metadata; final verifiedMembership = batch.membership; if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; - await _invokeBestEffort('cachePresentationChannels', { + await _invokeBestEffort({ + 'section': 'channels', 'communityId': communityID, 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], 'membershipEvents': [ @@ -84,25 +79,22 @@ Future cacheBuzzPushChannelEvents( }); } -/// Selects a bounded, paired set of verified channel metadata and membership events. +/// Selects the newest paired verified channel metadata and membership events. @visibleForTesting ({List metadata, List membership}) -selectBoundedPushChannelEvents( +selectPushChannelEvents( Iterable metadataEvents, - Iterable membershipEvents, { - @visibleForTesting int maximumChannels = _maximumPresentationChannels, -}) { - final verifiedMembershipByChannel = _boundedNewestEvents( + Iterable membershipEvents, +) { + final verifiedMembershipByChannel = _newestVerifiedEvents( membershipEvents, kind: 39002, - maximum: maximumChannels, scope: (event) => event.getTagValue('d'), ); final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); - final verifiedMetadataByChannel = _boundedNewestEvents( + final verifiedMetadataByChannel = _newestVerifiedEvents( metadataEvents, kind: 39000, - maximum: maximumChannels, scope: (event) => event.getTagValue('d'), allowedScopes: selectedChannelIDs.isEmpty ? null : selectedChannelIDs, ); @@ -120,15 +112,13 @@ selectBoundedPushChannelEvents( return (metadata: verifiedMetadata, membership: verifiedMembership); } -Map _boundedNewestEvents( +Map _newestVerifiedEvents( Iterable events, { required int kind, - required int maximum, required String? Function(NostrEvent event) scope, Set? allowedScopes, }) { final selected = {}; - if (maximum <= 0) return selected; for (final event in events) { if (event.kind != kind || !isVerifiedPushPresentationEvent(event)) continue; final key = scope(event); @@ -139,18 +129,7 @@ Map _boundedNewestEvents( if (_isNewerEvent(event, existing)) selected[key] = event; continue; } - if (selected.length < maximum) { - selected[key] = event; - continue; - } - final oldest = selected.entries.reduce( - (left, right) => _isNewerEvent(left.value, right.value) ? right : left, - ); - if (_isNewerEvent(event, oldest.value)) { - selected - ..remove(oldest.key) - ..[key] = event; - } + selected[key] = event; } return selected; } @@ -183,7 +162,8 @@ Future cacheBuzzPushAvatarFromLoadedBytes( try { final png = await _boundedAvatarPNG(sourceBytes); if (png == null) return; - await _invokeBestEffort('cachePresentationAvatar', { + await _invokeBestEffort({ + 'section': 'avatar', 'communityId': communityID, 'sourceUrl': sourceURL, 'png': png, @@ -193,15 +173,15 @@ Future cacheBuzzPushAvatarFromLoadedBytes( } } -Future _invokeBestEffort( - String method, - Map arguments, -) async { +Future _invokeBestEffort(Map arguments) async { try { - await _pushPresentationChannel.invokeMethod(method, arguments); + await _pushPresentationChannel.invokeMethod( + 'syncPushSnapshot', + arguments, + ); pushPresentationCacheError.value = null; } on MissingPluginException { - // Push-free builds and non-Runner embeddings intentionally omit the bridge. + // Non-Runner embeddings do not provide the native snapshot bridge. } catch (error, stackTrace) { pushPresentationCacheError.value = error.toString(); debugPrint('Push presentation cache update failed: $error'); diff --git a/mobile/lib/shared/push/push_snapshot.dart b/mobile/lib/shared/push/push_snapshot.dart index 2760d26ae86..f269d05c0c5 100644 --- a/mobile/lib/shared/push/push_snapshot.dart +++ b/mobile/lib/shared/push/push_snapshot.dart @@ -6,22 +6,33 @@ class BuzzPushCommunitySnapshot { final String name; final String relayUrl; final String? pubkey; - final BuzzPushLeaseSubscriptionState pushSubscriptionState; + final List subscriptions; - const BuzzPushCommunitySnapshot({ + BuzzPushCommunitySnapshot({ required this.id, required this.name, required this.relayUrl, this.pubkey, - required this.pushSubscriptionState, - }); + required Iterable subscriptions, + }) : subscriptions = List.unmodifiable(subscriptions); Map toJson() => { 'id': id, 'name': name, 'relayUrl': relayUrl, if (pubkey != null) 'pubkey': pubkey, - 'pushSubscriptionState': pushSubscriptionState.toJson(), + 'policies': [ + for (final subscription in subscriptions) + { + 'filter': subscription.filter.toJson(), + if (subscription.ignore.isNotEmpty) + 'ignore': [ + for (final filter in subscription.ignore) filter.toJson(), + ], + if (subscription.suppress != null) + 'suppress': subscription.suppress!.toJson(), + }, + ], }; factory BuzzPushCommunitySnapshot.fromJson(Map json) { @@ -30,9 +41,13 @@ class BuzzPushCommunitySnapshot { name: json['name'] as String, relayUrl: json['relayUrl'] as String, pubkey: json['pubkey'] as String?, - pushSubscriptionState: BuzzPushLeaseSubscriptionState.fromJson( - Map.from(json['pushSubscriptionState'] as Map), - ), + subscriptions: [ + for (final raw in json['policies'] as List) + BuzzPushSubscription.fromJson({ + ...Map.from(raw as Map), + 'class': 'default', + }), + ], ); } } diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index acd2e96e0ae..277031e84a1 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -12,7 +12,7 @@ const buzzPushMaxSubscriptions = 16; const buzzPushMaxIgnoreFilters = 8; const buzzPushHellthreadParticipantLimit = 20; -const _supportedNotificationClasses = {'silent', 'default', 'time_sensitive'}; +const _supportedNotificationClasses = {'default'}; const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); final _channelIdPattern = RegExp( @@ -185,24 +185,16 @@ class BuzzPushLeaseSubscriptionState { /// Monotonic generation of the relay-facing kind-30350 lease. final int? acceptedGeneration; - /// Generation sealed into the gateway's opaque relay delegation grant. - final int? acceptedGrantGeneration; - final String? acceptedInstallationId; - const BuzzPushLeaseSubscriptionState.desired({ this.desired = const [], this.accepted, this.acceptedGeneration, - this.acceptedGrantGeneration, - this.acceptedInstallationId, }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; BuzzPushLeaseSubscriptionState.accepted({ required Iterable desired, required Iterable acceptedSubscriptions, required this.acceptedGeneration, - required this.acceptedGrantGeneration, - required this.acceptedInstallationId, }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, desired = List.unmodifiable(desired), accepted = List.unmodifiable(acceptedSubscriptions) { @@ -211,16 +203,6 @@ class BuzzPushLeaseSubscriptionState { 'Accepted push authority requires a positive lease generation.', ); } - if (acceptedGrantGeneration == null || acceptedGrantGeneration! <= 0) { - throw const FormatException( - 'Accepted push authority requires a positive gateway grant generation.', - ); - } - if (acceptedInstallationId == null || acceptedInstallationId!.isEmpty) { - throw const FormatException( - 'Accepted push authority requires an installation ID.', - ); - } } List get authoritative => switch (authority) { @@ -238,16 +220,12 @@ class BuzzPushLeaseSubscriptionState { desired: updated, accepted: accepted, acceptedGeneration: acceptedGeneration, - acceptedGrantGeneration: acceptedGrantGeneration, - acceptedInstallationId: acceptedInstallationId, ), BuzzPushLeaseSubscriptionAuthority.accepted => BuzzPushLeaseSubscriptionState.accepted( desired: updated, acceptedSubscriptions: accepted!, acceptedGeneration: acceptedGeneration, - acceptedGrantGeneration: acceptedGrantGeneration, - acceptedInstallationId: acceptedInstallationId, ), }; } @@ -255,14 +233,10 @@ class BuzzPushLeaseSubscriptionState { BuzzPushLeaseSubscriptionState withAccepted({ required Iterable subscriptions, required int generation, - required int grantGeneration, - required String installationId, }) => BuzzPushLeaseSubscriptionState.accepted( desired: desired, acceptedSubscriptions: subscriptions, acceptedGeneration: generation, - acceptedGrantGeneration: grantGeneration, - acceptedInstallationId: installationId, ); Map toJson() => { @@ -271,10 +245,6 @@ class BuzzPushLeaseSubscriptionState { if (accepted != null) 'accepted': [for (final subscription in accepted!) subscription.toJson()], if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, - if (acceptedGrantGeneration != null) - 'acceptedGrantGeneration': acceptedGrantGeneration, - if (acceptedInstallationId != null) - 'acceptedInstallationId': acceptedInstallationId, }; factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { @@ -283,8 +253,6 @@ class BuzzPushLeaseSubscriptionState { 'desired', 'accepted', 'acceptedGeneration', - 'acceptedGrantGeneration', - 'acceptedInstallationId', }, 'push subscription state'); final authority = json['authority']; final desired = _subscriptionList( @@ -297,45 +265,22 @@ class BuzzPushLeaseSubscriptionState { ? null : _subscriptionList(acceptedRaw, 'accepted'); final acceptedGeneration = json['acceptedGeneration']; - // Pre-MVP snapshots coupled relay and gateway generations; interpreting - // that single value as both preserves them until the next publication. - final acceptedGrantGeneration = - json['acceptedGrantGeneration'] ?? acceptedGeneration; - final acceptedInstallationId = json['acceptedInstallationId']; if (acceptedGeneration != null && acceptedGeneration is! int) { throw const FormatException( 'Accepted push lease generation must be an integer.', ); } - if (acceptedGrantGeneration != null && acceptedGrantGeneration is! int) { - throw const FormatException( - 'Accepted gateway grant generation must be an integer.', - ); - } - if (acceptedInstallationId != null && acceptedInstallationId is! String) { - throw const FormatException( - 'Accepted push installation ID must be a string.', - ); - } return switch (authority) { 'desired' => BuzzPushLeaseSubscriptionState.desired( desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration as int?, - acceptedGrantGeneration: acceptedGrantGeneration as int?, - acceptedInstallationId: acceptedInstallationId as String?, ), - 'accepted' - when accepted != null && - acceptedGeneration is int && - acceptedGrantGeneration is int && - acceptedInstallationId is String => + 'accepted' when accepted != null && acceptedGeneration is int => BuzzPushLeaseSubscriptionState.accepted( desired: desired, acceptedSubscriptions: accepted, acceptedGeneration: acceptedGeneration, - acceptedGrantGeneration: acceptedGrantGeneration, - acceptedInstallationId: acceptedInstallationId, ), 'accepted' => throw const FormatException( 'Accepted push authority requires accepted subscriptions and generations.', diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 8cf1d5091a7..da1746df06c 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -189,6 +189,7 @@ void main() { container: container, child: MaterialApp( home: DeepLinkDispatcher( + key: const ValueKey('before-community-switch'), destinationBuilder: (channel, link) => _CapturedDestination(channel: channel, link: link), child: const Scaffold(body: SizedBox()), @@ -199,6 +200,25 @@ void main() { await tester.pumpAndSettle(); expect(await storage.loadActiveId(), _notificationCommunity.id); + expect(container.read(pendingDeepLinkProvider), link); + + // Production remounts the community-scoped app subtree after a switch. + // The parked link is consumed by the replacement dispatcher. + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + key: const ValueKey('after-community-switch'), + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final destination = tester.widget<_CapturedDestination>( find.byType(_CapturedDestination), ); diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index e174941f74a..7f7ea4b79e6 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -258,16 +258,6 @@ void main() { ); }); - test('descriptor rejects urgent kinds outside push kinds', () { - final information = _descriptorJson(relay.public); - (information['push'] as Map)['urgent_kinds'] = [7]; - - expect( - () => BuzzPushLeaseDescriptor.fromRelayInformation(information), - throwsA(isA()), - ); - }); - test('descriptor rejects unsupported h grammar', () { final information = _descriptorJson(relay.public); (information['push'] as Map)['h_grammar'] = 'opaque'; @@ -361,10 +351,9 @@ Map _descriptorJson(String relayPubkey) => { {'id': 'buzz-ios-dogfood', 'transport': 'apns'}, ], 'push_kinds': [9, 40002, 45001, 45003], - 'urgent_kinds': [], 'h_grammar': 'uuid-v4-lowercase', 'class_support': { - 'apns': ['silent', 'default', 'time_sensitive'], + 'apns': ['default'], }, 'limitation': { 'max_lease_ttl': 2592000, diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index c54a86cdd1d..6c0a5a938d6 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -100,7 +100,7 @@ void main() { if (call.method == 'endpointGrants') { return [_grantMap('new-grant')]; } - if (call.method == 'saveCommunitySnapshot') { + if (call.method == 'syncPushSnapshot') { snapshotArguments.add(call.arguments); return null; } @@ -142,20 +142,18 @@ void main() { 'endpointGrants', 'enrollPush', 'endpointGrants', - 'saveCommunitySnapshot', + 'syncPushSnapshot', ]); expect(snapshotArguments, [ { + 'section': 'communities', 'communities': [ { 'id': 'community-id', 'name': 'Community', 'relayUrl': 'wss://relay.example/', 'pubkey': 'd' * 64, - 'pushSubscriptionState': { - 'authority': 'desired', - 'desired': [], - }, + 'policies': [], }, ], 'signingKeys': {}, diff --git a/mobile/test/shared/push/push_presentation_cache_test.dart b/mobile/test/shared/push/push_presentation_cache_test.dart index 21ec3c8c6cc..fff3ef72571 100644 --- a/mobile/test/shared/push/push_presentation_cache_test.dart +++ b/mobile/test/shared/push/push_presentation_cache_test.dart @@ -61,51 +61,47 @@ void main() { ); }); - test( - 'bounded channel selection keeps metadata paired with selected rosters', - () { - NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { - final signed = nostr.Event.from( - kind: kind, - content: '', - tags: [ - ['d', channelID], - if (kind == 39002) - [ - 'p', - '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - ], - ], - secretKey: secretKey, - createdAt: createdAt, - ); - return NostrEvent.fromJson(signed.toMap()); - } - - final batch = selectBoundedPushChannelEvents( - [ - signedChannelEvent(39000, 'channel-0', 100), - signedChannelEvent(39000, 'channel-1', 300), - signedChannelEvent(39000, 'channel-2', 200), + test('channel selection keeps newest metadata paired with rosters', () { + NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { + final signed = nostr.Event.from( + kind: kind, + content: '', + tags: [ + ['d', channelID], + if (kind == 39002) + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ], ], - [ - signedChannelEvent(39002, 'channel-0', 300), - signedChannelEvent(39002, 'channel-1', 200), - signedChannelEvent(39002, 'channel-2', 100), - ], - maximumChannels: 2, + secretKey: secretKey, + createdAt: createdAt, ); + return NostrEvent.fromJson(signed.toMap()); + } - expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { - 'channel-0', - 'channel-1', - }); - expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { - 'channel-0', - 'channel-1', - }); - }, - ); + final batch = selectPushChannelEvents( + [ + signedChannelEvent(39000, 'channel-0', 100), + signedChannelEvent(39000, 'channel-1', 300), + signedChannelEvent(39000, 'channel-1', 200), + signedChannelEvent(39000, 'metadata-only', 400), + ], + [ + signedChannelEvent(39002, 'channel-0', 300), + signedChannelEvent(39002, 'channel-1', 200), + ], + ); + + expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + }); test('rejects changed content and malformed signatures', () { final signed = nostr.Event.from( diff --git a/mobile/test/shared/push/push_snapshot_test.dart b/mobile/test/shared/push/push_snapshot_test.dart index 730edf8e3fc..27c2c5ad217 100644 --- a/mobile/test/shared/push/push_snapshot_test.dart +++ b/mobile/test/shared/push/push_snapshot_test.dart @@ -3,7 +3,7 @@ import 'package:buzz/shared/push/push_subscription.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('push community snapshot carries explicit subscription authority', () { + test('push community snapshot carries flattened resolution policies', () { final subscription = buildDesiredBuzzPushSubscriptions( myPubkey: 'a' * 64, ).single; @@ -12,17 +12,12 @@ void main() { name: 'Team', relayUrl: 'https://relay.example.com', pubkey: 'a' * 64, - pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( - desired: [subscription], - ), + subscriptions: [subscription], ); final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); expect(decoded.toJson(), snapshot.toJson()); - expect( - decoded.pushSubscriptionState.authority, - BuzzPushLeaseSubscriptionAuthority.desired, - ); + expect(decoded.subscriptions, hasLength(1)); }); } diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 9c666a9e063..359a3496df3 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -43,27 +43,15 @@ void main() { ); }); - test('tracks relay lease and gateway grant generations independently', () { + test('persists only the relay-accepted lease generation', () { final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; - final state = - BuzzPushLeaseSubscriptionState.desired( - desired: [subscription], - ).withAccepted( - subscriptions: [subscription], - generation: 9, - grantGeneration: 3, - installationId: 'c' * 32, - ); + final state = BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 9); final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); expect(decoded.acceptedGeneration, 9); - expect(decoded.acceptedGrantGeneration, 3); - - final migrated = BuzzPushLeaseSubscriptionState.fromJson({ - ...state.toJson()..remove('acceptedGrantGeneration'), - }); - expect(migrated.acceptedGeneration, 9); - expect(migrated.acceptedGrantGeneration, 9); + expect(decoded.toJson(), state.toJson()); }); test('builds aligned self and unmuted channel subscriptions', () { diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index faef913bb9e..9dca8c82c37 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -107,9 +107,6 @@ run_unit_tests() { run_test_step "buzz-push-gateway tests" \ cargo test -p buzz-push-gateway -- --nocapture - run_test_step "buzz-push-gateway dev App Attest bypass tests" \ - cargo test -p buzz-push-gateway --features dev-app-attest-bypass -- --nocapture - # Kubernetes backend provider: pure decision layers driven by a fake # substrate, no cluster. Mirrors the nextest path in `just test-unit` — # the two lists must stay in step or the fallback silently covers less. diff --git a/scripts/test-ios-pbxproj-semantics.py b/scripts/test-ios-pbxproj-semantics.py deleted file mode 100755 index e2b2015904c..00000000000 --- a/scripts/test-ios-pbxproj-semantics.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the semantic iOS target/configuration table via macOS plutil.""" - -import json -import subprocess -import sys -from pathlib import Path - -PBXPROJ = Path("mobile/ios/Runner.xcodeproj/project.pbxproj") -# This asserts plutil-resolved pbxproj declarations, not object-key uniqueness or -# the final xcconfig-expanded bundle identity. -EXPECTED_ROWS = """\ -NotificationService Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService -NotificationService Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService -NotificationService Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService -Runner Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) -Runner Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) -Runner Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) -RunnerTests Debug Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests -RunnerTests Profile Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests -RunnerTests Release Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests -""".splitlines() - - -def parse_project(path: Path) -> dict: - result = subprocess.run( - ["plutil", "-convert", "json", "-o", "-", str(path)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - detail = result.stderr.strip() or "no error detail" - raise RuntimeError(f"plutil failed to parse {path}: {detail}") - if not result.stdout: - raise RuntimeError(f"plutil returned no JSON for {path}") - return json.loads(result.stdout) - - -def semantic_rows(project: dict) -> list[str]: - objects = project["objects"] - root = objects[project["rootObject"]] - rows = [] - for target_id in root["targets"]: - target = objects[target_id] - if target.get("isa") != "PBXNativeTarget": - continue - configuration_list = objects[target["buildConfigurationList"]] - for configuration_id in configuration_list["buildConfigurations"]: - configuration = objects[configuration_id] - settings = configuration.get("buildSettings", {}) - base_reference = configuration.get("baseConfigurationReference") - base_path = objects.get(base_reference, {}).get("path", "-") - rows.append( - " ".join( - [ - target.get("name", "-"), - configuration.get("name", "-"), - base_path, - settings.get("DEVELOPMENT_TEAM", "-"), - settings.get("CODE_SIGN_ENTITLEMENTS", "-"), - settings.get("PRODUCT_BUNDLE_IDENTIFIER", "-"), - ] - ) - ) - return sorted(rows) - - -def main() -> int: - path = Path(sys.argv[1]) if len(sys.argv) > 1 else PBXPROJ - try: - actual = semantic_rows(parse_project(path)) - except (KeyError, TypeError, json.JSONDecodeError, RuntimeError) as error: - print(f"FAIL: {error}", file=sys.stderr) - return 1 - - if actual != EXPECTED_ROWS: - print("FAIL: unexpected iOS target configuration semantics", file=sys.stderr) - print("expected:", *EXPECTED_ROWS, sep="\n", file=sys.stderr) - print("actual:", *actual, sep="\n", file=sys.stderr) - return 1 - - print("iOS target configuration semantics match") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index 279d31174e1..70637f52259 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -152,8 +152,6 @@ fi # ── Tracked build files: overrides are debug-only, release stays production ── debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig" release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig" -pbxproj="$repo_root/mobile/ios/Runner.xcodeproj/project.pbxproj" -runner_entitlements="$repo_root/mobile/ios/Runner/Runner.entitlements" gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" @@ -185,348 +183,6 @@ grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production display name" \ || fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz" -# These checks assert declarations in the two tracked xcconfigs only. They do -# not prove resolved build settings. The later gitignored includes -# (WorktreeOverrides.xcconfig and AppOverrides.xcconfig) can override these -# declarations and are explicitly outside this tracked-source assertion. The -# value check and declaration census are complementary: xcconfig is last-wins, -# while the census deliberately flags even a harmless duplicate declaration so -# a human reviews the changed declaration surface. -assert_xcconfig_value() { - # $1: file, $2: anchored value regex, $3: pass/failure description - if grep -qE "$2" "$1"; then - pass "$3" - else - fail "$3" - fi -} - -assert_xcconfig_declaration_count() { - # $1: file, $2: key, $3: expected count, $4: configuration label - local file="$1" key="$2" expected="$3" label="$4" count - count=$(grep -cE "^[[:space:]]*$key([[:space:]]*\[[^]]*\])*[[:space:]]*=" "$file" || true) - if [[ "$count" -eq "$expected" ]]; then - if [[ "$expected" -eq 0 ]]; then - pass "$label $key has no tracked declaration sites" - elif [[ "$expected" -eq 1 ]]; then - pass "$label $key has one tracked declaration site" - else - pass "$label $key has $count tracked declaration sites" - fi - elif [[ "$expected" -eq 0 ]]; then - fail "$label $key has $count tracked declaration sites; expected zero" - elif [[ "$expected" -eq 1 ]]; then - fail "$label $key has $count tracked declaration sites; expected exactly one" - else - fail "$label $key has $count tracked declaration sites; expected $expected" - fi -} - -assert_single_xcconfig_declaration() { - # $1: file, $2: key, $3: configuration label - assert_xcconfig_declaration_count "$1" "$2" 1 "$3" -} - -for config in "$debug_xcconfig" "$release_xcconfig"; do - assert_xcconfig_value "$config" \ - '^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \ - "$(basename "$config") derives the push App Group from the bundle" - assert_xcconfig_value "$config" \ - '^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \ - "$(basename "$config") derives the push Keychain group from the bundle" -done -assert_xcconfig_value "$debug_xcconfig" \ - '^BUZZ_IOS_PUSH_ENVIRONMENT = development$' \ - "Debug uses sandbox APNs" -assert_xcconfig_value "$debug_xcconfig" \ - '^BUZZ_APP_ATTEST_ENVIRONMENT = development$' \ - "Debug uses development App Attest" -assert_xcconfig_value "$release_xcconfig" \ - '^BUZZ_IOS_PUSH_ENVIRONMENT = production$' \ - "Release uses production APNs" -assert_xcconfig_value "$release_xcconfig" \ - '^BUZZ_APP_ATTEST_ENVIRONMENT = production$' \ - "Release uses production App Attest" -assert_xcconfig_value "$release_xcconfig" \ - '^CODE_SIGN_STYLE = Automatic$' \ - "Release code signing style is declared as automatic" -assert_xcconfig_value "$release_xcconfig" \ - '^CODE_SIGN_IDENTITY = iPhone Developer$' \ - "Release code signing identity is declared as iPhone Developer" - -for key in BUNDLE_IDENTIFIER; do - assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" - assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" -done - -for key in BUZZ_KEYCHAIN_ACCESS_GROUP BUZZ_IOS_PUSH_ENVIRONMENT BUZZ_APP_ATTEST_ENVIRONMENT BUZZ_APP_GROUP_IDENTIFIER; do - assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" - assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" -done - -for key in BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES DART_DEFINES; do - assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" - assert_xcconfig_declaration_count "$release_xcconfig" "$key" 0 "Release" -done - -for key in CODE_SIGN_STYLE CODE_SIGN_IDENTITY; do - assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" - assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" -done - -assert_xcconfig_declaration_count \ - "$debug_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Debug" -assert_xcconfig_declaration_count \ - "$release_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Release" -assert_single_xcconfig_declaration "$debug_xcconfig" APP_DISPLAY_NAME "Debug" -assert_single_xcconfig_declaration "$release_xcconfig" APP_DISPLAY_NAME "Release" - -# SWIFT_ACTIVE_COMPILATION_CONDITIONS is checked separately from the closed -# identity census above. This is a tracked-source assertion only; resolved Xcode -# build settings are intentionally outside this Linux-compatible test. -assert_xcconfig_value "$debug_xcconfig" \ - '^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) DEBUG$' \ - "Debug Swift compilation conditions inherit DEBUG" - -debug_swift_condition_count=$(grep -cE \ - '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ - "$debug_xcconfig" || true) -if [[ "$debug_swift_condition_count" -eq 1 ]]; then - pass "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has one tracked declaration site" -else - fail "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has $debug_swift_condition_count tracked declaration sites; expected exactly one" -fi - -release_swift_condition_count=$(grep -cE \ - '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ - "$release_xcconfig" || true) -if [[ "$release_swift_condition_count" -eq 0 ]]; then - pass "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has no tracked declaration sites" -else - fail "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has $release_swift_condition_count tracked declaration sites; expected zero" -fi - -grep -q 'aps-environment' "$runner_entitlements" \ - && pass "Runner entitlements always include APNs support" \ - || fail "Runner entitlements must include aps-environment" - -# Split the retired identifiers so the regression test does not match itself. -retired_bundle_id='com.buzz.buzz'"Mobile" -if git -C "$repo_root" grep -q -F "$retired_bundle_id"; then - fail "tracked files must not retain the retired iOS bundle identifier" -else - pass "tracked files do not retain the retired iOS bundle identifier" -fi -grep -q 'com.apple.developer.devicecheck.appattest-environment' "$runner_entitlements" \ - && pass "Runner uses the App Attest entitlement key accepted by Apple" \ - || fail "Runner must use com.apple.developer.devicecheck.appattest-environment" -retired_entitlement_key='com.apple.developer.app-attest.'"environment" -if grep -q "$retired_entitlement_key" "$runner_entitlements"; then - fail "Runner must not retain the invalid App Attest entitlement key" -else - pass "Runner omits the invalid App Attest entitlement key" -fi - -duplicate_pbx_object_ids=$(awk ' - # This bounded source-level smoke check recognizes the current two-tab - # object-key spellings. It is not a general OpenStep uniqueness check: - # measured exclusions include a comment before the key, a presentation - # comment spanning lines, and one-tab indentation (jb_b1/jb_b2/jb_b6). - # The macOS semantic check below owns their resolved build consequences. - function decomment(s, head, tailpart) { - while (match(s, /\/\*/)) { - head = substr(s, 1, RSTART - 1) - tailpart = substr(s, RSTART + 2) - if (!match(tailpart, /\*\//)) return head " " - s = head " " substr(tailpart, RSTART + RLENGTH) - } - return s - } - - /^\t\t/ { - line = decomment($0) - if (match(line, /^\t\t"?[[:alnum:]]+"?[[:space:]]*=/)) { - object_id = substr(line, RSTART, RLENGTH) - sub(/^\t\t"?/, "", object_id) - sub(/"?[[:space:]]*=$/, "", object_id) - if (++object_id_count[object_id] == 2) print object_id - } - } -' "$pbxproj" | sort) -if [[ -n "$duplicate_pbx_object_ids" ]]; then - fail "recognized iOS project object identifiers repeat: $(printf '%s\n' "$duplicate_pbx_object_ids" | paste -sd ' ' -)" -else - pass "recognized iOS project object identifiers do not repeat" -fi - -signing_map=$(awk ' - # PBX comments are separators, not text: strip them before parsing any - # object so a comment cannot hide a duplicate key from the ambiguity count. - function decomment(s, head, tailpart) { - while (match(s, /\/\*/)) { - head = substr(s, 1, RSTART - 1) - tailpart = substr(s, RSTART + 2) - if (!match(tailpart, /\*\//)) { return head " " } - s = head " " substr(tailpart, RSTART + RLENGTH) - } - return s - } - - FNR == 1 { pass++ } - - # Pass 1 indexes xcconfig paths and follows each PBXNativeTarget to its - # actual configuration-list object. Target names come from object fields, - # not presentation comments. - pass == 1 { - if (/isa[[:space:]]*=[[:space:]]*PBXFileReference/ && /\.xcconfig/) { - declaration = decomment($0) - if (match(declaration, /=[[:space:]]*\{[[:space:]]*isa[[:space:]]*=[[:space:]]*PBXFileReference[[:space:]]*;/)) { - declaration = substr(declaration, RSTART + RLENGTH) - } else { - declaration = "" - } - sub(/\}.*/, "", declaration) - xcconfig_path = "MISSING_PATH" - rest = declaration - path_matches = 0 - while (match(rest, /(^|;)[[:space:]]*path[[:space:]]*=[[:space:]]*[^;]+/)) { - candidate = substr(rest, RSTART, RLENGTH) - rest = substr(rest, RSTART + RLENGTH) - sub(/^;?[[:space:]]*path[[:space:]]*=[[:space:]]*/, "", candidate) - gsub(/"/, "", candidate) - sub(/[[:space:]]+$/, "", candidate) - path_matches++ - xcconfig_path = candidate - } - # More than one `path =` in one object means a decoy (a quoted value or - # an embedded comment) is shadowing the real key. Never guess which one - # the build uses: fail the row loudly instead. - if (path_matches > 1) xcconfig_path = "AMBIGUOUS_PATH" - xcconfig_paths[$1] = xcconfig_path - } - - if (/\/\* Begin PBXNativeTarget section \*\//) { - in_native_targets = 1 - next - } - if (/\/\* End PBXNativeTarget section \*\//) { - in_native_targets = 0 - next - } - if (!in_native_targets) next - - if (/^\t\t[^[:space:]]+ .* = \{$/) { - native_target_id = $1 - native_target_name = "" - native_target_list = "" - next - } - if (native_target_id != "" && /^\t\t\tname = /) { - native_target_name = $0 - sub(/^.*= */, "", native_target_name) - sub(/;.*/, "", native_target_name) - gsub(/"/, "", native_target_name) - next - } - if (native_target_id != "" && /^\t\t\tbuildConfigurationList = /) { - native_target_list = $3 - next - } - if (native_target_id != "" && /^\t\t\};/) { - if (native_target_list != "") { - if (native_target_name == "") native_target_name = "UNNAMED:" native_target_id - if (native_target_list in list_owners) { - list_owners[native_target_list] = "DUPLICATE:" list_owners[native_target_list] "+" native_target_name - } else { - list_owners[native_target_list] = native_target_name - } - } - native_target_id = "" - } - next - } - - # Pass 2 maps build-configuration object IDs through only those lists that - # real native targets own. PBXProject and other unowned lists are ignored. - pass == 2 { - if (/\/\* Begin XCConfigurationList section \*\//) { - in_configuration_lists = 1 - next - } - if (/\/\* End XCConfigurationList section \*\//) { - in_configuration_lists = 0 - next - } - if (!in_configuration_lists) next - - if (/^\t\t[^[:space:]]+ .* = \{$/) { - configuration_list_id = $1 - configuration_list_owner = configuration_list_id in list_owners ? list_owners[configuration_list_id] : "" - next - } - if (/buildConfigurations = \(/) { - in_list_configurations = 1 - next - } - if (in_list_configurations && /\);/) { - in_list_configurations = 0 - next - } - if (in_list_configurations && $1 ~ /^[[:alnum:]]+$/ && configuration_list_owner != "") { - if ($1 in targets) targets[$1] = "DUPLICATE:" targets[$1] "+" configuration_list_owner - else targets[$1] = configuration_list_owner - } - next - } - - # Pass 3 emits one row for each team-bearing build configuration. - !in_build_configuration && /\/\* (Debug|Release|Profile) \*\/ = \{/ { - in_build_configuration = 1 - configuration_id = $1 - configuration = $3 - base_configuration = "NONE" - team = "" - entitlements = "NONE" - depth = 0 - } - - in_build_configuration { - if (/baseConfigurationReference =/) { - base_configuration = $3 in xcconfig_paths ? xcconfig_paths[$3] : "UNRESOLVED:" $3 - } - if (/DEVELOPMENT_TEAM =/) { - team = $0 - sub(/^.*= */, "", team) - sub(/;.*/, "", team) - } - if (/CODE_SIGN_ENTITLEMENTS =/) { - entitlements = $0 - sub(/^.*= */, "", entitlements) - sub(/;.*/, "", entitlements) - } - - depth += gsub(/\{/, "{") - gsub(/\}/, "}") - if (depth == 0) { - if (team != "") { - target_name = configuration_id in targets ? targets[configuration_id] : "UNMAPPED:" configuration_id - print target_name, configuration, base_configuration, team, entitlements - } - in_build_configuration = 0 - } - } -' "$pbxproj" "$pbxproj" "$pbxproj" | sort) -expected_signing_map=$(printf '%s\n' \ - 'NotificationService Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ - 'NotificationService Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ - 'NotificationService Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ - 'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \ - 'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \ - 'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements') -if [[ "$signing_map" == "$expected_signing_map" ]]; then - pass "Runner and NotificationService signing settings match each build configuration" -else - fail "unexpected iOS signing map: $signing_map" -fi grep -q '$(APP_DISPLAY_NAME)' "$plist" \ && pass "Info.plist display name resolves from build settings" \ || fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)" From 5a5e767ddf8da3428f72c0d9f73314b508dd891c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 25 Aug 2026 11:02:40 -0700 Subject: [PATCH 18/27] docs(push): correct gateway profile scope Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- docs/push-gateway-deployment.md | 50 ++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index a9c4b019d48..e9a9ae16055 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -31,6 +31,18 @@ the server-owned APNs topic, certificate-backed connection pool, and environment. No client request or relay grant can supply or override an APNs topic. +This MVP has exactly one compiled-in application profile, +`buzz-ios-dogfood`. The chart value +`profiles.dogfood.appAttestAppId` is rendered as +`BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID`; the gateway rejects startup when it is +missing or empty. The exact `TEAMID.bundle-id` is environment-owned, +non-secret deployment configuration. The chart's production values file leaves +it empty deliberately so a production renderer must supply it from the GitOps +environment rather than baking a Block team identifier into this repository. +Supporting another application identity requires an explicit code, schema, +chart, credential, and deployment change; this gateway does not currently +select among multiple application profiles. + Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS` (default `10`, max `86400`) and `BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES` (default `10`, max `10000`). These are Buzz policy hypotheses, not Apple-published limits; tune under load while retaining a hard ceiling. ## Secret and key rotation rules @@ -113,22 +125,20 @@ capability—not a raw APNs token—into the encrypted relay lease. ## Internal dogfood evaluation and rollback -The MVP is ready to enable only when the canonical gateway has the dogfood -profile enabled with its server-owned App Attest app ID, APNs topic, production -certificate identity, and production APNs environment, and only the selected -internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS artifact -contains the native push bridge and Notification Service Extension, but the -client remains inactive until its current authenticated relay advertises a -fully valid NIP-11 `nip-pl` descriptor. The App Store gateway profile remains -configured but dormant. - -Local physical-device development may instead use the normal -`xyz.block.buzz.mobile` development identity with sandbox entitlements. Its -local gateway must enable only the closed App Store profile, -configured with that profile's server-owned App Attest application ID, APNs -topic, sandbox certificate, and sandbox environment. This is a development -integration proof, not dogfood release validation, and does not authorize -enabling the App Store profile on the canonical production deployment. +The MVP is ready to enable only when the canonical gateway's sole dogfood +profile is configured with its server-owned App Attest app ID, APNs topic, +production certificate identity, and production APNs environment, and only the +selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS +artifact contains the native push bridge and Notification Service Extension, +but the client remains inactive until its current authenticated relay +advertises a fully valid NIP-11 `nip-pl` descriptor. There is no App Store +gateway profile in this MVP. + +Physical-device validation must use an application whose App Attest identity +and APNs topic match the configured dogfood profile. The current gateway cannot +enroll `xyz.block.buzz.mobile` or another bundle identifier merely by changing +deployment values: adding another identity requires the explicit multi-profile +work described above. Dogfood end-to-end release validation starts after this feature reaches `main`: publish the next immutable `mobile-vX.Y.Z-rc.N` candidate from the exact current @@ -165,10 +175,10 @@ is designed. Rollback does not require deleting credentials or mutating existing leases. Set `BUZZ_PUSH_ENABLED=false` on the enabled relays to stop advertisement, lease -acceptance, matching, workers, and new gateway traffic; disable the dogfood -gateway profile if the gateway itself is unhealthy. Existing leases and gateway -authorities then expire naturally. Do not enable the App Store gateway profile -as part of this internal evaluation. +acceptance, matching, workers, and new gateway traffic. If the gateway itself +is unhealthy, disable the gateway deployment only after relay delivery is off. +Existing leases and gateway authorities then expire naturally. Adding an App +Store application profile is outside this internal evaluation. ## Helm production inputs From c26d2159d7e2e11d13668c87b30fd193df8bb2a3 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Tue, 14 Jul 2026 17:53:18 -0700 Subject: [PATCH 19/27] fix(push): honor current-generation revocation (BUZZ-SEC-010) Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/authority.rs | 74 +++++++++++++++++++++-- crates/buzz-push-gateway/src/postgres.rs | 4 +- docs/nips/NIP-PL.md | 2 +- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index ae70064afb9..6e5368b166d 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -133,11 +133,13 @@ pub trait AuthorityStore: Send + Sync { token_ciphertext: Vec, token_fingerprint: [u8; 32], ) -> Result<(), AuthorityError>; + /// Revoke an active delegation only when `expected_generation` is current, + /// retaining that generation as the replacement watermark. async fn revoke_delegation( &self, installation_id: Uuid, relay_pubkey: &str, - new_generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError>; async fn revoke_installation( &self, @@ -348,7 +350,7 @@ impl AuthorityStore for MemoryAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; let key = (id, relay.to_owned()); @@ -356,10 +358,9 @@ impl AuthorityStore for MemoryAuthorityStore { .delegations .get_mut(&key) .ok_or(AuthorityError::Rejected)?; - if generation <= old.generation { + if old.revoked || expected_generation != old.generation { return Err(AuthorityError::Rejected); } - old.generation = generation; old.revoked = true; Ok(()) } @@ -573,4 +574,69 @@ mod tests { .unwrap(); assert!(admitted(&store, &"33".repeat(32), request).await.is_err()); } + + #[tokio::test] + async fn delegation_revocation_requires_the_current_generation() { + let store = store().await; + + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 0) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 2) + .await, + Err(AuthorityError::Rejected) + ); + admitted(&store, &"44".repeat(32), Uuid::new_v4()) + .await + .expect("rejected revocations must leave generation 1 active"); + + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) + .await + .expect("the current generation can be revoked"); + assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4()) + .await + .is_err()); + + let replacement = |id, generation| Delegation { + id, + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation, + not_before: 900, + expires_at: 1_500, + revoked: false, + }; + assert_eq!( + store + .upsert_delegation(replacement(Uuid::from_u128(3), 1)) + .await, + Err(AuthorityError::Rejected) + ); + store + .upsert_delegation(replacement(Uuid::from_u128(4), 2)) + .await + .expect("only a strictly newer generation can reactivate the delegation"); + store + .authorize_delivery( + Uuid::from_u128(4), + &"11".repeat(32), + 1, + 2, + &"66".repeat(32), + Uuid::new_v4(), + 1_100, + 60, + 10, + 1_000, + ) + .await + .expect("generation 2 authority is active"); + } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 8492ac12c57..681ec4179cb 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -225,10 +225,10 @@ impl AuthorityStore for PostgresAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; - let result=sqlx::query("UPDATE push_gateway_delegations SET generation=$3,revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation<$3").bind(id).bind(relay).bind(generation).execute(&self.pool).await.map_err(db)?; + let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index bba5fb5c48a..10733c142df 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -362,7 +362,7 @@ Transcript domain `buzz.push.revoke-delegation.v1`; ordered object: {"v":1,"audience":"https://push.buzz.xyz/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} ``` -The generation identifies the current delegation generation. Success is `200 {"status":"revoked"}`. +The supplied generation MUST equal the current delegation generation. The gateway atomically compares it against the locked delegation row, marks that delegation revoked, and retains the same generation as its watermark. A stale or future generation is rejected without changing the delegation. Any later delegation for the same `(installation_handle, relay_pubkey)` MUST use a strictly greater generation. Success is `200 {"status":"revoked"}`. `POST /v1/installations/revoke` request: From 12c66ea62017b9d034839f80a70f5a760156b5e9 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 25 Aug 2026 20:16:37 -0700 Subject: [PATCH 20/27] fix(mobile): retry push bootstrap safely Signed-off-by: Tom Brow --- mobile/lib/shared/push/push_bootstrap.dart | 104 +++++++++++++++--- .../test/shared/push/push_bootstrap_test.dart | 85 ++++++++++++++ 2 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 mobile/test/shared/push/push_bootstrap_test.dart diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index ce4efb29671..63ed758eec3 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -14,6 +14,53 @@ import 'push_bridge.dart'; import 'push_relay_capability_provider.dart'; import 'push_subscription.dart'; +const _pushBootstrapRetryDelay = Duration(seconds: 5); + +@visibleForTesting +class BuzzPushAttemptGate { + BuzzPushAttemptGate({this.retryDelay = _pushBootstrapRetryDelay}); + + final Duration retryDelay; + String? _attempt; + Timer? _retryTimer; + + bool tryBegin(String attempt) { + if (_attempt == attempt) return false; + _retryTimer?.cancel(); + _retryTimer = null; + _attempt = attempt; + return true; + } + + void failed(String attempt, {required VoidCallback retry}) { + if (_attempt != attempt) return; + _attempt = null; + _retryTimer?.cancel(); + _retryTimer = Timer(retryDelay, () { + _retryTimer = null; + if (_attempt == null) retry(); + }); + } + + void dispose() => _retryTimer?.cancel(); +} + +@visibleForTesting +String buzzPushPublicationAttemptKey({ + required String communityId, + required String relayBaseUrl, + required String token, + required BuzzPushLeaseDescriptor descriptor, + required List subscriptions, +}) => [ + communityId, + relayBaseUrl, + token, + descriptor.executorKeyId, + descriptor.executorPubkey, + buzzPushSubscriptionsFingerprint(subscriptions), +].join('|'); + /// Starts the push lifecycle only after authenticated relay connectivity and a /// push-capable NIP-11 descriptor are both present. class BuzzPushBootstrap extends HookConsumerWidget { @@ -24,14 +71,24 @@ class BuzzPushBootstrap extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); - final registrationAttempt = useRef(null); - final publicationAttempt = useRef(null); + final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); + final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); + final registrationRetry = useState(0); + final publicationRetry = useState(0); final session = ref.watch(relaySessionProvider); final config = ref.watch(relayConfigProvider); final community = ref.watch(activeCommunityProvider).value; final memberPubkey = ref.watch(myPubkeyProvider); final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + useEffect( + () => () { + registrationAttempt.dispose(); + publicationAttempt.dispose(); + }, + const [], + ); + useEffect( () { if (!_ready(session, config, community, memberPubkey) || @@ -39,8 +96,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { return null; } final attempt = '${community!.id}|${config.baseUrl}'; - if (registrationAttempt.value == attempt) return null; - registrationAttempt.value = attempt; + if (!registrationAttempt.tryBegin(attempt)) return null; unawaited(() async { try { await startBuzzPushRegistrationIfCapable( @@ -48,14 +104,26 @@ class BuzzPushBootstrap extends HookConsumerWidget { startRegistration: startBuzzPushRegistration, ); } catch (error, stack) { - registrationAttempt.value = null; + registrationAttempt.failed( + attempt, + retry: () { + if (context.mounted) registrationRetry.value += 1; + }, + ); debugPrint('Push registration bootstrap failed: $error'); debugPrintStack(stackTrace: stack); } }()); return null; }, - [session.status, config.baseUrl, community?.id, memberPubkey, descriptor], + [ + session.status, + config.baseUrl, + community?.id, + memberPubkey, + descriptor, + registrationRetry.value, + ], ); final token = apnsDeviceToken.value; @@ -68,14 +136,14 @@ class BuzzPushBootstrap extends HookConsumerWidget { } final state = community!.pushSubscriptionState; if (state.desired.isEmpty) return null; - final attempt = [ - community.id, - config.baseUrl, - token, - buzzPushSubscriptionsFingerprint(state.desired), - ].join('|'); - if (publicationAttempt.value == attempt) return null; - publicationAttempt.value = attempt; + final attempt = buzzPushPublicationAttemptKey( + communityId: community.id, + relayBaseUrl: config.baseUrl, + token: token, + descriptor: descriptor, + subscriptions: state.desired, + ); + if (!publicationAttempt.tryBegin(attempt)) return null; final relay = SignedEventRelay( session: ref.read(relaySessionProvider.notifier), nsec: config.nsec!, @@ -85,7 +153,12 @@ class BuzzPushBootstrap extends HookConsumerWidget { Object error, StackTrace stack, ) { - publicationAttempt.value = null; + publicationAttempt.failed( + attempt, + retry: () { + if (context.mounted) publicationRetry.value += 1; + }, + ); debugPrint('Push lease bootstrap failed: $error'); debugPrintStack(stackTrace: stack); }), @@ -100,6 +173,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { memberPubkey, descriptor, token, + publicationRetry.value, ], ); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart new file mode 100644 index 00000000000..122e839b61a --- /dev/null +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -0,0 +1,85 @@ +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('failed bootstrap attempt becomes retryable after the delay', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('attempt'), isTrue); + gate.failed('attempt', retry: () => retries += 1); + await Future.delayed(Duration.zero); + + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + + test('a new attempt cancels an obsolete scheduled retry', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('old'), isTrue); + gate.failed('old', retry: () => retries += 1); + expect(gate.tryBegin('new'), isTrue); + await Future.delayed(Duration.zero); + + expect(retries, 0); + expect(gate.tryBegin('new'), isFalse); + }); + + test('publication attempt changes when the relay executor rotates', () { + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), + notificationClass: 'default', + ); + final original = buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + subscriptions: [subscription], + ); + + expect( + buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v2', pubkey: _hex('b')), + subscriptions: [subscription], + ), + isNot(original), + ); + expect( + buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('c')), + subscriptions: [subscription], + ), + isNot(original), + ); + }); +} + +BuzzPushLeaseDescriptor _descriptor({ + required String keyId, + required String pubkey, +}) => BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: keyId, + executorPubkey: pubkey, + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); + +String _hex(String character) => List.filled(64, character).join(); From 8e5ece0bdf27823232d86e42989381992ca792c2 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 25 Aug 2026 20:29:56 -0700 Subject: [PATCH 21/27] fix(push): renew gateway authority safely Signed-off-by: Tom Brow --- .../0003_challenge_issuance_quota.sql | 4 + crates/buzz-push-gateway/src/authority.rs | 221 ++++++++++++++++-- crates/buzz-push-gateway/src/http.rs | 9 +- crates/buzz-push-gateway/src/postgres.rs | 200 +++++++++++++++- docs/nips/NIP-PL.md | 6 +- .../BuzzDevPushEnrollmentDriver.swift | 20 +- .../BuzzDevPushEnrollmentDriverTests.swift | 26 +-- mobile/lib/shared/push/push_bootstrap.dart | 52 ++++- .../test/shared/push/push_bootstrap_test.dart | 13 ++ 9 files changed, 491 insertions(+), 60 deletions(-) create mode 100644 crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql diff --git a/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql b/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql new file mode 100644 index 00000000000..cc8222f6c6d --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql @@ -0,0 +1,4 @@ +-- The unauthenticated challenge route applies a deployment-global rolling +-- issuance quota. Keep its count query bounded as challenge volume grows. +CREATE INDEX push_gateway_challenges_created_at + ON push_gateway_challenges (created_at); diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 6e5368b166d..ee172d5114d 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -15,9 +15,16 @@ use uuid::Uuid; pub struct Challenge { pub id: Uuid, pub value: [u8; 32], + pub created_at: i64, pub expires_at: i64, } +/// Challenge issuance is intentionally bounded inside the durable authority +/// store so the public unauthenticated route cannot amplify database writes +/// across gateway replicas. +pub(crate) const CHALLENGE_QUOTA_WINDOW_SECONDS: i64 = 60; +pub(crate) const CHALLENGE_QUOTA_MAX_REQUESTS: usize = 600; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewInstallation { pub id: Uuid, @@ -96,6 +103,8 @@ pub enum DeliveryDisposition { pub enum AuthorityError { #[error("authority state rejected the request")] Rejected, + #[error("authority request rate exceeded")] + RateLimited, #[error("authority store unavailable")] Unavailable, } @@ -116,6 +125,7 @@ pub trait AuthorityStore: Send + Sync { async fn create_installation( &self, installation: NewInstallation, + now: i64, ) -> Result<(), AuthorityError>; async fn installation(&self, id: Uuid, now: i64) -> Result; async fn advance_assertion_counter( @@ -204,6 +214,17 @@ impl AuthorityStore for MemoryAuthorityStore { async fn put_challenge(&self, challenge: Challenge) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let window_start = challenge + .created_at + .saturating_sub(CHALLENGE_QUOTA_WINDOW_SECONDS); + if s.challenges + .values() + .filter(|existing| existing.created_at >= window_start) + .count() + >= CHALLENGE_QUOTA_MAX_REQUESTS + { + return Err(AuthorityError::RateLimited); + } if s.challenges.insert(challenge.id, challenge).is_some() { return Err(AuthorityError::Rejected); } @@ -224,13 +245,43 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(()) } - async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + async fn create_installation( + &self, + n: NewInstallation, + now: i64, + ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; let token_key = (n.profile, n.token_fingerprint); - if s.installations.contains_key(&n.id) || s.token_owners.contains_key(&token_key) { - // Token possession alone never supersedes a live installation. + if s.installations.contains_key(&n.id) { return Err(AuthorityError::Rejected); } + let replaced = s + .installations + .values() + .filter(|installation| { + installation.app_attest_key_id == n.app_attest_key_id + || (installation.profile == n.profile + && installation.token_fingerprint == n.token_fingerprint) + }) + .map(|installation| installation.id) + .collect::>(); + if replaced.iter().any(|id| { + s.installations + .get(id) + .is_some_and(|installation| !installation.revoked && installation.expires_at >= now) + }) { + // App identity and token possession never supersede a live installation. + return Err(AuthorityError::Rejected); + } + for id in replaced { + if let Some(old) = s.installations.remove(&id) { + s.token_owners.remove(&(old.profile, old.token_fingerprint)); + } + s.delegations + .retain(|(installation_id, _), _| *installation_id != id); + s.delegation_ids + .retain(|_, (installation_id, _)| *installation_id != id); + } s.token_owners.insert(token_key, n.id); s.installations.insert( n.id, @@ -283,15 +334,14 @@ impl AuthorityStore for MemoryAuthorityStore { async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; - let i = s + let installation = s .installations .get(&d.installation_id) .ok_or(AuthorityError::Rejected)?; - if i.revoked - || i.endpoint_epoch != d.endpoint_epoch + if installation.revoked + || installation.endpoint_epoch != d.endpoint_epoch || d.generation < 1 || d.not_before >= d.expires_at - || d.expires_at > i.expires_at { return Err(AuthorityError::Rejected); } @@ -303,6 +353,11 @@ impl AuthorityStore for MemoryAuthorityStore { { return Err(AuthorityError::Rejected); } + let installation = s + .installations + .get_mut(&d.installation_id) + .ok_or(AuthorityError::Rejected)?; + installation.expires_at = installation.expires_at.max(d.expires_at); s.delegation_ids.insert(d.id, key.clone()); s.delegations.insert(key, d); Ok(()) @@ -515,17 +570,20 @@ mod tests { async fn store() -> MemoryAuthorityStore { let store = MemoryAuthorityStore::default(); store - .create_installation(NewInstallation { - id: Uuid::from_u128(1), - app_attest_key_id: vec![1], - app_attest_public_key: vec![2; 33], - assertion_counter: 0, - profile: AppProfile::BuzzIosDogfood, - token_ciphertext: vec![3], - token_fingerprint: [4; 32], - endpoint_epoch: 1, - expires_at: 2_000, - }) + .create_installation( + NewInstallation { + id: Uuid::from_u128(1), + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 2_000, + }, + 1_000, + ) .await .unwrap(); store @@ -544,6 +602,133 @@ mod tests { store } + #[tokio::test] + async fn challenge_issuance_is_bounded_per_window() { + let store = MemoryAuthorityStore::default(); + for offset in 0..CHALLENGE_QUOTA_MAX_REQUESTS { + store + .put_challenge(Challenge { + id: Uuid::from_u128(offset as u128 + 1), + value: [offset as u8; 32], + created_at: 1_000, + expires_at: 1_300, + }) + .await + .expect("requests within the quota are admitted"); + } + assert_eq!( + store + .put_challenge(Challenge { + id: Uuid::new_v4(), + value: [0; 32], + created_at: 1_000, + expires_at: 1_300, + }) + .await, + Err(AuthorityError::RateLimited) + ); + store + .put_challenge(Challenge { + id: Uuid::new_v4(), + value: [0; 32], + created_at: 1_061, + expires_at: 1_361, + }) + .await + .expect("quota reopens after the rolling window"); + } + + #[tokio::test] + async fn authenticated_delegation_renews_installation_lifetime() { + let store = store().await; + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(3), + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation: 2, + not_before: 1_900, + expires_at: 2_500, + revoked: false, + }) + .await + .expect("new delegation renews its installation"); + assert_eq!( + store + .installation(Uuid::from_u128(1), 2_400) + .await + .expect("renewed installation remains live") + .expires_at, + 2_500 + ); + + assert_eq!( + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(4), + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation: 2, + not_before: 2_000, + expires_at: 3_000, + revoked: false, + }) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store.installation(Uuid::from_u128(1), 2_600).await, + Err(AuthorityError::Rejected), + "a rejected delegation must not extend installation authority" + ); + } + + #[tokio::test] + async fn expired_installation_can_be_replaced_but_live_installation_cannot() { + let store = store().await; + let replacement = |id| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![5; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![6], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 3_000, + }; + + assert_eq!( + store + .create_installation(replacement(Uuid::from_u128(5)), 1_999) + .await, + Err(AuthorityError::Rejected) + ); + store + .create_installation(replacement(Uuid::from_u128(5)), 2_001) + .await + .expect("expired token and App Attest ownership can be replaced"); + assert!(store.installation(Uuid::from_u128(1), 2_001).await.is_err()); + assert!(store.installation(Uuid::from_u128(5), 2_001).await.is_ok()); + assert!(store + .authorize_delivery( + Uuid::from_u128(2), + &"11".repeat(32), + 1, + 1, + &"77".repeat(32), + Uuid::new_v4(), + 2_100, + 60, + 10, + 2_001, + ) + .await + .is_err()); + } + #[tokio::test] async fn retry_releases_request_id_but_burns_auth_event() { let store = store().await; diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index b300dd8637f..886d9a4c194 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -88,6 +88,7 @@ fn decode_challenge(value: &str) -> Option<[u8; 32]> { fn authority_error(e: AuthorityError) -> Response { match e { AuthorityError::Rejected => error(StatusCode::NOT_FOUND, "not_authorized"), + AuthorityError::RateLimited => error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"), AuthorityError::Unavailable => { error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable") } @@ -130,6 +131,7 @@ async fn challenge(State(s): State, body: Bytes) -> Response { let c = Challenge { id: uuid::Uuid::new_v4(), value, + created_at: now, expires_at, }; if let Err(e) = s.authority.put_challenge(c.clone()).await { @@ -229,7 +231,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { endpoint_epoch: 1, expires_at: r.expires_at, }; - if let Err(e) = s.authority.create_installation(n).await { + if let Err(e) = s.authority.create_installation(n, now).await { return authority_error(e); } ( @@ -632,6 +634,11 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> crate::metrics::record_delivery_error("invalid_grant"); return error(StatusCode::NOT_FOUND, "invalid_grant"); } + Err(AuthorityError::RateLimited) => { + crate::metrics::record_admission(crate::metrics::Admission::Rejected); + crate::metrics::record_delivery_error("rate_limited"); + return error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"); + } Err(AuthorityError::Unavailable) => { crate::metrics::record_admission(crate::metrics::Admission::Unavailable); crate::metrics::record_delivery_error("temporarily_unavailable"); diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 681ec4179cb..19ba7daef0f 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -118,16 +118,35 @@ impl AuthorityStore for PostgresAuthorityStore { async fn put_challenge(&self, c: Challenge) -> Result<(), AuthorityError> { use sha2::{Digest, Sha256}; + const CHALLENGE_ISSUANCE_LOCK: i64 = 0x4255_5a5a_504c_0001; + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(CHALLENGE_ISSUANCE_LOCK) + .execute(&mut *tx) + .await + .map_err(db)?; + let window_start = at(c.created_at.saturating_sub(CHALLENGE_QUOTA_WINDOW_SECONDS))?; + let issued: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_gateway_challenges WHERE created_at >= $1", + ) + .bind(window_start) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + if issued >= CHALLENGE_QUOTA_MAX_REQUESTS as i64 { + return Err(AuthorityError::RateLimited); + } sqlx::query( - "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at) VALUES($1,$2,$3)", + "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at,created_at) VALUES($1,$2,$3,$4)", ) .bind(c.id) .bind(Sha256::digest(c.value).to_vec()) .bind(at(c.expires_at)?) - .execute(&self.pool) + .bind(at(c.created_at)?) + .execute(&mut *tx) .await .map_err(db)?; - Ok(()) + tx.commit().await.map_err(db) } async fn consume_challenge( &self, @@ -143,12 +162,55 @@ impl AuthorityStore for PostgresAuthorityStore { } Ok(()) } - async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + async fn create_installation( + &self, + n: NewInstallation, + now: i64, + ) -> Result<(), AuthorityError> { + let mut tx = self.pool.begin().await.map_err(db)?; + let now_at = at(now)?; + let existing = sqlx::query( + "SELECT id,expires_at,revoked_at FROM push_gateway_installations WHERE app_attest_key_id=$1 OR (app_profile=$2 AND token_fingerprint=$3) FOR UPDATE", + ) + .bind(&n.app_attest_key_id) + .bind(n.profile.as_str()) + .bind(n.token_fingerprint.to_vec()) + .fetch_all(&mut *tx) + .await + .map_err(db)?; + if existing.iter().any(|row| { + let revoked = row.try_get::>, _>("revoked_at"); + let expires = row.try_get::, _>("expires_at"); + match (revoked, expires) { + (Ok(None), Ok(expires_at)) => expires_at >= now_at, + (Ok(Some(_)), Ok(_)) => false, + _ => true, + } + }) { + return Err(AuthorityError::Rejected); + } + let replaced = existing + .iter() + .map(|row| row.try_get::("id").map_err(db)) + .collect::, _>>()?; + if !replaced.is_empty() { + sqlx::query("DELETE FROM push_gateway_delegations WHERE installation_id = ANY($1)") + .bind(&replaced) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM push_gateway_installations WHERE id = ANY($1)") + .bind(&replaced) + .execute(&mut *tx) + .await + .map_err(db)?; + } let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING") - .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&self.pool).await.map_err(db)?; + .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&mut *tx).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } + tx.commit().await.map_err(db)?; Ok(()) } async fn installation(&self, id: Uuid, now: i64) -> Result { @@ -191,7 +253,6 @@ impl AuthorityStore for PostgresAuthorityStore { .map_err(db)? .is_some() || i.try_get::("endpoint_epoch").map_err(db)? != d.endpoint_epoch - || at(d.expires_at)? > i.try_get::, _>("expires_at").map_err(db)? { return Err(AuthorityError::Rejected); } @@ -201,6 +262,12 @@ impl AuthorityStore for PostgresAuthorityStore { if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } + sqlx::query("UPDATE push_gateway_installations SET expires_at=GREATEST(expires_at,$2),updated_at=now() WHERE id=$1") + .bind(d.installation_id) + .bind(at(d.expires_at)?) + .execute(&mut *tx) + .await + .map_err(db)?; tx.commit().await.map_err(db)?; Ok(()) } @@ -623,7 +690,13 @@ mod tests { // Real DDL from migration 0010 (minus the _operator_global_tables audit // insert, which lives outside the isolated schema). sqlx::raw_sql( - "CREATE TABLE push_gateway_installations ( + "CREATE TABLE push_gateway_challenges ( + id UUID PRIMARY KEY, + challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE push_gateway_installations ( id UUID PRIMARY KEY, app_attest_key_id BYTEA NOT NULL UNIQUE, app_attest_public_key BYTEA NOT NULL, @@ -677,6 +750,53 @@ mod tests { const RELAY_HEX: &str = "11111111111111111111111111111111111111111111111111111111111111aa"; const DELEGATION_ID: u128 = 2; + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn concurrent_challenge_issuance_obeys_deployment_global_ceiling() { + let (pool, schema) = full_schema(4).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + for offset in 0..CHALLENGE_QUOTA_MAX_REQUESTS - 1 { + sqlx::query( + "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at,created_at) VALUES($1,$2,$3,$4)", + ) + .bind(Uuid::from_u128(offset as u128 + 1)) + .bind(vec![offset as u8; 32]) + .bind(at(now + 300).expect("valid expiry")) + .bind(at(now).expect("valid creation time")) + .execute(&pool) + .await + .expect("seed challenge quota"); + } + let challenge = |id| Challenge { + id, + value: [0; 32], + created_at: now, + expires_at: now + 300, + }; + let (first, second) = tokio::join!( + store.put_challenge(challenge(Uuid::new_v4())), + store.put_challenge(challenge(Uuid::new_v4())), + ); + assert_eq!( + [first.is_ok(), second.is_ok()] + .into_iter() + .filter(|admitted| *admitted) + .count(), + 1, + "the cross-connection lock admits only the final quota slot" + ); + assert!( + [first, second] + .into_iter() + .any(|result| result == Err(AuthorityError::RateLimited)), + "the quota loser receives an explicit rate-limit result" + ); + + pool.close().await; + drop_schema(&schema).await; + } + // One installation + one live delegation that admits at now=1_000. async fn install_authority(pool: &PgPool) { let now = Utc::now(); @@ -707,6 +827,72 @@ mod tests { .expect("insert delegation"); } + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn delegation_renews_and_expired_enrollment_recovers_token_ownership() { + let (pool, schema) = full_schema(2).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let installation = |id, expires_at| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at, + }; + + store + .create_installation(installation(Uuid::from_u128(1), now + 100), now) + .await + .expect("create initial installation"); + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(2), + installation_id: Uuid::from_u128(1), + relay_pubkey: RELAY_HEX.to_owned(), + endpoint_epoch: 1, + generation: 1, + not_before: now, + expires_at: now + 1_000, + revoked: false, + }) + .await + .expect("authenticated delegation renews installation"); + assert!(store + .installation(Uuid::from_u128(1), now + 500) + .await + .is_ok()); + assert_eq!( + store + .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 999,) + .await, + Err(AuthorityError::Rejected) + ); + store + .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 1_001) + .await + .expect("expired ownership can be replaced"); + let old_delegations: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_gateway_delegations WHERE installation_id=$1", + ) + .bind(Uuid::from_u128(1)) + .fetch_one(&pool) + .await + .expect("count replaced delegations"); + assert_eq!(old_delegations, 0); + assert!(store + .installation(Uuid::from_u128(3), now + 1_001) + .await + .is_ok()); + + pool.close().await; + drop_schema(&schema).await; + } + fn admit<'a>( store: &'a PostgresAuthorityStore, event_hex: &'a str, diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 10733c142df..c6dc160e3c8 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -290,7 +290,7 @@ Success `200`: {"challenge_id":"","challenge":"","expires_at":} ``` -The challenge is single-use. Invalid input is `400 invalid_request`; storage/randomness failure is `503 temporarily_unavailable`. +The challenge is single-use. Invalid input is `400 invalid_request`; deployment-global challenge issuance limits return `429 rate_limited`; storage/randomness failure is `503 temporarily_unavailable`. ### Installation enrollment @@ -314,7 +314,7 @@ The gateway verifies Apple's attestation chain, configured application identifie {"installation_handle":"","endpoint_epoch":1,"expires_at":} ``` -Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or duplicate key/token is `404 not_authorized`. +Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or a key/token owned by a live installation is `404 not_authorized`. A fresh verified enrollment may replace expired or revoked ownership so an app that missed its renewal window can recover. ### Relay delegation and capability issuance @@ -324,7 +324,7 @@ Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge o {"v":1,"challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"<64-lowercase-hex>","not_before":,"expires_at":,"assertion":""} ``` -`not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= min(now + configured_max_grant_lifetime, installation.expires_at)`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. Transcript domain `buzz.push.delegate.v1`; ordered object: +`not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= now + configured_max_grant_lifetime`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. A successful delegation atomically extends the authenticated installation lifetime through at least the delegation's `expires_at`, allowing renewal without duplicate token enrollment. Transcript domain `buzz.push.delegate.v1`; ordered object: ```json {"v":1,"audience":"https://push.buzz.xyz/v1/delegations","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"","not_before":,"expires_at":} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index be6ce3b97b5..c297153801a 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -434,18 +434,24 @@ public final class BuzzDevPushEnrollmentDriver { // A previously attested installation can delegate independently to a new // relay key, or issue a higher-generation grant for the same relay, - // without attempting duplicate APNs-token enrollment. + // without attempting duplicate APNs-token enrollment. An installation in + // its final five minutes is renewed by the authenticated delegation. let reusableInstallation = storedRecords.first { record in guard record.appProfile == Self.appProfile, record.endpointHash == endpointHash, record.endpointEpoch == Self.endpointEpoch, - record.expiresAt > nowSeconds + 300, + record.expiresAt > nowSeconds, let handle = record.gatewayInstallationHandle, let uuid = UUID(uuidString: handle) else { return false } return handle == uuid.uuidString.lowercased() } + let (renewedExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) + guard !expiresOverflow else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + let installation: UUID let expiresAt: Int64 if let reusableInstallation, @@ -453,13 +459,11 @@ public final class BuzzDevPushEnrollmentDriver { let existing = UUID(uuidString: handle) { installation = existing - expiresAt = reusableInstallation.expiresAt + expiresAt = reusableInstallation.expiresAt > nowSeconds + 300 + ? reusableInstallation.expiresAt + : renewedExpiration } else { - let (newExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) - guard !expiresOverflow else { - throw BuzzDevPushEnrollmentError.invalidGatewayURL - } - expiresAt = newExpiration + expiresAt = renewedExpiration let enrollmentChallenge = try await challenge() let preparedAttestation = try await appAttest.prepareAttestation() let enrollmentClientData = try BuzzPushTranscript.enroll( diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 5e19fa76619..0b42b48d4ba 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -612,11 +612,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.saved.count, 2) } - func testExpiredGrantReenrollsButReusesRelayLeaseAddress() async throws { + func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -634,7 +635,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { return Data(repeating: 0xFF, count: 16) } ) - var challengeCount = 0 URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://relay.example/"): @@ -647,29 +647,23 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ] ) case ("POST", "http://push.example/v1/installations/challenges"): - challengeCount += 1 return Self.response( request, status: 200, json: [ - "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge_id": Self.firstChallengeId, "challenge": Self.challenge, "expires_at": Self.now + 300, ] ) case ("POST", "http://push.example/v1/installations"): - return Self.response( - request, - status: 201, - json: [ - "installation_handle": Self.installationHandle, - "endpoint_epoch": 1, - "expires_at": Self.expiresAt, - ] - ) + XCTFail("An expiring installation must renew through authenticated delegation") + return Self.response(request, status: 500, json: [:]) case ("POST", "http://push.example/v1/delegations"): let body = try Self.body(request) - XCTAssertEqual(body["generation"] as? Int, 1) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["generation"] as? Int, 8) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) return Self.response( request, status: 201, @@ -687,7 +681,9 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) XCTAssertEqual(record.installationId, Self.installationId) - XCTAssertEqual(record.generation, 1) + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.generation, 8) + XCTAssertEqual(record.expiresAt, Self.expiresAt) XCTAssertEqual(record.endpointGrant, "refreshed-grant") } diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 63ed758eec3..bbd5bd73478 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -42,6 +42,21 @@ class BuzzPushAttemptGate { }); } + void retryAfter( + String attempt, { + required Duration delay, + required VoidCallback retry, + }) { + if (_attempt != attempt) return; + _retryTimer?.cancel(); + _retryTimer = Timer(delay, () { + _retryTimer = null; + if (_attempt != attempt) return; + _attempt = null; + retry(); + }); + } + void dispose() => _retryTimer?.cancel(); } @@ -148,11 +163,31 @@ class BuzzPushBootstrap extends HookConsumerWidget { session: ref.read(relaySessionProvider.notifier), nsec: config.nsec!, ); - unawaited( - _publish(ref, config, community, memberPubkey!, relay).catchError(( - Object error, - StackTrace stack, - ) { + unawaited(() async { + try { + final grant = await _publish( + ref, + config, + community, + memberPubkey!, + relay, + ); + final renewInMilliseconds = + grant.expiresAt * 1000 - + DateTime.now().millisecondsSinceEpoch - + const Duration(minutes: 5).inMilliseconds; + publicationAttempt.retryAfter( + attempt, + delay: Duration( + milliseconds: renewInMilliseconds > 1000 + ? renewInMilliseconds + : 1000, + ), + retry: () { + if (context.mounted) publicationRetry.value += 1; + }, + ); + } catch (error, stack) { publicationAttempt.failed( attempt, retry: () { @@ -161,8 +196,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { ); debugPrint('Push lease bootstrap failed: $error'); debugPrintStack(stackTrace: stack); - }), - ); + } + }()); return null; }, [ @@ -193,7 +228,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { memberPubkey != null && memberPubkey.isNotEmpty; - static Future _publish( + static Future _publish( WidgetRef ref, RelayConfig config, Community community, @@ -230,5 +265,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { subscriptions: desired, generation: leaseGeneration, ); + return grant; } } diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 122e839b61a..ca860441ae4 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -31,6 +31,19 @@ void main() { expect(gate.tryBegin('new'), isFalse); }); + test('successful bootstrap becomes retryable at renewal time', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('attempt'), isTrue); + gate.retryAfter('attempt', delay: Duration.zero, retry: () => retries += 1); + await Future.delayed(Duration.zero); + + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + test('publication attempt changes when the relay executor rotates', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), From 956c1d099a329df4fa1d6a7205c9ffd158ecd7b2 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 25 Aug 2026 20:34:18 -0700 Subject: [PATCH 22/27] fix(push): preserve applied migration checksum Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../migrations/0002_application_profiles.sql | 2 +- .../migrations/0004_dogfood_only_profile.sql | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql diff --git a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql index b661f9c3de2..45be402dc07 100644 --- a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql +++ b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql @@ -15,4 +15,4 @@ ALTER TABLE push_gateway_installations DROP CONSTRAINT push_gateway_installations_app_profile_check; ALTER TABLE push_gateway_installations ADD CONSTRAINT push_gateway_installations_app_profile_check - CHECK (app_profile = 'buzz-ios-dogfood'); + CHECK (app_profile IN ('buzz-ios-dogfood', 'buzz-ios-app-store')); diff --git a/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql b/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql new file mode 100644 index 00000000000..2274219d6ef --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql @@ -0,0 +1,16 @@ +-- The internal MVP now exposes only the dogfood application profile. Retire +-- dormant App Store authority before narrowing the server-owned registry. +DELETE FROM push_gateway_delegations +WHERE installation_id IN ( + SELECT id FROM push_gateway_installations + WHERE app_profile = 'buzz-ios-app-store' +); + +DELETE FROM push_gateway_installations +WHERE app_profile = 'buzz-ios-app-store'; + +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_check; +ALTER TABLE push_gateway_installations + ADD CONSTRAINT push_gateway_installations_app_profile_check + CHECK (app_profile = 'buzz-ios-dogfood'); From 7eb3a650bed2b6980e3c999e4a3208162eb223a4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 25 Aug 2026 20:42:44 -0700 Subject: [PATCH 23/27] fix(push): admit valid App Attest enrollment envelopes Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/app_attest.rs | 3 +- crates/buzz-push-gateway/src/http.rs | 112 ++++++++++++++++++++- crates/buzz-push-gateway/src/model.rs | 7 ++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index b8f86a35e39..df655e23d88 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -6,7 +6,6 @@ use byteorder::{BigEndian, ByteOrder}; use sha2::{Digest, Sha256}; use thiserror::Error; -const MAX_ATTESTATION_BYTES: usize = 16 * 1024; const MAX_ASSERTION_BYTES: usize = 1024; const APPLE_APP_ATTEST_ROOT_PEM_SHA256: [u8; 32] = [ 0xc7, 0x78, 0xd0, 0x9a, 0xc3, 0x41, 0xf7, 0xfd, 0x9f, 0x8f, 0x3b, 0x19, 0xe2, 0xb8, 0x15, 0xaf, @@ -57,7 +56,7 @@ impl AppAttestVerifier { let cbor = STANDARD .decode(attestation_b64) .map_err(|_| AppAttestError::Invalid)?; - if cbor.is_empty() || cbor.len() > MAX_ATTESTATION_BYTES { + if cbor.is_empty() || cbor.len() > crate::model::MAX_APP_ATTESTATION_BYTES { return Err(AppAttestError::Invalid); } let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?; diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 886d9a4c194..84ad2a42a90 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -756,16 +756,21 @@ pub fn router_with_metrics( state: AppState, metrics_handle: Option, ) -> (Router, Router) { - let public = Router::new() - .route("/v1/installations/challenges", post(challenge)) + let enrollment = Router::new() .route("/v1/installations", post(enroll)) + .layer(RequestBodyLimitLayer::new(MAX_ENROLL_REQUEST_BYTES)); + let standard_requests = Router::new() + .route("/v1/installations/challenges", post(challenge)) .route("/v1/delegations", post(delegate)) .route("/v1/delegations/revoke", post(revoke_delegation)) .route("/v1/installations/endpoint", post(rotate_endpoint)) .route("/v1/installations/revoke", post(revoke_installation)) .route("/v1/deliveries/apns", post(deliver)) + .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)); + let public = Router::new() + .merge(enrollment) + .merge(standard_requests) .with_state(state.clone()) - .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)) .layer(ConcurrencyLimitLayer::new(256)) .layer(TimeoutLayer::with_status_code( StatusCode::REQUEST_TIMEOUT, @@ -796,6 +801,107 @@ pub fn router_with_metrics( (public, health) } +#[cfg(test)] +mod request_limit_tests { + use super::*; + use crate::{ + authority::MemoryAuthorityStore, + grant::{GrantKey, GrantKeyring}, + token::{TokenKey, TokenKeyring}, + }; + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + struct NeverTransport; + + #[async_trait::async_trait] + impl PushTransport for NeverTransport { + async fn send(&self, _: DeliveryAttempt, _: &str) -> DeliveryOutcome { + panic!("request-size tests never send to APNs") + } + } + + fn fixed_now() -> i64 { + 1_750_000_000 + } + + fn state() -> AppState { + let app_attest = AppAttestVerifier::new( + "TEAMID.xyz.block.buzz.dogfood.mobile".to_owned(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .expect("pinned Apple root fixture"); + AppState { + grant_keyring: Arc::new( + GrantKeyring::new(vec![GrantKey::new("test", &[1; 32]).unwrap()]).unwrap(), + ), + authority: Arc::new(MemoryAuthorityStore::default()), + token_keyring: Arc::new( + TokenKeyring::new(vec![TokenKey::new("test", &[2; 32]).unwrap()]).unwrap(), + ), + profile: Arc::new(ProfileRuntime { + app_attest: Arc::new(app_attest), + transport: Arc::new(NeverTransport), + }), + delivery_url: "https://push.buzz.xyz/v1/deliveries/apns".parse().unwrap(), + max_grant_lifetime_seconds: 86_400, + max_installation_lifetime_seconds: 86_400, + endpoint_quota_window_seconds: 60, + endpoint_quota_max_deliveries: 10, + now: fixed_now, + accepting: Arc::new(AtomicBool::new(true)), + } + } + + fn maximum_enrollment_body() -> Vec { + serde_json::to_vec(&InstallationEnrollRequest { + v: WIRE_VERSION, + challenge_id: uuid::Uuid::nil(), + challenge: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0; 32]), + key_id: STANDARD.encode([0; 32]), + attestation: STANDARD.encode(vec![0; MAX_APP_ATTESTATION_BYTES]), + app_profile: AppProfile::BuzzIosDogfood, + endpoint: "ab".repeat(MAX_ENDPOINT_HEX_BYTES), + endpoint_epoch: 1, + expires_at: fixed_now() + 60, + }) + .unwrap() + } + + #[tokio::test] + async fn maximum_valid_enrollment_envelope_reaches_the_handler() { + let body = maximum_enrollment_body(); + assert!(body.len() > MAX_REQUEST_BYTES); + assert!(body.len() <= MAX_ENROLL_REQUEST_BYTES); + let (public, _) = router(state()); + let response = public + .oneshot( + Request::post("/v1/installations") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn enrollment_envelope_stays_bounded() { + let (public, _) = router(state()); + let response = public + .oneshot( + Request::post("/v1/installations") + .header("content-type", "application/json") + .body(Body::from(vec![b' '; MAX_ENROLL_REQUEST_BYTES + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } +} + /// Known-answer vectors for the exact App Attest transcript bytes defined by /// NIP-PL ("Exact App Attest transcript construction"). The fixture file is /// shared ground truth with client-side canonical encoders (the Swift NIP-PL diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index bcb5cc947d3..390f665d8ab 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -3,6 +3,13 @@ use serde::{Deserialize, Serialize}; pub const MAX_REQUEST_BYTES: usize = 8 * 1024; +/// Maximum decoded Apple App Attest object accepted by the verifier. +pub const MAX_APP_ATTESTATION_BYTES: usize = 16 * 1024; +/// Enrollment carries the maximum App Attest object as standard base64 plus a +/// bounded APNs endpoint and the closed JSON envelope. Other gateway requests +/// remain subject to `MAX_REQUEST_BYTES`. +pub const MAX_ENROLL_REQUEST_BYTES: usize = + MAX_APP_ATTESTATION_BYTES.div_ceil(3) * 4 + MAX_ENDPOINT_HEX_BYTES * 2 + 1024; pub const MAX_GRANT_BYTES: usize = 4096; pub const MAX_ENDPOINT_HEX_BYTES: usize = 512; pub const APNS_RECONNECT_PAYLOAD: &[u8] = From 47b75c95479a70d0448925ae0941b29cdc0e9db5 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 26 Aug 2026 10:32:05 -0700 Subject: [PATCH 24/27] fix(push): make recovery and opt-in durable Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/authority.rs | 54 +++++ crates/buzz-push-gateway/src/http.rs | 30 ++- crates/buzz-push-gateway/src/postgres.rs | 39 ++++ docs/nips/NIP-PL.md | 6 +- .../BuzzDevPushEnrollmentDriver.swift | 158 ++++++++++++-- .../BuzzPushPendingEnrollmentRecord.swift | 45 ++++ .../BuzzDevPushEnrollmentDriverTests.swift | 198 +++++++++++++++++- .../ios/Runner/PushEndpointGrantStore.swift | 66 +++++- mobile/lib/app.dart | 4 +- .../lib/features/settings/settings_page.dart | 3 + .../settings_page/notifications_section.dart | 43 ++++ mobile/lib/shared/community/community.dart | 8 + .../shared/community/community_provider.dart | 107 +++++++++- mobile/lib/shared/push/push_bootstrap.dart | 79 ++++--- mobile/lib/shared/push/push_bridge.dart | 16 +- mobile/lib/shared/push/push_subscription.dart | 51 +++++ .../push/push_subscription_provider.dart | 7 +- .../features/settings/settings_page_test.dart | 40 ++++ .../test/shared/auth/auth_provider_test.dart | 5 +- .../community/community_provider_test.dart | 181 +++++++++++++++- .../test/shared/push/push_bootstrap_test.dart | 71 +++++++ mobile/test/shared/push/push_bridge_test.dart | 1 + .../shared/push/push_subscription_test.dart | 25 ++- 23 files changed, 1156 insertions(+), 81 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift create mode 100644 mobile/lib/features/settings/settings_page/notifications_section.dart diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index ee172d5114d..1a7ef4b2a65 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -127,6 +127,18 @@ pub trait AuthorityStore: Send + Sync { installation: NewInstallation, now: i64, ) -> Result<(), AuthorityError>; + /// Return an exact live installation previously committed for the same + /// attested enrollment request. This is the idempotency seam used when a + /// client loses the successful response and replays the signed request. + async fn matching_installation( + &self, + app_attest_key_id: &[u8], + profile: AppProfile, + token_fingerprint: [u8; 32], + endpoint_epoch: i64, + expires_at: i64, + now: i64, + ) -> Result, AuthorityError>; async fn installation(&self, id: Uuid, now: i64) -> Result; async fn advance_assertion_counter( &self, @@ -311,6 +323,30 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(i.clone()) } + async fn matching_installation( + &self, + key_id: &[u8], + profile: AppProfile, + fingerprint: [u8; 32], + epoch: i64, + expires_at: i64, + now: i64, + ) -> Result, AuthorityError> { + let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + Ok(s.installations + .values() + .find(|installation| { + !installation.revoked + && installation.expires_at >= now + && installation.app_attest_key_id == key_id + && installation.profile == profile + && installation.token_fingerprint == fingerprint + && installation.endpoint_epoch == epoch + && installation.expires_at == expires_at + }) + .cloned()) + } + async fn advance_assertion_counter( &self, id: Uuid, @@ -602,6 +638,24 @@ mod tests { store } + #[tokio::test] + async fn exact_enrollment_replay_recovers_committed_installation() { + let store = store().await; + + let recovered = store + .matching_installation(&[1], AppProfile::BuzzIosDogfood, [4; 32], 1, 2_000, 1_001) + .await + .unwrap() + .expect("exact replay finds the committed installation"); + + assert_eq!(recovered.id, Uuid::from_u128(1)); + assert!(store + .matching_installation(&[1], AppProfile::BuzzIosDogfood, [5; 32], 1, 2_000, 1_001,) + .await + .unwrap() + .is_none()); + } + #[tokio::test] async fn challenge_issuance_is_bounded_per_window() { let store = MemoryAuthorityStore::default(); diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 84ad2a42a90..3e0dd07bc8a 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -208,6 +208,34 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Ok(value) => value, Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), }; + let fingerprint = endpoint_fingerprint(r.app_profile, &token); + match s + .authority + .matching_installation( + &verified.key_id, + r.app_profile, + fingerprint, + r.endpoint_epoch, + r.expires_at, + now, + ) + .await + { + Ok(Some(existing)) if existing.app_attest_public_key == verified.public_key => { + return ( + StatusCode::CREATED, + Json(InstallationEnrollResponse { + installation_handle: existing.id, + endpoint_epoch: existing.endpoint_epoch, + expires_at: existing.expires_at, + }), + ) + .into_response(); + } + Ok(Some(_)) => return error(StatusCode::NOT_FOUND, "not_authorized"), + Ok(None) => {} + Err(e) => return authority_error(e), + } if let Err(e) = s .authority .consume_challenge(r.challenge_id, challenge, now) @@ -227,7 +255,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { assertion_counter: 0, profile: r.app_profile, token_ciphertext: ciphertext, - token_fingerprint: endpoint_fingerprint(r.app_profile, &token), + token_fingerprint: fingerprint, endpoint_epoch: 1, expires_at: r.expires_at, }; diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 19ba7daef0f..6cbfb45893d 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -230,6 +230,45 @@ impl AuthorityStore for PostgresAuthorityStore { revoked: false, }) } + async fn matching_installation( + &self, + key_id: &[u8], + app_profile: AppProfile, + token_fingerprint: [u8; 32], + endpoint_epoch: i64, + expires_at: i64, + now: i64, + ) -> Result, AuthorityError> { + let r = sqlx::query("SELECT * FROM push_gateway_installations WHERE app_attest_key_id=$1 AND app_profile=$2 AND token_fingerprint=$3 AND endpoint_epoch=$4 AND expires_at=$5 AND revoked_at IS NULL AND expires_at >= $6") + .bind(key_id) + .bind(app_profile.as_str()) + .bind(token_fingerprint.to_vec()) + .bind(endpoint_epoch) + .bind(at(expires_at)?) + .bind(at(now)?) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + r.map(|r| { + let id = r.try_get("id").map_err(db)?; + Ok(Installation { + id, + app_attest_key_id: r.try_get("app_attest_key_id").map_err(db)?, + app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?, + assertion_counter: u32::try_from( + r.try_get::("assertion_counter").map_err(db)?, + ) + .map_err(|_| AuthorityError::Unavailable)?, + profile: profile(r.try_get("app_profile").map_err(db)?)?, + token_ciphertext: r.try_get("token_ciphertext").map_err(db)?, + token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?, + endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?, + expires_at: ts(r.try_get("expires_at").map_err(db)?), + revoked: false, + }) + }) + .transpose() + } async fn advance_assertion_counter( &self, id: Uuid, diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index c6dc160e3c8..b4aba368376 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -150,7 +150,7 @@ Each subscription carries exactly one `class`: The descriptor's `class_support` is authoritative: a lease naming an unsupported class MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. -Clients MUST NOT register any lease or subscription as a side effect of joining a channel or surface — absent explicit user opt-in the notifiable set is empty. +Clients MUST NOT register any lease or subscription as a side effect of joining a channel or surface — absent explicit persisted user opt-in the notifiable set is empty. Opt-out MUST stop renewal and publish the higher-generation inactive replacement defined below; platform display authorization is separate state and MUST NOT be treated as the user's Buzz opt-in. ### Quotas @@ -225,6 +225,8 @@ A future FCM profile MUST define one gateway-owned constant data message with id A lease is identified by `(author, kind, d)`. A replacement supersedes the prior lease at the same address only by passing the full acceptance sequence, including winning both NIP-01 addressable ordering and the strictly-increasing generation watermark (check 8). Any rejected replacement — stale by either ordering, or invalid for any other reason — MUST leave the stored event, effective push state, and watermark unchanged. +Before publishing any active replacement or inactive tombstone, a client MUST durably reserve its next generation. A failed or indeterminate publication consumes that local generation; retries advance again. This prevents a successful relay commit followed by a local persistence failure from trapping the client on a generation the relay has already accepted. + An active lease becomes ineffective when its `expiration` passes. Executors MUST NOT match, enqueue, or deliver wakes for an expired lease. Clients SHOULD refresh active leases before expiry; failure to refresh MUST NOT extend the prior lease. Expiry is a safety backstop, not evidence that a platform endpoint has been deleted. **Revocation.** Revocation is exclusively a higher-generation replacement with the minimal inactive plaintext — exactly `{"v", "origin", "generation", "active": false}`; `app_profile`, `transport`, `endpoint` and `subscriptions` MUST be absent. NIP-09 deletion is unsupported for `kind:30350`: relays MUST ignore deletion requests targeting this kind, so the stored/effective/watermark invariant has exactly one transition path. The executor validates the inactive schema without consulting endpoint or app-profile availability, so revocation succeeds even after an app profile or transport has been withdrawn from the descriptor. On acceptance the executor MUST treat it as a tombstone for that lease address: stop matching, cancel undelivered jobs where practical, and delete transport endpoint material when no longer required for audit or abuse prevention. Reactivation is an ordinary active replacement with a yet-higher generation. The executor MUST persist the generation watermark for a lease address until at least `max(last_active_expiration, tombstone_accepted_at + max_lease_ttl) + allowed_skew` when a tombstone exists, or `last_active_expiration + allowed_skew` when none does (after which any replay fails the expiration lower bound) — or a longer descriptor-advertised fixed retention — so a replayed older event can never resurrect a revoked lease. Logging out one installation MUST NOT alter sibling installation leases. @@ -314,6 +316,8 @@ The gateway verifies Apple's attestation chain, configured application identifie {"installation_handle":"","endpoint_epoch":1,"expires_at":} ``` +The client MUST durably journal the exact attested enrollment request before its first send and retain it until delegation state is durable. If that exact request is replayed after the installation commit, the gateway MUST return the same success response after re-verifying the attestation, even though the challenge was already consumed. Idempotency requires exact equality of attested key, profile, endpoint fingerprint, epoch, and expiration, and the recovered public key MUST equal the committed key; any mismatch remains indistinguishable from other authority rejection. This recovery rule grants no authority beyond replaying the already authenticated request. + Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or a key/token owned by a live installation is `404 not_authorized`. A fresh verified enrollment may replace expired or revoked ownership so an app that missed its renewal window can recover. ### Relay delegation and capability issuance diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index c297153801a..8003193da39 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -61,6 +61,12 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public protocol BuzzPushEndpointGrantStore { func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws + func pendingEnrollment( + relayOrigin: String, + appProfile: String + ) throws -> BuzzPushPendingEnrollmentRecord? + func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws + func removePendingEnrollment(relayOrigin: String, appProfile: String) throws } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -379,6 +385,20 @@ public final class BuzzDevPushEnrollmentDriver { let storedForOrigin = storedRecords.first { $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile } + var pendingEnrollment = try store.pendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + if let pending = pendingEnrollment, + pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash + || pending.expiresAt <= nowSeconds + { + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + pendingEnrollment = nil + } if let current = storedForOrigin, current.relayPubkey == relayPubkey, current.endpointHash == endpointHash, @@ -386,6 +406,10 @@ public final class BuzzDevPushEnrollmentDriver { current.expiresAt > nowSeconds + 300 { guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) return current } let refreshed = BuzzPushEndpointGrantRecord( @@ -402,6 +426,10 @@ public final class BuzzDevPushEnrollmentDriver { expiresAt: current.expiresAt ) try store.save(refreshed) + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) return refreshed } @@ -429,6 +457,10 @@ public final class BuzzDevPushEnrollmentDriver { expiresAt: sharedGrant.expiresAt ) try store.save(record) + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) return record } @@ -452,18 +484,29 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.invalidGatewayURL } - let installation: UUID - let expiresAt: Int64 - if let reusableInstallation, + var pending: BuzzPushPendingEnrollmentRecord + if let existingPending = pendingEnrollment { + pending = existingPending + } else if let reusableInstallation, let handle = reusableInstallation.gatewayInstallationHandle, let existing = UUID(uuidString: handle) { - installation = existing - expiresAt = reusableInstallation.expiresAt > nowSeconds + 300 + let expiresAt = + reusableInstallation.expiresAt > nowSeconds + 300 ? reusableInstallation.expiresAt : renewedExpiration + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + endpointHash: endpointHash, + appProfile: Self.appProfile, + expiresAt: expiresAt, + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + gatewayInstallationHandle: existing.uuidString.lowercased() + ) + try store.savePendingEnrollment(pending) } else { - expiresAt = renewedExpiration + let expiresAt = renewedExpiration let enrollmentChallenge = try await challenge() let preparedAttestation = try await appAttest.prepareAttestation() let enrollmentClientData = try BuzzPushTranscript.enroll( @@ -482,12 +525,73 @@ public final class BuzzDevPushEnrollmentDriver { guard attestation.keyId == preparedAttestation.keyId else { throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") } - installation = try await enrollInstallation( - challenge: enrollmentChallenge, - endpoint: endpoint, + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + endpointHash: endpointHash, + appProfile: Self.appProfile, expiresAt: expiresAt, - attestation: attestation + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + challengeId: enrollmentChallenge.id.uuidString.lowercased(), + challenge: enrollmentChallenge.value, + keyId: attestation.keyId, + attestation: attestation.attestation ) + // The exact signed request is durable before the first network attempt. + try store.savePendingEnrollment(pending) + } + + let installation: UUID + if let handle = pending.gatewayInstallationHandle, + let existing = UUID(uuidString: handle), + handle == existing.uuidString.lowercased() + { + installation = existing + } else { + guard let challengeId = pending.challengeId, + let challengeUUID = UUID(uuidString: challengeId), + challengeId == challengeUUID.uuidString.lowercased(), + let challengeValue = pending.challenge, + let keyId = pending.keyId, + let attestation = pending.attestation + else { + throw BuzzDevPushEnrollmentError.invalidResponse( + route: "pending development enrollment" + ) + } + do { + installation = try await enrollInstallation( + challenge: Challenge(id: challengeUUID, value: challengeValue), + endpoint: endpoint, + expiresAt: pending.expiresAt, + attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) + ) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations", _, actual: 404, _ + ) where pendingEnrollment != nil { + // No installation was committed and the original challenge expired. + // Discard the prepared request and start once with a fresh App Attest key. + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return try await enroll(deviceToken: deviceToken, relayURL: relayURL) + } + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: pending.relayOrigin, + relayPubkey: pending.relayPubkey, + endpointHash: pending.endpointHash, + appProfile: pending.appProfile, + expiresAt: pending.expiresAt, + installationId: pending.installationId, + gatewayInstallationHandle: installation.uuidString.lowercased(), + challengeId: pending.challengeId, + challenge: pending.challenge, + keyId: pending.keyId, + attestation: pending.attestation, + delegationGeneration: pending.delegationGeneration + ) + try store.savePendingEnrollment(pending) } let installationHandle = installation.uuidString.lowercased() @@ -499,9 +603,10 @@ public final class BuzzDevPushEnrollmentDriver { } .map(\.generation) .max() + let generationBase = max(currentGeneration ?? 0, pending.delegationGeneration) let generation: Int64 - if let currentGeneration { - let (next, overflow) = currentGeneration.addingReportingOverflow(1) + if generationBase > 0 { + let (next, overflow) = generationBase.addingReportingOverflow(1) guard !overflow, next > 0 else { throw BuzzDevPushEnrollmentError.generationExhausted } @@ -509,6 +614,23 @@ public final class BuzzDevPushEnrollmentDriver { } else { generation = 1 } + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: pending.relayOrigin, + relayPubkey: pending.relayPubkey, + endpointHash: pending.endpointHash, + appProfile: pending.appProfile, + expiresAt: pending.expiresAt, + installationId: pending.installationId, + gatewayInstallationHandle: installationHandle, + challengeId: pending.challengeId, + challenge: pending.challenge, + keyId: pending.keyId, + attestation: pending.attestation, + delegationGeneration: generation + ) + // Reserve before delegation so a committed delegation followed by a local + // save failure is retried at a strictly higher generation. + try store.savePendingEnrollment(pending) let delegationChallenge = try await challenge() let delegationClientData = try BuzzPushTranscript.delegate( @@ -519,7 +641,7 @@ public final class BuzzDevPushEnrollmentDriver { generation: generation, relayPubkey: relayPubkey, notBefore: nowSeconds, - expiresAt: expiresAt + expiresAt: pending.expiresAt ) let assertion = try await appAttest.assertion(clientData: delegationClientData) let endpointGrant = try await delegate( @@ -528,7 +650,7 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: relayPubkey, generation: generation, notBefore: nowSeconds, - expiresAt: expiresAt, + expiresAt: pending.expiresAt, assertion: assertion ) @@ -537,15 +659,19 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, gatewayInstallationHandle: installationHandle, - installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + installationId: pending.installationId, endpointGrant: endpointGrant, endpointHash: endpointHash, appProfile: Self.appProfile, endpointEpoch: Self.endpointEpoch, generation: generation, - expiresAt: expiresAt + expiresAt: pending.expiresAt ) try store.save(record) + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) return record } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift new file mode 100644 index 00000000000..402f90be30f --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -0,0 +1,45 @@ +/// Crash-recovery journal written before installation or delegation requests. +/// It contains no APNs endpoint, only its hash and the exact authenticated +/// enrollment material needed to replay a committed request idempotently. +public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { + public let relayOrigin: String + public let relayPubkey: String + public let endpointHash: String + public let appProfile: String + public let expiresAt: Int64 + public let installationId: String + public let gatewayInstallationHandle: String? + public let challengeId: String? + public let challenge: String? + public let keyId: String? + public let attestation: String? + public let delegationGeneration: Int64 + + public init( + relayOrigin: String, + relayPubkey: String, + endpointHash: String, + appProfile: String, + expiresAt: Int64, + installationId: String, + gatewayInstallationHandle: String? = nil, + challengeId: String? = nil, + challenge: String? = nil, + keyId: String? = nil, + attestation: String? = nil, + delegationGeneration: Int64 = 0 + ) { + self.relayOrigin = relayOrigin + self.relayPubkey = relayPubkey + self.endpointHash = endpointHash + self.appProfile = appProfile + self.expiresAt = expiresAt + self.installationId = installationId + self.gatewayInstallationHandle = gatewayInstallationHandle + self.challengeId = challengeId + self.challenge = challenge + self.keyId = keyId + self.attestation = attestation + self.delegationGeneration = delegationGeneration + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 0b42b48d4ba..2345bc46c68 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -54,7 +54,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { "keys": [ ["id": "current", "pubkey": Self.relayPubkey, "current": true] ] - ] + ], ] ) case ("POST", "http://push.example/v1/installations/challenges"): @@ -158,6 +158,164 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.saved, [record]) } + func testCommittedInstallationRecoversAfterFinalGrantSaveFailure() async throws { + let store = MemoryGrantStore(grantSaveFailuresRemaining: 1) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + var installationCount = 0 + var delegationCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + installationCount += 1 + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + delegationCount += 1 + let body = try Self.body(request) + XCTAssertEqual(body["generation"] as? Int, delegationCount) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant-\(delegationCount)"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected the injected local save failure") + } catch { + XCTAssertEqual((error as NSError).domain, "MemoryGrantStore") + } + XCTAssertEqual(store.pending.first?.delegationGeneration, 1) + + let recovered = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(installationCount, 1) + XCTAssertEqual(delegationCount, 2) + XCTAssertEqual(recovered.generation, 2) + XCTAssertEqual(recovered.endpointGrant, "opaque-grant-2") + XCTAssertTrue(store.pending.isEmpty) + } + + func testCommittedInstallationRecoversAfterResponseLoss() async throws { + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + var installationCount = 0 + var firstInstallationBody: [String: Any]? + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + installationCount += 1 + let body = try Self.body(request) + if installationCount == 1 { + firstInstallationBody = body + throw URLError(.networkConnectionLost) + } + XCTAssertTrue( + NSDictionary(dictionary: body).isEqual(to: try XCTUnwrap(firstInstallationBody)) + ) + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected the simulated lost installation response") + } catch { + XCTAssertEqual((error as NSError).domain, NSURLErrorDomain) + } + XCTAssertNil(store.pending.first?.gatewayInstallationHandle) + + let recovered = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(challengeCount, 2) + XCTAssertEqual(installationCount, 2) + XCTAssertEqual(recovered.endpointGrant, "opaque-grant") + XCTAssertTrue(store.pending.isEmpty) + } + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { let relayURL = URL(string: "wss://relay.example:8443/")! let store = MemoryGrantStore() @@ -217,7 +375,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testLegacyGrantDecodesWithoutMetadataAuthority() throws { let data = Data( - #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#.utf8 + #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# + .utf8 ) let record = try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data) @@ -700,7 +859,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ["pubkey": Self.relayPubkey, "current": true], ["pubkey": String(repeating: "b", count: 64), "current": true], ] - ] + ], ] ) } @@ -987,14 +1146,45 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] - init(records: [BuzzPushEndpointGrantRecord] = []) { saved = records } + var pending: [BuzzPushPendingEnrollmentRecord] = [] + var grantSaveFailuresRemaining: Int + init( + records: [BuzzPushEndpointGrantRecord] = [], + grantSaveFailuresRemaining: Int = 0 + ) { + saved = records + self.grantSaveFailuresRemaining = grantSaveFailuresRemaining + } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { + if grantSaveFailuresRemaining > 0 { + grantSaveFailuresRemaining -= 1 + throw NSError(domain: "MemoryGrantStore", code: 1) + } saved.removeAll { $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile } saved.append(record) } + func pendingEnrollment( + relayOrigin: String, + appProfile: String + ) throws -> BuzzPushPendingEnrollmentRecord? { + pending.first { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + } + func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { + pending.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + pending.append(record) + } + func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + pending.removeAll { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + } } private final class RecordingAppAttest: BuzzDevAppAttesting { diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 9b9c5554567..ffedbedd19a 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -6,7 +6,8 @@ import Security /// UserDefaults or logs. Dart can read the closed record through the push bridge. final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { private static let service = "buzz.push.endpoint-grants" - private static let account = "v1" + private static let recordsAccount = "v1" + private static let pendingAccount = "pending-v1" private let accessGroup: String? @@ -15,7 +16,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } func records() throws -> [BuzzPushEndpointGrantRecord] { - var query = baseQuery() + var query = baseQuery(account: Self.recordsAccount) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? @@ -41,13 +42,60 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile } all.append(record) - try replace(all) + try replace(all, account: Self.recordsAccount) } - private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws { - let data = try JSONEncoder().encode(records) + func pendingEnrollment( + relayOrigin: String, + appProfile: String + ) throws -> BuzzPushPendingEnrollmentRecord? { + try pendingEnrollments().first { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + } + + func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { + var all = try pendingEnrollments() + all.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + all.append(record) + try replace(all, account: Self.pendingAccount) + } + + func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + var all = try pendingEnrollments() + all.removeAll { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + try replace(all, account: Self.pendingAccount) + } + + private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] { + var query = baseQuery(account: Self.pendingAccount) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read pending enrollment") + } + do { + return try JSONDecoder().decode([BuzzPushPendingEnrollmentRecord].self, from: data) + } catch { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Stored pending enrollments are invalid: \(error)"] + ) + } + } + + private func replace(_ values: [T], account: String) throws { + let data = try JSONEncoder().encode(values) let updateStatus = SecItemUpdate( - baseQuery() as CFDictionary, + baseQuery(account: account) as CFDictionary, [kSecValueData as String: data] as CFDictionary ) if updateStatus == errSecSuccess { return } @@ -55,7 +103,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { throw keychainError(updateStatus, operation: "update") } - var add = baseQuery() + var add = baseQuery(account: account) add[kSecValueData as String] = data add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let addStatus = SecItemAdd(add as CFDictionary, nil) @@ -64,11 +112,11 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } - private func baseQuery() -> [String: Any] { + private func baseQuery(account: String) -> [String: Any] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: Self.service, - kSecAttrAccount as String: Self.account, + kSecAttrAccount as String: account, ] if let accessGroup, !accessGroup.isEmpty { query[kSecAttrAccessGroup as String] = accessGroup diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index a468eccc125..c5e0b277bd9 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -327,7 +327,9 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); - if (ref.watch(currentRelayPushDescriptorProvider).value != null) { + if (ref.watch(activeCommunityProvider).value?.pushNotificationsEnabled == + true && + ref.watch(currentRelayPushDescriptorProvider).value != null) { ref.watch(pushSubscriptionSyncProvider); } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 5fd934f251b..b153ca6931f 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -28,6 +29,7 @@ import 'theme_picker_page.dart'; part 'settings_page/appearance_section.dart'; part 'settings_page/community_section.dart'; part 'settings_page/connection_section.dart'; +part 'settings_page/notifications_section.dart'; Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink(); @@ -214,6 +216,7 @@ class SettingsPage extends HookConsumerWidget { children: [ profileHeader, _CommunitySection(invitePageBuilder: invitePageBuilder), + const _NotificationsSection(), const _AppearanceSection(), _ConnectionSection( identityRecoveryPageBuilder: identityRecoveryPageBuilder, diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart new file mode 100644 index 00000000000..697d086cb3a --- /dev/null +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -0,0 +1,43 @@ +part of '../settings_page.dart'; + +class _NotificationsSection extends ConsumerWidget { + const _NotificationsSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return const SizedBox.shrink(); + } + final community = ref.watch(activeCommunityProvider).value; + if (community == null) return const SizedBox.shrink(); + + return AppListCard( + label: 'Notifications', + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const ValueKey('push-notifications-enabled'), + icon: LucideIcons.bell, + title: 'Push notifications', + subtitle: 'Receive message notifications from this community', + trailing: Switch.adaptive( + value: community.pushNotificationsEnabled, + onChanged: (enabled) => unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, enabled), + ), + ), + onTap: () => unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled( + community.id, + !community.pushNotificationsEnabled, + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 20198869d40..86c4657973d 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -14,6 +14,7 @@ class Community { final String? pubkey; final String? nsec; final SensitiveActionPolicy sensitiveActionPolicy; + final bool pushNotificationsEnabled; final BuzzPushLeaseSubscriptionState pushSubscriptionState; /// Whether invite-created starter channels still need to be recovered. @@ -27,6 +28,7 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + this.pushNotificationsEnabled = false, this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), this.starterSetupIncomplete = false, required this.addedAt, @@ -59,6 +61,7 @@ class Community { Object? pubkey = _sentinel, Object? nsec = _sentinel, SensitiveActionPolicy? sensitiveActionPolicy, + bool? pushNotificationsEnabled, BuzzPushLeaseSubscriptionState? pushSubscriptionState, bool? starterSetupIncomplete, }) { @@ -70,6 +73,8 @@ class Community { nsec: nsec == _sentinel ? this.nsec : nsec as String?, sensitiveActionPolicy: sensitiveActionPolicy ?? this.sensitiveActionPolicy, + pushNotificationsEnabled: + pushNotificationsEnabled ?? this.pushNotificationsEnabled, pushSubscriptionState: pushSubscriptionState ?? this.pushSubscriptionState, starterSetupIncomplete: @@ -85,6 +90,7 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, + 'pushNotificationsEnabled': pushNotificationsEnabled, 'pushSubscriptionState': pushSubscriptionState.toJson(), 'starterSetupIncomplete': starterSetupIncomplete, 'addedAt': addedAt.toIso8601String(), @@ -100,6 +106,8 @@ class Community { (value) => value.name == json['sensitiveActionPolicy'], orElse: () => SensitiveActionPolicy.disabledByUser, ), + pushNotificationsEnabled: + json['pushNotificationsEnabled'] as bool? ?? false, pushSubscriptionState: json['pushSubscriptionState'] == null ? const BuzzPushLeaseSubscriptionState.desired() : BuzzPushLeaseSubscriptionState.fromJson( diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index d853e60d4e6..afe0d05b8a3 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,4 +1,5 @@ import 'dart:developer' as developer; +import 'dart:math'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -91,18 +92,24 @@ final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) { }); typedef CommunityPushLeaseDeactivator = - Future Function(Community community); + Future Function(Community community, {int? generation}); final communityPushLeaseDeactivatorProvider = Provider((ref) { - return (community) => _deactivateCommunityPushLease(community); + return (community, {generation}) => + _deactivateCommunityPushLease(community, generation: generation); }); -Future _deactivateCommunityPushLease(Community community) async { +Future _deactivateCommunityPushLease( + Community community, { + int? generation, +}) async { final state = community.pushSubscriptionState; final acceptedGeneration = state.acceptedGeneration; final nsec = community.nsec; - if (acceptedGeneration == null || nsec == null || nsec.isEmpty) { + if ((acceptedGeneration == null && generation == null) || + nsec == null || + nsec.isEmpty) { return; } try { @@ -129,11 +136,12 @@ Future _deactivateCommunityPushLease(Community community) async { // Skip over the one renewal generation that could already be in flight // when removal begins. Strict relay monotonicity then makes any stale // active publication lose to this tombstone. - final generation = acceptedGeneration + 2; + final tombstoneGeneration = + generation ?? (state.generationCursor ?? acceptedGeneration!) + 2; await publishBuzzPushLeaseTombstone( descriptor: descriptor, installationId: installationId, - generation: generation, + generation: tombstoneGeneration, nsec: nsec, memberPubkey: memberPubkey, submit: ({required kind, required content, required tags, createdAt}) => @@ -169,6 +177,7 @@ class _CommunitySnapshotSync { community.relayUrl, community.pubkey, community.nsec, + community.pushNotificationsEnabled, buzzPushSubscriptionStateFingerprint( community.pushSubscriptionState, ), @@ -197,6 +206,17 @@ Future syncStoredCommunitySnapshot(Ref ref) async { } class CommunityListNotifier extends AsyncNotifier> { + Future _pushMutationTail = Future.value(); + + Future _serializePushMutation(Future Function() operation) { + final result = _pushMutationTail.then((_) => operation()); + _pushMutationTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + @override Future> build() async { final storage = ref.read(communityStorageProvider); @@ -297,7 +317,7 @@ class CommunityListNotifier extends AsyncNotifier> { Future updateDesiredPushSubscriptions( String id, List desired, - ) async { + ) => _serializePushMutation(() async { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); final index = current.indexWhere((community) => community.id == id); @@ -319,19 +339,52 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); await syncCommunitySnapshot(ref, updatedList); + }); + + Future reservePushLeaseGeneration(String id) { + return _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) throw StateError('Push community is unavailable.'); + + final community = current[index]; + if (!community.pushNotificationsEnabled) { + throw StateError('Push notifications are disabled.'); + } + final cursor = + community.pushSubscriptionState.generationCursor ?? + community.pushSubscriptionState.acceptedGeneration ?? + 0; + final generation = cursor + 1; + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState + .withReservedGeneration(generation), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + return generation; + }); } Future markPushLeaseAccepted( String id, { required List subscriptions, required int generation, - }) async { + }) => _serializePushMutation(() async { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); final index = current.indexWhere((community) => community.id == id); if (index < 0) return; final community = current[index]; + final acceptedGeneration = + community.pushSubscriptionState.acceptedGeneration ?? 0; + final generationCursor = + community.pushSubscriptionState.generationCursor ?? 0; + if (generation < max(acceptedGeneration, generationCursor)) return; final updated = community.copyWith( pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, @@ -342,6 +395,44 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); await syncCommunitySnapshot(ref, updatedList); + }); + + Future setPushNotificationsEnabled(String id, bool enabled) async { + Community? deactivation; + int? tombstoneGeneration; + await _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + if (community.pushNotificationsEnabled == enabled) return; + var pushState = community.pushSubscriptionState; + if (!enabled && + (pushState.acceptedGeneration != null || + pushState.generationCursor != null)) { + final cursor = + pushState.generationCursor ?? pushState.acceptedGeneration ?? 0; + tombstoneGeneration = cursor + 1; + pushState = pushState.withReservedGeneration(tombstoneGeneration!); + } + final updated = community.copyWith( + pushNotificationsEnabled: enabled, + pushSubscriptionState: pushState, + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + if (!enabled && tombstoneGeneration != null) deactivation = updated; + }); + if (deactivation != null) { + await ref.read(communityPushLeaseDeactivatorProvider)( + deactivation!, + generation: tombstoneGeneration, + ); + } } Future renameCommunity(String id, String name) async { diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index bbd5bd73478..fb180e2e9e0 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -76,6 +76,24 @@ String buzzPushPublicationAttemptKey({ buzzPushSubscriptionsFingerprint(subscriptions), ].join('|'); +@visibleForTesting +bool buzzPushLifecycleEnabled({ + required Community? community, + required BuzzPushLeaseDescriptor? descriptor, +}) => community?.pushNotificationsEnabled == true && descriptor != null; + +@visibleForTesting +Future publishBuzzPushLeaseRecoverably({ + required Future Function() reserveGeneration, + required Future Function(int generation) publish, + required Future Function(int generation) markAccepted, +}) async { + final generation = await reserveGeneration(); + await publish(generation); + await markAccepted(generation); + return generation; +} + /// Starts the push lifecycle only after authenticated relay connectivity and a /// push-capable NIP-11 descriptor are both present. class BuzzPushBootstrap extends HookConsumerWidget { @@ -107,15 +125,20 @@ class BuzzPushBootstrap extends HookConsumerWidget { useEffect( () { if (!_ready(session, config, community, memberPubkey) || - descriptor == null) { + !buzzPushLifecycleEnabled( + community: community, + descriptor: descriptor, + )) { return null; } - final attempt = '${community!.id}|${config.baseUrl}'; + final activeCommunity = community!; + final activeDescriptor = descriptor!; + final attempt = '${activeCommunity.id}|${config.baseUrl}'; if (!registrationAttempt.tryBegin(attempt)) return null; unawaited(() async { try { await startBuzzPushRegistrationIfCapable( - descriptor, + activeDescriptor, startRegistration: startBuzzPushRegistration, ); } catch (error, stack) { @@ -145,17 +168,22 @@ class BuzzPushBootstrap extends HookConsumerWidget { useEffect( () { if (!_ready(session, config, community, memberPubkey) || - descriptor == null || + !buzzPushLifecycleEnabled( + community: community, + descriptor: descriptor, + ) || token == null) { return null; } - final state = community!.pushSubscriptionState; + final activeCommunity = community!; + final activeDescriptor = descriptor!; + final state = activeCommunity.pushSubscriptionState; if (state.desired.isEmpty) return null; final attempt = buzzPushPublicationAttemptKey( - communityId: community.id, + communityId: activeCommunity.id, relayBaseUrl: config.baseUrl, token: token, - descriptor: descriptor, + descriptor: activeDescriptor, subscriptions: state.desired, ); if (!publicationAttempt.tryBegin(attempt)) return null; @@ -168,7 +196,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { final grant = await _publish( ref, config, - community, + activeCommunity, memberPubkey!, relay, ); @@ -247,24 +275,25 @@ class BuzzPushBootstrap extends HookConsumerWidget { // Relay lease replacement and gateway delegation are independent state // machines. Subscription changes advance only the kind-30350 generation; // the opaque grant remains reusable until its own authority changes. - final leaseGeneration = (state.acceptedGeneration ?? 0) + 1; - - await publishBuzzDevPushLeaseThroughRelay( - grant: grant, - leaseGeneration: leaseGeneration, - descriptor: descriptor, - nsec: config.nsec!, - memberPubkey: memberPubkey, - subscriptions: desired, - relay: relay, + final notifier = ref.read(communityListProvider.notifier); + await publishBuzzPushLeaseRecoverably( + reserveGeneration: () => + notifier.reservePushLeaseGeneration(community.id), + publish: (leaseGeneration) => publishBuzzDevPushLeaseThroughRelay( + grant: grant, + leaseGeneration: leaseGeneration, + descriptor: descriptor, + nsec: config.nsec!, + memberPubkey: memberPubkey, + subscriptions: desired, + relay: relay, + ), + markAccepted: (leaseGeneration) => notifier.markPushLeaseAccepted( + community.id, + subscriptions: desired, + generation: leaseGeneration, + ), ); - await ref - .read(communityListProvider.notifier) - .markPushLeaseAccepted( - community.id, - subscriptions: desired, - generation: leaseGeneration, - ); return grant; } } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 513971fcf62..0b5e53915cf 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -182,16 +182,18 @@ Future registerBuzzPushCommunitySnapshot( try { final snapshots = [ for (final community in communities) - BuzzPushCommunitySnapshot( - id: community.id, - name: community.name, - relayUrl: community.relayUrl, - pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), - subscriptions: community.pushSubscriptionState.authoritative, - ), + if (community.pushNotificationsEnabled) + BuzzPushCommunitySnapshot( + id: community.id, + name: community.name, + relayUrl: community.relayUrl, + pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + subscriptions: community.pushSubscriptionState.authoritative, + ), ]; final signingKeys = {}; for (final community in communities) { + if (!community.pushNotificationsEnabled) continue; final nsec = community.nsec; if (nsec == null || nsec.isEmpty) continue; try { diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 277031e84a1..d804817af9e 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -185,16 +185,23 @@ class BuzzPushLeaseSubscriptionState { /// Monotonic generation of the relay-facing kind-30350 lease. final int? acceptedGeneration; + /// Highest lease generation durably reserved by the client. This advances + /// before relay publication so a relay commit followed by a local failure + /// cannot make the next retry reuse a stale generation. + final int? generationCursor; + const BuzzPushLeaseSubscriptionState.desired({ this.desired = const [], this.accepted, this.acceptedGeneration, + this.generationCursor, }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; BuzzPushLeaseSubscriptionState.accepted({ required Iterable desired, required Iterable acceptedSubscriptions, required this.acceptedGeneration, + this.generationCursor, }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, desired = List.unmodifiable(desired), accepted = List.unmodifiable(acceptedSubscriptions) { @@ -203,6 +210,11 @@ class BuzzPushLeaseSubscriptionState { 'Accepted push authority requires a positive lease generation.', ); } + if (generationCursor != null && generationCursor! < acceptedGeneration!) { + throw const FormatException( + 'Push lease generation cursor cannot trail the accepted generation.', + ); + } } List get authoritative => switch (authority) { @@ -220,12 +232,14 @@ class BuzzPushLeaseSubscriptionState { desired: updated, accepted: accepted, acceptedGeneration: acceptedGeneration, + generationCursor: generationCursor, ), BuzzPushLeaseSubscriptionAuthority.accepted => BuzzPushLeaseSubscriptionState.accepted( desired: updated, acceptedSubscriptions: accepted!, acceptedGeneration: acceptedGeneration, + generationCursor: generationCursor, ), }; } @@ -237,14 +251,42 @@ class BuzzPushLeaseSubscriptionState { desired: desired, acceptedSubscriptions: subscriptions, acceptedGeneration: generation, + generationCursor: generationCursor == null || generation > generationCursor! + ? generation + : generationCursor, ); + BuzzPushLeaseSubscriptionState withReservedGeneration(int generation) { + if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) { + throw const FormatException( + 'Reserved push lease generation must advance monotonically.', + ); + } + return switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => + BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted!, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + ), + }; + } + Map toJson() => { 'authority': authority.name, 'desired': [for (final subscription in desired) subscription.toJson()], if (accepted != null) 'accepted': [for (final subscription in accepted!) subscription.toJson()], if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, + if (generationCursor != null) 'generationCursor': generationCursor, }; factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { @@ -253,6 +295,7 @@ class BuzzPushLeaseSubscriptionState { 'desired', 'accepted', 'acceptedGeneration', + 'generationCursor', }, 'push subscription state'); final authority = json['authority']; final desired = _subscriptionList( @@ -265,22 +308,30 @@ class BuzzPushLeaseSubscriptionState { ? null : _subscriptionList(acceptedRaw, 'accepted'); final acceptedGeneration = json['acceptedGeneration']; + final generationCursor = json['generationCursor']; if (acceptedGeneration != null && acceptedGeneration is! int) { throw const FormatException( 'Accepted push lease generation must be an integer.', ); } + if (generationCursor != null && generationCursor is! int) { + throw const FormatException( + 'Push lease generation cursor must be an integer.', + ); + } return switch (authority) { 'desired' => BuzzPushLeaseSubscriptionState.desired( desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration as int?, + generationCursor: generationCursor as int?, ), 'accepted' when accepted != null && acceptedGeneration is int => BuzzPushLeaseSubscriptionState.accepted( desired: desired, acceptedSubscriptions: accepted, acceptedGeneration: acceptedGeneration, + generationCursor: generationCursor as int?, ), 'accepted' => throw const FormatException( 'Accepted push authority requires accepted subscriptions and generations.', diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart index 653de293527..9d24e7cebc9 100644 --- a/mobile/lib/shared/push/push_subscription_provider.dart +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -17,7 +17,12 @@ final pushSubscriptionSyncProvider = Provider((ref) { final active = ref.watch(activeCommunityProvider).value; final channels = ref.watch(channelsProvider).value; final mutes = ref.watch(channelMutesProvider); - if (active == null || channels == null || !mutes.isReady) return; + if (active == null || + !active.pushNotificationsEnabled || + channels == null || + !mutes.isReady) { + return; + } final subscriptions = desiredBuzzPushSubscriptions( community: active, diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 018aeca586d..b914a9cd737 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -1,5 +1,7 @@ import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; @@ -10,6 +12,44 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + testWidgets('shows the persisted per-community push opt-in on iOS', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith((ref) async => community), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('push-notifications-enabled')), + findsOneWidget, + ); + expect(tester.widget(find.byType(Switch)).value, isTrue); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('opens profile edit choices and routes photo directly', ( tester, ) async { diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index be1f0ec4b60..17289825986 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -223,8 +223,9 @@ void main() { snapshots.add(List.of(communities)); }), communityPushLeaseDeactivatorProvider.overrideWithValue(( - community, - ) async { + community, { + generation, + }) async { deactivatedCommunityIds.add(community.id); }), ], diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index aad07d139f1..6c8eb7c9867 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; import 'package:nostr/nostr.dart' as nostr; import 'community_storage_test.dart'; @@ -16,12 +17,14 @@ void main() { late ProviderContainer container; late List> snapshots; late List deactivatedCommunityIds; + late List deactivationGenerations; setUp(() { fakeSecure = FakeSecureStorage(); communityStorage = CommunityStorage(secure: fakeSecure); snapshots = []; deactivatedCommunityIds = []; + deactivationGenerations = []; }); tearDown(() => container.dispose()); @@ -34,9 +37,11 @@ void main() { snapshots.add(List.of(communities)); }), communityPushLeaseDeactivatorProvider.overrideWithValue(( - community, - ) async { + community, { + generation, + }) async { deactivatedCommunityIds.add(community.id); + deactivationGenerations.add(generation); }), ], ); @@ -98,6 +103,178 @@ void main() { expect(communities.first.name, 'Test'); }); + test( + 'push notifications default off and opt-in survives restart', + () async { + container = createContainer(); + await container.read(communityListProvider.future); + final community = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ); + await container + .read(communityListProvider.notifier) + .addCommunity(community); + expect( + (await container.read( + communityListProvider.future, + )).single.pushNotificationsEnabled, + isFalse, + ); + + await container + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, true); + container.dispose(); + container = createContainer(); + + expect( + (await container.read( + communityListProvider.future, + )).single.pushNotificationsEnabled, + isTrue, + ); + }, + ); + + test( + 'lease retry reserves beyond a locally unaccepted generation', + () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final subscriptionState = BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 4); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: subscriptionState, + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + expect(await notifier.reservePushLeaseGeneration(community.id), 5); + expect(await notifier.reservePushLeaseGeneration(community.id), 6); + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushSubscriptionState.acceptedGeneration, 4); + expect(stored.pushSubscriptionState.generationCursor, 6); + }, + ); + + test('older lease success cannot regress accepted generation', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 6), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: const [], + generation: 5, + ); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushSubscriptionState.acceptedGeneration, 6); + expect( + stored.pushSubscriptionState.accepted!.single.toJson(), + subscription.toJson(), + ); + }); + + test('opt-out tombstones an in-flight first publication', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + expect(await notifier.reservePushLeaseGeneration(community.id), 1); + await notifier.setPushNotificationsEnabled(community.id, false); + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: [subscription], + generation: 1, + ); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.acceptedGeneration, isNull); + expect(stored.pushSubscriptionState.generationCursor, 2); + expect(deactivationGenerations, [2]); + }); + + test('opt-out persists first and publishes a higher tombstone', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 7), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + await notifier.setPushNotificationsEnabled(community.id, false); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.generationCursor, 8); + expect(deactivatedCommunityIds, [community.id]); + expect(deactivationGenerations, [8]); + + container.dispose(); + container = createContainer(); + expect( + (await container.read( + communityListProvider.future, + )).single.pushNotificationsEnabled, + isFalse, + ); + }); + test('removeCommunity removes from list', () async { container = createContainer(); await container.read(communityListProvider.future); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index ca860441ae4..b2daa5c12d6 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -1,4 +1,5 @@ import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/push/push_bootstrap.dart'; import 'package:buzz/shared/push/push_subscription.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -78,6 +79,76 @@ void main() { isNot(original), ); }); + + test('relay capability alone does not activate push without opt-in', () { + final disabled = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ); + final enabled = disabled.copyWith(pushNotificationsEnabled: true); + + expect( + buzzPushLifecycleEnabled( + community: disabled, + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + ), + isFalse, + ); + expect( + buzzPushLifecycleEnabled( + community: enabled, + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + ), + isTrue, + ); + expect( + buzzPushLifecycleEnabled(community: enabled, descriptor: null), + isFalse, + ); + }); + + test( + 'relay commit followed by local failure retries at a newer generation', + () async { + var durableCursor = 0; + var relayGeneration = 0; + var acceptedGeneration = 0; + var failLocalSave = true; + + Future reserve() async => ++durableCursor; + Future publish(int generation) async { + expect(generation, greaterThan(relayGeneration)); + relayGeneration = generation; + } + + Future markAccepted(int generation) async { + if (failLocalSave) { + failLocalSave = false; + throw StateError('injected local persistence failure'); + } + acceptedGeneration = generation; + } + + await expectLater( + publishBuzzPushLeaseRecoverably( + reserveGeneration: reserve, + publish: publish, + markAccepted: markAccepted, + ), + throwsStateError, + ); + expect(relayGeneration, 1); + expect(acceptedGeneration, 0); + + await publishBuzzPushLeaseRecoverably( + reserveGeneration: reserve, + publish: publish, + markAccepted: markAccepted, + ); + expect(relayGeneration, 2); + expect(acceptedGeneration, 2); + }, + ); } BuzzPushLeaseDescriptor _descriptor({ diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 6c0a5a938d6..f11e9c9deee 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -120,6 +120,7 @@ void main() { name: 'Community', relayUrl: 'wss://relay.example/', pubkey: 'd' * 64, + pushNotificationsEnabled: true, addedAt: DateTime.fromMillisecondsSinceEpoch(0), ), ], diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 359a3496df3..3ef21938e2a 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -43,17 +43,34 @@ void main() { ); }); - test('persists only the relay-accepted lease generation', () { + test('persists accepted and reserved relay lease generations', () { final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; - final state = BuzzPushLeaseSubscriptionState.desired( - desired: [subscription], - ).withAccepted(subscriptions: [subscription], generation: 9); + final state = + BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) + .withAccepted(subscriptions: [subscription], generation: 9) + .withReservedGeneration(10); final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); expect(decoded.acceptedGeneration, 9); + expect(decoded.generationCursor, 10); expect(decoded.toJson(), state.toJson()); }); + test('a retry reserves beyond a committed but unrecorded generation', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + final accepted = BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 4); + final committed = accepted.withReservedGeneration(5); + + final recovered = BuzzPushLeaseSubscriptionState.fromJson( + committed.toJson(), + ).withReservedGeneration(6); + + expect(recovered.acceptedGeneration, 4); + expect(recovered.generationCursor, 6); + }); + test('builds aligned self and unmuted channel subscriptions', () { final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me.toUpperCase(), From 9b7ad4639415b62688072c7d07824a7409438f8b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 26 Aug 2026 11:11:32 -0700 Subject: [PATCH 25/27] docs(push): preserve broad NIP-PL contract Signed-off-by: Tom Brow --- crates/buzz-push-gateway/src/http.rs | 1 + docs/nips/NIP-PL.md | 54 ++++++++++++++++------------ 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 3e0dd07bc8a..5ad87884107 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -899,6 +899,7 @@ mod request_limit_tests { #[tokio::test] async fn maximum_valid_enrollment_envelope_reaches_the_handler() { let body = maximum_enrollment_body(); + assert_eq!(MAX_ENROLL_REQUEST_BYTES, 23_896); assert!(body.len() > MAX_REQUEST_BYTES); assert!(body.len() <= MAX_ENROLL_REQUEST_BYTES); let (public, _) = router(state()); diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index b4aba368376..6575c98bfb3 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -17,7 +17,7 @@ Push Leases ## Abstract -This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs or FCM) when the filter matches. +This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs, FCM, optionally UnifiedPush) when the filter matches. The push payload is a **wake signal** authored entirely by the configured transport service: a fixed reconnect instruction, never relay-supplied bytes, event ids, event content, URLs, ciphertext, or extensible custom data. On wake, the client reconnects and fetches authoritative events over normal `REQ`. Push delivery is lossy and best-effort — duplicates and omissions are both possible; the relay remains the single source of truth. Platform transports are execution profiles for the lease, not the protocol's content plane. @@ -51,8 +51,8 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d - **origin**: the canonical origin identifier the descriptor advertises for a relay/community; the tenant key (see Acceptance and Origin Binding). - **wake signal**: the fixed, transport-authored reconnect payload defined in Wake Delivery. It contains no relay-supplied application data. - **subscription**: one `{filter, class, ignore?, suppress?}` entry inside a lease. -- **priority class**: `default` in this profile. -- **transport profile**: the APNs/FCM-specific execution rules for a lease. +- **priority class**: one of `silent`, `default`, `time_sensitive`, `urgent`. +- **transport profile**: the APNs/FCM/UnifiedPush-specific execution rules for a lease. ## The Lease Event @@ -73,7 +73,7 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d } ``` -- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. +- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, UnifiedPush endpoint, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. - `expiration` (NIP-40) is REQUIRED and MUST satisfy `now − allowed_skew < expiration ≤ now + max_lease_ttl` at acceptance (`invalid: lease ttl too long` / `invalid: lease already expired`; `max_lease_ttl` descriptor-advertised, default 30 days; RECOMMENDED `allowed_skew` 15 minutes). The executor MUST stop matching once it passes. Inactive (tombstone) replacements carry a public `expiration` under the same bound; it dates the tombstone, not any matching. Expiry is the self-healing backstop for every abuse and leak below. - `exec` names the descriptor encryption key the content was produced for (see Executor Discovery). - Public tags are exactly one `d`, one `expiration`, one `exec`, and at most one `alt`, each with exactly one value; duplicated tags, extra tags, or extra tag values MUST be rejected. The executor MUST reject a lease carrying filter, kind, author, endpoint, or platform data in public tags. @@ -87,12 +87,12 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d "v": 1, "origin": "", // tenant binding, verified — never routed on "app_profile": "com.example.app/ios", // selects transport credentials - "transport": "apns", // "apns" | "fcm" - "endpoint": "", // APNs or FCM token/capability + "transport": "apns", // "apns" | "fcm" | "unifiedpush" + "endpoint": "", // APNs token / FCM token / UP URL "generation": 3, // strictly increasing per lease address "active": true, // false = revocation tombstone "subscriptions": [ - { "filter": { "kinds": [9], "#p": [""] }, "class": "default" }, + { "filter": { "kinds": [9], "#p": [""] }, "class": "time_sensitive" }, { "filter": { "kinds": [9], "#h": [""] }, "class": "default", "ignore": [ { "kinds": [9], "authors": [""], "#h": [""] } ], "suppress": { "p_tags_max": 20 } } @@ -146,11 +146,18 @@ Each subscription carries exactly one `class`: | Class | Meaning | APNs `interruption-level` | Android importance | |---|---|---|---| +| `silent` | Sync-only wake, no alert | not user-visible; see APNs profile | `IMPORTANCE_MIN` | | `default` | Standard notification | `active` | `IMPORTANCE_DEFAULT` | +| `time_sensitive` | Breaks through Focus/DND within OS policy | `time-sensitive` | `IMPORTANCE_HIGH` | +| `urgent` | Reserved: approval gates | `critical` if entitled, else `time-sensitive` | `IMPORTANCE_HIGH` + full-screen intent where policy allows | -The descriptor's `class_support` is authoritative: a lease naming an unsupported class MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. +Classes are strictly ordered: `silent` < `default` < `time_sensitive` < `urgent`. When one deduplicated wake covers matches from multiple subscriptions or leases targeting the same endpoint (see Coalescing), the wake's effective class is the highest eligible class among those matches. The descriptor's `class_support` is authoritative: a lease naming a class unsupported for its transport MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. -Clients MUST NOT register any lease or subscription as a side effect of joining a channel or surface — absent explicit persisted user opt-in the notifiable set is empty. Opt-out MUST stop renewal and publish the higher-generation inactive replacement defined below; platform display authorization is separate state and MUST NOT be treated as the user's Buzz opt-in. +The executor MUST restrict `urgent` to the descriptor-advertised allow-list of approval-request kinds whose eligibility is decidable from the public event envelope (`invalid: class not permitted for kind`). Urgent DMs are explicitly out of scope for v1: gift-wrapped DM content is opaque to the executor, so no privacy-safe urgency marker exists yet; a future revision may add one. + +`silent` remains a matching preference only. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. + +Clients MUST NOT register any lease or subscription as a side effect of joining a channel or surface — absent explicit user opt-in the notifiable set is empty. ### Quotas @@ -169,8 +176,10 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in "app_profiles": [ { "id": "com.example.app/ios", "transport": "apns" }, { "id": "com.example.app/android", "transport": "fcm" } ], "push_kinds": [9, 1059, 40007, 46010, 7], + "urgent_kinds": [46010], "h_grammar": "uuid-v4-lowercase", - "class_support": { "apns": ["default"], "fcm": ["default"] }, + "class_support": { "apns": ["silent","default","time_sensitive","urgent"], + "fcm": ["silent","default","time_sensitive","urgent"] }, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, @@ -183,9 +192,9 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in } ``` -A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. +A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; `urgent_kinds ⊆ push_kinds`; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. -The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. +The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. Executors MUST NOT dereference a client-supplied `endpoint` URL except as the selected transport profile explicitly defines (UnifiedPush is the only profile whose endpoint is a URL, and it is validated per that profile before use). Leases MUST be author-only reads, as specified in Acceptance and Origin Binding, following the NIP-ER access pattern. @@ -221,12 +230,14 @@ The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"bo A future FCM profile MUST define one gateway-owned constant data message with identical noninterference semantics. Until that constant and its wire tests are registered, FCM is not a conforming v1 public-gateway profile. +### UnifiedPush (optional) + +UnifiedPush is not a conforming public-gateway profile in v1 because arbitrary distributor endpoints and message bodies do not meet the fixed-payload authority boundary. A future profile requires a separately registered constant body and hostile-endpoint analysis. + ## Lease and Key Lifecycle A lease is identified by `(author, kind, d)`. A replacement supersedes the prior lease at the same address only by passing the full acceptance sequence, including winning both NIP-01 addressable ordering and the strictly-increasing generation watermark (check 8). Any rejected replacement — stale by either ordering, or invalid for any other reason — MUST leave the stored event, effective push state, and watermark unchanged. -Before publishing any active replacement or inactive tombstone, a client MUST durably reserve its next generation. A failed or indeterminate publication consumes that local generation; retries advance again. This prevents a successful relay commit followed by a local persistence failure from trapping the client on a generation the relay has already accepted. - An active lease becomes ineffective when its `expiration` passes. Executors MUST NOT match, enqueue, or deliver wakes for an expired lease. Clients SHOULD refresh active leases before expiry; failure to refresh MUST NOT extend the prior lease. Expiry is a safety backstop, not evidence that a platform endpoint has been deleted. **Revocation.** Revocation is exclusively a higher-generation replacement with the minimal inactive plaintext — exactly `{"v", "origin", "generation", "active": false}`; `app_profile`, `transport`, `endpoint` and `subscriptions` MUST be absent. NIP-09 deletion is unsupported for `kind:30350`: relays MUST ignore deletion requests targeting this kind, so the stored/effective/watermark invariant has exactly one transition path. The executor validates the inactive schema without consulting endpoint or app-profile availability, so revocation succeeds even after an app profile or transport has been withdrawn from the descriptor. On acceptance the executor MUST treat it as a tombstone for that lease address: stop matching, cancel undelivered jobs where practical, and delete transport endpoint material when no longer required for audit or abuse prevention. Reactivation is an ordinary active replacement with a yet-higher generation. The executor MUST persist the generation watermark for a lease address until at least `max(last_active_expiration, tombstone_accepted_at + max_lease_ttl) + allowed_skew` when a tombstone exists, or `last_active_expiration + allowed_skew` when none does (after which any replay fails the expiration lower bound) — or a longer descriptor-advertised fixed retention — so a replayed older event can never resurrect a revoked lease. Logging out one installation MUST NOT alter sibling installation leases. @@ -266,9 +277,9 @@ The opaque string returned as `endpoint_grant` by `POST /v1/delegations` is the ### Common HTTP and value rules -All routes below accept only `POST`. Clients MUST send `Content-Type: application/json`; bodies are UTF-8 JSON and MUST be at most 8192 bytes. Every request object is closed: unknown members, duplicate members at any depth, missing or incorrectly typed members, trailing non-whitespace data, or a `v` other than integer `1` are `400 {"error":"invalid_request"}`. Integers are signed JSON integers in the ranges stated below. Unix times are integer seconds. UUIDs use the canonical lowercase hyphenated representation. Relay pubkeys are exactly 64 lowercase hexadecimal characters. APNs endpoints are non-empty, even-length lowercase hexadecimal strings encoding at most 512 bytes. Challenges are exactly 32 bytes encoded as unpadded URL-safe base64. `key_id`, `attestation`, and `assertion` use padded or unpadded standard base64 as accepted by Apple's App Attest API; decoded key ids are exactly 32 bytes, attestations are 1..16384 bytes, and assertions are 1..1024 bytes. An `endpoint_grant`, including its key-id prefix, MUST be at most 4096 bytes. +All routes below accept only `POST`. Clients MUST send `Content-Type: application/json`; bodies are UTF-8 JSON and MUST be at most 8192 bytes, except `POST /v1/installations`, whose body MUST be at most 23896 bytes. That installation-only ceiling is derived from the maximum permitted base64-encoded 16384-byte App Attest object, the maximum 512-byte APNs endpoint encoded as hex, and 1024 bytes for the remaining closed envelope. A body over its applicable limit is rejected with HTTP `413` before JSON parsing. Every request object is closed: unknown members, duplicate members at any depth, missing or incorrectly typed members, trailing non-whitespace data, or a `v` other than integer `1` are `400 {"error":"invalid_request"}`. Integers are signed JSON integers in the ranges stated below. Unix times are integer seconds. UUIDs use the canonical lowercase hyphenated representation. Relay pubkeys are exactly 64 lowercase hexadecimal characters. APNs endpoints are non-empty, even-length lowercase hexadecimal strings encoding at most 512 bytes. Challenges are exactly 32 bytes encoded as unpadded URL-safe base64. `key_id`, `attestation`, and `assertion` use padded or unpadded standard base64 as accepted by Apple's App Attest API; decoded key ids are exactly 32 bytes, attestations are 1..16384 bytes, and assertions are 1..1024 bytes. An `endpoint_grant`, including its key-id prefix, MUST be at most 4096 bytes. -Successful and error responses are UTF-8 `application/json`. Closed error bodies are `{"error":"invalid_request"}`, `{"error":"invalid_attestation"}`, `{"error":"not_authorized"}`, `{"error":"invalid_auth"}`, `{"error":"invalid_grant"}`, `{"error":"temporarily_unavailable"}`, `{"error":"configuration_fault"}`, or `{"error":"not_ready"}`. Authority/custody/quota rejection MUST NOT reveal whether an installation, delegation, or endpoint exists. In particular, delivery grant/authority/replay/quota failures collapse to `404 invalid_grant`; storage failures use `503 temporarily_unavailable`. +Handler responses are UTF-8 `application/json`. Closed error bodies are `{"error":"invalid_request"}`, `{"error":"invalid_attestation"}`, `{"error":"not_authorized"}`, `{"error":"invalid_auth"}`, `{"error":"invalid_grant"}`, `{"error":"rate_limited"}`, `{"error":"temporarily_unavailable"}`, `{"error":"configuration_fault"}`, or `{"error":"not_ready"}`. Authority, custody, and quota rejection MUST NOT reveal whether an installation, delegation, or endpoint exists. Delivery grant, authority, and replay failures collapse to `404 invalid_grant`; endpoint quota exhaustion uses `429 rate_limited`; storage failures use `503 temporarily_unavailable`. ### Exact App Attest transcript construction @@ -366,7 +377,7 @@ Transcript domain `buzz.push.revoke-delegation.v1`; ordered object: {"v":1,"audience":"https://push.buzz.xyz/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} ``` -The supplied generation MUST equal the current delegation generation. The gateway atomically compares it against the locked delegation row, marks that delegation revoked, and retains the same generation as its watermark. A stale or future generation is rejected without changing the delegation. Any later delegation for the same `(installation_handle, relay_pubkey)` MUST use a strictly greater generation. Success is `200 {"status":"revoked"}`. +The generation identifies the current delegation generation. Success is `200 {"status":"revoked"}`. `POST /v1/installations/revoke` request: @@ -402,12 +413,11 @@ Responses: - `503 {"error":"configuration_fault"}` — provider configuration fault; request reservation released after processing. - `400 {"error":"invalid_request"}` — malformed request or permanent APNs request fault; a provider-reached permanent fault is terminal. - `401 {"error":"invalid_auth"}` — absent or invalid NIP-98 authorization. -- `404 {"error":"invalid_grant"}` — capability, signer, authority, replay, expiry, or quota rejection. +- `404 {"error":"invalid_grant"}` — capability, signer, authority, replay, or expiry rejection. +- `429 {"error":"rate_limited"}` — endpoint delivery quota exhausted. - `503 {"error":"temporarily_unavailable"}` — durable authority/custody/disposition failure. -The gateway performs one APNs request per admitted delivery attempt. The -application body is always the exact constant registered in the APNs transport -profile above; no request or grant field enters it. +The gateway performs one APNs request, except that an APNs expired-provider-token response permits one credential refresh and one retry. The application body is always the exact constant registered in the APNs transport profile above; no request or grant field enters it. ## Implementation Notes (Buzz, non-normative) @@ -437,6 +447,6 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - `kind:30350`: push lease (addressable) - `exec` tag: executor encryption-key identifier for `kind:30350` - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery -- Classes: `default` +- Classes: `silent`, `default`, `time_sensitive`, `urgent` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) - Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` From db1637fc90bbba455ef8b3de37a9af9ca3af1308 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 26 Aug 2026 15:54:18 -0700 Subject: [PATCH 26/27] Persist and retry push opt-out tombstones Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/community/community.dart | 43 ++-- .../shared/community/community_provider.dart | 218 +++++++++++++----- mobile/lib/shared/push/push_bootstrap.dart | 70 ++++++ mobile/lib/shared/push/push_subscription.dart | 106 ++++++++- .../community/community_provider_test.dart | 79 ++++++- .../community/community_storage_test.dart | 47 ++++ .../test/shared/push/push_bootstrap_test.dart | 35 +++ 7 files changed, 511 insertions(+), 87 deletions(-) diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 86c4657973d..fa5d004f2e1 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -96,26 +96,35 @@ class Community { 'addedAt': addedAt.toIso8601String(), }; - factory Community.fromJson(Map json) => Community( - id: json['id'] as String, - name: json['name'] as String, - relayUrl: json['relayUrl'] as String, - pubkey: json['pubkey'] as String?, - nsec: json['nsec'] as String?, - sensitiveActionPolicy: SensitiveActionPolicy.values.firstWhere( - (value) => value.name == json['sensitiveActionPolicy'], - orElse: () => SensitiveActionPolicy.disabledByUser, - ), - pushNotificationsEnabled: - json['pushNotificationsEnabled'] as bool? ?? false, - pushSubscriptionState: json['pushSubscriptionState'] == null + factory Community.fromJson(Map json) { + final pushNotificationsEnabled = + json['pushNotificationsEnabled'] as bool? ?? false; + var pushSubscriptionState = json['pushSubscriptionState'] == null ? const BuzzPushLeaseSubscriptionState.desired() : BuzzPushLeaseSubscriptionState.fromJson( Map.from(json['pushSubscriptionState'] as Map), - ), - starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, - addedAt: DateTime.parse(json['addedAt'] as String), - ); + ); + if (!pushNotificationsEnabled && + pushSubscriptionState.pendingTombstoneGeneration == null) { + pushSubscriptionState = pushSubscriptionState + .withPendingTombstoneAtCursor(); + } + return Community( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + nsec: json['nsec'] as String?, + sensitiveActionPolicy: SensitiveActionPolicy.values.firstWhere( + (value) => value.name == json['sensitiveActionPolicy'], + orElse: () => SensitiveActionPolicy.disabledByUser, + ), + pushNotificationsEnabled: pushNotificationsEnabled, + pushSubscriptionState: pushSubscriptionState, + starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, + addedAt: DateTime.parse(json['addedAt'] as String), + ); + } /// Derive a human-friendly community name from a relay URL. static String nameFromUrl(String url) { diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index afe0d05b8a3..d77b72dfef4 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -107,59 +107,56 @@ Future _deactivateCommunityPushLease( final state = community.pushSubscriptionState; final acceptedGeneration = state.acceptedGeneration; final nsec = community.nsec; - if ((acceptedGeneration == null && generation == null) || - nsec == null || - nsec.isEmpty) { + if (acceptedGeneration == null && generation == null) { return; } - try { - final decoded = nostr.Nip19.decode(payload: nsec); - final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; - final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); - final installationId = (await readBuzzPushEndpointGrants()) - .where( - (grant) => - grant.relayOrigin == descriptor.origin && - grant.appProfile == buzzDevPushAppProfile, - ) - .map((grant) => grant.installationId) - .firstOrNull; - if (installationId == null) return; - final uri = Uri.parse(community.relayUrl); - final httpScheme = switch (uri.scheme) { - 'wss' => 'https', - 'ws' => 'http', - _ => uri.scheme, - }; - final wsScheme = httpScheme == 'https' ? 'wss' : 'ws'; - final wsUrl = uri.replace(scheme: wsScheme).toString(); - // Skip over the one renewal generation that could already be in flight - // when removal begins. Strict relay monotonicity then makes any stale - // active publication lose to this tombstone. - final tombstoneGeneration = - generation ?? (state.generationCursor ?? acceptedGeneration!) + 2; - await publishBuzzPushLeaseTombstone( - descriptor: descriptor, - installationId: installationId, - generation: tombstoneGeneration, - nsec: nsec, - memberPubkey: memberPubkey, - submit: ({required kind, required content, required tags, createdAt}) => - submitSignedEventOnce( - wsUrl: wsUrl, - nsec: nsec, - kind: kind, - content: content, - tags: tags, - createdAt: createdAt, - ), - ); - pushLeaseCleanupError.value = null; - } catch (error, stackTrace) { - // Community removal remains local-first. A failed best-effort tombstone is - // observable here and the already-bounded relay lease expires naturally. - reportPushLeaseCleanupError(error, stackTrace); + if (nsec == null || nsec.isEmpty) { + throw StateError('Push lease tombstone requires community signing key.'); + } + final decoded = nostr.Nip19.decode(payload: nsec); + final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; + final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final installationId = (await readBuzzPushEndpointGrants()) + .where( + (grant) => + grant.relayOrigin == descriptor.origin && + grant.appProfile == buzzDevPushAppProfile, + ) + .map((grant) => grant.installationId) + .firstOrNull; + if (installationId == null) { + throw StateError('No endpoint grant exists for push lease tombstone.'); } + final uri = Uri.parse(community.relayUrl); + final httpScheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => uri.scheme, + }; + final wsScheme = httpScheme == 'https' ? 'wss' : 'ws'; + final wsUrl = uri.replace(scheme: wsScheme).toString(); + // Skip over the one renewal generation that could already be in flight + // when removal begins. Strict relay monotonicity then makes any stale + // active publication lose to this tombstone. + final tombstoneGeneration = + generation ?? (state.generationCursor ?? acceptedGeneration!) + 2; + await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: installationId, + generation: tombstoneGeneration, + nsec: nsec, + memberPubkey: memberPubkey, + submit: ({required kind, required content, required tags, createdAt}) => + submitSignedEventOnce( + wsUrl: wsUrl, + nsec: nsec, + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + ), + ); + pushLeaseCleanupError.value = null; } class _CommunitySnapshotSync { @@ -207,6 +204,7 @@ Future syncStoredCommunitySnapshot(Ref ref) async { class CommunityListNotifier extends AsyncNotifier> { Future _pushMutationTail = Future.value(); + final Map> _tombstoneAttempts = {}; Future _serializePushMutation(Future Function() operation) { final result = _pushMutationTail.then((_) => operation()); @@ -269,9 +267,15 @@ class CommunityListNotifier extends AsyncNotifier> { (community) => community.id == id, ); if (removedIndex >= 0) { - await ref.read(communityPushLeaseDeactivatorProvider)( - current[removedIndex], - ); + try { + await ref.read(communityPushLeaseDeactivatorProvider)( + current[removedIndex], + ); + } catch (error, stackTrace) { + // Community removal remains local-first. Its already-bounded relay + // lease expires even if this best-effort final tombstone fails. + reportPushLeaseCleanupError(error, stackTrace); + } } await storage.remove(id); @@ -398,8 +402,7 @@ class CommunityListNotifier extends AsyncNotifier> { }); Future setPushNotificationsEnabled(String id, bool enabled) async { - Community? deactivation; - int? tombstoneGeneration; + var shouldDeactivate = false; await _serializePushMutation(() async { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); @@ -414,8 +417,7 @@ class CommunityListNotifier extends AsyncNotifier> { pushState.generationCursor != null)) { final cursor = pushState.generationCursor ?? pushState.acceptedGeneration ?? 0; - tombstoneGeneration = cursor + 1; - pushState = pushState.withReservedGeneration(tombstoneGeneration!); + pushState = pushState.withPendingTombstone(cursor + 1); } final updated = community.copyWith( pushNotificationsEnabled: enabled, @@ -425,16 +427,110 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); await syncCommunitySnapshot(ref, updatedList); - if (!enabled && tombstoneGeneration != null) deactivation = updated; + shouldDeactivate = + !enabled && pushState.pendingTombstoneGeneration != null; }); - if (deactivation != null) { + if (shouldDeactivate) { + try { + await retryPendingPushLeaseTombstone(id); + } catch (_) { + // The durable journal remains pending. BuzzPushBootstrap retries it + // after reconnect while all registration/enrollment paths stay off. + } + } + } + + /// Publishes a durably journaled opt-out tombstone. + /// + /// A retry advances the generation before network I/O. That makes an + /// ambiguous relay-commit/local-save failure idempotent in effect: the next + /// inactive replacement wins even if the prior tombstone already committed. + Future retryPendingPushLeaseTombstone( + String id, { + bool advanceGeneration = false, + }) { + final existing = _tombstoneAttempts[id]; + if (existing != null) return existing; + final attempt = _retryPendingPushLeaseTombstone( + id, + advanceGeneration: advanceGeneration, + ); + _tombstoneAttempts[id] = attempt; + void clearAttempt() { + if (identical(_tombstoneAttempts[id], attempt)) { + _tombstoneAttempts.remove(id); + } + } + + attempt.then( + (_) => clearAttempt(), + onError: (_, _) => clearAttempt(), + ); + return attempt; + } + + Future _retryPendingPushLeaseTombstone( + String id, { + required bool advanceGeneration, + }) async { + Community? pendingCommunity; + int? pendingGeneration; + await _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + final community = current[index]; + var pushState = community.pushSubscriptionState; + final pending = pushState.pendingTombstoneGeneration; + if (community.pushNotificationsEnabled || pending == null) return; + if (advanceGeneration) { + final cursor = pushState.generationCursor ?? pending; + pushState = pushState.withPendingTombstone(cursor + 1); + } + final updated = community.copyWith(pushSubscriptionState: pushState); + if (!identical(pushState, community.pushSubscriptionState)) { + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + } + pendingCommunity = updated; + pendingGeneration = pushState.pendingTombstoneGeneration; + }); + final community = pendingCommunity; + final generation = pendingGeneration; + if (community == null || generation == null) return; + try { await ref.read(communityPushLeaseDeactivatorProvider)( - deactivation!, - generation: tombstoneGeneration, + community, + generation: generation, ); + await _markPushLeaseTombstoneAccepted(id, generation); + pushLeaseCleanupError.value = null; + } catch (error, stackTrace) { + reportPushLeaseCleanupError(error, stackTrace); + rethrow; } } + Future _markPushLeaseTombstoneAccepted(String id, int generation) => + _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + final community = current[index]; + final updatedState = community.pushSubscriptionState + .withAcceptedTombstone(generation); + if (identical(updatedState, community.pushSubscriptionState)) return; + final updated = community.copyWith(pushSubscriptionState: updatedState); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + }); + Future renameCommunity(String id, String name) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? []; diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index fb180e2e9e0..b653a58ed73 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -57,6 +57,13 @@ class BuzzPushAttemptGate { }); } + void complete(String attempt) { + if (_attempt != attempt) return; + _retryTimer?.cancel(); + _retryTimer = null; + _attempt = null; + } + void dispose() => _retryTimer?.cancel(); } @@ -106,9 +113,12 @@ class BuzzPushBootstrap extends HookConsumerWidget { useListenable(apnsDeviceToken); final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); + final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); final registrationRetry = useState(0); final publicationRetry = useState(0); + final tombstoneRetry = useState(0); final session = ref.watch(relaySessionProvider); + final communities = ref.watch(communityListProvider).value ?? const []; final config = ref.watch(relayConfigProvider); final community = ref.watch(activeCommunityProvider).value; final memberPubkey = ref.watch(myPubkeyProvider); @@ -118,10 +128,70 @@ class BuzzPushBootstrap extends HookConsumerWidget { () => () { registrationAttempt.dispose(); publicationAttempt.dispose(); + tombstoneAttempt.dispose(); }, const [], ); + useEffect( + () { + final pendingCommunities = communities + .where( + (candidate) => + !candidate.pushNotificationsEnabled && + candidate.pushSubscriptionState.pendingTombstoneGeneration != + null, + ) + .toList(); + if (session.status != SessionStatus.connected || + pendingCommunities.isEmpty) { + return null; + } + const attempt = 'pending-tombstones'; + if (!tombstoneAttempt.tryBegin(attempt)) return null; + unawaited(() async { + try { + Object? firstError; + StackTrace? firstStack; + for (final pendingCommunity in pendingCommunities) { + try { + await ref + .read(communityListProvider.notifier) + .retryPendingPushLeaseTombstone( + pendingCommunity.id, + advanceGeneration: true, + ); + } catch (error, stack) { + firstError ??= error; + firstStack ??= stack; + } + } + if (firstError != null) { + Error.throwWithStackTrace(firstError, firstStack!); + } + tombstoneAttempt.complete(attempt); + } catch (error, stack) { + tombstoneAttempt.failed( + attempt, + retry: () { + if (context.mounted) tombstoneRetry.value += 1; + }, + ); + debugPrint('Push lease tombstone retry failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [ + session.status, + for (final candidate in communities) + '${candidate.id}|${candidate.pushNotificationsEnabled}|' + '${candidate.pushSubscriptionState.pendingTombstoneGeneration}', + tombstoneRetry.value, + ], + ); + useEffect( () { if (!_ready(session, config, community, memberPubkey) || diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index d804817af9e..41ef1808fec 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -190,18 +190,33 @@ class BuzzPushLeaseSubscriptionState { /// cannot make the next retry reuse a stale generation. final int? generationCursor; + /// Higher-generation inactive lease that still needs relay acceptance. + /// + /// This is persisted before publication. Ambiguous retries reserve a newer + /// generation so a relay-accepted tombstone whose local acknowledgement was + /// lost is safely superseded without weakening strict relay monotonicity. + final int? pendingTombstoneGeneration; + const BuzzPushLeaseSubscriptionState.desired({ this.desired = const [], this.accepted, this.acceptedGeneration, this.generationCursor, - }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; + this.pendingTombstoneGeneration, + }) : assert( + pendingTombstoneGeneration == null || + (pendingTombstoneGeneration > (acceptedGeneration ?? 0) && + generationCursor != null && + pendingTombstoneGeneration <= generationCursor), + ), + authority = BuzzPushLeaseSubscriptionAuthority.desired; BuzzPushLeaseSubscriptionState.accepted({ required Iterable desired, required Iterable acceptedSubscriptions, required this.acceptedGeneration, this.generationCursor, + this.pendingTombstoneGeneration, }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, desired = List.unmodifiable(desired), accepted = List.unmodifiable(acceptedSubscriptions) { @@ -215,6 +230,19 @@ class BuzzPushLeaseSubscriptionState { 'Push lease generation cursor cannot trail the accepted generation.', ); } + _validatePendingTombstone(); + } + + void _validatePendingTombstone() { + final pending = pendingTombstoneGeneration; + if (pending == null) return; + if (pending <= (acceptedGeneration ?? 0) || + generationCursor == null || + pending > generationCursor!) { + throw const FormatException( + 'Pending push tombstone must be newer than accepted state and durably reserved.', + ); + } } List get authoritative => switch (authority) { @@ -233,6 +261,7 @@ class BuzzPushLeaseSubscriptionState { accepted: accepted, acceptedGeneration: acceptedGeneration, generationCursor: generationCursor, + pendingTombstoneGeneration: pendingTombstoneGeneration, ), BuzzPushLeaseSubscriptionAuthority.accepted => BuzzPushLeaseSubscriptionState.accepted( @@ -240,6 +269,7 @@ class BuzzPushLeaseSubscriptionState { acceptedSubscriptions: accepted!, acceptedGeneration: acceptedGeneration, generationCursor: generationCursor, + pendingTombstoneGeneration: pendingTombstoneGeneration, ), }; } @@ -254,6 +284,11 @@ class BuzzPushLeaseSubscriptionState { generationCursor: generationCursor == null || generation > generationCursor! ? generation : generationCursor, + pendingTombstoneGeneration: + pendingTombstoneGeneration != null && + generation < pendingTombstoneGeneration! + ? pendingTombstoneGeneration + : null, ); BuzzPushLeaseSubscriptionState withReservedGeneration(int generation) { @@ -269,6 +304,7 @@ class BuzzPushLeaseSubscriptionState { accepted: accepted, acceptedGeneration: acceptedGeneration, generationCursor: generation, + pendingTombstoneGeneration: pendingTombstoneGeneration, ), BuzzPushLeaseSubscriptionAuthority.accepted => BuzzPushLeaseSubscriptionState.accepted( @@ -276,10 +312,57 @@ class BuzzPushLeaseSubscriptionState { acceptedSubscriptions: accepted!, acceptedGeneration: acceptedGeneration, generationCursor: generation, + pendingTombstoneGeneration: pendingTombstoneGeneration, ), }; } + BuzzPushLeaseSubscriptionState withPendingTombstone(int generation) { + if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) { + throw const FormatException( + 'Pending push tombstone generation must advance monotonically.', + ); + } + return BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + pendingTombstoneGeneration: generation, + ); + } + + /// Migrates a generation reserved by an older client before the explicit + /// tombstone journal field existed. + BuzzPushLeaseSubscriptionState withPendingTombstoneAtCursor() { + final generation = generationCursor; + if (generation == null || generation <= (acceptedGeneration ?? 0)) { + return this; + } + return BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + pendingTombstoneGeneration: generation, + ); + } + + BuzzPushLeaseSubscriptionState withAcceptedTombstone(int generation) { + if (generation != pendingTombstoneGeneration || + generation < (acceptedGeneration ?? 0)) { + return this; + } + return BuzzPushLeaseSubscriptionState.desired( + desired: desired, + acceptedGeneration: generation, + generationCursor: + generationCursor == null || generation > generationCursor! + ? generation + : generationCursor, + ); + } + Map toJson() => { 'authority': authority.name, 'desired': [for (final subscription in desired) subscription.toJson()], @@ -287,6 +370,8 @@ class BuzzPushLeaseSubscriptionState { 'accepted': [for (final subscription in accepted!) subscription.toJson()], if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, if (generationCursor != null) 'generationCursor': generationCursor, + if (pendingTombstoneGeneration != null) + 'pendingTombstoneGeneration': pendingTombstoneGeneration, }; factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { @@ -296,6 +381,7 @@ class BuzzPushLeaseSubscriptionState { 'accepted', 'acceptedGeneration', 'generationCursor', + 'pendingTombstoneGeneration', }, 'push subscription state'); final authority = json['authority']; final desired = _subscriptionList( @@ -309,6 +395,7 @@ class BuzzPushLeaseSubscriptionState { : _subscriptionList(acceptedRaw, 'accepted'); final acceptedGeneration = json['acceptedGeneration']; final generationCursor = json['generationCursor']; + final pendingTombstoneGeneration = json['pendingTombstoneGeneration']; if (acceptedGeneration != null && acceptedGeneration is! int) { throw const FormatException( 'Accepted push lease generation must be an integer.', @@ -319,12 +406,28 @@ class BuzzPushLeaseSubscriptionState { 'Push lease generation cursor must be an integer.', ); } + if (pendingTombstoneGeneration != null && + pendingTombstoneGeneration is! int) { + throw const FormatException( + 'Pending push tombstone generation must be an integer.', + ); + } + if (authority == 'desired' && + pendingTombstoneGeneration is int && + (pendingTombstoneGeneration <= (acceptedGeneration as int? ?? 0) || + generationCursor is! int || + pendingTombstoneGeneration > generationCursor)) { + throw const FormatException( + 'Pending push tombstone must be newer than accepted state and durably reserved.', + ); + } return switch (authority) { 'desired' => BuzzPushLeaseSubscriptionState.desired( desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration as int?, generationCursor: generationCursor as int?, + pendingTombstoneGeneration: pendingTombstoneGeneration as int?, ), 'accepted' when accepted != null && acceptedGeneration is int => BuzzPushLeaseSubscriptionState.accepted( @@ -332,6 +435,7 @@ class BuzzPushLeaseSubscriptionState { acceptedSubscriptions: accepted, acceptedGeneration: acceptedGeneration, generationCursor: generationCursor as int?, + pendingTombstoneGeneration: pendingTombstoneGeneration as int?, ), 'accepted' => throw const FormatException( 'Accepted push authority requires accepted subscriptions and generations.', diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index 6c8eb7c9867..117e1a646e1 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -18,6 +18,7 @@ void main() { late List> snapshots; late List deactivatedCommunityIds; late List deactivationGenerations; + late CommunityPushLeaseDeactivator deactivator; setUp(() { fakeSecure = FakeSecureStorage(); @@ -25,6 +26,10 @@ void main() { snapshots = []; deactivatedCommunityIds = []; deactivationGenerations = []; + deactivator = (community, {generation}) async { + deactivatedCommunityIds.add(community.id); + deactivationGenerations.add(generation); + }; }); tearDown(() => container.dispose()); @@ -36,13 +41,7 @@ void main() { communitySnapshotWriterProvider.overrideWithValue((communities) async { snapshots.add(List.of(communities)); }), - communityPushLeaseDeactivatorProvider.overrideWithValue(( - community, { - generation, - }) async { - deactivatedCommunityIds.add(community.id); - deactivationGenerations.add(generation); - }), + communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator), ], ); } @@ -232,8 +231,9 @@ void main() { final stored = (await communityStorage.loadAll()).single; expect(stored.pushNotificationsEnabled, isFalse); - expect(stored.pushSubscriptionState.acceptedGeneration, isNull); + expect(stored.pushSubscriptionState.acceptedGeneration, 2); expect(stored.pushSubscriptionState.generationCursor, 2); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull); expect(deactivationGenerations, [2]); }); @@ -262,6 +262,8 @@ void main() { final stored = (await communityStorage.loadAll()).single; expect(stored.pushNotificationsEnabled, isFalse); expect(stored.pushSubscriptionState.generationCursor, 8); + expect(stored.pushSubscriptionState.acceptedGeneration, 8); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull); expect(deactivatedCommunityIds, [community.id]); expect(deactivationGenerations, [8]); @@ -275,6 +277,67 @@ void main() { ); }); + test( + 'failed opt-out tombstone retries after restart at a newer generation', + () async { + var failTombstone = true; + deactivator = (community, {generation}) async { + deactivatedCommunityIds.add(community.id); + deactivationGenerations.add(generation); + if (failTombstone) { + throw StateError('injected tombstone failure'); + } + }; + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 7), + ); + await container + .read(communityListProvider.notifier) + .addCommunity(community); + + await container + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, false); + + var stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.acceptedGeneration, 7); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, 8); + expect(deactivationGenerations, [8]); + + container.dispose(); + failTombstone = false; + container = createContainer(); + await container.read(communityListProvider.future); + await container + .read(communityListProvider.notifier) + .retryPendingPushLeaseTombstone( + community.id, + advanceGeneration: true, + ); + + stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.acceptedGeneration, 9); + expect(stored.pushSubscriptionState.generationCursor, 9); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull); + expect(deactivationGenerations, [8, 9]); + }, + ); + test('removeCommunity removes from list', () async { container = createContainer(); await container.read(communityListProvider.future); diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index 2bb41ca24c8..486457e6ac5 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -150,6 +150,53 @@ void main() { ); }); + test('round-trips a pending push tombstone journal', () async { + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final state = + BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) + .withAccepted(subscriptions: [subscription], generation: 4) + .withPendingTombstone(5); + final community = Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + ).copyWith(pushSubscriptionState: state); + + await storage.save(community); + final loaded = (await storage.loadAll()).single; + + expect(loaded.pushSubscriptionState.toJson(), state.toJson()); + expect(loaded.pushSubscriptionState.pendingTombstoneGeneration, 5); + }); + + test( + 'migrates a disabled reserved generation into a tombstone journal', + () { + final community = + Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + ).copyWith( + pushNotificationsEnabled: false, + pushSubscriptionState: + const BuzzPushLeaseSubscriptionState.desired( + acceptedGeneration: 4, + generationCursor: 5, + ), + ); + final json = community.toJson(); + (json['pushSubscriptionState'] as Map).remove( + 'pendingTombstoneGeneration', + ); + + final migrated = Community.fromJson(json); + + expect(migrated.pushSubscriptionState.pendingTombstoneGeneration, 5); + }, + ); + test('save updates existing community with same id', () async { final ws = Community.create( name: 'Original', diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index b2daa5c12d6..6380837bf6d 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -45,6 +45,15 @@ void main() { expect(gate.tryBegin('attempt'), isTrue); }); + test('completed bootstrap attempt can run again for later work', () { + final gate = BuzzPushAttemptGate(); + addTearDown(gate.dispose); + + expect(gate.tryBegin('attempt'), isTrue); + gate.complete('attempt'); + expect(gate.tryBegin('attempt'), isTrue); + }); + test('publication attempt changes when the relay executor rotates', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), @@ -107,6 +116,32 @@ void main() { ); }); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith( + pushNotificationsEnabled: false, + pushSubscriptionState: + BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) + .withAccepted(subscriptions: [subscription], generation: 3) + .withPendingTombstone(4), + ); + + expect( + buzzPushLifecycleEnabled( + community: community, + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + ), + isFalse, + ); + }); + test( 'relay commit followed by local failure retries at a newer generation', () async { From 156571af508f7e0ca687f244a30289f48fc0b734 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 26 Aug 2026 15:54:28 -0700 Subject: [PATCH 27/27] Surface iOS notification permission state Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/ios/Runner/AppDelegate.swift | 43 +++++++ mobile/ios/RunnerTests/RunnerTests.swift | 9 ++ .../lib/features/settings/settings_page.dart | 1 + .../settings_page/notifications_section.dart | 38 +++++- mobile/lib/shared/push/push_bridge.dart | 82 ++++++++++++- .../features/settings/settings_page_test.dart | 110 ++++++++++++++++++ mobile/test/shared/push/push_bridge_test.dart | 102 ++++++++++++++++ 7 files changed, 381 insertions(+), 4 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index b3f89333996..74e60182204 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -339,6 +339,14 @@ import os.log startPushRegistration(result: result) case "takePendingNotificationResponse": result(pushNavigationBuffer.take()?.flutterArguments) + case "notificationAuthorizationStatus": + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + result(Self.pushAuthorizationStatusName(settings.authorizationStatus)) + } + } + case "openNotificationSettings": + openNotificationSettings(result: result) case "endpointGrants": do { result(try endpointGrantStore.records().map(\.flutterArguments)) @@ -358,6 +366,41 @@ import os.log } } + static func pushAuthorizationStatusName(_ status: UNAuthorizationStatus) -> String { + switch status { + case .notDetermined: + return "notDetermined" + case .denied: + return "denied" + case .authorized: + return "authorized" + case .provisional: + return "provisional" + case .ephemeral: + return "ephemeral" + @unknown default: + return "unknown" + } + } + + private func openNotificationSettings(result: @escaping FlutterResult) { + let settingsURLString: String + if #available(iOS 16.0, *) { + settingsURLString = UIApplication.openNotificationSettingsURLString + } else { + settingsURLString = UIApplication.openSettingsURLString + } + guard let url = URL(string: settingsURLString) else { + result(false) + return + } + UIApplication.shared.open(url, options: [:]) { opened in + DispatchQueue.main.async { + result(opened) + } + } + } + private func startPushRegistration(result: @escaping FlutterResult) { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, error in diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 65087046711..14c35dd21ec 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,21 @@ import AVFoundation import Flutter import UIKit +import UserNotifications import XCTest @testable import Buzz class RunnerTests: XCTestCase { + func testPushAuthorizationStatusNamesCoverDisplayPermissionStates() { + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.notDetermined), "notDetermined") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.denied), "denied") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.authorized), "authorized") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.provisional), "provisional") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.ephemeral), "ephemeral") + } + func testHuddleActiveTalkerSelectorBoundsAndReactivates() { var selector = HuddleActiveTalkerSelector(capacity: 15) diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index b153ca6931f..2e13b632052 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -13,6 +13,7 @@ import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/push/push_bridge.dart'; import '../pairing/pairing_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index 697d086cb3a..8560846b0ef 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -10,6 +10,28 @@ class _NotificationsSection extends ConsumerWidget { } final community = ref.watch(activeCommunityProvider).value; if (community == null) return const SizedBox.shrink(); + final authorization = ref.watch(buzzPushAuthorizationStatusProvider); + final status = authorization.value; + final permissionUnavailable = authorization.hasError; + final permissionDenied = status == BuzzPushAuthorizationStatus.denied; + final showSettingsRecovery = + community.pushNotificationsEnabled && + (permissionDenied || permissionUnavailable); + final subtitle = !community.pushNotificationsEnabled + ? 'Off for this community' + : switch (status) { + BuzzPushAuthorizationStatus.notDetermined => + 'Waiting for iOS notification permission', + BuzzPushAuthorizationStatus.denied => + 'Enabled in Buzz, but disabled in iOS Settings', + BuzzPushAuthorizationStatus.authorized || + BuzzPushAuthorizationStatus.provisional || + BuzzPushAuthorizationStatus.ephemeral => + 'Receive message notifications from this community', + null when authorization.isLoading => + 'Checking iOS notification permission', + null => 'Enabled in Buzz; iOS permission status unavailable', + }; return AppListCard( label: 'Notifications', @@ -19,7 +41,12 @@ class _NotificationsSection extends ConsumerWidget { key: const ValueKey('push-notifications-enabled'), icon: LucideIcons.bell, title: 'Push notifications', - subtitle: 'Receive message notifications from this community', + subtitle: subtitle, + subtitleStyle: showSettingsRecovery + ? context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ) + : null, trailing: Switch.adaptive( value: community.pushNotificationsEnabled, onChanged: (enabled) => unawaited( @@ -37,6 +64,15 @@ class _NotificationsSection extends ConsumerWidget { ), ), ), + if (showSettingsRecovery) + AppListRow( + key: const ValueKey('push-notifications-open-settings'), + icon: LucideIcons.settings, + title: 'Open iOS Notification Settings', + onTap: () => unawaited( + ref.read(buzzPushNotificationSettingsOpenerProvider)(), + ), + ), ], ); } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 0b5e53915cf..a0c674f14f6 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -1,14 +1,92 @@ +import 'dart:async'; + import 'package:nostr/nostr.dart' as nostr; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../community/community.dart'; import '../deeplink/deep_link.dart'; import '../relay/relay_provider.dart'; +import '../relay/app_lifecycle_provider.dart'; import 'push_snapshot.dart'; const _channel = MethodChannel('buzz/push'); +enum BuzzPushAuthorizationStatus { + notDetermined, + denied, + authorized, + provisional, + ephemeral, +} + +typedef BuzzPushAuthorizationStatusReader = + Future Function(); +typedef BuzzPushNotificationSettingsOpener = Future Function(); + +final buzzPushAuthorizationStatusReaderProvider = + Provider((ref) { + return readBuzzPushAuthorizationStatus; + }); + +final buzzPushNotificationSettingsOpenerProvider = + Provider((ref) { + return openBuzzPushNotificationSettings; + }); + +final buzzPushAuthorizationStatusProvider = + AsyncNotifierProvider< + BuzzPushAuthorizationStatusNotifier, + BuzzPushAuthorizationStatus + >(BuzzPushAuthorizationStatusNotifier.new); + +class BuzzPushAuthorizationStatusNotifier + extends AsyncNotifier { + @override + Future build() async { + ref.listen(appLifecycleProvider, (previous, next) { + if (previous != AppLifecycleState.resumed && + next == AppLifecycleState.resumed) { + unawaited(refresh()); + } + }); + return ref.read(buzzPushAuthorizationStatusReaderProvider)(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + ref.read(buzzPushAuthorizationStatusReaderProvider), + ); + } +} + +Future readBuzzPushAuthorizationStatus() async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return BuzzPushAuthorizationStatus.authorized; + } + final raw = await _channel.invokeMethod( + 'notificationAuthorizationStatus', + ); + return switch (raw) { + 'notDetermined' => BuzzPushAuthorizationStatus.notDetermined, + 'denied' => BuzzPushAuthorizationStatus.denied, + 'authorized' => BuzzPushAuthorizationStatus.authorized, + 'provisional' => BuzzPushAuthorizationStatus.provisional, + 'ephemeral' => BuzzPushAuthorizationStatus.ephemeral, + _ => throw FormatException( + 'Native push bridge returned unknown authorization status: $raw', + ), + }; +} + +Future openBuzzPushNotificationSettings() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return false; + return await _channel.invokeMethod('openNotificationSettings') ?? false; +} + /// Latest APNs registration state, including callbacks replayed by iOS after /// the Flutter method channel attaches. final apnsDeviceToken = ValueNotifier(null); @@ -169,9 +247,7 @@ void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) { void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) { pushLeaseCleanupError.value = error.toString(); - debugPrint( - 'Push lease cleanup failed; relay expiry remains the fallback: $error', - ); + debugPrint('Push lease cleanup failed: $error'); debugPrintStack(stackTrace: stackTrace); } diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index b914a9cd737..6ad7e51219c 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -3,6 +3,8 @@ import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; import 'package:flutter/material.dart'; @@ -29,6 +31,10 @@ void main() { overrides: [ savedPrefsProvider.overrideWithValue(prefs), activeCommunityProvider.overrideWith((ref) async => community), + appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => BuzzPushAuthorizationStatus.authorized, + ), ], child: MaterialApp( theme: AppTheme.light(), @@ -50,6 +56,105 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('shows denied display permission and opens iOS settings', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true); + var openSettingsCalls = 0; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith((ref) async => community), + appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => BuzzPushAuthorizationStatus.denied, + ), + buzzPushNotificationSettingsOpenerProvider.overrideWithValue( + () async { + openSettingsCalls += 1; + return true; + }, + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + find.text('Enabled in Buzz, but disabled in iOS Settings'), + findsOneWidget, + ); + await tester.tap( + find.byKey(const ValueKey('push-notifications-open-settings')), + ); + await tester.pump(); + expect(openSettingsCalls, 1); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('shows permission lookup errors with settings recovery', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith((ref) async => community), + appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => throw StateError('authorization unavailable'), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Enabled in Buzz; iOS permission status unavailable'), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('push-notifications-open-settings')), + findsOneWidget, + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('opens profile edit choices and routes photo directly', ( tester, ) async { @@ -331,3 +436,8 @@ void main() { ); }); } + +class _SettingsLifecycleNotifier extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index f11e9c9deee..4bff3a2c2c3 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -4,9 +4,12 @@ import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; const _channel = MethodChannel('buzz/push'); @@ -57,6 +60,85 @@ void main() { }, ); + test('reads native notification authorization status', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'notificationAuthorizationStatus'); + return 'denied'; + }); + + expect( + await readBuzzPushAuthorizationStatus(), + BuzzPushAuthorizationStatus.denied, + ); + }); + + test('opens native notification settings', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'openNotificationSettings'); + return true; + }); + + expect(await openBuzzPushNotificationSettings(), isTrue); + }); + + test('refreshes not-determined permission to denied on resume', () async { + final statuses = [ + BuzzPushAuthorizationStatus.notDetermined, + BuzzPushAuthorizationStatus.denied, + ]; + final container = ProviderContainer( + overrides: [ + appLifecycleProvider.overrideWith(_TestAppLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => statuses.removeAt(0), + ), + ], + ); + addTearDown(container.dispose); + + expect( + await container.read(buzzPushAuthorizationStatusProvider.future), + BuzzPushAuthorizationStatus.notDetermined, + ); + final lifecycle = + container.read(appLifecycleProvider.notifier) + as _TestAppLifecycleNotifier; + lifecycle.setState(AppLifecycleState.paused); + lifecycle.setState(AppLifecycleState.resumed); + await _waitForAuthorization(container, BuzzPushAuthorizationStatus.denied); + }); + + test('refreshes externally revoked permission on resume', () async { + final statuses = [ + BuzzPushAuthorizationStatus.authorized, + BuzzPushAuthorizationStatus.denied, + ]; + final container = ProviderContainer( + overrides: [ + appLifecycleProvider.overrideWith(_TestAppLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => statuses.removeAt(0), + ), + ], + ); + addTearDown(container.dispose); + + expect( + await container.read(buzzPushAuthorizationStatusProvider.future), + BuzzPushAuthorizationStatus.authorized, + ); + final lifecycle = + container.read(appLifecycleProvider.notifier) + as _TestAppLifecycleNotifier; + lifecycle.setState(AppLifecycleState.paused); + lifecycle.setState(AppLifecycleState.resumed); + await _waitForAuthorization(container, BuzzPushAuthorizationStatus.denied); + }); + test('reads and exposes persisted endpoint grants on iOS', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; final messenger = @@ -302,6 +384,26 @@ void main() { }); } +class _TestAppLifecycleNotifier extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; + + void setState(AppLifecycleState value) => state = value; +} + +Future _waitForAuthorization( + ProviderContainer container, + BuzzPushAuthorizationStatus expected, +) async { + for (var attempt = 0; attempt < 20; attempt++) { + if (container.read(buzzPushAuthorizationStatusProvider).value == expected) { + return; + } + await Future.delayed(Duration.zero); + } + fail('Authorization status did not refresh to $expected'); +} + Map _grantMap(String endpointGrant) => { 'relayOrigin': 'wss://relay.example', 'relayPubkey': 'a' * 64,