From 76f7e5ef0cc4a7d71d9562ecfb3e4a6887a51461 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 15 Sep 2026 14:56:45 -0700 Subject: [PATCH] feat(push): make gateway origin configurable (#7261) Make the push gateway origin configurable across the relay, gateway, mobile client, and deployment chart. This is a fresh deployment. The gateway has only run on personal development infrastructure, and push has no existing enabled users. Legacy migration, configured-to-unconfigured mobile transitions, and rolling upgrades from pre-launch gateway binaries are not supported. Populated pre-launch databases are rejected with an informative error before migrations run; their data is untouched. Mobile builds may omit the gateway URL, leaving push unavailable. Once push is enabled, subsequent builds must retain that configuration. ### Validation - All applicable CI checks pass at `5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust, mobile/Swift, desktop/integration, security, image builds, and Helm checks. An unrelated agent cancellation test passed on retry. - PostgreSQL CI passed 384 tests, including the production-entry-point regression for refusing populated pre-launch databases before migrations, fresh initialization, and subsequent initialized deployments. - Focused local validation passed all 10 gateway PostgreSQL tests under the CI discovery filter and all 3 unconfigured-mobile tests. Repository-wide PostgreSQL discovery validation and the complete pre-push hooks passed. - Physical-device APNs/App Attest behavior and deployment to a live cluster were not verified in this work. --------- Signed-off-by: Tom Brow Co-authored-by: Codex --- .env.example | 5 +- .github/workflows/_ci-clients.yml | 10 +- AGENTS.md | 2 +- Justfile | 25 +- crates/buzz-db/src/runtime/migration.rs | 2 +- .../0005_retain_revocation_tombstones.sql | 15 + crates/buzz-push-gateway/src/authority.rs | 111 ++++++- crates/buzz-push-gateway/src/config.rs | 122 ++++++-- crates/buzz-push-gateway/src/http.rs | 108 +++++-- crates/buzz-push-gateway/src/main.rs | 2 +- crates/buzz-push-gateway/src/postgres.rs | 285 ++++++++++++----- .../src/postgres/bootstrap_guard.sql | 29 ++ .../src/postgres/bootstrap_tests.rs | 78 +++++ crates/buzz-relay/src/config.rs | 48 +-- .../buzz-push-gateway/ci/ci-values.yaml | 2 + .../templates/deployment.yaml | 2 +- .../templates/httproute.yaml | 4 +- .../charts/buzz-push-gateway/tests/render.sh | 39 ++- .../buzz-push-gateway/values-production.yaml | 7 +- .../buzz-push-gateway/values.schema.json | 18 +- deploy/charts/buzz-push-gateway/values.yaml | 5 +- deploy/compose/.env.example | 4 + deploy/compose/README.md | 4 + docs/nips/NIP-PL.md | 4 +- docs/push-gateway-deployment.md | 40 ++- ...0045_retain_push_revocation_tombstones.sql | 11 + mobile/README.md | 31 +- mobile/android/app/build.gradle.kts | 40 +++ .../BuzzDevPushEnrollmentDriver.swift | 288 ++++++++++++------ .../BuzzPushPendingEnrollmentRecord.swift | 26 +- .../BuzzPushKit/BuzzPushTranscript.swift | 73 +++-- .../BuzzDevPushEnrollmentDriverTests.swift | 234 ++++++++++---- .../BuzzPushTranscriptTests.swift | 41 +++ mobile/ios/Runner.xcodeproj/project.pbxproj | 2 +- mobile/ios/Runner/AppDelegate.swift | 11 +- .../ios/Runner/PushEndpointGrantStore.swift | 33 +- .../BuzzCommunicationNotificationTests.swift | 2 + .../settings_page/notifications_section.dart | 14 + .../shared/community/community_provider.dart | 13 +- mobile/lib/shared/push/push_bootstrap.dart | 111 ++++--- mobile/lib/shared/push/push_bridge.dart | 14 +- .../push/push_relay_capability_provider.dart | 2 + mobile/lib/shared/relay/relay_provider.dart | 10 +- mobile/scripts/require-push-gateway-origin.sh | 55 ++++ .../scripts/validate_push_gateway_origin.dart | 37 +++ .../channels/voice_note_recording_test.dart | 37 ++- .../community/community_provider_test.dart | 59 +++- .../test/shared/push/push_bootstrap_test.dart | 38 ++- mobile/test/shared/push/push_bridge_test.dart | 1 + .../push_gateway_origin_validator_test.dart | 95 ++++++ .../push/push_unconfigured_build_test.dart | 116 +++++++ schema/schema.sql | 10 +- scripts/test-mobile-gateway-recipes.sh | 58 ++++ 53 files changed, 1941 insertions(+), 492 deletions(-) create mode 100644 crates/buzz-push-gateway/migrations/0005_retain_revocation_tombstones.sql create mode 100644 crates/buzz-push-gateway/src/postgres/bootstrap_guard.sql create mode 100644 crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs create mode 100644 deploy/charts/buzz-push-gateway/ci/ci-values.yaml create mode 100644 migrations/0045_retain_push_revocation_tombstones.sql create mode 100644 mobile/scripts/require-push-gateway-origin.sh create mode 100644 mobile/scripts/validate_push_gateway_origin.dart create mode 100644 mobile/test/shared/push/push_gateway_origin_validator_test.dart create mode 100644 mobile/test/shared/push/push_unconfigured_build_test.dart create mode 100644 scripts/test-mobile-gateway-recipes.sh diff --git a/.env.example b/.env.example index 6d12738247..683bceb4cc 100644 --- a/.env.example +++ b/.env.example @@ -75,10 +75,9 @@ RELAY_URL=ws://localhost:3000 # 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. +# never enables it. When enabled, the exact HTTPS delivery URL is required. BUZZ_PUSH_ENABLED=false -# BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns +BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # ----------------------------------------------------------------------------- # Admin Dashboard (private moderation surface) diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 1d4f0ab50d..8238769df2 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -111,7 +111,11 @@ jobs: - name: Analyze run: cd mobile && flutter analyze - name: Test - run: cd mobile && flutter test + run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example + - name: Test mobile without push configuration + run: cd mobile && flutter test test/shared/push/push_unconfigured_build_test.dart + - name: Test mobile recipe argument forwarding + run: /bin/bash scripts/test-mobile-gateway-recipes.sh - name: Build Android debug APK run: just mobile-build-android @@ -134,7 +138,9 @@ jobs: - name: Build complete iOS simulator app run: cd mobile && flutter build ios --simulator --debug --no-pub - name: Build complete unsigned iOS release - run: cd mobile && flutter build ios --release --no-codesign --no-pub + run: | + /bin/bash scripts/test-mobile-gateway-recipes.sh + cd mobile && flutter build ios --release --no-codesign --no-pub results: name: Results diff --git a/AGENTS.md b/AGENTS.md index 2679499012..7afdc429fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -691,7 +691,7 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. cd mobile dart format --output=none --set-exit-if-changed . flutter analyze -flutter test +flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example ``` Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests). diff --git a/Justfile b/Justfile index 00c4fecc2f..4554c694ef 100644 --- a/Justfile +++ b/Justfile @@ -829,7 +829,9 @@ mobile-check: # Run mobile tests mobile-test: - unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test + /bin/bash ./scripts/test-mobile-gateway-recipes.sh + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test test/shared/push/push_unconfigured_build_test.dart # Regenerate the emoji dataset asset from desktop's emoji-mart install. # Output is committed — rerun after bumping @emoji-mart/data. @@ -838,8 +840,16 @@ mobile-emoji-data: # Compile an unsigned Android debug APK (worktree-aware debug identity) mobile-build-android: + #!/usr/bin/env bash + set -euo pipefail ./scripts/mobile-worktree-overrides.sh - unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub + set -- build apk --debug --no-pub + if [[ -n "${BUZZ_PUSH_GATEWAY_URL:-}" ]]; then + set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${BUZZ_PUSH_GATEWAY_URL}" + fi + unset GIT_DIR GIT_WORK_TREE + cd {{mobile_dir}} + flutter "$@" # Run the mobile app on iOS simulator (worktree-aware debug identity) mobile-dev: @@ -850,9 +860,18 @@ mobile-dev: sleep 3 fi ./scripts/mobile-worktree-overrides.sh + gateway_url="${BUZZ_PUSH_GATEWAY_URL:-}" + overrides_file="{{mobile_dir}}/ios/Flutter/AppOverrides.xcconfig" + if [[ -z "$gateway_url" && -f "$overrides_file" ]]; then + gateway_url="$(sed -nE 's/^[[:space:]]*BUZZ_PUSH_GATEWAY_URL[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$overrides_file" | tail -n 1 | sed 's/\$()//g')" + fi + set -- run + if [[ -n "$gateway_url" ]]; then + set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${gateway_url}" + fi cd {{mobile_dir}} unset GIT_DIR GIT_WORK_TREE - flutter run + flutter "$@" # Uninstall stale worktree-suffixed Buzz debug installs (production apps kept) mobile-clean: diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 5901512504..ecd48bef59 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 44); + assert_eq!(migrations.len(), 45); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] diff --git a/crates/buzz-push-gateway/migrations/0005_retain_revocation_tombstones.sql b/crates/buzz-push-gateway/migrations/0005_retain_revocation_tombstones.sql new file mode 100644 index 0000000000..bb00d1db13 --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0005_retain_revocation_tombstones.sql @@ -0,0 +1,15 @@ +-- Revoked installations remain as retry tombstones until authority expiry. +-- Restrict ownership uniqueness to live rows so a replacement can enroll +-- without deleting the old installation's idempotency state. +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_attest_key_id_key; +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_token_fingerprint_key; + +CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key + ON push_gateway_installations (app_attest_key_id) + WHERE revoked_at IS NULL; + +CREATE UNIQUE INDEX push_gateway_installations_active_profile_token + ON push_gateway_installations (app_profile, token_fingerprint) + WHERE revoked_at IS NULL; diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 1a7ef4b2a6..9f71f6ee69 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -103,6 +103,8 @@ pub enum DeliveryDisposition { pub enum AuthorityError { #[error("authority state rejected the request")] Rejected, + #[error("installation authority is already live")] + Conflict, #[error("authority request rate exceeded")] RateLimited, #[error("authority store unavailable")] @@ -140,6 +142,13 @@ pub trait AuthorityStore: Send + Sync { now: i64, ) -> Result, AuthorityError>; async fn installation(&self, id: Uuid, now: i64) -> Result; + /// Load an unexpired installation for authenticated revocation retry, + /// including its terminal tombstone. + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result; async fn advance_assertion_counter( &self, installation_id: Uuid, @@ -155,8 +164,9 @@ 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. + /// Revoke a delegation only when `expected_generation` is current, + /// retaining that generation as the replacement watermark. Repeating the + /// exact revocation succeeds so clients can recover from a lost response. async fn revoke_delegation( &self, installation_id: Uuid, @@ -267,7 +277,7 @@ impl AuthorityStore for MemoryAuthorityStore { if s.installations.contains_key(&n.id) { return Err(AuthorityError::Rejected); } - let replaced = s + let matching = s .installations .values() .filter(|installation| { @@ -277,14 +287,22 @@ impl AuthorityStore for MemoryAuthorityStore { }) .map(|installation| installation.id) .collect::>(); - if replaced.iter().any(|id| { + if matching.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); + return Err(AuthorityError::Conflict); } + let replaced = matching + .into_iter() + .filter(|id| { + s.installations + .get(id) + .is_some_and(|installation| installation.expires_at < now) + }) + .collect::>(); for id in replaced { if let Some(old) = s.installations.remove(&id) { s.token_owners.remove(&(old.profile, old.token_fingerprint)); @@ -323,6 +341,20 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(i.clone()) } + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result { + let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let i = s + .installations + .get(&id) + .filter(|i| i.expires_at >= now) + .ok_or(AuthorityError::Rejected)?; + Ok(i.clone()) + } + async fn matching_installation( &self, key_id: &[u8], @@ -361,7 +393,7 @@ impl AuthorityStore for MemoryAuthorityStore { .installations .get_mut(&id) .ok_or(AuthorityError::Rejected)?; - if i.revoked || i.assertion_counter != previous { + if i.assertion_counter != previous { return Err(AuthorityError::Rejected); } i.assertion_counter = next; @@ -449,9 +481,12 @@ impl AuthorityStore for MemoryAuthorityStore { .delegations .get_mut(&key) .ok_or(AuthorityError::Rejected)?; - if old.revoked || expected_generation != old.generation { + if expected_generation != old.generation { return Err(AuthorityError::Rejected); } + if old.revoked { + return Ok(()); + } old.revoked = true; Ok(()) } @@ -470,6 +505,9 @@ impl AuthorityStore for MemoryAuthorityStore { .installations .get_mut(&id) .ok_or(AuthorityError::Rejected)?; + if i.revoked && i.endpoint_epoch == new { + return Ok(()); + } if i.revoked || i.endpoint_epoch != expected { return Err(AuthorityError::Rejected); } @@ -758,7 +796,7 @@ mod tests { store .create_installation(replacement(Uuid::from_u128(5)), 1_999) .await, - Err(AuthorityError::Rejected) + Err(AuthorityError::Conflict) ); store .create_installation(replacement(Uuid::from_u128(5)), 2_001) @@ -838,6 +876,10 @@ mod tests { .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) .await .expect("the current generation can be revoked"); + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) + .await + .expect("the exact revocation is idempotent after response loss"); assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4()) .await .is_err()); @@ -878,4 +920,57 @@ mod tests { .await .expect("generation 2 authority is active"); } + + #[tokio::test] + async fn installation_revocation_is_authenticated_and_idempotent() { + let store = store().await; + let id = Uuid::from_u128(1); + let before = store.installation(id, 1_000).await.unwrap(); + + store + .revoke_installation(id, 1, 2) + .await + .expect("the current endpoint epoch can be revoked"); + let tombstone = store + .installation_for_revocation(id, 1_000) + .await + .expect("revocation retry can authenticate against the tombstone"); + assert!(tombstone.revoked); + store + .advance_assertion_counter(id, before.assertion_counter, before.assertion_counter + 1) + .await + .expect("an authenticated tombstone retry can advance its assertion counter"); + store + .revoke_installation(id, 1, 2) + .await + .expect("the exact installation revocation is idempotent"); + store + .create_installation( + NewInstallation { + id: Uuid::from_u128(5), + 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, + }, + 1_001, + ) + .await + .expect("a replacement can reuse ownership without deleting the tombstone"); + assert!( + store + .installation_for_revocation(id, 1_001) + .await + .expect("replacement preserves the prior revocation tombstone") + .revoked + ); + assert_eq!( + store.revoke_installation(id, 2, 3).await, + Err(AuthorityError::Rejected) + ); + } } diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index f8485a628d..1835468fee 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -26,7 +26,8 @@ pub struct KeyConfig { pub struct Config { pub bind_addr: SocketAddr, pub health_addr: SocketAddr, - pub public_delivery_url: url::Url, + /// External gateway origin, delivery URL, and registered transcript audiences. + pub gateway_urls: GatewayUrls, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -41,6 +42,54 @@ pub struct Config { /// externally presented delivery capabilities. pub token_keys: Vec, } + +/// Gateway transport URLs and registered NIP-PL v1 transcript audiences. +#[derive(Debug, Clone)] +pub struct GatewayUrls { + /// External HTTPS origin serving the gateway. + pub origin: url::Url, + /// Exact NIP-98 delivery endpoint used by relays. + pub delivery: url::Url, + /// App Attest audience for installation enrollment. + pub enroll_audience: String, + /// App Attest audience for relay delegation. + pub delegate_audience: String, + /// App Attest audience for endpoint rotation. + pub rotate_endpoint_audience: String, + /// App Attest audience for delegation revocation. + pub revoke_delegation_audience: String, + /// App Attest audience for installation revocation. + pub revoke_installation_audience: String, +} + +impl GatewayUrls { + pub(crate) fn from_origin(origin: url::Url) -> Result { + let derive = |path: &str| { + origin + .join(path) + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")) + }; + let delivery = derive("v1/deliveries/apns")?; + // NIP-PL v1 registers these exact audience strings. The configurable + // origin controls transport only; changing transcript bytes requires + // a separately versioned protocol profile. + let enroll_audience = "https://push.buzz.xyz/v1/installations".to_owned(); + let delegate_audience = "https://push.buzz.xyz/v1/delegations".to_owned(); + let rotate_endpoint_audience = "https://push.buzz.xyz/v1/installations/endpoint".to_owned(); + let revoke_delegation_audience = "https://push.buzz.xyz/v1/delegations/revoke".to_owned(); + let revoke_installation_audience = + "https://push.buzz.xyz/v1/installations/revoke".to_owned(); + Ok(Self { + origin, + delivery, + enroll_audience, + delegate_audience, + rotate_endpoint_audience, + revoke_delegation_audience, + revoke_installation_audience, + }) + } +} #[derive(Debug, Error)] pub enum ConfigError { #[error("missing required environment variable {0}")] @@ -132,20 +181,21 @@ impl Config { }) { return Err(ConfigError::Invalid("BUZZ_PUSH_TOKEN_KEYS")); } - let public_delivery_url = req(e, "BUZZ_PUSH_PUBLIC_DELIVERY_URL")? + let gateway_origin = req(e, "BUZZ_PUSH_GATEWAY_ORIGIN")? .parse::() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL"))?; - if public_delivery_url.scheme() != "https" - || public_delivery_url.host_str() != Some("push.buzz.xyz") - || public_delivery_url.port().is_some() - || public_delivery_url.path() != "/v1/deliveries/apns" - || public_delivery_url.query().is_some() - || public_delivery_url.fragment().is_some() - || !public_delivery_url.username().is_empty() - || public_delivery_url.password().is_some() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN"))?; + if gateway_origin.scheme() != "https" + || gateway_origin.host().is_none() + || gateway_origin.port().is_some() + || gateway_origin.path() != "/" + || gateway_origin.query().is_some() + || gateway_origin.fragment().is_some() + || !gateway_origin.username().is_empty() + || gateway_origin.password().is_some() { - return Err(ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL")); + return Err(ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")); } + let gateway_urls = GatewayUrls::from_origin(gateway_origin)?; let max_grant_lifetime_seconds = req(e, "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS")? .parse::() .ok() @@ -191,7 +241,7 @@ impl Config { Ok(Self { bind_addr, health_addr, - public_delivery_url, + gateway_urls, max_grant_lifetime_seconds, max_installation_lifetime_seconds, endpoint_quota_window_seconds, @@ -227,8 +277,8 @@ mod tests { ), ), ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL".into(), - "https://push.buzz.xyz/v1/deliveries/apns".into(), + "BUZZ_PUSH_GATEWAY_ORIGIN".into(), + "https://push.example".into(), ), ( "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(), @@ -285,6 +335,36 @@ mod tests { } } + #[test] + fn gateway_transport_uses_configured_origin_and_transcript_audiences_stay_registered() { + let config = Config::from_map(&base()).unwrap(); + assert_eq!(config.gateway_urls.origin.as_str(), "https://push.example/"); + assert_eq!( + config.gateway_urls.delivery.as_str(), + "https://push.example/v1/deliveries/apns" + ); + assert_eq!( + config.gateway_urls.enroll_audience, + "https://push.buzz.xyz/v1/installations" + ); + assert_eq!( + config.gateway_urls.delegate_audience, + "https://push.buzz.xyz/v1/delegations" + ); + assert_eq!( + config.gateway_urls.rotate_endpoint_audience, + "https://push.buzz.xyz/v1/installations/endpoint" + ); + assert_eq!( + config.gateway_urls.revoke_delegation_audience, + "https://push.buzz.xyz/v1/delegations/revoke" + ); + assert_eq!( + config.gateway_urls.revoke_installation_audience, + "https://push.buzz.xyz/v1/installations/revoke" + ); + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); @@ -298,14 +378,10 @@ mod tests { #[test] fn malformed_security_configuration_fails_startup() { for (key, value) in [ - ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL", - "http://push.example/v1/deliveries/apns", - ), - ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL", - "https://push.example/v1/deliveries/apns", - ), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "http://push.example"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example/path"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example?token=x"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://user@push.example"), ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 9a6c66a519..657c549b1e 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -5,6 +5,7 @@ use crate::{ authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, + config::GatewayUrls, grant::GrantKeyring, model::*, token::TokenKeyring, @@ -46,7 +47,8 @@ pub struct AppState { /// 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, + /// Security-sensitive endpoints and audiences derived from one gateway origin. + pub gateway_urls: Arc, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -88,12 +90,16 @@ 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::Conflict => installation_conflict(), AuthorityError::RateLimited => error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"), AuthorityError::Unavailable => { error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable") } } } +fn installation_conflict() -> Response { + error(StatusCode::CONFLICT, "installation_conflict") +} fn endpoint_bytes(endpoint: &str) -> Option> { valid_endpoint(endpoint) .then(|| hex::decode(endpoint).ok()) @@ -151,7 +157,7 @@ async fn challenge(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct EnrollTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, key_id: &'a str, @@ -186,7 +192,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { }; let t = EnrollTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations", + audience: &s.gateway_urls.enroll_audience, challenge_id: r.challenge_id, challenge: &r.challenge, key_id: &r.key_id, @@ -232,7 +238,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { ) .into_response(); } - Ok(Some(_)) => return error(StatusCode::NOT_FOUND, "not_authorized"), + Ok(Some(_)) => return installation_conflict(), Ok(None) => {} Err(e) => return authority_error(e), } @@ -273,23 +279,31 @@ async fn enroll(State(s): State, body: Bytes) -> Response { .into_response() } +struct AssertionChallenge<'a> { + id: uuid::Uuid, + text: &'a str, +} + async fn verify_installation_assertion( s: &AppState, installation_id: uuid::Uuid, - challenge_id: uuid::Uuid, - challenge_text: &str, + challenge: AssertionChallenge<'_>, assertion: &str, domain: &str, signed: &T, + include_revoked: bool, ) -> Result<(), Response> { let now = (s.now)(); - let challenge = decode_challenge(challenge_text) + let challenge_bytes = decode_challenge(challenge.text) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; - let installation = s - .authority - .installation(installation_id, now) - .await - .map_err(authority_error)?; + let installation = if include_revoked { + s.authority + .installation_for_revocation(installation_id, now) + .await + } else { + s.authority.installation(installation_id, now).await + } + .map_err(authority_error)?; if installation.profile != AppProfile::BuzzIosDogfood { return Err(error(StatusCode::NOT_FOUND, "not_authorized")); } @@ -303,12 +317,12 @@ async fn verify_installation_assertion( transcript.as_bytes(), &installation.app_attest_public_key, installation.assertion_counter, - challenge_text, - challenge_text, + challenge.text, + challenge.text, ) .map_err(|_| error(StatusCode::UNAUTHORIZED, "invalid_attestation"))?; s.authority - .consume_challenge(challenge_id, challenge, now) + .consume_challenge(challenge.id, challenge_bytes, now) .await .map_err(authority_error)?; s.authority @@ -324,7 +338,7 @@ async fn verify_installation_assertion( #[derive(serde::Serialize)] struct DelegateTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -352,7 +366,7 @@ async fn delegate(State(s): State, body: Bytes) -> Response { } let t = DelegateTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/delegations", + audience: &s.gateway_urls.delegate_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -365,11 +379,14 @@ async fn delegate(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.delegate.v1", &t, + false, ) .await { @@ -413,7 +430,7 @@ async fn delegate(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct RotateTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -446,7 +463,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { }; let t = RotateTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations/endpoint", + audience: &s.gateway_urls.rotate_endpoint_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -457,11 +474,14 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.rotate-endpoint.v1", &t, + false, ) .await { @@ -489,7 +509,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct RevokeDelegationTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -506,7 +526,7 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { } let t = RevokeDelegationTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/delegations/revoke", + audience: &s.gateway_urls.revoke_delegation_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -516,11 +536,14 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.revoke-delegation.v1", &t, + false, ) .await { @@ -538,7 +561,7 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct RevokeInstallationTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -558,7 +581,7 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response } let t = RevokeInstallationTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations/revoke", + audience: &s.gateway_urls.revoke_installation_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -568,11 +591,14 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.revoke-installation.v1", &t, + true, ) .await { @@ -613,7 +639,7 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> }; let relay = match verify_auth_header( auth, - &s.delivery_url, + &s.gateway_urls.delivery, HttpMethod::POST, Timestamp::now(), Some(&body), @@ -662,6 +688,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::Conflict) => { + crate::metrics::record_admission(crate::metrics::Admission::Rejected); + 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"); @@ -875,7 +906,9 @@ mod request_limit_tests { app_attest: Arc::new(app_attest), transport: Arc::new(NeverTransport), }), - delivery_url: "https://push.buzz.xyz/v1/deliveries/apns".parse().unwrap(), + gateway_urls: Arc::new( + GatewayUrls::from_origin("https://push.example".parse().unwrap()).unwrap(), + ), max_grant_lifetime_seconds: 86_400, max_installation_lifetime_seconds: 86_400, endpoint_quota_window_seconds: 60, @@ -934,6 +967,15 @@ mod request_limit_tests { assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } + #[test] + fn live_installation_conflict_has_an_unambiguous_status() { + assert_eq!( + authority_error(AuthorityError::Conflict).status(), + StatusCode::CONFLICT + ); + assert_eq!(installation_conflict().status(), StatusCode::CONFLICT); + } + #[test] fn ambiguous_apns_profile_failures_remain_retryable_at_the_relay_boundary() { for reason in ["BadDeviceToken", "DeviceTokenNotForTopic"] { diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index db35b25110..f71bf21d6b 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -95,7 +95,7 @@ async fn main() -> Result<(), Box> { authority, token_keyring: Arc::new(token_keyring), profile: Arc::new(profile), - delivery_url: c.public_delivery_url, + gateway_urls: Arc::new(c.gateway_urls), 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, diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 17fff2a242..2459cefe11 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -7,6 +7,12 @@ use chrono::{DateTime, Utc}; use sqlx::{AssertSqlSafe, PgPool, Row}; use uuid::Uuid; +#[cfg(test)] +#[path = "postgres/bootstrap_tests.rs"] +mod bootstrap_postgres_tests; + +static GATEWAY_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + #[derive(Clone)] pub struct PostgresAuthorityStore { pool: PgPool, @@ -20,7 +26,10 @@ impl PostgresAuthorityStore { pool: &PgPool, runtime_role: &str, ) -> Result<(), Box> { - sqlx::migrate!("./migrations").run(pool).await?; + sqlx::raw_sql(include_str!("postgres/bootstrap_guard.sql")) + .execute(pool) + .await?; + GATEWAY_MIGRATOR.run(pool).await?; if runtime_role.is_empty() || runtime_role.len() > 63 || !runtime_role @@ -187,20 +196,29 @@ impl AuthorityStore for PostgresAuthorityStore { _ => true, } }) { - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } let replaced = existing .iter() - .map(|row| row.try_get::("id").map_err(db)) + .map(|row| { + Ok(( + row.try_get::("id").map_err(db)?, + row.try_get::, _>("expires_at").map_err(db)?, + )) + }) .collect::, _>>()?; - if !replaced.is_empty() { + let expired = replaced + .into_iter() + .filter_map(|(id, expires_at)| (expires_at < now_at).then_some(id)) + .collect::>(); + if !expired.is_empty() { sqlx::query("DELETE FROM push_gateway_delegations WHERE installation_id = ANY($1)") - .bind(&replaced) + .bind(&expired) .execute(&mut *tx) .await .map_err(db)?; sqlx::query("DELETE FROM push_gateway_installations WHERE id = ANY($1)") - .bind(&replaced) + .bind(&expired) .execute(&mut *tx) .await .map_err(db)?; @@ -208,7 +226,7 @@ impl AuthorityStore for PostgresAuthorityStore { 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(&mut *tx).await.map_err(db)?; if result.rows_affected() != 1 { - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } tx.commit().await.map_err(db)?; Ok(()) @@ -230,6 +248,37 @@ impl AuthorityStore for PostgresAuthorityStore { revoked: false, }) } + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result { + let r = sqlx::query( + "SELECT * FROM push_gateway_installations WHERE id=$1 AND expires_at >= $2", + ) + .bind(id) + .bind(at(now)?) + .fetch_optional(&self.pool) + .await + .map_err(db)? + .ok_or(AuthorityError::Rejected)?; + 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: r + .try_get::>, _>("revoked_at") + .map_err(db)? + .is_some(), + }) + } async fn matching_installation( &self, key_id: &[u8], @@ -278,7 +327,7 @@ impl AuthorityStore for PostgresAuthorityStore { if next <= previous { return Err(AuthorityError::Rejected); } - let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2 AND revoked_at IS NULL") + let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2") .bind(id).bind(i64::from(previous)).bind(i64::from(next)).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); @@ -334,9 +383,12 @@ impl AuthorityStore for PostgresAuthorityStore { expected_generation: i64, ) -> Result<(), AuthorityError> { let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; - 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)?; + 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); + let already_revoked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM push_gateway_delegations WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NOT NULL)").bind(id).bind(&relay).bind(expected_generation).fetch_one(&self.pool).await.map_err(db)?; + if !already_revoked { + return Err(AuthorityError::Rejected); + } } Ok(()) } @@ -351,7 +403,10 @@ impl AuthorityStore for PostgresAuthorityStore { } let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,revoked_at=now(),updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { - return Err(AuthorityError::Rejected); + let already_revoked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM push_gateway_installations WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NOT NULL)").bind(id).bind(new).fetch_one(&self.pool).await.map_err(db)?; + if !already_revoked { + return Err(AuthorityError::Rejected); + } } Ok(()) } @@ -484,26 +539,28 @@ impl AuthorityStore for PostgresAuthorityStore { .execute(&mut *tx) .await .map_err(db)?; - // A parent may become retention-eligible before an otherwise-active - // child. Parent eligibility must therefore reap every child first; - // otherwise the installation delete violates the delegation FK and - // rolls back all cleanup in this transaction. + // Revoked rows are idempotency tombstones for cleanup retries, so keep + // them for the full authority lifetime. A parent may expire before an + // otherwise-active child; reap every child of an expired parent first + // so the installation delete cannot violate the delegation FK. sqlx::query( "DELETE FROM push_gateway_delegations d WHERE d.expires_at < $1 - OR d.revoked_at < $1 - interval '1 day' OR EXISTS ( SELECT 1 FROM push_gateway_installations i WHERE i.id = d.installation_id - AND (i.expires_at < $1 OR i.revoked_at < $1 - interval '1 day') + AND i.expires_at < $1 )", ) .bind(at(now)?) .execute(&mut *tx) .await .map_err(db)?; - sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1 OR revoked_at < $1 - interval '1 day'") - .bind(at(now)?).execute(&mut *tx).await.map_err(db)?; + sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1") + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; tx.commit().await.map_err(db)?; Ok(()) } @@ -573,6 +630,30 @@ mod postgres_tests { runtime.ready().await.is_ok(), "migrated least-privilege runtime is ready" ); + let legacy_uniqueness_constraints: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint + WHERE conrelid='push_gateway_installations'::regclass + AND conname IN ( + 'push_gateway_installations_app_attest_key_id_key', + 'push_gateway_installations_app_profile_token_fingerprint_key')", + ) + .fetch_one(&migration_pool) + .await + .expect("inspect retired unconditional uniqueness constraints"); + assert_eq!(legacy_uniqueness_constraints, 0); + let active_uniqueness_indexes: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_indexes + WHERE schemaname=current_schema() + AND tablename='push_gateway_installations' + AND indexname IN ( + 'push_gateway_installations_active_app_attest_key', + 'push_gateway_installations_active_profile_token') + AND indexdef LIKE '%WHERE (revoked_at IS NULL)%'", + ) + .fetch_one(&migration_pool) + .await + .expect("inspect active-only uniqueness indexes"); + assert_eq!(active_uniqueness_indexes, 2); assert!( sqlx::query("CREATE TABLE forbidden_runtime_ddl(id INT)") .execute(&runtime_pool) @@ -606,7 +687,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires PostgreSQL"] - async fn reaper_deletes_active_child_of_retention_eligible_revoked_installation() { + async fn reaper_retains_revocation_tombstones_until_authority_expiry() { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_owned()); @@ -646,32 +727,54 @@ mod postgres_tests { .expect("create authority retention tables"); let now = Utc::now(); - let installation_id = Uuid::new_v4(); + let revoked_installation_id = Uuid::new_v4(); sqlx::query( "INSERT INTO push_gateway_installations(id, expires_at, revoked_at) VALUES ($1, $2, $3)", ) - .bind(installation_id) + .bind(revoked_installation_id) .bind(now + chrono::Duration::days(30)) .bind(now - chrono::Duration::days(2)) .execute(&pool) .await - .expect("insert retention-eligible revoked installation"); + .expect("insert revoked installation tombstone"); sqlx::query( "INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at) VALUES ($1, $2, $3, NULL)", ) .bind(Uuid::new_v4()) - .bind(installation_id) + .bind(revoked_installation_id) .bind(now + chrono::Duration::days(7)) .execute(&pool) .await .expect("insert active future-expiring child delegation"); + let active_installation_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO push_gateway_installations(id, expires_at, revoked_at) + VALUES ($1, $2, NULL)", + ) + .bind(active_installation_id) + .bind(now + chrono::Duration::days(30)) + .execute(&pool) + .await + .expect("insert active installation"); + sqlx::query( + "INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at) + VALUES ($1, $2, $3, $4)", + ) + .bind(Uuid::new_v4()) + .bind(active_installation_id) + .bind(now + chrono::Duration::days(30)) + .bind(now - chrono::Duration::days(2)) + .execute(&pool) + .await + .expect("insert revoked delegation tombstone"); + PostgresAuthorityStore::new(pool.clone()) .reap_expired(now.timestamp()) .await - .expect("reaper must delete the child before its revoked parent"); + .expect("reap before authority expiry"); let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") .fetch_one(&pool) .await @@ -681,6 +784,22 @@ mod postgres_tests { .fetch_one(&pool) .await .expect("count installations"); + assert_eq!(delegations, 2); + assert_eq!(installations, 2); + + PostgresAuthorityStore::new(pool.clone()) + .reap_expired((now + chrono::Duration::days(31)).timestamp()) + .await + .expect("reap after authority expiry"); + let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") + .fetch_one(&pool) + .await + .expect("count expired delegations"); + let installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count expired installations"); assert_eq!(delegations, 0); assert_eq!(installations, 0); @@ -726,63 +845,13 @@ mod postgres_tests { .connect(&database_url) .await .expect("connect isolated-schema pool"); - // 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_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, - assertion_counter BIGINT NOT NULL, - app_profile TEXT NOT NULL, - token_ciphertext BYTEA NOT NULL, - token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32), - endpoint_epoch BIGINT NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (app_profile, token_fingerprint) - ); - CREATE TABLE push_gateway_delegations ( - id UUID PRIMARY KEY, - installation_id UUID NOT NULL REFERENCES push_gateway_installations(id), - relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), - endpoint_epoch BIGINT NOT NULL, - generation BIGINT NOT NULL, - not_before TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (installation_id, relay_pubkey) - ); - CREATE TABLE push_gateway_endpoint_quotas ( - token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32), - window_started_at TIMESTAMPTZ NOT NULL, - admitted BIGINT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - CREATE TABLE push_gateway_delivery_auth_replays ( - relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), - auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32), - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (relay_pubkey, auth_event_id) - ); - CREATE TABLE push_gateway_delivery_request_replays ( - relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), - request_id UUID NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (relay_pubkey, request_id) - );", - ) - .execute(&pool) - .await - .expect("create authority admission tables"); + // Exercise the same scoped migrations as production, including active-only + // uniqueness and future schema changes. The isolated search_path keeps + // these tables and SQLx's history separate from other tests. + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("apply gateway migrations"); (pool, schema) } @@ -909,7 +978,7 @@ mod postgres_tests { store .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 999,) .await, - Err(AuthorityError::Rejected) + Err(AuthorityError::Conflict) ); store .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 1_001) @@ -932,6 +1001,56 @@ mod postgres_tests { drop_schema(&schema).await; } + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn replacement_installation_preserves_unexpired_revocation_tombstone() { + let (pool, schema) = full_schema(1).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let installation = |id| 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: now + 2_592_000, + }; + let original_id = Uuid::from_u128(1); + + store + .create_installation(installation(original_id), now) + .await + .expect("create original installation"); + store + .revoke_installation(original_id, 1, 2) + .await + .expect("revoke original installation"); + store + .create_installation(installation(Uuid::from_u128(2)), now + 1) + .await + .expect("create replacement with the same key and token"); + + assert!( + store + .installation_for_revocation(original_id, now + 1) + .await + .expect("the original tombstone remains retryable") + .revoked + ); + let installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count original tombstone and replacement"); + assert_eq!(installations, 2); + + pool.close().await; + drop_schema(&schema).await; + } + fn admit<'a>( store: &'a PostgresAuthorityStore, event_hex: &'a str, diff --git a/crates/buzz-push-gateway/src/postgres/bootstrap_guard.sql b/crates/buzz-push-gateway/src/postgres/bootstrap_guard.sql new file mode 100644 index 0000000000..cf21b9bbf9 --- /dev/null +++ b/crates/buzz-push-gateway/src/postgres/bootstrap_guard.sql @@ -0,0 +1,29 @@ +-- This release supports fresh gateway initialization, not populated legacy +-- development databases. Run before migrations that could alter legacy data. +DO $$ +DECLARE + initialized boolean := false; + populated boolean; + candidate record; +BEGIN + IF to_regclass('_sqlx_migrations') IS NOT NULL THEN + EXECUTE 'SELECT EXISTS (SELECT 1 FROM _sqlx_migrations WHERE version = 5 AND success)' + INTO initialized; + END IF; + -- Ordinary subsequent deployments of the initialized gateway retain data. + IF initialized THEN + RETURN; + END IF; + + FOR candidate IN + SELECT schemaname, tablename FROM pg_tables + WHERE schemaname = current_schema() AND tablename <> '_sqlx_migrations' + LOOP + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.%I)', + candidate.schemaname, candidate.tablename) INTO populated; + IF populated THEN + RAISE EXCEPTION 'Cannot initialize push gateway: non-empty pre-launch database (table %). Legacy upgrades are unsupported. Stop the development gateway and provision a fresh dedicated database. No migrations have run.', candidate.tablename; + END IF; + END LOOP; +END +$$; diff --git a/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs b/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs new file mode 100644 index 0000000000..117cc798c2 --- /dev/null +++ b/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs @@ -0,0 +1,78 @@ +use super::*; +use sqlx::postgres::PgPoolOptions; + +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn bootstrap_refuses_legacy_data_before_migrations_and_allows_initialized_data() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL").expect("test database URL"); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect test database"); + let schema = format!("bootstrap_{}", Uuid::new_v4().simple()); + sqlx::raw_sql(AssertSqlSafe(format!( + "CREATE SCHEMA {schema}; SET search_path TO {schema}" + ))) + .execute(&pool) + .await + .expect("isolated schema"); + + // Legacy schema plus real authority data. The production entry point must + // refuse before SQLx can create its history or run destructive old steps. + for migration in GATEWAY_MIGRATOR.iter().take(3) { + sqlx::raw_sql(migration.sql.as_ref()) + .execute(&pool) + .await + .expect("legacy schema"); + } + sqlx::query("INSERT INTO push_gateway_challenges (id, challenge_hash, expires_at) VALUES ($1, $2, now() + interval '1 hour')") + .bind(Uuid::new_v4()) + .bind(vec![1_u8; 32]) + .execute(&pool) + .await + .expect("legacy challenge"); + let error = PostgresAuthorityStore::apply_migrations_and_grants(&pool, "") + .await + .expect_err("populated legacy database must be refused"); + assert!(error.to_string().contains("non-empty pre-launch database")); + let (count, history_missing): (i64, bool) = sqlx::query_as( + "SELECT count(*), to_regclass('_sqlx_migrations') IS NULL FROM push_gateway_challenges", + ) + .fetch_one(&pool) + .await + .expect("unchanged legacy state"); + assert_eq!(count, 1); + assert!(history_missing, "refusal must precede all SQLx migrations"); + + // Only the test discards its isolated fixture, never the gateway. + sqlx::raw_sql(AssertSqlSafe(format!( + "DROP SCHEMA {schema} CASCADE; CREATE SCHEMA {schema}; SET search_path TO {schema}" + ))) + .execute(&pool) + .await + .expect("fresh fixture"); + for populated in [false, true] { + if populated { + sqlx::query("INSERT INTO push_gateway_challenges (id, challenge_hash, expires_at) VALUES ($1, $2, now() + interval '1 hour')") + .bind(Uuid::new_v4()) + .bind(vec![2_u8; 32]) + .execute(&pool) + .await + .expect("initialized gateway data"); + } + // An invalid role deliberately stops after migrations, avoiding grants + // against the shared test database's public schema. + let error = PostgresAuthorityStore::apply_migrations_and_grants(&pool, "") + .await + .expect_err("invalid role after successful initialization"); + assert!(error.to_string().contains("runtime database role")); + } + sqlx::raw_sql(AssertSqlSafe(format!( + "SET search_path TO public; DROP SCHEMA {schema} CASCADE" + ))) + .execute(&pool) + .await + .expect("remove isolated test fixture"); + pool.close().await; +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e035752ec3..5d831b3f65 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -347,8 +347,8 @@ pub struct Config { /// 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. - /// An absent setting selects the canonical Buzz gateway. An explicitly - /// empty setting is allowed only while push is disabled. + /// Required while push is enabled. 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, @@ -447,8 +447,6 @@ fn parse_operator_api_origin(raw: &str) -> Result { Ok(raw.trim_end_matches('/').to_string()) } -const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns"; - fn parse_push_gateway_delivery_url(raw: &str) -> Result { let url = url::Url::parse(raw.trim()).map_err(|e| { ConfigError::InvalidValue(format!( @@ -457,6 +455,7 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { })?; if url.scheme() != "https" || url.host().is_none() + || url.port().is_some() || !url.username().is_empty() || url.password().is_some() || url.path() != "/v1/deliveries/apns" @@ -464,7 +463,7 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without credentials, query, or fragment" + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without an explicit port, credentials, query, or fragment" .to_string(), )); } @@ -978,9 +977,18 @@ impl Config { } Ok(raw) if raw.trim().is_empty() => None, Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?), - Err(_) => Some(parse_push_gateway_delivery_url( - DEFAULT_PUSH_GATEWAY_DELIVERY_URL, - )?), + Err(std::env::VarError::NotPresent) if push_enabled => { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be configured when BUZZ_PUSH_ENABLED=true" + .to_string(), + )); + } + Err(std::env::VarError::NotPresent) => None, + Err(error) => { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be valid UTF-8: {error}" + ))); + } }; let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") { Ok(raw) => raw @@ -2174,7 +2182,7 @@ mod tests { } #[test] - fn push_is_opt_in_and_gateway_defaults_to_buzz() { + fn push_is_opt_in_and_gateway_is_required_when_enabled() { 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"); @@ -2182,15 +2190,20 @@ mod tests { 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) - ); + assert!(config.push_gateway_delivery_url.is_none()); std::env::set_var("BUZZ_PUSH_ENABLED", "true"); + let result = Config::from_env(); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must be configured") + )); + + std::env::set_var( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL", + "https://push.example/v1/deliveries/apns", + ); let config = Config::from_env().expect("enabled push config"); assert!(config.push_enabled); assert_eq!( @@ -2198,7 +2211,7 @@ mod tests { .push_gateway_delivery_url .as_ref() .map(url::Url::as_str), - Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) + Some("https://push.example/v1/deliveries/apns") ); std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", ""); @@ -2248,6 +2261,7 @@ mod tests { assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); for invalid in [ "http://push.example/v1/deliveries/apns", + "https://push.example:8443/v1/deliveries/apns", "https://push.example/v1/deliveries/apns/", "https://push.example/v1/deliveries/apns?token=x", "https://user@push.example/v1/deliveries/apns", diff --git a/deploy/charts/buzz-push-gateway/ci/ci-values.yaml b/deploy/charts/buzz-push-gateway/ci/ci-values.yaml new file mode 100644 index 0000000000..f429ceef93 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/ci/ci-values.yaml @@ -0,0 +1,2 @@ +# Chart-testing input only. Real deployments must inject their own origin. +gatewayOrigin: https://push.example diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index ecdc97582a..faefb1e917 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -32,7 +32,7 @@ spec: env: - { name: BUZZ_PUSH_BIND_ADDR, value: "0.0.0.0:8080" } - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } - - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } + - { name: BUZZ_PUSH_GATEWAY_ORIGIN, value: {{ required "gatewayOrigin is required" .Values.gatewayOrigin | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | 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 }} } diff --git a/deploy/charts/buzz-push-gateway/templates/httproute.yaml b/deploy/charts/buzz-push-gateway/templates/httproute.yaml index 88f4d89dbc..f94afe174d 100644 --- a/deploy/charts/buzz-push-gateway/templates/httproute.yaml +++ b/deploy/charts/buzz-push-gateway/templates/httproute.yaml @@ -1,11 +1,13 @@ {{- if .Values.httpRoute.enabled }} +{{- $gatewayURL := urlParse (required "gatewayOrigin is required" .Values.gatewayOrigin) }} apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: {{ include "push.name" . }} spec: parentRefs: {{- toYaml .Values.httpRoute.parentRefs | nindent 4 }} - hostnames: {{- toYaml .Values.httpRoute.hostnames | nindent 4 }} + hostnames: + - {{ required "gatewayOrigin must include a hostname" $gatewayURL.host | quote }} rules: - matches: - path: { type: PathPrefix, value: / } diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 97568ba2d0..675fea6fde 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -2,15 +2,17 @@ set -euo pipefail out=$(mktemp); production_out=$(mktemp); route_out=$(mktemp); datadog_out=$(mktemp) trap 'rm -f "$out" "$production_out" "$route_out" "$datadog_out" "${monitoring_out:-}"' EXIT +gateway_origin_arg=(--set 'gatewayOrigin=https://push.example') -# Defaults must lint and render without parameter injection. -helm lint deploy/charts/buzz-push-gateway >/dev/null -helm template push deploy/charts/buzz-push-gateway >"$out" +# Generic values require the deployment-owned gateway origin. +helm lint deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >/dev/null +helm template push deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >"$out" # Production values support a platform-owned ingress without rendering an # HTTPRoute. The environment-owned inputs remain mandatory. production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + --set 'gatewayOrigin=https://push.example' --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' ) @@ -20,6 +22,7 @@ helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$pr # Gateway API remains an explicit supported ingress mode when an operator opts # in and supplies the environment-owned parent. helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set httpRoute.enabled=true \ --set 'httpRoute.parentRefs[0].name=production-gateway' \ --set 'httpRoute.parentRefs[0].namespace=gateway-system' \ @@ -63,8 +66,12 @@ 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_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS + BUZZ_PUSH_GATEWAY_ORIGIN ]) assert!(required.subset?(env_names)) +gateway_origin = d.dig("spec", "template", "spec", "containers", 0, "env") + .find { |entry| entry["name"] == "BUZZ_PUSH_GATEWAY_ORIGIN" } +assert!(gateway_origin["value"] == "https://push.example") 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) @@ -100,12 +107,26 @@ production_image = production_deployment.dig("spec", "template", "spec", "contai assert!(production_image == "ghcr.io/block/buzz-push-gateway@sha256:#{"a" * 64}", production_image.inspect) route = YAML.load_stream(File.read(ARGV[2])).compact.find { |x| x["kind"] == "HTTPRoute" } assert!(!route.dig("spec", "parentRefs").empty?) -assert!(route.dig("spec", "hostnames").include?("push.buzz.xyz")) +assert!(route.dig("spec", "hostnames") == ["push.example"]) RUBY +# A gateway origin is a required deployment input, even when HTTPRoute is off. +if helm template push deploy/charts/buzz-push-gateway >/dev/null 2>&1; then + echo 'expected missing gatewayOrigin to fail' >&2 + exit 1 +fi +for invalid_origin in 'http://push.example' 'https://push.example:8443' 'https://push.example/base'; do + if helm template push deploy/charts/buzz-push-gateway \ + --set "gatewayOrigin=$invalid_origin" >/dev/null 2>&1; then + echo "expected malformed gatewayOrigin $invalid_origin to fail" >&2 + exit 1 + fi +done + # Legacy token-auth values must fail rather than silently selecting the default # certificate Secret. if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --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 @@ -113,7 +134,7 @@ if helm template push deploy/charts/buzz-push-gateway \ 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 +if helm template push deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" --set httpRoute.enabled=true >/dev/null 2>&1; then echo 'expected httpRoute.enabled=true without parentRefs to fail' >&2 exit 1 fi @@ -129,6 +150,7 @@ fi # keyed to the named monitoring source — never a blanket 8081 rule. monitoring_out=$(mktemp) helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set podMonitor.enabled=true \ --set prometheusRule.enabled=true \ --set networkPolicy.monitoring.enabled=true \ @@ -162,9 +184,11 @@ RUBY # Datadog discovers the same private endpoint from pod annotations and needs no # prometheus-operator CRDs. Its agent ingress remains selector-scoped. helm lint deploy/charts/buzz-push-gateway \ - -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml >/dev/null + -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml \ + "${gateway_origin_arg[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway \ -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml \ + "${gateway_origin_arg[@]}" \ >"$datadog_out" env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -rjson -ryaml -rset \ @@ -200,6 +224,7 @@ RUBY # Negative: monitoring enabled with default empty selectors must fail (would # otherwise render a blanket 8081 rule matching all namespaces/pods). if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set podMonitor.enabled=true \ --set networkPolicy.monitoring.enabled=true >/dev/null 2>&1; then echo 'expected monitoring.enabled with empty selectors to fail' >&2 @@ -209,6 +234,7 @@ fi # Negative: PodMonitor without ingress is an unreachable scraper and must fail. # Scoped ingress without PodMonitor is valid for annotation-discovered agents. if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set podMonitor.enabled=true \ --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ @@ -219,6 +245,7 @@ fi # Negative: retry-ratio threshold is a fraction; a value > 1 must fail schema. if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set prometheusRule.enabled=true \ --set prometheusRule.apnsRetryRatioThreshold=2 >/dev/null 2>&1; then echo 'expected apnsRetryRatioThreshold=2 to fail' >&2 diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 85dd8af1a8..3882e935e6 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -3,16 +3,15 @@ image: tag: "" digest: "" +gatewayOrigin: "" profiles: dogfood: appAttestAppId: "" httpRoute: - # Keep disabled when the platform already routes push.buzz.xyz to this - # Service. Gateway API users enable it and inject an explicit parentRef. + # Keep disabled when the platform already routes the configured gateway + # origin to this Service. Gateway API users enable it and inject a parentRef. enabled: false parentRefs: [] - hostnames: - - push.buzz.xyz networkPolicy: apnsEgressCidrs: - 0.0.0.0/0 diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 1339b777e5..5f83965368 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -11,8 +11,10 @@ "type": "string", "minLength": 1 }, - "publicDeliveryUrl": { - "const": "https://push.buzz.xyz/v1/deliveries/apns" + "gatewayOrigin": { + "type": "string", + "format": "uri", + "pattern": "^https://[^/?#@:]+/?$" }, "maxGrantLifetimeSeconds": { "type": "integer", @@ -40,10 +42,10 @@ }, "httpRoute": { "type": "object", + "additionalProperties": false, "required": [ "enabled", - "parentRefs", - "hostnames" + "parentRefs" ], "properties": { "enabled": { @@ -51,12 +53,6 @@ }, "parentRefs": { "type": "array" - }, - "hostnames": { - "type": "array", - "contains": { - "const": "push.buzz.xyz" - } } }, "allOf": [ @@ -310,7 +306,7 @@ "required": [ "replicaCount", "existingSecret", - "publicDeliveryUrl", + "gatewayOrigin", "maxGrantLifetimeSeconds", "profiles", "httpRoute", diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 245d1a682e..904cfbe788 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -18,7 +18,9 @@ migration: resources: requests: {cpu: 50m, memory: 64Mi} limits: {cpu: 250m, memory: 128Mi} -publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns +# Exact externally reachable HTTPS origin. The gateway derives transport routes +# from this value; NIP-PL v1 App Attest audiences remain registered constants. +gatewayOrigin: "" maxGrantLifetimeSeconds: 2592000 profiles: dogfood: @@ -41,7 +43,6 @@ httpRoute: # existing ingress or service mesh route should keep this disabled. enabled: false parentRefs: [] - hostnames: [push.buzz.xyz] resources: requests: {cpu: 100m, memory: 128Mi} limits: {cpu: "1", memory: 512Mi} diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index ab410b932f..100ec893c3 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -18,6 +18,10 @@ BUZZ_REQUIRE_RELAY_MEMBERSHIP=true BUZZ_ALLOW_NIP_OA_AUTH=true BUZZ_AUTO_MIGRATE=true BUZZ_GIT_CONFORMANCE_PROBE=true +# Push stays disabled unless explicitly enabled. Keep the delivery endpoint +# explicit so enabling it never depends on an in-code deployment fallback. +BUZZ_PUSH_ENABLED=false +BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info # Owner identity. Set to a 64-character hex Nostr pubkey. diff --git a/deploy/compose/README.md b/deploy/compose/README.md index bb0e63fe15..b12f9ff48c 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -38,6 +38,10 @@ keypair. migrations. - The stack uses Postgres, Redis, MinIO, and a git data volume because those are real Buzz dependencies today. Minimal mode can simplify this later. +- Mobile push remains off by default. To use the public gateway, keep the + template's explicit `BUZZ_PUSH_GATEWAY_DELIVERY_URL` and set + `BUZZ_PUSH_ENABLED=true`. To use another gateway, replace the exact HTTPS + `/v1/deliveries/apns` URL before enabling push. - The bundled Compose stack fixes the relay endpoint to `http://minio:9000` and `BUZZ_S3_ADDRESSING_STYLE=path`: Docker DNS resolves `minio`, not `.minio`. It is not configurable for an external S3 provider through diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 6575c98bfb..c45f900e82 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -279,7 +279,7 @@ The opaque string returned as `endpoint_grant` by `POST /v1/delegations` is the 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. -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`. +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":"installation_conflict"}`, `{"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 @@ -329,7 +329,7 @@ The gateway verifies Apple's attestation chain, configured application identifie 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. +Invalid attestation is `401 invalid_attestation`; a consumed or expired challenge is `404 not_authorized`. After successful attestation verification, a key or token owned by a different live installation is `409 installation_conflict`; the conflicting request does not replace existing live authority. 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/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index c4151a677c..d3aebe1f7d 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -1,10 +1,10 @@ # Buzz Push Gateway deployment -`buzz-push-gateway` is the standalone public APNs last hop intended for `push.buzz.xyz`. Build it with `Dockerfile.push-gateway`; do not run it in the relay image or give relays APNs credentials. +`buzz-push-gateway` is a standalone APNs last hop. Build it with `Dockerfile.push-gateway`; do not run it in the relay image or give relays APNs credentials. ## Network and health -- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route `https://push.buzz.xyz` to this port. +- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route the configured `BUZZ_PUSH_GATEWAY_ORIGIN` to this port. - Private health listener: `BUZZ_PUSH_HEALTH_ADDR` (default `0.0.0.0:8081`). Probe `/_liveness` and `/_readiness`; do not expose this port publicly. The chart has no pod-ingress allowance for 8081; Kubernetes node/kubelet-origin probe traffic is exempt from NetworkPolicy. Add a narrowly selected monitoring source only if the target CNI requires pod-origin health scraping. - Readiness fails when PostgreSQL authority is unavailable. Graceful shutdown stops accepting new requests before draining in-flight APNs calls. @@ -13,7 +13,7 @@ | Variable | Purpose | |---|---| | `DATABASE_URL` | PostgreSQL authority/admission store. Runtime credentials need DML on the six gateway tables, not DDL. | -| `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | +| `BUZZ_PUSH_GATEWAY_ORIGIN` | Exact externally reachable HTTPS origin. No credentials, port, path, query, or fragment. The gateway derives its transport routes from it; NIP-PL v1 App Attest audiences remain the registered `https://push.buzz.xyz/v1/...` constants. | | `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_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | @@ -24,7 +24,7 @@ | `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 canonical `push.buzz.xyz` MVP serves the dogfood application identity +The current 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 @@ -53,6 +53,25 @@ The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefo ## PostgreSQL and replicas +### First deployment, not a legacy upgrade + +This gateway has never been deployed outside personal development infrastructure. +This release supports a fresh dedicated database, not migration of existing +development authority. Before running any schema migrations, `--migrate-only` +refuses a non-empty database that has not completed gateway initialization +(migration 0005). The error names the populated table and tells the operator to +stop the development gateway and provision a fresh database. It does not delete +or migrate that data. Normal deployments of an already initialized gateway may +retain their data. + +Do not run a rolling upgrade from a pre-launch development binary. Stop that +deployment before initialization, use the fresh database, and do not roll back +to a pre-launch binary against the new database. The rolling strategy is for +compatible versions after initial deployment; no legacy writer compatibility or +mobile configured-to-unconfigured migration is supported. Push has no existing +enabled users. After enabling push, retain gateway configuration in updates to +that app identity; omitting it is not a push shutdown mechanism. + The gateway's dedicated pool does not consume the relay-oriented `BUZZ_DB_LOCK_TIMEOUT_MS`, `BUZZ_DB_IDLE_TXN_TIMEOUT_MS`, or `BUZZ_DB_STATEMENT_TIMEOUT_MS` settings. Its session-timeout policy remains separate from the `buzz-db` writer policy and must be designed and rolled out independently. All replicas must share one PostgreSQL database. Delivery authority, replay admission, and endpoint quota reservation are transactional there, so replica count does not multiply the abuse ceiling. The gateway owns a scoped migration history under `crates/buzz-push-gateway/migrations`; it creates only the six `push_gateway_*` authority tables plus SQLx's migration-history table and never runs relay migrations. @@ -122,10 +141,9 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe 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 +false. When enabled, `BUZZ_PUSH_GATEWAY_DELIVERY_URL` is required and must be an +exact HTTPS `/v1/deliveries/apns` URL. An absent or 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 @@ -157,7 +175,7 @@ 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's sole dogfood +The MVP is ready to enable only when the configured 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 @@ -178,7 +196,7 @@ publish the next immutable `mobile-vX.Y.Z-rc.N` candidate from the exact current 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 +exact-message tap routing against the configured gateway and a push-enabled internal relay before widening the internal evaluation. Before that first candidate, the private dogfood builder's manual signing and @@ -229,7 +247,7 @@ the environment's GitOps values; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` remains an intentionally invalid production-input contract: deployment CI must inject the verified image digest, the provisioned -dogfood Apple application identifier, and the actual PostgreSQL network. In an +dogfood Apple application identifier, `gatewayOrigin`, and the actual PostgreSQL network. In an environment with an existing ingress or service mesh route, keep `httpRoute.enabled=false`. If this chart owns a Gateway API route, enable it and inject an environment-owned `parentRef`; schema validation rejects an enabled diff --git a/migrations/0045_retain_push_revocation_tombstones.sql b/migrations/0045_retain_push_revocation_tombstones.sql new file mode 100644 index 0000000000..0f480f574b --- /dev/null +++ b/migrations/0045_retain_push_revocation_tombstones.sql @@ -0,0 +1,11 @@ +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_attest_key_id_key; +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_token_fingerprint_key; + +CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key + ON push_gateway_installations (app_attest_key_id) + WHERE revoked_at IS NULL; +CREATE UNIQUE INDEX push_gateway_installations_active_profile_token + ON push_gateway_installations (app_profile, token_fingerprint) + WHERE revoked_at IS NULL; diff --git a/mobile/README.md b/mobile/README.md index c108dcece2..e58a6387aa 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -25,7 +25,7 @@ engine all come from the same Flutter version. just mobile-dev # Direct (uses the app's configured community; apply worktree overrides first): -cd mobile && flutter run +cd mobile && flutter run --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example ``` ### Worktree-aware debug identity @@ -70,13 +70,18 @@ For direct Xcode / Android Studio / `flutter run` development, run switch to refresh the display label (the install identity never changes); the persisted files are then picked up by any subsequent build. In the main checkout the script is a no-op that removes stale override files, restoring -the plain `Buzz` identity. +the plain `Buzz` identity. To enable push in direct Xcode builds and Runner tests, supply a +`BUZZ_PUSH_GATEWAY_URL` build setting in the gitignored +`mobile/ios/Flutter/AppOverrides.xcconfig`; the build phase validates and +passes it through as a Flutter Dart define. Since `//` begins an xcconfig +comment, spell the origin as `BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example`. For an Android debug build that must remain installed alongside other Buzz worktree builds, set an explicit launcher name and package suffix when invoking the generator or a recipe that invokes it: ```bash +BUZZ_PUSH_GATEWAY_URL="https://push.example" \ BUZZ_ANDROID_DEBUG_APP_NAME="Buzz Huddles" \ BUZZ_ANDROID_DEBUG_ID_SUFFIX=".huddles_829c" \ ./bin/just mobile-build-android @@ -104,6 +109,25 @@ 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. +Mobile builds without a gateway origin succeed with push unavailable. To enable +push, supply the gateway origin explicitly: + +```bash +flutter build ios --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example +flutter build apk --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example +``` + +The iOS and Android build gates validate any supplied define, rejecting empty or +malformed values. Release/profile builds require an HTTPS origin without an +explicit port. An absent define disables permission requests, APNs registration, +gateway enrollment, and lease publication; Settings shows push as unavailable. +No production gateway is selected implicitly. Enrollment +grants and crash-recovery journals are scoped to this origin. Push has not +shipped to existing users, so there is no legacy-state or cross-gateway +migration. Changing gateways requires fresh enrollment; old installations +expire under their original gateway's lease policy. Current-gateway response +loss is still retried from the exact journaled request. + 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, @@ -117,6 +141,7 @@ BUNDLE_IDENTIFIER = xyz.block.buzz.mobile BUZZ_DEVELOPMENT_TEAM = EYF346PHUG BUZZ_IOS_PUSH_ENVIRONMENT = development BUZZ_APP_ATTEST_ENVIRONMENT = development +BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example ``` This exercises the client, extension, relay, and gateway integration without @@ -148,7 +173,7 @@ short sender pubkey, community subtitle, and no image. ```bash dart format --output=none --set-exit-if-changed . flutter analyze -flutter test +flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example ``` Or from the repo root: `just mobile-check` and `just mobile-test`. diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 8ba678d1dd..0cd277da39 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI +import java.util.Base64 import java.util.Properties plugins { @@ -20,6 +22,44 @@ val uploadSigningValues = ) val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrBlank() }.keys val hasUploadSigning = missingUploadSigningValues.isEmpty() +val dartDefines = providers.gradleProperty("dart-defines").orNull.orEmpty() +val pushGatewayDefinePrefix = "BUZZ_PUSH_GATEWAY_URL=" +val pushGatewayOrigins = + dartDefines.split(',').mapNotNull { encoded -> + val define = runCatching { String(Base64.getDecoder().decode(encoded)) }.getOrNull() + define + ?.takeIf { it.startsWith(pushGatewayDefinePrefix) } + ?.removePrefix(pushGatewayDefinePrefix) + } +fun isValidPushGatewayOrigin(value: String, requireHttps: Boolean): Boolean { + val uri = runCatching { URI(value) }.getOrNull() ?: return false + val scheme = uri.scheme?.lowercase() + return (scheme == "https" || (!requireHttps && scheme == "http")) && + !uri.host.isNullOrBlank() && + uri.rawUserInfo == null && + (uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") && + uri.rawQuery == null && + uri.rawFragment == null && + (if (requireHttps) uri.port == -1 else uri.port == -1 || uri.port in 1..65535) +} + +tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { + doFirst { + val requireHttps = !name.endsWith("Debug", ignoreCase = true) + val hasValidPushGatewayOrigin = + pushGatewayOrigins.isEmpty() || + (pushGatewayOrigins.size == 1 && + isValidPushGatewayOrigin(pushGatewayOrigins.single(), requireHttps)) + if (!hasValidPushGatewayOrigin) { + throw GradleException( + "When supplied, BUZZ_PUSH_GATEWAY_URL must be an " + + (if (requireHttps) "HTTPS" else "HTTP(S)") + + " origin without " + (if (requireHttps) "an explicit port, " else "") + + "credentials, path, query, or fragment.", + ) + } + } +} // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): debug builds from a git worktree get a diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 8003193da3..dabed2175f 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -12,6 +12,9 @@ import Foundation /// The opaque gateway capability and binding metadata needed by a later lease publisher. public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { + /// Gateway authority that issued this opaque capability. + public let gatewayOrigin: String + /// Canonical WebSocket relay origin that owns the client lease. public let relayOrigin: String /// NIP-PL delegation key selected from the relay push descriptor. public let relayPubkey: String @@ -20,19 +23,31 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { /// Gateway installation authority. This is distinct from [installationId], /// which is the unlinkable per-relay-origin NIP-PL lease address. public let gatewayInstallationHandle: String? + /// App Attest key that authenticates mutations for the gateway installation. + public let appAttestKeyId: String + /// Unlinkable per-relay lease address, distinct from the gateway handle. public let installationId: String + /// Opaque capability issued by the gateway for relay delivery. public let endpointGrant: String + /// Lowercase SHA-256 digest of the binary APNs device token. public let endpointHash: String + /// Server-owned application identity used for enrollment. public let appProfile: String + /// Monotonic endpoint version bound into the gateway grant. public let endpointEpoch: Int64 + /// Monotonic delegation version bound into the gateway grant. public let generation: Int64 + /// Unix expiration time of the installation or delegation authority. public let expiresAt: Int64 + /// Creates a gateway-scoped durable record from authenticated enrollment state. public init( + gatewayOrigin: String, relayOrigin: String, relayPubkey: String, relayMetadataPubkey: String? = nil, gatewayInstallationHandle: String? = nil, + appAttestKeyId: String, installationId: String, endpointGrant: String, endpointHash: String, @@ -42,10 +57,12 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { expiresAt: Int64 ) { precondition(generation > 0, "Endpoint grant generation must be positive") + self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey self.relayMetadataPubkey = relayMetadataPubkey self.gatewayInstallationHandle = gatewayInstallationHandle + self.appAttestKeyId = appAttestKeyId self.installationId = installationId self.endpointGrant = endpointGrant self.endpointHash = endpointHash @@ -59,14 +76,24 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { /// 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 { + /// Returns durable grants across configured origins without migrating them. func records() throws -> [BuzzPushEndpointGrantRecord] + /// Atomically replaces the grant for its gateway, relay origin, and app profile. func save(_ record: BuzzPushEndpointGrantRecord) throws + /// Loads the exact retry journal for a gateway, relay origin, and app profile. func pendingEnrollment( + gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? + /// Persists the retry journal before any enrollment or delegation mutation. func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws + /// Removes a journal only after success or authoritative terminal failure. + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -108,7 +135,7 @@ protocol BuzzDevAppAttesting { func prepareAttestation() async throws -> BuzzDevAttestation func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws -> BuzzDevAttestation - func assertion(clientData: Data) async throws -> String + func assertion(keyId: String, clientData: Data) async throws -> String } struct BuzzDevAttestation: Equatable { @@ -282,12 +309,10 @@ struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { ) } - func assertion(clientData: Data) async throws -> String { + func assertion(keyId: String, 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 { + guard BuzzAppAttestKeyId.isValid(keyId) else { throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId } let object = try await service.generateAssertion( @@ -310,6 +335,7 @@ public final class BuzzDevPushEnrollmentDriver { public static let endpointEpoch: Int64 = 1 private let gatewayBaseURL: URL + private let gatewayOrigin: String private let store: BuzzPushEndpointGrantStore private let session: URLSession private let appAttest: BuzzDevAppAttesting @@ -351,10 +377,17 @@ public final class BuzzDevPushEnrollmentDriver { try BuzzSecureRandom.bytes(count: 16) } ) throws { - guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { + guard lifetimeSeconds > 0 else { throw BuzzDevPushEnrollmentError.invalidGatewayURL } - self.gatewayBaseURL = gatewayBaseURL + let canonical: (url: URL, text: String) + do { + canonical = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayBaseURL) + } catch { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + self.gatewayBaseURL = canonical.url + self.gatewayOrigin = canonical.text self.store = store self.session = session self.appAttest = appAttest @@ -363,8 +396,9 @@ public final class BuzzDevPushEnrollmentDriver { self.installationIdBytes = installationIdBytes } + /// Returns the persisted opaque grants available to the lease publisher. public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { - try store.records() + try store.records().filter { $0.gatewayOrigin == gatewayOrigin } } /// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint, @@ -383,9 +417,11 @@ public final class BuzzDevPushEnrollmentDriver { let storedRecords = try store.records() let storedForOrigin = storedRecords.first { - $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin.text + && $0.appProfile == Self.appProfile } - var pendingEnrollment = try store.pendingEnrollment( + let pendingEnrollment = try store.pendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -393,71 +429,50 @@ public final class BuzzDevPushEnrollmentDriver { 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, - current.endpointEpoch == Self.endpointEpoch, - current.expiresAt > nowSeconds + 300 - { - guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { + // Finish an unexpired request using its exact journaled endpoint before + // replacing it. A token/key change does not make a response-lost request + // safe to discard, nor does it require revoking sibling installations. + if pending.expiresAt > nowSeconds { + _ = try await resumeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: nil + ) + } else { try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) - 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) - try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile - ) - return refreshed + return try await enroll(deviceToken: deviceToken, relayURL: relayURL) } - - // 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 newestSharedGrant = storedRecords.filter { + $0.gatewayOrigin == gatewayOrigin && $0.relayPubkey == relayPubkey + && $0.appProfile == Self.appProfile + && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch + && $0.expiresAt > nowSeconds + 300 + }.max { $0.generation < $1.generation } + if pendingEnrollment == nil, let reusableGrant = newestSharedGrant { + // Reuse the newest delegation without invalidating sibling communities. let record = BuzzPushEndpointGrantRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, - gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, - installationId: try makeInstallationId(), - endpointGrant: sharedGrant.endpointGrant, + gatewayInstallationHandle: reusableGrant.gatewayInstallationHandle, + appAttestKeyId: reusableGrant.appAttestKeyId, + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + endpointGrant: reusableGrant.endpointGrant, endpointHash: endpointHash, appProfile: Self.appProfile, - endpointEpoch: sharedGrant.endpointEpoch, - generation: sharedGrant.generation, - expiresAt: sharedGrant.expiresAt + endpointEpoch: reusableGrant.endpointEpoch, + generation: reusableGrant.generation, + expiresAt: reusableGrant.expiresAt ) try store.save(record) try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -469,7 +484,8 @@ public final class BuzzDevPushEnrollmentDriver { // 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, + guard record.gatewayOrigin == gatewayOrigin, + record.appProfile == Self.appProfile, record.endpointHash == endpointHash, record.endpointEpoch == Self.endpointEpoch, record.expiresAt > nowSeconds, @@ -496,13 +512,16 @@ public final class BuzzDevPushEnrollmentDriver { ? reusableInstallation.expiresAt : renewedExpiration pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, installationId: try storedForOrigin?.installationId ?? makeInstallationId(), - gatewayInstallationHandle: existing.uuidString.lowercased() + gatewayInstallationHandle: existing.uuidString.lowercased(), + keyId: reusableInstallation.appAttestKeyId ) try store.savePendingEnrollment(pending) } else { @@ -510,6 +529,7 @@ public final class BuzzDevPushEnrollmentDriver { let enrollmentChallenge = try await challenge() let preparedAttestation = try await appAttest.prepareAttestation() let enrollmentClientData = try BuzzPushTranscript.enroll( + gatewayOrigin: gatewayBaseURL, challengeId: enrollmentChallenge.id, challenge: enrollmentChallenge.value, keyId: preparedAttestation.keyId, @@ -526,8 +546,10 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, @@ -541,6 +563,62 @@ public final class BuzzDevPushEnrollmentDriver { try store.savePendingEnrollment(pending) } + if pendingEnrollment != nil { + if let record = try await resumeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: relayKeys.metadataPubkey + ) { + return record + } + return try await enroll(deviceToken: deviceToken, relayURL: relayURL) + } + return try await completeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: relayKeys.metadataPubkey + ) + } + + private func resumeEnrollment( + _ pending: BuzzPushPendingEnrollmentRecord, + storedRecords: [BuzzPushEndpointGrantRecord], + relayMetadataPubkey: String? + ) async throws -> BuzzPushEndpointGrantRecord? { + do { + return try await completeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: relayMetadataPubkey + ) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations", _, actual: 404, _ + ) where pending.gatewayInstallationHandle == nil { + // Exact replay found no committed installation and its challenge expired. + // Only this terminal response permits replacing the old journal. A fresh + // request's failure propagates instead of recursively retrying forever. + try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, + appProfile: pending.appProfile + ) + return nil + } + } + + private func completeEnrollment( + _ journal: BuzzPushPendingEnrollmentRecord, + storedRecords: [BuzzPushEndpointGrantRecord], + relayMetadataPubkey: String? + ) async throws -> BuzzPushEndpointGrantRecord { + var pending = journal + let endpoint = pending.endpoint + guard Self.endpointHash(endpoint) == pending.endpointHash + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "pending enrollment endpoint") + } + let nowSeconds = Int64(now().timeIntervalSince1970) + let relayPubkey = pending.relayPubkey let installation: UUID if let handle = pending.gatewayInstallationHandle, let existing = UUID(uuidString: handle), @@ -559,27 +637,17 @@ public final class BuzzDevPushEnrollmentDriver { 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) - } + installation = try await enrollInstallation( + challenge: Challenge(id: challengeUUID, value: challengeValue), + endpoint: endpoint, + expiresAt: pending.expiresAt, + attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) + ) pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -598,7 +666,7 @@ public final class BuzzDevPushEnrollmentDriver { let currentGeneration = storedRecords .filter { - $0.gatewayInstallationHandle == installationHandle + $0.gatewayOrigin == gatewayOrigin && $0.gatewayInstallationHandle == installationHandle && $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile } .map(\.generation) @@ -615,8 +683,10 @@ public final class BuzzDevPushEnrollmentDriver { generation = 1 } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -634,6 +704,7 @@ public final class BuzzDevPushEnrollmentDriver { let delegationChallenge = try await challenge() let delegationClientData = try BuzzPushTranscript.delegate( + gatewayOrigin: gatewayBaseURL, challengeId: delegationChallenge.id, challenge: delegationChallenge.value, installationHandle: installation, @@ -643,7 +714,13 @@ public final class BuzzDevPushEnrollmentDriver { notBefore: nowSeconds, expiresAt: pending.expiresAt ) - let assertion = try await appAttest.assertion(clientData: delegationClientData) + guard let appAttestKeyId = pending.keyId else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let assertion = try await appAttest.assertion( + keyId: appAttestKeyId, + clientData: delegationClientData + ) let endpointGrant = try await delegate( challenge: delegationChallenge, installationHandle: installation, @@ -655,13 +732,15 @@ public final class BuzzDevPushEnrollmentDriver { ) let record = BuzzPushEndpointGrantRecord( - relayOrigin: relayOrigin.text, + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, relayPubkey: relayPubkey, - relayMetadataPubkey: relayKeys.metadataPubkey, + relayMetadataPubkey: relayMetadataPubkey, gatewayInstallationHandle: installationHandle, + appAttestKeyId: appAttestKeyId, installationId: pending.installationId, endpointGrant: endpointGrant, - endpointHash: endpointHash, + endpointHash: pending.endpointHash, appProfile: Self.appProfile, endpointEpoch: Self.endpointEpoch, generation: generation, @@ -669,7 +748,8 @@ public final class BuzzDevPushEnrollmentDriver { ) try store.save(record) try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, appProfile: Self.appProfile ) return record @@ -828,16 +908,6 @@ public final class BuzzDevPushEnrollmentDriver { } } - 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, @@ -889,6 +959,30 @@ public final class BuzzDevPushEnrollmentDriver { private static func lowercaseHex(_ data: Data) -> String { data.map { String(format: "%02x", $0) }.joined() } + + private static func endpointHash(_ endpoint: String) -> String? { + let utf8 = Array(endpoint.utf8) + guard !utf8.isEmpty, utf8.count <= 512, utf8.count.isMultiple(of: 2) else { + return nil + } + var bytes = [UInt8]() + bytes.reserveCapacity(utf8.count / 2) + for index in stride(from: 0, to: utf8.count, by: 2) { + guard let high = hexNibble(utf8[index]), let low = hexNibble(utf8[index + 1]) else { + return nil + } + bytes.append(high << 4 | low) + } + return lowercaseHex(Data(SHA256.hash(data: Data(bytes)))) + } + + private static func hexNibble(_ byte: UInt8) -> UInt8? { + switch byte { + case 48...57: byte - 48 + case 97...102: byte - 87 + default: nil + } + } } private struct VersionRequest: Encodable { let v: Int } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift index 402f90be30..8143b4385d 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -1,23 +1,42 @@ /// 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. +/// The Keychain-backed journal retains the APNs endpoint only when an enrollment +/// request may need exact replay after response loss. public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { + /// Gateway authority for which this retry journal remains valid. + public let gatewayOrigin: String + /// Canonical WebSocket relay origin that owns the client lease. public let relayOrigin: String + /// Relay delegation key captured when the request was prepared. public let relayPubkey: String + /// Protected APNs endpoint needed to replay an enrollment without the current token. + public let endpoint: String + /// Lowercase SHA-256 digest of the binary APNs device token. public let endpointHash: String + /// Server-owned application identity used for enrollment. public let appProfile: String + /// Unix expiration time of the installation or delegation authority. public let expiresAt: Int64 + /// Unlinkable per-relay lease address, distinct from the gateway handle. public let installationId: String + /// Gateway installation identifier, available after enrollment succeeds. public let gatewayInstallationHandle: String? + /// Identifier of the journaled enrollment challenge. public let challengeId: String? + /// Exact challenge bytes encoded as unpadded base64url. public let challenge: String? + /// App Attest key that signed the journaled request. public let keyId: String? + /// Exact attestation object retained for idempotent enrollment replay. public let attestation: String? + /// Highest delegation generation reserved before a network request. public let delegationGeneration: Int64 + /// Creates a gateway-scoped durable record from authenticated enrollment state. public init( + gatewayOrigin: String, relayOrigin: String, relayPubkey: String, + endpoint: String, endpointHash: String, appProfile: String, expiresAt: Int64, @@ -29,8 +48,10 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { attestation: String? = nil, delegationGeneration: Int64 = 0 ) { + self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey + self.endpoint = endpoint self.endpointHash = endpointHash self.appProfile = appProfile self.expiresAt = expiresAt @@ -42,4 +63,5 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { self.attestation = attestation self.delegationGeneration = delegationGeneration } + } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift index fd0bc34d69..c33c5fad28 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -6,6 +6,8 @@ public enum BuzzPushTranscriptError: Error, Equatable { /// authority-bearing strings; rather than guess at UTF-8-vs-escaping /// behavior we fail closed. case nonASCIIInput(field: String) + /// The configured gateway was not an HTTP or HTTPS origin. + case invalidGatewayOrigin } /// Canonical NIP-PL App Attest transcript encoder. @@ -27,11 +29,9 @@ public enum BuzzPushTranscriptError: Error, Equatable { /// 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. +/// The `audience` member uses the fixed origin and route registered by NIP-PL +/// v1. A configurable gateway origin changes transport routing, not these +/// protocol bytes. public enum BuzzPushTranscript { // MARK: Domains @@ -41,22 +41,18 @@ public enum BuzzPushTranscript { 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 + /// Origin registered by NIP-PL v1 for every App Attest transcript audience. + private static let registeredAudienceOrigin = "https://push.buzz.xyz" + // MARK: Transcripts /// `buzz.push.enroll.v1` — these exact bytes are the App Attest /// `clientData` supplied to attestation verification. public static func enroll( + gatewayOrigin: URL, challengeId: UUID, challenge: String, keyId: String, @@ -67,7 +63,7 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.enrollAudience) + try o.string("audience", audience(gatewayOrigin, route: "v1/installations")) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") try o.string("key_id", keyId, field: "key_id") @@ -81,6 +77,7 @@ public enum BuzzPushTranscript { /// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func delegate( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -92,7 +89,7 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.delegateAudience) + try o.string("audience", audience(gatewayOrigin, route: "v1/delegations")) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -107,6 +104,7 @@ public enum BuzzPushTranscript { /// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func rotateEndpoint( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -116,7 +114,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.rotateEndpointAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/installations/endpoint") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -129,6 +130,7 @@ public enum BuzzPushTranscript { /// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func revokeDelegation( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -137,7 +139,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.revokeDelegationAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/delegations/revoke") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -149,6 +154,7 @@ public enum BuzzPushTranscript { /// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func revokeInstallation( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -157,7 +163,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.revokeInstallationAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/installations/revoke") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -172,6 +181,34 @@ public enum BuzzPushTranscript { Data((domain + "\n" + object.encoded()).utf8) } + /// Validates and canonicalizes a configured gateway origin. + public static func canonicalGatewayOrigin(_ value: URL) throws -> (url: URL, text: String) { + guard let scheme = value.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = value.host?.lowercased(), + value.path.isEmpty || value.path == "/", + value.user == nil, + value.password == nil, + value.query == nil, + value.fragment == nil + else { + throw BuzzPushTranscriptError.invalidGatewayOrigin + } + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = value.port + guard let url = components.url, let text = components.string else { + throw BuzzPushTranscriptError.invalidGatewayOrigin + } + return (url, text) + } + + private static func audience(_ gatewayOrigin: URL, route: String) throws -> String { + _ = try canonicalGatewayOrigin(gatewayOrigin) + return "\(registeredAudienceOrigin)/\(route)" + } + /// 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. diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 2345bc46c6..2edc513ae3 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -11,6 +11,7 @@ import XCTest final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private static let gatewayURL = URL(string: "http://push.example/")! + private static let gatewayOrigin = "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" @@ -121,12 +122,16 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.gatewayOrigin, Self.gatewayOrigin) XCTAssertEqual(record.relayOrigin, "wss://relay.example") try assertMatchesVector( "enroll", actual: appAttest.clientData[0], expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", - fixture: makeFixtureTranscript(name: "enroll", replacements: []) + fixture: makeFixtureTranscript( + name: "enroll", + replacements: [] + ) ) try assertMatchesVector( "delegate", @@ -142,10 +147,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual( record, BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "opaque-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -316,6 +323,76 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.pending.isEmpty) } + func testResponseLostEnrollmentSurvivesTokenRotationAndAnotherReplayFailure() async throws { + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + let rotatedToken = Data(repeating: 42, count: 32) + let rotatedHandle = "44444444-4444-4444-8444-444444444444" + var oldRequests = 0 + var newRequests = 0 + var firstBody: [String: Any]? + var delegatedHandles: [String] = [] + 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"): + 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/installations"): + let body = try Self.body(request) + let oldEndpoint = body["endpoint"] as? String == Self.endpoint + if oldEndpoint { + oldRequests += 1 + if let firstBody { + XCTAssertTrue(NSDictionary(dictionary: body).isEqual(to: firstBody)) + } else { + firstBody = body + } + if oldRequests <= 2 { throw URLError(.networkConnectionLost) } + } else { + newRequests += 1 + XCTAssertEqual(body["endpoint"] as? String, Self.hex(rotatedToken)) + XCTAssertEqual(delegatedHandles, [Self.installationHandle]) + } + return Self.response(request, status: 201, json: [ + "installation_handle": oldEndpoint ? Self.installationHandle : rotatedHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ]) + case ("POST", "http://push.example/v1/delegations"): + let handle = try XCTUnwrap(Self.body(request)["installation_handle"] as? String) + delegatedHandles.append(handle) + return Self.response(request, status: 201, json: ["endpoint_grant": "grant-\(handle)"]) + default: + XCTFail("Unexpected request, including any migration/revocation route: \(request.url!)") + return Self.response(request, status: 500, json: [:]) + } + } + + for token in [Data((1...32).map(UInt8.init)), rotatedToken] { + do { + _ = try await driver.enroll(deviceToken: token, relayURL: Self.relayURL) + XCTFail("Expected response loss") + } catch { + XCTAssertEqual((error as NSError).domain, NSURLErrorDomain) + } + XCTAssertEqual(store.pending.first?.endpoint, Self.endpoint) + XCTAssertTrue(store.saved.isEmpty) + } + let grant = try await driver.enroll(deviceToken: rotatedToken, relayURL: Self.relayURL) + XCTAssertEqual(oldRequests, 3) + XCTAssertEqual(newRequests, 1) + XCTAssertEqual(delegatedHandles, [Self.installationHandle, rotatedHandle]) + XCTAssertEqual(grant.endpointHash, Self.hex(SHA256.hash(data: rotatedToken))) + XCTAssertTrue(store.pending.isEmpty) + } + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { let relayURL = URL(string: "wss://relay.example:8443/")! let store = MemoryGrantStore() @@ -373,16 +450,44 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") } - func testLegacyGrantDecodesWithoutMetadataAuthority() throws { + func testLegacyGrantWithoutGatewayOriginIsRejected() 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) + XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) + } - XCTAssertEqual(record.relayPubkey, Self.relayPubkey) - XCTAssertNil(record.relayMetadataPubkey) + func testGrantWithoutAppAttestKeyIsRejected() throws { + let data = Data( + #"{"gatewayOrigin":"https://push.example","relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# + .utf8 + ) + + XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) + } + + func testOtherGatewayGrantIsNeitherExportedNorMigrated() throws { + let other = BuzzPushEndpointGrantRecord( + gatewayOrigin: "https://other-push.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "other-gateway-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: [other]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + + XCTAssertEqual(try driver.endpointGrants(), []) + XCTAssertEqual(store.saved, [other]) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) } func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { @@ -424,13 +529,13 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) } - func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws { + func testRealAppAttestAssertionUsesRequestedKeyAndMapsObject() 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) + let assertion = try await provider.assertion(keyId: Self.keyId, clientData: clientData) XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) @@ -441,6 +546,22 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(service.generateKeyCallCount, 0) } + func testRealAppAttestAssertionUsesInstallationKeyInsteadOfLatestStoredKey() async throws { + let installationKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() + let service = RecordingDCAppAttestService(assertionObject: Data([0x07])) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + _ = try await provider.assertion( + keyId: installationKeyId, + clientData: Data("retired installation transcript".utf8) + ) + + XCTAssertEqual(service.assertedKeyIds, [installationKeyId]) + } + func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws { for invalidKeyId in [ "not-a-key-id", @@ -489,7 +610,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) do { - _ = try await provider.assertion(clientData: Data("delegation transcript".utf8)) + _ = try await provider.assertion( + keyId: Self.keyId, + clientData: Data("delegation transcript".utf8) + ) XCTFail("Expected the DeviceCheck error") } catch { XCTAssertEqual((error as NSError).domain, expected.domain) @@ -616,52 +740,14 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } } - func testReusesPersistedUnexpiredGrant() async throws { - 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)))), - 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: [ - "self": Self.relayPubkey, - "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( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -703,10 +789,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws { let secondRelayPubkey = String(repeating: "b", count: 64) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "first-relay-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -773,10 +861,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -878,9 +968,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let oldMetadataPubkey = String(repeating: "c", count: 64) let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: pushPubkey, relayMetadataPubkey: oldMetadataPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -919,9 +1011,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -956,9 +1050,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -1024,6 +1120,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } private func makeDriver( + gatewayBaseURL: URL = BuzzDevPushEnrollmentDriverTests.gatewayURL, store: BuzzPushEndpointGrantStore, appAttest: BuzzDevAppAttesting, installationIdBytes: @escaping () throws -> Data = { @@ -1033,7 +1130,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [URLProtocolStub.self] return try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: Self.gatewayURL, + gatewayBaseURL: gatewayBaseURL, store: store, session: URLSession(configuration: configuration), appAttest: appAttest, @@ -1148,12 +1245,17 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] var grantSaveFailuresRemaining: Int + var pendingRemoveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], - grantSaveFailuresRemaining: Int = 0 + pending: [BuzzPushPendingEnrollmentRecord] = [], + grantSaveFailuresRemaining: Int = 0, + pendingRemoveFailuresRemaining: Int = 0 ) { saved = records + self.pending = pending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining + self.pendingRemoveFailuresRemaining = pendingRemoveFailuresRemaining } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { @@ -1162,39 +1264,56 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { throw NSError(domain: "MemoryGrantStore", code: 1) } saved.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } saved.append(record) } func pendingEnrollment( + gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? { pending.first { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } } func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { pending.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } pending.append(record) } - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws { + if pendingRemoveFailuresRemaining > 0 { + pendingRemoveFailuresRemaining -= 1 + throw NSError(domain: "MemoryGrantStore", code: 3) + } pending.removeAll { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } } } private final class RecordingAppAttest: BuzzDevAppAttesting { var clientData: [Data] = [] + var preparedAttestations: [BuzzDevAttestation] = [] + var assertionKeyIds: [String] = [] func prepareAttestation() async throws -> BuzzDevAttestation { - BuzzDevAttestation( + let prepared = BuzzDevAttestation( keyId: BuzzDevPushEnrollmentDriverTests.keyId, attestation: BuzzDevPushEnrollmentDriverTests.attestation ) + preparedAttestations.append(prepared) + return prepared } func attestation( @@ -1205,8 +1324,9 @@ private final class RecordingAppAttest: BuzzDevAppAttesting { return prepared } - func assertion(clientData: Data) async throws -> String { + func assertion(keyId: String, clientData: Data) async throws -> String { self.clientData.append(clientData) + assertionKeyIds.append(keyId) return BuzzDevPushEnrollmentDriverTests.assertion } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift index 3cb242bc4c..c08fe9c82c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -42,6 +42,7 @@ final class BuzzPushTranscriptTests: XCTestCase { // Deterministic inputs mirroring the fixture's `inputs` block. static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + static let gatewayOrigin = URL(string: "https://push.buzz.xyz")! static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" @@ -70,6 +71,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testEnrollVector() throws { try assertMatchesVector("enroll", BuzzPushTranscript.enroll( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, keyId: Self.keyId, @@ -82,6 +84,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testDelegateVector() throws { try assertMatchesVector("delegate", BuzzPushTranscript.delegate( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -95,6 +98,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRotateEndpointVector() throws { try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -106,6 +110,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRevokeDelegationVector() throws { try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -116,6 +121,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRevokeInstallationVector() throws { try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -132,6 +138,41 @@ final class BuzzPushTranscriptTests: XCTestCase { ) } + func testConfiguredTransportOriginKeepsRegisteredTranscriptAudience() throws { + let bytes = try BuzzPushTranscript.delegate( + gatewayOrigin: URL(string: "https://push.example")!, + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + ) + + XCTAssertTrue( + String(decoding: bytes, as: UTF8.self) + .contains("\"audience\":\"https://push.buzz.xyz/v1/delegations\"") + ) + } + + func testInvalidGatewayOriginRejected() { + XCTAssertThrowsError(try BuzzPushTranscript.delegate( + gatewayOrigin: URL(string: "https://push.example/path")!, + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + )) { + XCTAssertEqual($0 as? BuzzPushTranscriptError, .invalidGatewayOrigin) + } + } + // MARK: Escaping edges (the exact JSONSerialization failure modes) func testSolidusIsNotEscaped() throws { diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 21946f4077..755923548e 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -512,7 +512,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "set -e\n. \"$SRCROOT/../scripts/require-push-gateway-origin.sh\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index b5b21445c6..cf7f1a8422 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -509,7 +509,16 @@ import os.log openNotificationSettings(result: result) case "endpointGrants": do { - result(try endpointGrantStore.records().map(\.flutterArguments)) + guard let arguments = call.arguments as? [String: Any], + let gatewayText = arguments["gatewayUrl"] as? String, + let gatewayURL = URL(string: gatewayText) + else { throw BuzzDevPushEnrollmentError.invalidGatewayURL } + let driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: pushKeychainAccessGroup + ) + result(try driver.endpointGrants().map(\.flutterArguments)) } catch { result( FlutterError( diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index ffedbedd19..89112e7470 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -6,8 +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 recordsAccount = "v1" - private static let pendingAccount = "pending-v1" + private static let recordsAccount = "v2" + private static let pendingAccount = "pending-v2" private let accessGroup: String? @@ -39,36 +39,45 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { func save(_ record: BuzzPushEndpointGrantRecord) throws { var all = try records() all.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } all.append(record) - try replace(all, account: Self.recordsAccount) + try replaceValue(all, account: Self.recordsAccount) } func pendingEnrollment( + gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? { try pendingEnrollments().first { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $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 + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } all.append(record) - try replace(all, account: Self.pendingAccount) + try replaceValue(all, account: Self.pendingAccount) } - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws { var all = try pendingEnrollments() all.removeAll { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } - try replace(all, account: Self.pendingAccount) + try replaceValue(all, account: Self.pendingAccount) } private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] { @@ -92,8 +101,8 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } - private func replace(_ values: [T], account: String) throws { - let data = try JSONEncoder().encode(values) + private func replaceValue(_ value: T, account: String) throws { + let data = try JSONEncoder().encode(value) let updateStatus = SecItemUpdate( baseQuery(account: account) as CFDictionary, [kSecValueData as String: data] as CFDictionary diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index cb8f453c14..c07ce6787d 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -390,9 +390,11 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { metadataPubkey: String ) -> BuzzPushEndpointGrantRecord { BuzzPushEndpointGrantRecord( + gatewayOrigin: "https://push.example", relayOrigin: "https://relay.example", relayPubkey: String(repeating: "c", count: 64), relayMetadataPubkey: metadataPubkey, + appAttestKeyId: Data(repeating: 0xAA, count: 32).base64EncodedString(), installationId: "installation", endpointGrant: "opaque-grant", endpointHash: String(repeating: "d", count: 64), diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index 8560846b0e..bcb140a44f 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -10,6 +10,20 @@ class _NotificationsSection extends ConsumerWidget { } final community = ref.watch(activeCommunityProvider).value; if (community == null) return const SizedBox.shrink(); + if (!Env.pushGatewayConfigured) { + return AppListCard( + label: 'Notifications', + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const ValueKey('push-notifications-unavailable'), + icon: LucideIcons.bell, + title: 'Push notifications', + subtitle: 'Unavailable in this build', + ), + ], + ); + } final authorization = ref.watch(buzzPushAuthorizationStatusProvider); final status = authorization.value; final permissionUnavailable = authorization.hasError; diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 8deb39a0b6..1af81bc600 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -549,7 +549,7 @@ class CommunityListNotifier extends AsyncNotifier> { }); } - Future markPushLeaseAccepted( + Future markPushLeaseAccepted( String id, { required List subscriptions, required int generation, @@ -557,14 +557,20 @@ class CommunityListNotifier extends AsyncNotifier> { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); final index = current.indexWhere((community) => community.id == id); - if (index < 0) return; + if (index < 0) return false; final community = current[index]; final acceptedGeneration = community.pushSubscriptionState.acceptedGeneration ?? 0; final generationCursor = community.pushSubscriptionState.generationCursor ?? 0; - if (generation < max(acceptedGeneration, generationCursor)) return; + if (generation < max(acceptedGeneration, generationCursor)) return false; + if (buzzPushSubscriptionsFingerprint( + community.pushSubscriptionState.desired, + ) != + buzzPushSubscriptionsFingerprint(subscriptions)) { + return false; + } final updated = community.copyWith( pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, @@ -575,6 +581,7 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); await syncCommunitySnapshot(ref, updatedList); + return true; }); Future setPushNotificationsEnabled(String id, bool enabled) async { diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0e86745f92..847668fefe 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -88,17 +88,65 @@ String buzzPushPublicationAttemptKey({ bool buzzPushLifecycleEnabled({ required Community? community, required BuzzPushLeaseDescriptor? descriptor, -}) => community?.pushNotificationsEnabled == true && descriptor != null; +}) => + Env.pushGatewayConfigured && + community?.pushNotificationsEnabled == true && + descriptor != null; + +/// Starts APNs registration when the active community opts in. +@visibleForTesting +class BuzzPushRegistrationBootstrap extends HookWidget { + const BuzzPushRegistrationBootstrap({ + required this.shouldRegister, + required this.attemptKey, + required this.child, + this.startRegistration = startBuzzPushRegistration, + super.key, + }); + + final bool shouldRegister; + final String attemptKey; + final Widget child; + final Future Function() startRegistration; + + @override + Widget build(BuildContext context) { + final attemptGate = useMemoized(BuzzPushAttemptGate.new); + final retry = useState(0); + useEffect(() => attemptGate.dispose, const []); + useEffect(() { + if (!shouldRegister || !attemptGate.tryBegin(attemptKey)) return null; + unawaited(() async { + try { + await startRegistration(); + } catch (error, stack) { + attemptGate.failed( + attemptKey, + retry: () { + if (context.mounted) retry.value += 1; + }, + ); + debugPrint('Push registration bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, [shouldRegister, attemptKey, retry.value]); + return child; + } +} @visibleForTesting Future publishBuzzPushLeaseRecoverably({ required Future Function() reserveGeneration, required Future Function(int generation) publish, - required Future Function(int generation) markAccepted, + required Future Function(int generation) markAccepted, }) async { final generation = await reserveGeneration(); await publish(generation); - await markAccepted(generation); + if (!await markAccepted(generation)) { + throw StateError('A newer push lease superseded the published generation.'); + } return generation; } @@ -112,10 +160,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { 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 revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); @@ -143,7 +189,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { useEffect( () => () { - registrationAttempt.dispose(); publicationAttempt.dispose(); tombstoneAttempt.dispose(); }, @@ -209,48 +254,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { ], ); - useEffect( - () { - if (!_ready(session, config, community, memberPubkey) || - !buzzPushLifecycleEnabled( - community: community, - descriptor: descriptor, - )) { - return null; - } - final activeCommunity = community!; - final activeDescriptor = descriptor!; - final attempt = '${activeCommunity.id}|${config.baseUrl}'; - if (!registrationAttempt.tryBegin(attempt)) return null; - unawaited(() async { - try { - await startBuzzPushRegistrationIfCapable( - activeDescriptor, - startRegistration: startBuzzPushRegistration, - ); - } catch (error, stack) { - 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, - registrationRetry.value, - ], - ); - final token = apnsDeviceToken.value; useEffect( () { @@ -319,6 +322,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { session.status, config.baseUrl, community?.id, + community?.pushNotificationsEnabled, community?.pushSubscriptionState, memberPubkey, descriptor, @@ -327,7 +331,16 @@ class BuzzPushBootstrap extends HookConsumerWidget { ], ); - return child; + return BuzzPushRegistrationBootstrap( + shouldRegister: + _ready(session, config, community, memberPubkey) && + buzzPushLifecycleEnabled( + community: community, + descriptor: descriptor, + ), + attemptKey: '${community?.id}|${config.baseUrl}', + child: child, + ); } static bool _ready( diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 6f91e5625d..636f40a78d 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -140,7 +140,10 @@ Future syncPendingBuzzPushNotificationResponse() async { /// 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; + if (!Env.pushGatewayConfigured || + defaultTargetPlatform != TargetPlatform.iOS) { + return; + } try { await _channel.invokeMethod('startRegistration'); } on MissingPluginException { @@ -188,9 +191,14 @@ class BuzzPushEndpointGrant { } Future> readBuzzPushEndpointGrants() async { - if (defaultTargetPlatform != TargetPlatform.iOS) return const []; + if (!Env.pushGatewayConfigured || + defaultTargetPlatform != TargetPlatform.iOS) { + return const []; + } try { - final raw = await _channel.invokeListMethod('endpointGrants'); + final raw = await _channel.invokeListMethod('endpointGrants', { + 'gatewayUrl': Env.pushGatewayUrl, + }); final grants = [ for (final value in raw ?? const []) BuzzPushEndpointGrant.fromMap(value as Map), diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart index f2e8eebd8b..bcfaf06fa4 100644 --- a/mobile/lib/shared/push/push_relay_capability_provider.dart +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -15,11 +15,13 @@ final buzzPushDescriptorFetcherProvider = Provider( /// The fully validated push capability advertised by the current relay. /// +/// An unconfigured artifact has no capability and never starts discovery. /// 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 { + if (!Env.pushGatewayConfigured) return null; final session = ref.watch(relaySessionProvider); final config = ref.watch(relayConfigProvider); final community = ref.watch(activeCommunityProvider).value; diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 00fcf65b71..73643ff020 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -71,10 +71,12 @@ class Env { 'BUZZ_RELAY_URL', defaultValue: 'http://localhost:3000', ); - static const pushGatewayUrl = String.fromEnvironment( - 'BUZZ_PUSH_GATEWAY_URL', - defaultValue: 'https://push.buzz.xyz', - ); + + /// Optional gateway origin. Without it this artifact has no push capability. + static const pushGatewayUrl = String.fromEnvironment('BUZZ_PUSH_GATEWAY_URL'); + + /// Whether this artifact can offer push notification enrollment. + static const pushGatewayConfigured = pushGatewayUrl != ''; } class RelayConfigNotifier extends Notifier { diff --git a/mobile/scripts/require-push-gateway-origin.sh b/mobile/scripts/require-push-gateway-origin.sh new file mode 100644 index 0000000000..d473bb1466 --- /dev/null +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +gateway_origin= +has_dart_define=false +old_ifs=$IFS +IFS=',' +for encoded in ${DART_DEFINES:-}; do + decoded=$(printf '%s' "$encoded" | base64 --decode 2>/dev/null || printf '%s' "$encoded" | base64 -D 2>/dev/null || true) + case "$decoded" in + BUZZ_PUSH_GATEWAY_URL=*) + if [ "$has_dart_define" = true ]; then + echo "error: BUZZ_PUSH_GATEWAY_URL must be supplied at most once." >&2 + exit 1 + fi + has_dart_define=true + gateway_origin=${decoded#BUZZ_PUSH_GATEWAY_URL=} + ;; + esac +done +IFS=$old_ifs + +if [ "$has_dart_define" = false ] && [ -n "${BUZZ_PUSH_GATEWAY_URL:-}" ]; then + gateway_origin=$BUZZ_PUSH_GATEWAY_URL + encoded=$(printf '%s' "BUZZ_PUSH_GATEWAY_URL=$gateway_origin" | base64 | tr -d '\n') + DART_DEFINES=${DART_DEFINES:+$DART_DEFINES,}$encoded + export DART_DEFINES +fi + +if [ "$has_dart_define" = false ] && [ -z "$gateway_origin" ]; then + # An unconfigured artifact deliberately has no push capability. + return 0 2>/dev/null || exit 0 +fi + +if [ -n "${SRCROOT:-}" ]; then + script_dir=$SRCROOT/../scripts +else + script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fi +dart_bin=${FLUTTER_ROOT:+$FLUTTER_ROOT/bin/dart} +if [ -z "$dart_bin" ] || [ ! -x "$dart_bin" ]; then + dart_bin=$(command -v dart || true) +fi +if [ -z "$dart_bin" ]; then + echo "error: Dart is required to validate BUZZ_PUSH_GATEWAY_URL." >&2 + exit 1 +fi +case ${CONFIGURATION:-Release} in + Debug*) + "$dart_bin" "$script_dir/validate_push_gateway_origin.dart" "$gateway_origin" + ;; + *) + "$dart_bin" "$script_dir/validate_push_gateway_origin.dart" --require-https "$gateway_origin" + ;; +esac diff --git a/mobile/scripts/validate_push_gateway_origin.dart b/mobile/scripts/validate_push_gateway_origin.dart new file mode 100644 index 0000000000..97a8ed7aa4 --- /dev/null +++ b/mobile/scripts/validate_push_gateway_origin.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +bool isValidPushGatewayOrigin(String value, {bool requireHttps = false}) { + try { + final uri = Uri.parse(value); + if (requireHttps + ? uri.scheme != 'https' + : uri.scheme != 'http' && uri.scheme != 'https') { + return false; + } + if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty) { + return false; + } + if (uri.path.isNotEmpty && uri.path != '/') return false; + if (uri.hasQuery || uri.hasFragment) return false; + if (requireHttps && uri.hasPort) return false; + final port = uri.port; + return port >= 1 && port <= 65535; + } on FormatException { + return false; + } +} + +void main(List arguments) { + final requireHttps = + arguments.isNotEmpty && arguments.first == '--require-https'; + final values = requireHttps ? arguments.skip(1).toList() : arguments; + if (values.length == 1 && + isValidPushGatewayOrigin(values.single, requireHttps: requireHttps)) { + return; + } + final requirement = requireHttps + ? 'an HTTPS origin without an explicit port, credentials, path, query, or fragment' + : 'an HTTP(S) origin without credentials, path, query, or fragment'; + stderr.writeln('error: BUZZ_PUSH_GATEWAY_URL must be $requirement.'); + exitCode = 1; +} diff --git a/mobile/test/features/channels/voice_note_recording_test.dart b/mobile/test/features/channels/voice_note_recording_test.dart index 6669cc620c..4eed60907a 100644 --- a/mobile/test/features/channels/voice_note_recording_test.dart +++ b/mobile/test/features/channels/voice_note_recording_test.dart @@ -391,9 +391,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-toggle-cancel-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -402,6 +399,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -464,9 +463,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-activation-cancel-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: coordinator, client: client, @@ -475,6 +471,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); addTearDown(previous.dispose); var authGeneration = 0; await player.loadRemote( @@ -525,9 +523,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-source-load-cancel-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -536,6 +531,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -591,9 +588,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-source-load-retry-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -602,6 +596,9 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Teardowns run in reverse order. Remove the fake player's files before + // dispose starts its unawaited file cleanup, avoiding competing deletes. + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -655,9 +652,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-download-limit-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -667,6 +661,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); await player.loadRemote( 'https://example.com/voice-note.mp4', headers: () => const {}, @@ -726,9 +722,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-retry-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -737,6 +730,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Delete before dispose starts its unawaited file cleanup (reverse order). + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -781,7 +776,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-pause-test', ); - addTearDown(() => directory.delete(recursive: true)); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -790,6 +784,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); await player.loadRemote( 'https://example.com/voice-note.mp4', headers: () => const {}, @@ -823,7 +819,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-source-replacement-test', ); - addTearDown(() => directory.delete(recursive: true)); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -832,6 +827,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); await player.loadRemote( 'https://example.com/first.mp4', headers: () => const {}, diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index fb49b64d40..59722b91e1 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -304,10 +304,13 @@ void main() { final notifier = container.read(communityListProvider.notifier); await notifier.addCommunity(community); - await notifier.markPushLeaseAccepted( - community.id, - subscriptions: const [], - generation: 5, + expect( + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: const [], + generation: 5, + ), + isFalse, ); final stored = (await communityStorage.loadAll()).single; @@ -318,6 +321,54 @@ void main() { ); }); + test('lease success cannot accept stale desired subscriptions', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final original = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final replacement = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['b' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [original], + ), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + final generation = await notifier.reservePushLeaseGeneration( + community.id, + ); + await notifier.updateDesiredPushSubscriptions(community.id, [ + replacement, + ]); + + expect( + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: [original], + generation: generation, + ), + isFalse, + ); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushSubscriptionState.accepted, isNull); + expect( + stored.pushSubscriptionState.desired.single.toJson(), + replacement.toJson(), + ); + }); + test('opt-out tombstones an in-flight first publication', () 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 6380837bf6..2a3752e9f8 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -3,8 +3,43 @@ 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'; +import 'package:flutter/widgets.dart'; void main() { + testWidgets('first opt-in starts registration without switching community', ( + tester, + ) async { + var registrations = 0; + Widget bootstrap(bool enabled) => BuzzPushRegistrationBootstrap( + shouldRegister: enabled, + attemptKey: 'same-community|same-relay', + startRegistration: () async { + registrations += 1; + }, + child: const SizedBox(), + ); + await tester.pumpWidget(bootstrap(false)); + expect(registrations, 0); + await tester.pumpWidget(bootstrap(true)); + expect(registrations, 1); + await tester.pumpWidget(bootstrap(true)); + expect(registrations, 1); + }); + + test( + 'superseded lease acceptance is a retryable publication failure', + () async { + await expectLater( + publishBuzzPushLeaseRecoverably( + reserveGeneration: () async => 1, + publish: (_) async {}, + markAccepted: (_) async => false, + ), + throwsStateError, + ); + }, + ); + test('failed bootstrap attempt becomes retryable after the delay', () async { final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); addTearDown(gate.dispose); @@ -156,12 +191,13 @@ void main() { relayGeneration = generation; } - Future markAccepted(int generation) async { + Future markAccepted(int generation) async { if (failLocalSave) { failLocalSave = false; throw StateError('injected local persistence failure'); } acceptedGeneration = generation; + return true; } await expectLater( diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index ce25c93bd2..9e87b7bf28 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -191,6 +191,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; messenger.setMockMethodCallHandler(_channel, (call) async { expect(call.method, 'endpointGrants'); + expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); return [_grantMap('opaque-grant')]; }); diff --git a/mobile/test/shared/push/push_gateway_origin_validator_test.dart b/mobile/test/shared/push/push_gateway_origin_validator_test.dart new file mode 100644 index 0000000000..3463a31b47 --- /dev/null +++ b/mobile/test/shared/push/push_gateway_origin_validator_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../../scripts/validate_push_gateway_origin.dart'; + +void main() { + test( + 'iOS build phase allows omission and validates supplied origins', + () async { + Future buildPhase( + Map environment, + ) => Process.run( + '/bin/sh', + [ + '-c', + '. ./scripts/require-push-gateway-origin.sh; printf "continued:%s" "\${DART_DEFINES:-}"', + ], + includeParentEnvironment: false, + environment: { + 'PATH': Platform.environment['PATH']!, + 'SRCROOT': '${Directory.current.path}/ios', + 'CONFIGURATION': 'Release', + ...environment, + }, + ); + String define(String origin) => + base64.encode(utf8.encode('BUZZ_PUSH_GATEWAY_URL=$origin')); + final absent = await buildPhase({}); + expect(absent.exitCode, 0, reason: '${absent.stderr}'); + expect(absent.stdout, 'continued:'); + final configured = await buildPhase({ + 'BUZZ_PUSH_GATEWAY_URL': 'https://push.example', + }); + expect(configured.exitCode, 0, reason: '${configured.stderr}'); + expect(configured.stdout, 'continued:${define('https://push.example')}'); + for (final origin in [ + '', + 'not-a-url', + 'https://push.example/path', + 'http://localhost:8080', + ]) { + final invalid = await buildPhase({'DART_DEFINES': define(origin)}); + expect(invalid.exitCode, isNot(0), reason: origin); + } + final debug = await buildPhase({ + 'CONFIGURATION': 'Debug', + 'DART_DEFINES': define('http://localhost:8080'), + }); + expect(debug.exitCode, 0, reason: '${debug.stderr}'); + }, + skip: Platform.isWindows, + ); + + test('accepts origin-only HTTP and HTTPS gateway URLs', () { + for (final value in [ + 'https://push.example', + 'https://push.example/', + 'http://localhost:8080', + ]) { + expect(isValidPushGatewayOrigin(value), isTrue, reason: value); + } + }); + + test('requires HTTPS for release and profile builds', () { + expect( + isValidPushGatewayOrigin('https://push.example', requireHttps: true), + isTrue, + ); + expect( + isValidPushGatewayOrigin('http://localhost:8080', requireHttps: true), + isFalse, + ); + expect( + isValidPushGatewayOrigin('https://push.example:8443', requireHttps: true), + isFalse, + ); + }); + + test('rejects malformed or non-origin gateway URLs', () { + for (final value in [ + '', + 'push.example', + 'ftp://push.example', + 'https://push.example/path', + 'https://push.example?token=x', + 'https://push.example#fragment', + 'https://user@push.example', + 'https://push.example:70000', + ]) { + expect(isValidPushGatewayOrigin(value), isFalse, reason: value); + } + }); +} diff --git a/mobile/test/shared/push/push_unconfigured_build_test.dart b/mobile/test/shared/push/push_unconfigured_build_test.dart new file mode 100644 index 0000000000..fc626e50ea --- /dev/null +++ b/mobile/test/shared/push/push_unconfigured_build_test.dart @@ -0,0 +1,116 @@ +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/theme/theme.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'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('artifact without a gateway define', () { + test( + 'does not discover push capability or enter the opted-in lifecycle', + () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith( + () => throw StateError('must not start relay discovery'), + ), + ], + ); + addTearDown(container.dispose); + expect( + await container.read(currentRelayPushDescriptorProvider.future), + isNull, + ); + expect( + buzzPushLifecycleEnabled( + community: Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true), + descriptor: const BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'key', + executorPubkey: 'pubkey', + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, + ), + ), + isFalse, + ); + }, + ); + + test('never requests native registration or gateway grants', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/push'); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await startBuzzPushRegistration(); + expect(await readBuzzPushEndpointGrants(), isEmpty); + expect(calls, isEmpty); + }); + + testWidgets( + 'Settings explains push is unavailable without offering opt-in', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith( + (ref) async => Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ), + ), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => + throw StateError('must not query notification permission'), + ), + ], + 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('Unavailable in this build'), findsOneWidget); + expect(find.byType(Switch), findsNothing); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }, + ); + }, skip: Env.pushGatewayConfigured); +} diff --git a/schema/schema.sql b/schema/schema.sql index 0950812562..0b4b689666 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1131,7 +1131,7 @@ CREATE INDEX push_gateway_challenges_expiry ON push_gateway_challenges (expires_ CREATE TABLE push_gateway_installations ( id UUID PRIMARY KEY, - app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128), + app_attest_key_id BYTEA NOT NULL CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128), app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256), assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295), app_profile TEXT NOT NULL CHECK (app_profile = 'buzz-ios-dogfood'), @@ -1141,9 +1141,12 @@ CREATE TABLE push_gateway_installations ( expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (app_profile, token_fingerprint) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key + ON push_gateway_installations (app_attest_key_id) WHERE revoked_at IS NULL; +CREATE UNIQUE INDEX push_gateway_installations_active_profile_token + ON push_gateway_installations (app_profile, token_fingerprint) WHERE revoked_at IS NULL; CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL; CREATE TABLE push_gateway_delegations ( @@ -1892,4 +1895,3 @@ CREATE INDEX idx_relay_operator_audit_target INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally'); - diff --git a/scripts/test-mobile-gateway-recipes.sh b/scripts/test-mobile-gateway-recipes.sh new file mode 100644 index 0000000000..ae6741eb1a --- /dev/null +++ b/scripts/test-mobile-gateway-recipes.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Exercise the production recipes with macOS's system Bash, not a rewritten copy. +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/.." && pwd) +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +mkdir -p "$fixture/bin" "$fixture/scripts" "$fixture/mobile/ios/Flutter" +for recipe in mobile-dev mobile-build-android; do + # Render with the real task runner before installing stubs: a Hermit just + # shim can otherwise prepend the real Flutter to PATH ahead of our stub. + just --justfile "$repo_root/Justfile" --dry-run "$recipe" > "$fixture/$recipe" 2>&1 +done +cat > "$fixture/bin/flutter" <<'STUB' +#!/bin/bash +set -eu +printf '%s\n' "$@" > "$CALL_LOG" +STUB +cat > "$fixture/bin/pgrep" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat > "$fixture/scripts/mobile-worktree-overrides.sh" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$fixture/bin/flutter" "$fixture/bin/pgrep" "$fixture/scripts/mobile-worktree-overrides.sh" +export PATH="$fixture/bin:$PATH" +export CALL_LOG="$fixture/arguments" +unset BUZZ_PUSH_GATEWAY_URL + +for recipe in mobile-dev mobile-build-android; do + for mode in omitted supplied; do + unset BUZZ_PUSH_GATEWAY_URL + if [[ "$mode" == supplied ]]; then + # A space catches accidental splitting. The build gate validates URLs; + # this test checks exact argument forwarding. + export BUZZ_PUSH_GATEWAY_URL='https://push.example/value with space' + fi + (cd "$fixture" && /bin/bash "$fixture/$recipe") + if [[ "$recipe" == mobile-dev ]]; then + printf '%s\n' run > "$fixture/expected" + else + printf '%s\n' build apk --debug --no-pub > "$fixture/expected" + fi + if [[ "$mode" == supplied ]]; then + printf '%s\n' "--dart-define=BUZZ_PUSH_GATEWAY_URL=$BUZZ_PUSH_GATEWAY_URL" >> "$fixture/expected" + fi + diff -u "$fixture/expected" "$CALL_LOG" + printf 'PASS %s %s\n' "$recipe" "$mode" + done +done + +unset BUZZ_PUSH_GATEWAY_URL +printf '%s\n' 'BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example' > "$fixture/mobile/ios/Flutter/AppOverrides.xcconfig" +(cd "$fixture" && /bin/bash "$fixture/mobile-dev") +printf '%s\n' run '--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example' > "$fixture/expected" +diff -u "$fixture/expected" "$CALL_LOG" +printf 'PASS mobile-dev Xcode override\n'