From af33072eb0597ab31abcac196d23ceb14629e63a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 14:06:02 -0700 Subject: [PATCH 01/67] feat(push): configure gateway origin and relay endpoint Signed-off-by: Tom Brow --- crates/buzz-push-gateway/src/config.rs | 118 ++++++++++++++++++++----- crates/buzz-push-gateway/src/http.rs | 30 ++++--- crates/buzz-push-gateway/src/main.rs | 2 +- crates/buzz-relay/src/config.rs | 40 ++++++--- 4 files changed, 139 insertions(+), 51 deletions(-) diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index f8485a628de..95d34ab368c 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 and every security-sensitive URL derived from it. + 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,50 @@ pub struct Config { /// externally presented delivery capabilities. pub token_keys: Vec, } + +/// Canonical gateway URLs derived from one explicitly configured origin. +#[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")?; + let enroll_audience = derive("v1/installations")?.to_string(); + let delegate_audience = derive("v1/delegations")?.to_string(); + let rotate_endpoint_audience = derive("v1/installations/endpoint")?.to_string(); + let revoke_delegation_audience = derive("v1/delegations/revoke")?.to_string(); + let revoke_installation_audience = derive("v1/installations/revoke")?.to_string(); + 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 +177,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 +237,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 +273,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 +331,36 @@ mod tests { } } + #[test] + fn gateway_urls_are_derived_from_the_configured_origin() { + 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.example/v1/installations" + ); + assert_eq!( + config.gateway_urls.delegate_audience, + "https://push.example/v1/delegations" + ); + assert_eq!( + config.gateway_urls.rotate_endpoint_audience, + "https://push.example/v1/installations/endpoint" + ); + assert_eq!( + config.gateway_urls.revoke_delegation_audience, + "https://push.example/v1/delegations/revoke" + ); + assert_eq!( + config.gateway_urls.revoke_installation_audience, + "https://push.example/v1/installations/revoke" + ); + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); @@ -298,14 +374,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 9a6c66a519a..c6899105e33 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, @@ -151,7 +153,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 +188,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, @@ -324,7 +326,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 +354,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, @@ -413,7 +415,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 +448,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, @@ -489,7 +491,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 +508,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, @@ -538,7 +540,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 +560,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, @@ -613,7 +615,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), @@ -875,7 +877,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, diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index db35b251104..f71bf21d6b2 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-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e035752ec3a..6cead06918a 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -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!( @@ -978,9 +976,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 +2181,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 +2189,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 +2210,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", ""); From 7d7cc99e6417f0df3f96da713953d79917c263da Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 14:26:38 -0700 Subject: [PATCH 02/67] feat(push): require configured mobile and chart origins Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .env.example | 5 +- .github/workflows/_ci-clients.yml | 6 +- Justfile | 8 +- .../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 | 22 ++--- docs/push-gateway-deployment.md | 21 +++-- mobile/README.md | 12 +++ mobile/android/app/build.gradle.kts | 19 +++++ .../BuzzDevPushEnrollmentDriver.swift | 61 ++++++++++---- .../BuzzPushPendingEnrollmentRecord.swift | 4 + .../BuzzPushKit/BuzzPushTranscript.swift | 73 ++++++++++++----- .../BuzzDevPushEnrollmentDriverTests.swift | 80 ++++++++++++++++--- .../BuzzPushTranscriptTests.swift | 41 ++++++++++ mobile/ios/Runner.xcodeproj/project.pbxproj | 2 +- mobile/ios/Runner/AppDelegate.swift | 27 ++++++- .../ios/Runner/PushEndpointGrantStore.swift | 49 ++++++++++-- mobile/lib/shared/push/push_bridge.dart | 4 +- mobile/lib/shared/relay/relay_provider.dart | 5 +- mobile/scripts/require-push-gateway-origin.sh | 18 +++++ mobile/test/shared/push/push_bridge_test.dart | 9 +-- 27 files changed, 428 insertions(+), 121 deletions(-) create mode 100644 mobile/scripts/require-push-gateway-origin.sh diff --git a/.env.example b/.env.example index 6d127382479..683bceb4cc0 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 af5861e5f13..650b421d10b 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -111,9 +111,9 @@ 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: Build Android debug APK - run: just mobile-build-android + run: BUZZ_PUSH_GATEWAY_URL=https://push.example just mobile-build-android mobile-swift: name: Mobile Swift @@ -132,7 +132,7 @@ jobs: - name: Test run: swift test --package-path mobile/ios/BuzzPushKit - name: Build complete unsigned iOS release - run: cd mobile && flutter build ios --release --no-codesign --no-pub + run: cd mobile && flutter build ios --release --no-codesign --no-pub --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example results: name: Results diff --git a/Justfile b/Justfile index c81adb2381b..648d57c124a 100644 --- a/Justfile +++ b/Justfile @@ -804,7 +804,7 @@ mobile-check: # Run mobile tests mobile-test: - unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example # Regenerate the emoji dataset asset from desktop's emoji-mart install. # Output is committed — rerun after bumping @emoji-mart/data. @@ -813,8 +813,9 @@ mobile-emoji-data: # Compile an unsigned Android debug APK (worktree-aware debug identity) mobile-build-android: + test -n "${BUZZ_PUSH_GATEWAY_URL:-}" || { echo "BUZZ_PUSH_GATEWAY_URL is required" >&2; exit 1; } ./scripts/mobile-worktree-overrides.sh - unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub --dart-define="BUZZ_PUSH_GATEWAY_URL=${BUZZ_PUSH_GATEWAY_URL}" # Run the mobile app on iOS simulator (worktree-aware debug identity) mobile-dev: @@ -825,9 +826,10 @@ mobile-dev: sleep 3 fi ./scripts/mobile-worktree-overrides.sh + test -n "${BUZZ_PUSH_GATEWAY_URL:-}" || { echo "BUZZ_PUSH_GATEWAY_URL is required" >&2; exit 1; } cd {{mobile_dir}} unset GIT_DIR GIT_WORK_TREE - flutter run + flutter run --dart-define="BUZZ_PUSH_GATEWAY_URL=${BUZZ_PUSH_GATEWAY_URL}" # Uninstall stale worktree-suffixed Buzz debug installs (production apps kept) mobile-clean: diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index ecdc97582af..faefb1e9178 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 88f4d89dbcb..f94afe174d3 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 97568ba2d0c..675fea6fde1 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 85dd8af1a8c..3882e935e67 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 1339b777e50..5f83965368c 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 245d1a682ec..c7894f25b09 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 every protocol +# route and App Attest audience from this one value. +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 ab410b932f7..100ec893c3f 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 bb0e63fe15d..b12f9ff48c6 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 6575c98bfb3..2fa89a1a8e6 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -258,15 +258,15 @@ A pubkey-only client cannot create, replace, or revoke a lease. If a platform en Implementations MUST NOT interpret this section as NIP-26 delegation. A future specification may define a narrowly scoped installation authorization for unattended endpoint rotation, but such a capability is neither required nor implied here. -## Public APNs Gateway Profile (Buzz, normative) +## APNs Gateway Profile (Buzz, normative) -This section registers the public last-hop profile served at `https://push.buzz.xyz`. It is an optional profile of NIP-PL, but every requirement in this section is normative for implementations that use it. The gateway is stateful: it retains installation authority, encrypted APNs-token custody, relay delegations, replay reservations, and endpoint quotas. The relay remains the executor and retains lease acceptance, matching, tenant authorization, endpoint uniqueness, coalescing, durable jobs/retries, and lease-generation invalidation. +This section registers a last-hop profile served at a deployment-configured HTTPS origin. It is an optional profile of NIP-PL, but every requirement in this section is normative for implementations that use it. The configured origin has no credentials, port, path, query, or fragment. The gateway is stateful: it retains installation authority, encrypted APNs-token custody, relay delegations, replay reservations, and endpoint quotas. The relay remains the executor and retains lease acceptance, matching, tenant authorization, endpoint uniqueness, coalescing, durable jobs/retries, and lease-generation invalidation. ### Registered values and lease mapping The registered `app_profile` value is `buzz-ios-dogfood`. It identifies the closed Buzz dogfood application identity, not an APNs transport environment. -The canonical gateway owns its exact App Attest application identifier, APNs +The configured gateway owns its exact App Attest application identifier, APNs topic, certificate-backed connection pool, and APNs environment. Enrollment succeeds only when App Attest cryptographically verifies the configured application identifier. The gateway MUST NOT accept an APNs topic from a client. The APNs token @@ -289,7 +289,7 @@ Every App Attest operation signs a **transcript**, not the received request byte + "\\n" + ``` -The JSON object has no insignificant whitespace and members appear in the exact order shown below. Strings use JSON escaping for quotation mark, reverse solidus, and U+0000..U+001F; all authority-bearing strings admitted by this profile are ASCII. UUID strings are canonical lowercase-hyphenated. Integers use shortest decimal notation. The fixed `audience` value is part of the signed object and prevents cross-route use. For enrollment, these exact transcript bytes are the App Attest `clientData` supplied to attestation verification. For every assertion route, `clientDataHash = SHA-256(transcript bytes)` is verified by App Attest. The separately stored challenge must equal the request `challenge`, is single-use, expires after 300 seconds, and is consumed only after successful cryptographic verification. Assertion `signCount` MUST strictly increase atomically for the installation. +The JSON object has no insignificant whitespace and members appear in the exact order shown below. Strings use JSON escaping for quotation mark, reverse solidus, and U+0000..U+001F; all authority-bearing strings admitted by this profile are ASCII. UUID strings are canonical lowercase-hyphenated. Integers use shortest decimal notation. The `audience` value is the configured gateway origin plus the fixed route shown below. It is part of the signed object and prevents cross-origin and cross-route use. For enrollment, these exact transcript bytes are the App Attest `clientData` supplied to attestation verification. For every assertion route, `clientDataHash = SHA-256(transcript bytes)` is verified by App Attest. The separately stored challenge must equal the request `challenge`, is single-use, expires after 300 seconds, and is consumed only after successful cryptographic verification. Assertion `signCount` MUST strictly increase atomically for the installation. ### Challenge @@ -318,7 +318,7 @@ Request members, in any request order: `expires_at` MUST satisfy `now < expires_at <= now + configured_max_installation_lifetime`; the selected profile MUST be enabled. The exact transcript is domain `buzz.push.enroll.v1` followed by this ordered object: ```json -{"v":1,"audience":"https://push.buzz.xyz/v1/installations","challenge_id":"","challenge":"","key_id":"","app_profile":"","endpoint":"","endpoint_epoch":1,"expires_at":} +{"v":1,"audience":"/v1/installations","challenge_id":"","challenge":"","key_id":"","app_profile":"","endpoint":"","endpoint_epoch":1,"expires_at":} ``` The gateway verifies Apple's attestation chain, configured application identifier, production AAGUID, key identifier, and transcript. Apple documents no APNs-token-to-App-Attest-key binding; token provenance at enrollment is an explicit bootstrap assumption. It then stores only encrypted token custody plus its fingerprint. Success `201`: @@ -342,7 +342,7 @@ Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge o `not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= now + configured_max_grant_lifetime`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. A successful delegation atomically extends the authenticated installation lifetime through at least the delegation's `expires_at`, allowing renewal without duplicate token enrollment. Transcript domain `buzz.push.delegate.v1`; ordered object: ```json -{"v":1,"audience":"https://push.buzz.xyz/v1/delegations","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"","not_before":,"expires_at":} +{"v":1,"audience":"/v1/delegations","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"","not_before":,"expires_at":} ``` Success `201`: `{"endpoint_grant":""}`. The sealed grant contains no APNs token. Grant-key rotation MUST retain decrypt-only predecessor keys through the maximum lifetime of grants they issued. @@ -358,7 +358,7 @@ Success `201`: `{"endpoint_grant":""}`. The sealed grant cont `new_endpoint_epoch` MUST equal `endpoint_epoch + 1` without overflow. Transcript domain `buzz.push.rotate-endpoint.v1`; ordered object: ```json -{"v":1,"audience":"https://push.buzz.xyz/v1/installations/endpoint","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":,"endpoint":""} +{"v":1,"audience":"/v1/installations/endpoint","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":,"endpoint":""} ``` A successful atomic rotation invalidates every grant sealed to the old epoch and returns `200 {"status":"rotated"}`. @@ -374,7 +374,7 @@ A successful atomic rotation invalidates every grant sealed to the old epoch and Transcript domain `buzz.push.revoke-delegation.v1`; ordered object: ```json -{"v":1,"audience":"https://push.buzz.xyz/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} +{"v":1,"audience":"/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} ``` The generation identifies the current delegation generation. Success is `200 {"status":"revoked"}`. @@ -388,14 +388,14 @@ The generation identifies the current delegation generation. Success is `200 {"s `new_endpoint_epoch` MUST equal `endpoint_epoch + 1` without overflow. Transcript domain `buzz.push.revoke-installation.v1`; ordered object: ```json -{"v":1,"audience":"https://push.buzz.xyz/v1/installations/revoke","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":} +{"v":1,"audience":"/v1/installations/revoke","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":} ``` Success is `200 {"status":"revoked"}`. The revocation atomically invalidates the installation and every delegation. ### Relay delivery -`POST /v1/deliveries/apns` has the exact externally configured URL `https://push.buzz.xyz/v1/deliveries/apns`. Request: +`POST /v1/deliveries/apns` has the exact externally configured URL `/v1/deliveries/apns`. Request: ```json {"v":1,"endpoint_grant":"","request_id":"","expires_at":} @@ -449,4 +449,4 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery - Classes: `silent`, `default`, `time_sensitive`, `urgent` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` +- APNs gateway profile: deployment-configured HTTPS origin; app profile `buzz-ios-dogfood`; wire version `1` diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index c4151a677ce..008a933b868 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 all routes and App Attest audiences from it. | | `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 @@ -122,10 +122,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 +156,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 +177,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 +228,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/mobile/README.md b/mobile/README.md index c108dcece25..7a8d0185dd2 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -104,6 +104,18 @@ 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. +Every mobile build must 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 fail when the define is absent. The app binds +enrollment state to this origin and discards legacy or mismatched grants and +pending enrollment journals before enrolling again. Notification permission is +not reset. + 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, diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index d0ae877e9dd..6f4218ee236 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Properties +import java.util.Base64 plugins { id("com.android.application") @@ -20,6 +21,24 @@ 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 hasPushGatewayOrigin = + dartDefines.split(',').any { encoded -> + val define = runCatching { String(Base64.getDecoder().decode(encoded)) }.getOrNull() + define?.startsWith(pushGatewayDefinePrefix) == true && + define.length > pushGatewayDefinePrefix.length + } + +tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { + doFirst { + if (!hasPushGatewayOrigin) { + throw GradleException( + "BUZZ_PUSH_GATEWAY_URL must be supplied with --dart-define for every mobile build.", + ) + } + } +} // 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 8003193da39..0ec4bc3c5dd 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -12,6 +12,8 @@ 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 public let relayOrigin: String /// NIP-PL delegation key selected from the relay push descriptor. public let relayPubkey: String @@ -29,6 +31,7 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public let expiresAt: Int64 public init( + gatewayOrigin: String, relayOrigin: String, relayPubkey: String, relayMetadataPubkey: String? = nil, @@ -42,6 +45,7 @@ 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 @@ -59,14 +63,21 @@ 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 { + /// Discards legacy records and records issued by any other gateway authority. + func reset(forGatewayOrigin gatewayOrigin: String) throws func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws func pendingEnrollment( + gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -310,6 +321,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 +363,18 @@ public final class BuzzDevPushEnrollmentDriver { try BuzzSecureRandom.bytes(count: 16) } ) throws { - guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { + guard lifetimeSeconds > 0 else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + let canonical: (url: URL, text: String) + do { + canonical = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayBaseURL) + } catch { throw BuzzDevPushEnrollmentError.invalidGatewayURL } - self.gatewayBaseURL = gatewayBaseURL + try store.reset(forGatewayOrigin: canonical.text) + self.gatewayBaseURL = canonical.url + self.gatewayOrigin = canonical.text self.store = store self.session = session self.appAttest = appAttest @@ -383,9 +403,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( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -394,6 +416,7 @@ public final class BuzzDevPushEnrollmentDriver { || pending.expiresAt <= nowSeconds { try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -407,12 +430,14 @@ public final class BuzzDevPushEnrollmentDriver { { guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) return current } let refreshed = BuzzPushEndpointGrantRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: current.relayOrigin, relayPubkey: current.relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, @@ -427,6 +452,7 @@ public final class BuzzDevPushEnrollmentDriver { ) try store.save(refreshed) try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -438,12 +464,14 @@ public final class BuzzDevPushEnrollmentDriver { // 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.gatewayOrigin == gatewayOrigin && $0.relayPubkey == relayPubkey + && $0.appProfile == Self.appProfile && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch && $0.expiresAt > nowSeconds + 300 }) { let record = BuzzPushEndpointGrantRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, @@ -458,6 +486,7 @@ public final class BuzzDevPushEnrollmentDriver { ) try store.save(record) try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -469,7 +498,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,6 +526,7 @@ public final class BuzzDevPushEnrollmentDriver { ? reusableInstallation.expiresAt : renewedExpiration pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, endpointHash: endpointHash, @@ -510,6 +541,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,6 +558,7 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, endpointHash: endpointHash, @@ -572,12 +605,14 @@ public final class BuzzDevPushEnrollmentDriver { // 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( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) return try await enroll(deviceToken: deviceToken, relayURL: relayURL) } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, endpointHash: pending.endpointHash, @@ -615,6 +650,7 @@ public final class BuzzDevPushEnrollmentDriver { generation = 1 } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, endpointHash: pending.endpointHash, @@ -634,6 +670,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, @@ -655,6 +692,7 @@ public final class BuzzDevPushEnrollmentDriver { ) let record = BuzzPushEndpointGrantRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, @@ -669,6 +707,7 @@ public final class BuzzDevPushEnrollmentDriver { ) try store.save(record) try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -828,16 +867,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, diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift index 402f90be30f..36d8807bb2d 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -2,6 +2,8 @@ /// It contains no APNs endpoint, only its hash and the exact authenticated /// enrollment material needed to replay a committed request idempotently. public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { + /// Gateway authority for which this retry journal remains valid. + public let gatewayOrigin: String public let relayOrigin: String public let relayPubkey: String public let endpointHash: String @@ -16,6 +18,7 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { public let delegationGeneration: Int64 public init( + gatewayOrigin: String, relayOrigin: String, relayPubkey: String, endpointHash: String, @@ -29,6 +32,7 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { attestation: String? = nil, delegationGeneration: Int64 = 0 ) { + self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey self.endpointHash = endpointHash diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift index fd0bc34d696..ab6c8354067 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 binds the transcript to the configured gateway origin +/// and one fixed protocol route. A transcript for one gateway or route cannot +/// be replayed against another. public enum BuzzPushTranscript { // MARK: Domains @@ -41,14 +41,6 @@ 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 @@ -57,6 +49,7 @@ public enum BuzzPushTranscript { /// `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 +60,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 +74,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 +86,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 +101,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 +111,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 +127,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 +136,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 +151,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 +160,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 +178,37 @@ 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 { + let canonical = try canonicalGatewayOrigin(gatewayOrigin) + guard let value = URL(string: route, relativeTo: canonical.url)?.absoluteURL else { + throw BuzzPushTranscriptError.invalidGatewayOrigin + } + return value.absoluteString + } + /// 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 2345bc46c68..6144baf0393 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,20 +122,25 @@ 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: []) + expectedSHA256: "8250aa191e62be0ad9d26ad7425ec99fb4df13ede580f01205fde057dacaea11", + fixture: makeFixtureTranscript( + name: "enroll", + replacements: [("https://push.buzz.xyz", Self.gatewayOrigin)] + ) ) try assertMatchesVector( "delegate", actual: appAttest.clientData[1], - expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", + expectedSHA256: "c4d1c03d89f044b6a00818069002a6318b5ee42c246a5dbcca3722ab6377df49", fixture: makeFixtureTranscript( name: "delegate", replacements: [ + ("https://push.buzz.xyz", Self.gatewayOrigin), (Self.firstChallengeId, Self.secondChallengeId) ] ) @@ -142,6 +148,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual( record, BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, @@ -373,16 +380,43 @@ 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 testDriverDiscardsGrantAndPendingStateFromAnotherGateway() throws { + let record = BuzzPushEndpointGrantRecord( + gatewayOrigin: "https://old-gateway.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "old-grant", + endpointHash: String(repeating: "b", count: 64), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: "https://old-gateway.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + endpointHash: String(repeating: "b", count: 64), + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + installationId: Self.installationId + ) + let store = MemoryGrantStore(records: [record], pending: [pending]) + + _ = try makeDriver(store: store, appAttest: RecordingAppAttest()) + + XCTAssertTrue(store.saved.isEmpty) + XCTAssertTrue(store.pending.isEmpty) } func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { @@ -618,6 +652,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testReusesPersistedUnexpiredGrant() async throws { let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, @@ -658,6 +693,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, @@ -703,6 +739,7 @@ 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, @@ -773,6 +810,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, @@ -878,6 +916,7 @@ 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, @@ -919,6 +958,7 @@ 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, @@ -956,6 +996,7 @@ 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, @@ -1150,11 +1191,17 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var grantSaveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], + pending: [BuzzPushPendingEnrollmentRecord] = [], grantSaveFailuresRemaining: Int = 0 ) { saved = records + self.pending = pending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining } + func reset(forGatewayOrigin gatewayOrigin: String) throws { + saved.removeAll { $0.gatewayOrigin != gatewayOrigin } + pending.removeAll { $0.gatewayOrigin != gatewayOrigin } + } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { if grantSaveFailuresRemaining > 0 { @@ -1162,27 +1209,36 @@ 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 { pending.removeAll { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift index 3cb242bc4cf..3b3fb05343c 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 testConfiguredOriginBindsTranscriptAudience() 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.example/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 3061f92b6a8..288ef1ae3ce 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -503,7 +503,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "set -e\n/bin/sh \"$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 a770451b619..6fa9fbbfc32 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -345,7 +345,32 @@ import os.log } switch call.method { case "startRegistration": - startPushRegistration(result: result) + guard let arguments = call.arguments as? [String: Any], + let gatewayText = arguments["gatewayUrl"] as? String, + let gatewayURL = URL(string: gatewayText) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Push registration requires gatewayUrl.", + details: nil + ) + ) + return + } + do { + let gatewayOrigin = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayURL).text + try endpointGrantStore.reset(forGatewayOrigin: gatewayOrigin) + startPushRegistration(result: result) + } catch { + result( + FlutterError( + code: "push_gateway_configuration_failed", + message: "Push gateway configuration is invalid.", + details: error.localizedDescription + ) + ) + } case "takePendingNotificationResponse": result(pushNavigationBuffer.take()?.flutterArguments) case "notificationAuthorizationStatus": diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index ffedbedd19a..d30f042a468 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -6,8 +6,10 @@ 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 legacyRecordsAccount = "v1" + private static let legacyPendingAccount = "pending-v1" + private static let recordsAccount = "v2" + private static let pendingAccount = "pending-v2" private let accessGroup: String? @@ -15,6 +17,23 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { self.accessGroup = accessGroup } + func reset(forGatewayOrigin gatewayOrigin: String) throws { + try delete(account: Self.legacyRecordsAccount) + try delete(account: Self.legacyPendingAccount) + + let allRecords = try records() + let retainedRecords = allRecords.filter { $0.gatewayOrigin == gatewayOrigin } + if retainedRecords.count != allRecords.count { + try replace(retainedRecords, account: Self.recordsAccount) + } + + let allPending = try pendingEnrollments() + let retainedPending = allPending.filter { $0.gatewayOrigin == gatewayOrigin } + if retainedPending.count != allPending.count { + try replace(retainedPending, account: Self.pendingAccount) + } + } + func records() throws -> [BuzzPushEndpointGrantRecord] { var query = baseQuery(account: Self.recordsAccount) query[kSecReturnData as String] = true @@ -39,34 +58,43 @@ 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) } 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) } - 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) } @@ -112,6 +140,13 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } + private func delete(account: String) throws { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw keychainError(status, operation: "delete legacy state") + } + } + private func baseQuery(account: String) -> [String: Any] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index a0c674f14f6..4f6122971a4 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -142,7 +142,9 @@ Future syncPendingBuzzPushNotificationResponse() async { Future startBuzzPushRegistration() async { if (defaultTargetPlatform != TargetPlatform.iOS) return; try { - await _channel.invokeMethod('startRegistration'); + await _channel.invokeMethod('startRegistration', { + 'gatewayUrl': Env.pushGatewayUrl, + }); } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. } diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 00fcf65b716..98bbc7dd10c 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -71,10 +71,7 @@ class Env { 'BUZZ_RELAY_URL', defaultValue: 'http://localhost:3000', ); - static const pushGatewayUrl = String.fromEnvironment( - 'BUZZ_PUSH_GATEWAY_URL', - defaultValue: 'https://push.buzz.xyz', - ); + static const pushGatewayUrl = String.fromEnvironment('BUZZ_PUSH_GATEWAY_URL'); } 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 00000000000..0eee3036fe3 --- /dev/null +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +configured=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=?*) configured=true ;; + esac +done +IFS=$old_ifs + +if [ "$configured" != true ]; then + echo "error: BUZZ_PUSH_GATEWAY_URL must be supplied with --dart-define for every mobile build." >&2 + exit 1 +fi diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 4bff3a2c2c3..cdc26aaeac5 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -53,6 +53,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_channel, (call) async { expect(call.method, 'startRegistration'); + expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); return null; }); @@ -246,11 +247,9 @@ void main() { }, ); - test('development push gateway matches the compiled configuration', () { - const expectedGateway = String.fromEnvironment( - 'BUZZ_PUSH_GATEWAY_URL', - defaultValue: 'https://push.buzz.xyz', - ); + test('push gateway matches the required compiled configuration', () { + const expectedGateway = String.fromEnvironment('BUZZ_PUSH_GATEWAY_URL'); + expect(expectedGateway, isNotEmpty); expect(Env.pushGatewayUrl, expectedGateway); }); From 6616b83b927fe74daaf930fa6bc85c797727e2fe Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 14:32:13 -0700 Subject: [PATCH 03/67] ci(helm): provide explicit gateway origin Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- deploy/charts/buzz-push-gateway/ci/ci-values.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 deploy/charts/buzz-push-gateway/ci/ci-values.yaml 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 00000000000..f429ceef93e --- /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 From 4a60901c39ad6021a6afbe5d3649aba46e23f578 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 14:51:10 -0700 Subject: [PATCH 04/67] fix(push): clean up legacy enrollment state Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 205 +++++++++++++++++- .../BuzzDevPushEnrollmentDriverTests.swift | 201 ++++++++++++++++- .../ios/Runner/PushEndpointGrantStore.swift | 48 +++- 3 files changed, 447 insertions(+), 7 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 0ec4bc3c5dd..e89cd496dc5 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -60,11 +60,86 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { } } +/// Endpoint grant persisted by clients that predate gateway-origin binding. +public struct BuzzPushLegacyEndpointGrantRecord: Codable, Equatable, Sendable { + /// Relay origin associated with the legacy grant. + public let relayOrigin: String + /// Relay delegation key associated with the legacy grant. + public let relayPubkey: String + /// Optional relay metadata authority. + public let relayMetadataPubkey: String? + /// Gateway installation that owns the enrolled endpoint. + public let gatewayInstallationHandle: String? + /// Unlinkable relay-facing installation identifier. + public let installationId: String + /// Opaque relay delegation capability. + public let endpointGrant: String + /// Digest of the enrolled APNs endpoint. + public let endpointHash: String + /// Gateway application profile. + public let appProfile: String + /// Endpoint rotation epoch. + public let endpointEpoch: Int64 + /// Delegation generation. + public let generation: Int64 + /// Grant expiration timestamp. + public let expiresAt: Int64 +} + +/// Crash-recovery journal persisted by clients that predate gateway-origin binding. +public struct BuzzPushLegacyPendingEnrollmentRecord: Codable, Equatable, Sendable { + /// Relay origin associated with the pending enrollment. + public let relayOrigin: String + /// Relay delegation key associated with the pending enrollment. + public let relayPubkey: String + /// Digest of the APNs endpoint used by the exact request. + public let endpointHash: String + /// Gateway application profile. + public let appProfile: String + /// Requested installation expiration timestamp. + public let expiresAt: Int64 + /// Unlinkable relay-facing installation identifier. + public let installationId: String + /// Recovered gateway installation, when the request already committed. + public let gatewayInstallationHandle: String? + /// Exact enrollment challenge identifier. + public let challengeId: String? + /// Exact enrollment challenge value. + public let challenge: String? + /// App Attest key used by the exact enrollment request. + public let keyId: String? + /// App Attest object used by the exact enrollment request. + public let attestation: String? + /// Last attempted delegation generation. + public let delegationGeneration: Int64 +} + +/// Durable pre-origin enrollment state that must be resolved before replacement. +public struct BuzzPushLegacyEnrollmentState: Equatable, Sendable { + /// Persisted legacy grants. + public let records: [BuzzPushLegacyEndpointGrantRecord] + /// Persisted legacy enrollment retry journals. + public let pending: [BuzzPushLegacyPendingEnrollmentRecord] + + /// Creates a legacy-state snapshot for cleanup. + public init( + records: [BuzzPushLegacyEndpointGrantRecord], + pending: [BuzzPushLegacyPendingEnrollmentRecord] + ) { + self.records = records + self.pending = pending + } +} + /// 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 { - /// Discards legacy records and records issued by any other gateway authority. + /// Discards records issued by any other explicitly identified gateway authority. func reset(forGatewayOrigin gatewayOrigin: String) throws + /// Returns pre-origin state without deleting its durable retry record. + func legacyEnrollmentState() throws -> BuzzPushLegacyEnrollmentState + /// Removes pre-origin state after its gateway installation has been resolved. + func removeLegacyEnrollmentState() throws func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws func pendingEnrollment( @@ -394,11 +469,12 @@ public final class BuzzDevPushEnrollmentDriver { relayURL: URL ) async throws -> BuzzPushEndpointGrantRecord { precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") + let endpoint = Self.lowercaseHex(deviceToken) + let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + try await resolveLegacyEnrollmentState(endpoint: endpoint, endpointHash: endpointHash) let relayOrigin = try Self.relayOrigin(relayURL) let relayKeys = try await fetchCurrentRelayKeys(from: relayOrigin.url) let relayPubkey = relayKeys.pushPubkey - let endpoint = Self.lowercaseHex(deviceToken) - let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) let nowSeconds = Int64(now().timeIntervalSince1970) let storedRecords = try store.records() @@ -714,6 +790,77 @@ public final class BuzzDevPushEnrollmentDriver { return record } + private func resolveLegacyEnrollmentState( + endpoint: String, + endpointHash: String + ) async throws { + let legacy = try store.legacyEnrollmentState() + guard !legacy.records.isEmpty || !legacy.pending.isEmpty else { return } + + let nowSeconds = Int64(now().timeIntervalSince1970) + var installationEpochs: [UUID: Int64] = [:] + for record in legacy.records { + guard record.endpointHash == endpointHash, + record.appProfile == Self.appProfile, + record.expiresAt > nowSeconds + else { continue } + guard let value = record.gatewayInstallationHandle, + let handle = UUID(uuidString: value), + value == handle.uuidString.lowercased(), + record.endpointEpoch > 0 + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "legacy endpoint grant") + } + installationEpochs[handle] = max(installationEpochs[handle] ?? 0, record.endpointEpoch) + } + + for pending in legacy.pending { + guard pending.endpointHash == endpointHash, + pending.appProfile == Self.appProfile, + pending.expiresAt > nowSeconds + else { continue } + if let value = pending.gatewayInstallationHandle { + guard let handle = UUID(uuidString: value), + value == handle.uuidString.lowercased() + else { + throw BuzzDevPushEnrollmentError.invalidResponse( + route: "legacy pending enrollment" + ) + } + installationEpochs[handle] = max(installationEpochs[handle] ?? 0, Self.endpointEpoch) + continue + } + + guard let challengeId = pending.challengeId, + let challengeUUID = UUID(uuidString: challengeId), + challengeId == challengeUUID.uuidString.lowercased(), + let challengeValue = pending.challenge, + let keyId = pending.keyId, + let attestation = pending.attestation + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "legacy pending enrollment") + } + let recovered = try await enrollInstallation( + challenge: Challenge(id: challengeUUID, value: challengeValue), + endpoint: endpoint, + expiresAt: pending.expiresAt, + attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) + ) + installationEpochs[recovered] = Self.endpointEpoch + } + + for (handle, endpointEpoch) in installationEpochs { + do { + try await revokeInstallation(handle: handle, endpointEpoch: endpointEpoch) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations/revoke", _, actual: 404, _ + ) { + // The configured authority has no live installation to clean up. + } + } + try store.removeLegacyEnrollmentState() + } + private func makeInstallationId() throws -> String { let bytes = try installationIdBytes() precondition( @@ -802,6 +949,39 @@ public final class BuzzDevPushEnrollmentDriver { return response.endpointGrant } + private func revokeInstallation(handle: UUID, endpointEpoch: Int64) async throws { + let (newEndpointEpoch, overflow) = endpointEpoch.addingReportingOverflow(1) + guard !overflow else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "legacy endpoint grant") + } + let revokeChallenge = try await challenge() + let clientData = try BuzzPushTranscript.revokeInstallation( + gatewayOrigin: gatewayBaseURL, + challengeId: revokeChallenge.id, + challenge: revokeChallenge.value, + installationHandle: handle, + endpointEpoch: endpointEpoch, + newEndpointEpoch: newEndpointEpoch + ) + let assertion = try await appAttest.assertion(clientData: clientData) + let response: MutationResponse = try await post( + route: "v1/installations/revoke", + expectedStatus: 200, + body: InstallationRevocationRequest( + v: 1, + challengeId: revokeChallenge.id.uuidString.lowercased(), + challenge: revokeChallenge.value, + installationHandle: handle.uuidString.lowercased(), + endpointEpoch: endpointEpoch, + newEndpointEpoch: newEndpointEpoch, + assertion: assertion + ) + ) + guard response.status == "revoked" else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/revoke") + } + } + private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys { var request = URLRequest(url: relayOrigin) request.httpMethod = "GET" @@ -967,6 +1147,25 @@ private struct InstallationResponse: Decodable { case expiresAt = "expires_at" } } +private struct InstallationRevocationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let installationHandle: String + let endpointEpoch: Int64 + let newEndpointEpoch: Int64 + let assertion: String + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case newEndpointEpoch = "new_endpoint_epoch" + case assertion + } +} +private struct MutationResponse: Decodable { let status: String } private struct DelegationRequest: Encodable { let v: Int let challengeId: String diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 6144baf0393..8029b50f77b 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -141,7 +141,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { name: "delegate", replacements: [ ("https://push.buzz.xyz", Self.gatewayOrigin), - (Self.firstChallengeId, Self.secondChallengeId) + (Self.firstChallengeId, Self.secondChallengeId), ] ) ) @@ -419,6 +419,192 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.pending.isEmpty) } + func testLegacyInstallationIsRevokedBeforeStateIsDiscarded() async throws { + let legacy = BuzzPushLegacyEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: nil, + gatewayInstallationHandle: Self.installationHandle, + installationId: Self.installationId, + endpointGrant: "legacy-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(legacyRecords: [legacy]) + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + 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/revoke"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["new_endpoint_epoch"] as? Int, 2) + XCTAssertEqual(body["assertion"] as? String, Self.assertion) + return Self.response(request, status: 200, json: ["status": "revoked"]) + case ("GET", "https://relay.example/"): + return Self.response(request, status: 500, json: ["error": "stop_after_cleanup"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected the relay request to stop enrollment") + } catch let error as BuzzDevPushEnrollmentError { + XCTAssertEqual( + error, + .unexpectedStatus( + route: "NIP-11", + expected: 200, + actual: 500, + body: #"{"error":"stop_after_cleanup"}"# + ) + ) + } + XCTAssertTrue(store.legacyRecords.isEmpty) + XCTAssertEqual(appAttest.clientData.count, 1) + } + + func testFailedLegacyRevocationPreservesDurableState() async throws { + let legacy = BuzzPushLegacyEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: nil, + gatewayInstallationHandle: Self.installationHandle, + installationId: Self.installationId, + endpointGrant: "legacy-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(legacyRecords: [legacy]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + if request.url?.path == "/v1/installations/challenges" { + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + } + return Self.response(request, status: 503, json: ["error": "temporarily_unavailable"]) + } + + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected legacy cleanup to fail") + } catch let error as BuzzDevPushEnrollmentError { + XCTAssertEqual( + error, + .unexpectedStatus( + route: "v1/installations/revoke", + expected: 200, + actual: 503, + body: #"{"error":"temporarily_unavailable"}"# + ) + ) + } + XCTAssertEqual(store.legacyRecords, [legacy]) + } + + func testLegacyPendingEnrollmentIsRecoveredThenRevoked() async throws { + let endpointData = Data((1...32).map(UInt8.init)) + let pending = BuzzPushLegacyPendingEnrollmentRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + endpointHash: Self.hex(SHA256.hash(data: endpointData)), + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + installationId: Self.installationId, + gatewayInstallationHandle: nil, + challengeId: Self.firstChallengeId, + challenge: Self.challenge, + keyId: Self.keyId, + attestation: Self.attestation, + delegationGeneration: 0 + ) + let store = MemoryGrantStore(legacyPending: [pending]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("POST", "http://push.example/v1/installations"): + let body = try Self.body(request) + XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId) + XCTAssertEqual(body["endpoint"] as? String, Self.endpoint) + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations/revoke"): + return Self.response(request, status: 200, json: ["status": "revoked"]) + case ("GET", "https://relay.example/"): + return Self.response(request, status: 500, json: ["error": "stop_after_cleanup"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll(deviceToken: endpointData, relayURL: Self.relayURL) + XCTFail("Expected the relay request to stop enrollment") + } catch let error as BuzzDevPushEnrollmentError { + XCTAssertEqual( + error, + .unexpectedStatus( + route: "NIP-11", + expected: 200, + actual: 500, + body: #"{"error":"stop_after_cleanup"}"# + ) + ) + } + XCTAssertTrue(store.legacyPending.isEmpty) + } + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { let service = RecordingDCAppAttestService(isSupported: false) let provider = BuzzDCAppAttestProvider( @@ -1188,20 +1374,33 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] + var legacyRecords: [BuzzPushLegacyEndpointGrantRecord] + var legacyPending: [BuzzPushLegacyPendingEnrollmentRecord] var grantSaveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], pending: [BuzzPushPendingEnrollmentRecord] = [], + legacyRecords: [BuzzPushLegacyEndpointGrantRecord] = [], + legacyPending: [BuzzPushLegacyPendingEnrollmentRecord] = [], grantSaveFailuresRemaining: Int = 0 ) { saved = records self.pending = pending + self.legacyRecords = legacyRecords + self.legacyPending = legacyPending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining } func reset(forGatewayOrigin gatewayOrigin: String) throws { saved.removeAll { $0.gatewayOrigin != gatewayOrigin } pending.removeAll { $0.gatewayOrigin != gatewayOrigin } } + func legacyEnrollmentState() throws -> BuzzPushLegacyEnrollmentState { + BuzzPushLegacyEnrollmentState(records: legacyRecords, pending: legacyPending) + } + func removeLegacyEnrollmentState() throws { + legacyRecords = [] + legacyPending = [] + } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { if grantSaveFailuresRemaining > 0 { diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index d30f042a468..fd478595048 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -18,9 +18,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } func reset(forGatewayOrigin gatewayOrigin: String) throws { - try delete(account: Self.legacyRecordsAccount) - try delete(account: Self.legacyPendingAccount) - let allRecords = try records() let retainedRecords = allRecords.filter { $0.gatewayOrigin == gatewayOrigin } if retainedRecords.count != allRecords.count { @@ -34,6 +31,26 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } + func legacyEnrollmentState() throws -> BuzzPushLegacyEnrollmentState { + BuzzPushLegacyEnrollmentState( + records: try read( + [BuzzPushLegacyEndpointGrantRecord].self, + account: Self.legacyRecordsAccount, + operation: "read legacy endpoint grants" + ) ?? [], + pending: try read( + [BuzzPushLegacyPendingEnrollmentRecord].self, + account: Self.legacyPendingAccount, + operation: "read legacy pending enrollments" + ) ?? [] + ) + } + + func removeLegacyEnrollmentState() throws { + try delete(account: Self.legacyRecordsAccount) + try delete(account: Self.legacyPendingAccount) + } + func records() throws -> [BuzzPushEndpointGrantRecord] { var query = baseQuery(account: Self.recordsAccount) query[kSecReturnData as String] = true @@ -140,6 +157,31 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } + private func read( + _ type: T.Type, + account: String, + operation: String + ) throws -> T? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: operation) + } + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Stored legacy push state is invalid: \(error)"] + ) + } + } + private func delete(account: String) throws { let status = SecItemDelete(baseQuery(account: account) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { From bbc6cf359ec2e429e71b95ef97bf0e7426e0a135 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 14:55:45 -0700 Subject: [PATCH 05/67] Revert "fix(push): clean up legacy enrollment state" This reverts commit 4a60901c39ad6021a6afbe5d3649aba46e23f578. Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 205 +----------------- .../BuzzDevPushEnrollmentDriverTests.swift | 201 +---------------- .../ios/Runner/PushEndpointGrantStore.swift | 48 +--- 3 files changed, 7 insertions(+), 447 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index e89cd496dc5..0ec4bc3c5dd 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -60,86 +60,11 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { } } -/// Endpoint grant persisted by clients that predate gateway-origin binding. -public struct BuzzPushLegacyEndpointGrantRecord: Codable, Equatable, Sendable { - /// Relay origin associated with the legacy grant. - public let relayOrigin: String - /// Relay delegation key associated with the legacy grant. - public let relayPubkey: String - /// Optional relay metadata authority. - public let relayMetadataPubkey: String? - /// Gateway installation that owns the enrolled endpoint. - public let gatewayInstallationHandle: String? - /// Unlinkable relay-facing installation identifier. - public let installationId: String - /// Opaque relay delegation capability. - public let endpointGrant: String - /// Digest of the enrolled APNs endpoint. - public let endpointHash: String - /// Gateway application profile. - public let appProfile: String - /// Endpoint rotation epoch. - public let endpointEpoch: Int64 - /// Delegation generation. - public let generation: Int64 - /// Grant expiration timestamp. - public let expiresAt: Int64 -} - -/// Crash-recovery journal persisted by clients that predate gateway-origin binding. -public struct BuzzPushLegacyPendingEnrollmentRecord: Codable, Equatable, Sendable { - /// Relay origin associated with the pending enrollment. - public let relayOrigin: String - /// Relay delegation key associated with the pending enrollment. - public let relayPubkey: String - /// Digest of the APNs endpoint used by the exact request. - public let endpointHash: String - /// Gateway application profile. - public let appProfile: String - /// Requested installation expiration timestamp. - public let expiresAt: Int64 - /// Unlinkable relay-facing installation identifier. - public let installationId: String - /// Recovered gateway installation, when the request already committed. - public let gatewayInstallationHandle: String? - /// Exact enrollment challenge identifier. - public let challengeId: String? - /// Exact enrollment challenge value. - public let challenge: String? - /// App Attest key used by the exact enrollment request. - public let keyId: String? - /// App Attest object used by the exact enrollment request. - public let attestation: String? - /// Last attempted delegation generation. - public let delegationGeneration: Int64 -} - -/// Durable pre-origin enrollment state that must be resolved before replacement. -public struct BuzzPushLegacyEnrollmentState: Equatable, Sendable { - /// Persisted legacy grants. - public let records: [BuzzPushLegacyEndpointGrantRecord] - /// Persisted legacy enrollment retry journals. - public let pending: [BuzzPushLegacyPendingEnrollmentRecord] - - /// Creates a legacy-state snapshot for cleanup. - public init( - records: [BuzzPushLegacyEndpointGrantRecord], - pending: [BuzzPushLegacyPendingEnrollmentRecord] - ) { - self.records = records - self.pending = pending - } -} - /// 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 { - /// Discards records issued by any other explicitly identified gateway authority. + /// Discards legacy records and records issued by any other gateway authority. func reset(forGatewayOrigin gatewayOrigin: String) throws - /// Returns pre-origin state without deleting its durable retry record. - func legacyEnrollmentState() throws -> BuzzPushLegacyEnrollmentState - /// Removes pre-origin state after its gateway installation has been resolved. - func removeLegacyEnrollmentState() throws func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws func pendingEnrollment( @@ -469,12 +394,11 @@ public final class BuzzDevPushEnrollmentDriver { relayURL: URL ) async throws -> BuzzPushEndpointGrantRecord { precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") - let endpoint = Self.lowercaseHex(deviceToken) - let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) - try await resolveLegacyEnrollmentState(endpoint: endpoint, endpointHash: endpointHash) let relayOrigin = try Self.relayOrigin(relayURL) let relayKeys = try await fetchCurrentRelayKeys(from: relayOrigin.url) let relayPubkey = relayKeys.pushPubkey + let endpoint = Self.lowercaseHex(deviceToken) + let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) let nowSeconds = Int64(now().timeIntervalSince1970) let storedRecords = try store.records() @@ -790,77 +714,6 @@ public final class BuzzDevPushEnrollmentDriver { return record } - private func resolveLegacyEnrollmentState( - endpoint: String, - endpointHash: String - ) async throws { - let legacy = try store.legacyEnrollmentState() - guard !legacy.records.isEmpty || !legacy.pending.isEmpty else { return } - - let nowSeconds = Int64(now().timeIntervalSince1970) - var installationEpochs: [UUID: Int64] = [:] - for record in legacy.records { - guard record.endpointHash == endpointHash, - record.appProfile == Self.appProfile, - record.expiresAt > nowSeconds - else { continue } - guard let value = record.gatewayInstallationHandle, - let handle = UUID(uuidString: value), - value == handle.uuidString.lowercased(), - record.endpointEpoch > 0 - else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "legacy endpoint grant") - } - installationEpochs[handle] = max(installationEpochs[handle] ?? 0, record.endpointEpoch) - } - - for pending in legacy.pending { - guard pending.endpointHash == endpointHash, - pending.appProfile == Self.appProfile, - pending.expiresAt > nowSeconds - else { continue } - if let value = pending.gatewayInstallationHandle { - guard let handle = UUID(uuidString: value), - value == handle.uuidString.lowercased() - else { - throw BuzzDevPushEnrollmentError.invalidResponse( - route: "legacy pending enrollment" - ) - } - installationEpochs[handle] = max(installationEpochs[handle] ?? 0, Self.endpointEpoch) - continue - } - - guard let challengeId = pending.challengeId, - let challengeUUID = UUID(uuidString: challengeId), - challengeId == challengeUUID.uuidString.lowercased(), - let challengeValue = pending.challenge, - let keyId = pending.keyId, - let attestation = pending.attestation - else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "legacy pending enrollment") - } - let recovered = try await enrollInstallation( - challenge: Challenge(id: challengeUUID, value: challengeValue), - endpoint: endpoint, - expiresAt: pending.expiresAt, - attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) - ) - installationEpochs[recovered] = Self.endpointEpoch - } - - for (handle, endpointEpoch) in installationEpochs { - do { - try await revokeInstallation(handle: handle, endpointEpoch: endpointEpoch) - } catch BuzzDevPushEnrollmentError.unexpectedStatus( - route: "v1/installations/revoke", _, actual: 404, _ - ) { - // The configured authority has no live installation to clean up. - } - } - try store.removeLegacyEnrollmentState() - } - private func makeInstallationId() throws -> String { let bytes = try installationIdBytes() precondition( @@ -949,39 +802,6 @@ public final class BuzzDevPushEnrollmentDriver { return response.endpointGrant } - private func revokeInstallation(handle: UUID, endpointEpoch: Int64) async throws { - let (newEndpointEpoch, overflow) = endpointEpoch.addingReportingOverflow(1) - guard !overflow else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "legacy endpoint grant") - } - let revokeChallenge = try await challenge() - let clientData = try BuzzPushTranscript.revokeInstallation( - gatewayOrigin: gatewayBaseURL, - challengeId: revokeChallenge.id, - challenge: revokeChallenge.value, - installationHandle: handle, - endpointEpoch: endpointEpoch, - newEndpointEpoch: newEndpointEpoch - ) - let assertion = try await appAttest.assertion(clientData: clientData) - let response: MutationResponse = try await post( - route: "v1/installations/revoke", - expectedStatus: 200, - body: InstallationRevocationRequest( - v: 1, - challengeId: revokeChallenge.id.uuidString.lowercased(), - challenge: revokeChallenge.value, - installationHandle: handle.uuidString.lowercased(), - endpointEpoch: endpointEpoch, - newEndpointEpoch: newEndpointEpoch, - assertion: assertion - ) - ) - guard response.status == "revoked" else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/revoke") - } - } - private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys { var request = URLRequest(url: relayOrigin) request.httpMethod = "GET" @@ -1147,25 +967,6 @@ private struct InstallationResponse: Decodable { case expiresAt = "expires_at" } } -private struct InstallationRevocationRequest: Encodable { - let v: Int - let challengeId: String - let challenge: String - let installationHandle: String - let endpointEpoch: Int64 - let newEndpointEpoch: Int64 - let assertion: String - enum CodingKeys: String, CodingKey { - case v - case challengeId = "challenge_id" - case challenge - case installationHandle = "installation_handle" - case endpointEpoch = "endpoint_epoch" - case newEndpointEpoch = "new_endpoint_epoch" - case assertion - } -} -private struct MutationResponse: Decodable { let status: String } private struct DelegationRequest: Encodable { let v: Int let challengeId: String diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 8029b50f77b..6144baf0393 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -141,7 +141,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { name: "delegate", replacements: [ ("https://push.buzz.xyz", Self.gatewayOrigin), - (Self.firstChallengeId, Self.secondChallengeId), + (Self.firstChallengeId, Self.secondChallengeId) ] ) ) @@ -419,192 +419,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.pending.isEmpty) } - func testLegacyInstallationIsRevokedBeforeStateIsDiscarded() async throws { - let legacy = BuzzPushLegacyEndpointGrantRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - relayMetadataPubkey: nil, - gatewayInstallationHandle: Self.installationHandle, - installationId: Self.installationId, - endpointGrant: "legacy-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(legacyRecords: [legacy]) - let appAttest = RecordingAppAttest() - let driver = try makeDriver(store: store, appAttest: appAttest) - URLProtocolStub.handler = { request in - switch (request.httpMethod, request.url?.absoluteString) { - 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/revoke"): - let body = try Self.body(request) - XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) - XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) - XCTAssertEqual(body["new_endpoint_epoch"] as? Int, 2) - XCTAssertEqual(body["assertion"] as? String, Self.assertion) - return Self.response(request, status: 200, json: ["status": "revoked"]) - case ("GET", "https://relay.example/"): - return Self.response(request, status: 500, json: ["error": "stop_after_cleanup"]) - default: - XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") - return Self.response(request, status: 500, json: [:]) - } - } - - do { - _ = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) - XCTFail("Expected the relay request to stop enrollment") - } catch let error as BuzzDevPushEnrollmentError { - XCTAssertEqual( - error, - .unexpectedStatus( - route: "NIP-11", - expected: 200, - actual: 500, - body: #"{"error":"stop_after_cleanup"}"# - ) - ) - } - XCTAssertTrue(store.legacyRecords.isEmpty) - XCTAssertEqual(appAttest.clientData.count, 1) - } - - func testFailedLegacyRevocationPreservesDurableState() async throws { - let legacy = BuzzPushLegacyEndpointGrantRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - relayMetadataPubkey: nil, - gatewayInstallationHandle: Self.installationHandle, - installationId: Self.installationId, - endpointGrant: "legacy-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(legacyRecords: [legacy]) - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) - URLProtocolStub.handler = { request in - if request.url?.path == "/v1/installations/challenges" { - return Self.response( - request, - status: 200, - json: [ - "challenge_id": Self.firstChallengeId, - "challenge": Self.challenge, - "expires_at": Self.now + 300, - ] - ) - } - return Self.response(request, status: 503, json: ["error": "temporarily_unavailable"]) - } - - do { - _ = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) - XCTFail("Expected legacy cleanup to fail") - } catch let error as BuzzDevPushEnrollmentError { - XCTAssertEqual( - error, - .unexpectedStatus( - route: "v1/installations/revoke", - expected: 200, - actual: 503, - body: #"{"error":"temporarily_unavailable"}"# - ) - ) - } - XCTAssertEqual(store.legacyRecords, [legacy]) - } - - func testLegacyPendingEnrollmentIsRecoveredThenRevoked() async throws { - let endpointData = Data((1...32).map(UInt8.init)) - let pending = BuzzPushLegacyPendingEnrollmentRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - endpointHash: Self.hex(SHA256.hash(data: endpointData)), - appProfile: "buzz-ios-dogfood", - expiresAt: Self.expiresAt, - installationId: Self.installationId, - gatewayInstallationHandle: nil, - challengeId: Self.firstChallengeId, - challenge: Self.challenge, - keyId: Self.keyId, - attestation: Self.attestation, - delegationGeneration: 0 - ) - let store = MemoryGrantStore(legacyPending: [pending]) - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) - URLProtocolStub.handler = { request in - switch (request.httpMethod, request.url?.absoluteString) { - case ("POST", "http://push.example/v1/installations"): - let body = try Self.body(request) - XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId) - XCTAssertEqual(body["endpoint"] as? String, Self.endpoint) - return Self.response( - request, - status: 201, - json: [ - "installation_handle": Self.installationHandle, - "endpoint_epoch": 1, - "expires_at": Self.expiresAt, - ] - ) - case ("POST", "http://push.example/v1/installations/challenges"): - return Self.response( - request, - status: 200, - json: [ - "challenge_id": Self.secondChallengeId, - "challenge": Self.challenge, - "expires_at": Self.now + 300, - ] - ) - case ("POST", "http://push.example/v1/installations/revoke"): - return Self.response(request, status: 200, json: ["status": "revoked"]) - case ("GET", "https://relay.example/"): - return Self.response(request, status: 500, json: ["error": "stop_after_cleanup"]) - default: - XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") - return Self.response(request, status: 500, json: [:]) - } - } - - do { - _ = try await driver.enroll(deviceToken: endpointData, relayURL: Self.relayURL) - XCTFail("Expected the relay request to stop enrollment") - } catch let error as BuzzDevPushEnrollmentError { - XCTAssertEqual( - error, - .unexpectedStatus( - route: "NIP-11", - expected: 200, - actual: 500, - body: #"{"error":"stop_after_cleanup"}"# - ) - ) - } - XCTAssertTrue(store.legacyPending.isEmpty) - } - func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { let service = RecordingDCAppAttestService(isSupported: false) let provider = BuzzDCAppAttestProvider( @@ -1374,33 +1188,20 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] - var legacyRecords: [BuzzPushLegacyEndpointGrantRecord] - var legacyPending: [BuzzPushLegacyPendingEnrollmentRecord] var grantSaveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], pending: [BuzzPushPendingEnrollmentRecord] = [], - legacyRecords: [BuzzPushLegacyEndpointGrantRecord] = [], - legacyPending: [BuzzPushLegacyPendingEnrollmentRecord] = [], grantSaveFailuresRemaining: Int = 0 ) { saved = records self.pending = pending - self.legacyRecords = legacyRecords - self.legacyPending = legacyPending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining } func reset(forGatewayOrigin gatewayOrigin: String) throws { saved.removeAll { $0.gatewayOrigin != gatewayOrigin } pending.removeAll { $0.gatewayOrigin != gatewayOrigin } } - func legacyEnrollmentState() throws -> BuzzPushLegacyEnrollmentState { - BuzzPushLegacyEnrollmentState(records: legacyRecords, pending: legacyPending) - } - func removeLegacyEnrollmentState() throws { - legacyRecords = [] - legacyPending = [] - } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { if grantSaveFailuresRemaining > 0 { diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index fd478595048..d30f042a468 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -18,6 +18,9 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } func reset(forGatewayOrigin gatewayOrigin: String) throws { + try delete(account: Self.legacyRecordsAccount) + try delete(account: Self.legacyPendingAccount) + let allRecords = try records() let retainedRecords = allRecords.filter { $0.gatewayOrigin == gatewayOrigin } if retainedRecords.count != allRecords.count { @@ -31,26 +34,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } - func legacyEnrollmentState() throws -> BuzzPushLegacyEnrollmentState { - BuzzPushLegacyEnrollmentState( - records: try read( - [BuzzPushLegacyEndpointGrantRecord].self, - account: Self.legacyRecordsAccount, - operation: "read legacy endpoint grants" - ) ?? [], - pending: try read( - [BuzzPushLegacyPendingEnrollmentRecord].self, - account: Self.legacyPendingAccount, - operation: "read legacy pending enrollments" - ) ?? [] - ) - } - - func removeLegacyEnrollmentState() throws { - try delete(account: Self.legacyRecordsAccount) - try delete(account: Self.legacyPendingAccount) - } - func records() throws -> [BuzzPushEndpointGrantRecord] { var query = baseQuery(account: Self.recordsAccount) query[kSecReturnData as String] = true @@ -157,31 +140,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } - private func read( - _ type: T.Type, - account: String, - operation: String - ) throws -> T? { - var query = baseQuery(account: account) - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - throw keychainError(status, operation: operation) - } - do { - return try JSONDecoder().decode(type, from: data) - } catch { - throw NSError( - domain: "BuzzPushEndpointGrantStore", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Stored legacy push state is invalid: \(error)"] - ) - } - } - private func delete(account: String) throws { let status = SecItemDelete(baseQuery(account: account) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { From f2c4ce76e267d87ee14d57d3cf60616be1d41a4e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 14:56:31 -0700 Subject: [PATCH 06/67] docs(nip-pl): keep canonical gateway profile Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- docs/nips/NIP-PL.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 2fa89a1a8e6..6575c98bfb3 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -258,15 +258,15 @@ A pubkey-only client cannot create, replace, or revoke a lease. If a platform en Implementations MUST NOT interpret this section as NIP-26 delegation. A future specification may define a narrowly scoped installation authorization for unattended endpoint rotation, but such a capability is neither required nor implied here. -## APNs Gateway Profile (Buzz, normative) +## Public APNs Gateway Profile (Buzz, normative) -This section registers a last-hop profile served at a deployment-configured HTTPS origin. It is an optional profile of NIP-PL, but every requirement in this section is normative for implementations that use it. The configured origin has no credentials, port, path, query, or fragment. The gateway is stateful: it retains installation authority, encrypted APNs-token custody, relay delegations, replay reservations, and endpoint quotas. The relay remains the executor and retains lease acceptance, matching, tenant authorization, endpoint uniqueness, coalescing, durable jobs/retries, and lease-generation invalidation. +This section registers the public last-hop profile served at `https://push.buzz.xyz`. It is an optional profile of NIP-PL, but every requirement in this section is normative for implementations that use it. The gateway is stateful: it retains installation authority, encrypted APNs-token custody, relay delegations, replay reservations, and endpoint quotas. The relay remains the executor and retains lease acceptance, matching, tenant authorization, endpoint uniqueness, coalescing, durable jobs/retries, and lease-generation invalidation. ### Registered values and lease mapping The registered `app_profile` value is `buzz-ios-dogfood`. It identifies the closed Buzz dogfood application identity, not an APNs transport environment. -The configured gateway owns its exact App Attest application identifier, APNs +The canonical gateway owns its exact App Attest application identifier, APNs topic, certificate-backed connection pool, and APNs environment. Enrollment succeeds only when App Attest cryptographically verifies the configured application identifier. The gateway MUST NOT accept an APNs topic from a client. The APNs token @@ -289,7 +289,7 @@ Every App Attest operation signs a **transcript**, not the received request byte + "\\n" + ``` -The JSON object has no insignificant whitespace and members appear in the exact order shown below. Strings use JSON escaping for quotation mark, reverse solidus, and U+0000..U+001F; all authority-bearing strings admitted by this profile are ASCII. UUID strings are canonical lowercase-hyphenated. Integers use shortest decimal notation. The `audience` value is the configured gateway origin plus the fixed route shown below. It is part of the signed object and prevents cross-origin and cross-route use. For enrollment, these exact transcript bytes are the App Attest `clientData` supplied to attestation verification. For every assertion route, `clientDataHash = SHA-256(transcript bytes)` is verified by App Attest. The separately stored challenge must equal the request `challenge`, is single-use, expires after 300 seconds, and is consumed only after successful cryptographic verification. Assertion `signCount` MUST strictly increase atomically for the installation. +The JSON object has no insignificant whitespace and members appear in the exact order shown below. Strings use JSON escaping for quotation mark, reverse solidus, and U+0000..U+001F; all authority-bearing strings admitted by this profile are ASCII. UUID strings are canonical lowercase-hyphenated. Integers use shortest decimal notation. The fixed `audience` value is part of the signed object and prevents cross-route use. For enrollment, these exact transcript bytes are the App Attest `clientData` supplied to attestation verification. For every assertion route, `clientDataHash = SHA-256(transcript bytes)` is verified by App Attest. The separately stored challenge must equal the request `challenge`, is single-use, expires after 300 seconds, and is consumed only after successful cryptographic verification. Assertion `signCount` MUST strictly increase atomically for the installation. ### Challenge @@ -318,7 +318,7 @@ Request members, in any request order: `expires_at` MUST satisfy `now < expires_at <= now + configured_max_installation_lifetime`; the selected profile MUST be enabled. The exact transcript is domain `buzz.push.enroll.v1` followed by this ordered object: ```json -{"v":1,"audience":"/v1/installations","challenge_id":"","challenge":"","key_id":"","app_profile":"","endpoint":"","endpoint_epoch":1,"expires_at":} +{"v":1,"audience":"https://push.buzz.xyz/v1/installations","challenge_id":"","challenge":"","key_id":"","app_profile":"","endpoint":"","endpoint_epoch":1,"expires_at":} ``` The gateway verifies Apple's attestation chain, configured application identifier, production AAGUID, key identifier, and transcript. Apple documents no APNs-token-to-App-Attest-key binding; token provenance at enrollment is an explicit bootstrap assumption. It then stores only encrypted token custody plus its fingerprint. Success `201`: @@ -342,7 +342,7 @@ Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge o `not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= now + configured_max_grant_lifetime`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. A successful delegation atomically extends the authenticated installation lifetime through at least the delegation's `expires_at`, allowing renewal without duplicate token enrollment. Transcript domain `buzz.push.delegate.v1`; ordered object: ```json -{"v":1,"audience":"/v1/delegations","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"","not_before":,"expires_at":} +{"v":1,"audience":"https://push.buzz.xyz/v1/delegations","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"","not_before":,"expires_at":} ``` Success `201`: `{"endpoint_grant":""}`. The sealed grant contains no APNs token. Grant-key rotation MUST retain decrypt-only predecessor keys through the maximum lifetime of grants they issued. @@ -358,7 +358,7 @@ Success `201`: `{"endpoint_grant":""}`. The sealed grant cont `new_endpoint_epoch` MUST equal `endpoint_epoch + 1` without overflow. Transcript domain `buzz.push.rotate-endpoint.v1`; ordered object: ```json -{"v":1,"audience":"/v1/installations/endpoint","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":,"endpoint":""} +{"v":1,"audience":"https://push.buzz.xyz/v1/installations/endpoint","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":,"endpoint":""} ``` A successful atomic rotation invalidates every grant sealed to the old epoch and returns `200 {"status":"rotated"}`. @@ -374,7 +374,7 @@ A successful atomic rotation invalidates every grant sealed to the old epoch and Transcript domain `buzz.push.revoke-delegation.v1`; ordered object: ```json -{"v":1,"audience":"/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} +{"v":1,"audience":"https://push.buzz.xyz/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} ``` The generation identifies the current delegation generation. Success is `200 {"status":"revoked"}`. @@ -388,14 +388,14 @@ The generation identifies the current delegation generation. Success is `200 {"s `new_endpoint_epoch` MUST equal `endpoint_epoch + 1` without overflow. Transcript domain `buzz.push.revoke-installation.v1`; ordered object: ```json -{"v":1,"audience":"/v1/installations/revoke","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":} +{"v":1,"audience":"https://push.buzz.xyz/v1/installations/revoke","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"new_endpoint_epoch":} ``` Success is `200 {"status":"revoked"}`. The revocation atomically invalidates the installation and every delegation. ### Relay delivery -`POST /v1/deliveries/apns` has the exact externally configured URL `/v1/deliveries/apns`. Request: +`POST /v1/deliveries/apns` has the exact externally configured URL `https://push.buzz.xyz/v1/deliveries/apns`. Request: ```json {"v":1,"endpoint_grant":"","request_id":"","expires_at":} @@ -449,4 +449,4 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery - Classes: `silent`, `default`, `time_sensitive`, `urgent` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- APNs gateway profile: deployment-configured HTTPS origin; app profile `buzz-ios-dogfood`; wire version `1` +- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` From aede861221cf69648d9f5ad53d105a3340cb61b5 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 15:07:34 -0700 Subject: [PATCH 07/67] fix(push): bind enrollment to relay gateway Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-relay/src/config.rs | 4 +- crates/buzz-relay/src/nip11.rs | 52 ++++++++++++++++--- .../BuzzDevPushEnrollmentDriver.swift | 7 +++ .../BuzzDevPushEnrollmentDriverTests.swift | 29 +++++++++++ mobile/lib/shared/push/dev_push_lease.dart | 41 +++++++++++++++ mobile/lib/shared/push/push_bootstrap.dart | 4 ++ .../test/shared/push/dev_push_lease_test.dart | 37 +++++++++++++ .../test/shared/push/push_bootstrap_test.dart | 1 + .../push_relay_capability_provider_test.dart | 1 + 9 files changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 6cead06918a..e50774c93bb 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, diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index e6b18cdd0f8..6dbd4e85207 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -234,12 +234,14 @@ pub async fn relay_info_handler( fn push_descriptor( push_configured: bool, relay_url: &str, + gateway_origin: Option<&str>, executor_key_id: &str, relay_keypair: &nostr::Keys, tenant_host: Option<&str>, ) -> Option { let host = tenant_host?; push_configured.then_some(())?; + let gateway_origin = gateway_origin?; let scheme = if relay_url.starts_with("wss://") { "wss" } else { @@ -247,6 +249,7 @@ fn push_descriptor( }; Some(serde_json::json!({ "origin": format!("{scheme}://{host}"), + "gateway_origin": gateway_origin, "keys": [{ "id": executor_key_id, "pubkey": relay_keypair.public_key().to_hex(), @@ -301,9 +304,15 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st } else { None }; + let gateway_origin = state + .config + .push_gateway_delivery_url + .as_ref() + .map(|url| url.origin().ascii_serialization()); if let Some(push) = push_descriptor( state.config.push_enabled, &state.config.relay_url, + gateway_origin.as_deref(), &state.config.push_executor_key_id, &state.relay_keypair, tenant_host.as_deref(), @@ -406,13 +415,44 @@ mod tests { #[test] fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { let keys = nostr::Keys::generate(); - assert!( - push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none() - ); - assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none()); - let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example")) - .expect("configured push descriptor"); + assert!(push_descriptor( + false, + "ws://relay", + Some("https://push.example"), + "key", + &keys, + Some("tenant.example") + ) + .is_none()); + assert!(push_descriptor( + true, + "ws://relay", + None, + "key", + &keys, + Some("tenant.example") + ) + .is_none()); + assert!(push_descriptor( + true, + "ws://relay", + Some("https://push.example"), + "key", + &keys, + None + ) + .is_none()); + let descriptor = push_descriptor( + true, + "ws://relay", + Some("https://push.example"), + "key", + &keys, + Some("tenant.example"), + ) + .expect("configured push descriptor"); assert_eq!(descriptor["origin"], "ws://tenant.example"); + assert_eq!(descriptor["gateway_origin"], "https://push.example"); assert_eq!( descriptor["push_kinds"], serde_json::json!(crate::handlers::push_lease::PUSH_KINDS) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 0ec4bc3c5dd..3ccece20b5a 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -816,6 +816,7 @@ public final class BuzzDevPushEnrollmentDriver { } let current = document.push.keys.filter(\.current) guard current.count == 1, + document.push.gatewayOrigin == gatewayOrigin, Self.isLowercaseHexPubkey(current[0].pubkey) else { throw BuzzDevPushEnrollmentError.invalidRelayDescriptor @@ -1001,7 +1002,13 @@ private struct RelayInformation: Decodable { let pubkey: String let current: Bool } + let gatewayOrigin: String let keys: [Key] + + enum CodingKeys: String, CodingKey { + case gatewayOrigin = "gateway_origin" + case keys + } } let relaySelf: String? let push: Push diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 6144baf0393..90da089b7e1 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -911,6 +911,30 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 1) } + func testRejectsMismatchedGatewayOriginBeforeGatewayEnrollment() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "push": [ + "gateway_origin": "https://other-push.example", + "keys": [["pubkey": Self.relayPubkey, "current": true]], + ] + ] + ) + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected an invalid relay descriptor") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor) + } + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + func testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws { let pushPubkey = String(repeating: "b", count: 64) let oldMetadataPubkey = String(repeating: "c", count: 64) @@ -1170,6 +1194,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { status: Int, json: [String: Any] ) -> (HTTPURLResponse, Data) { + var json = json + if var push = json["push"] as? [String: Any], push["gateway_origin"] == nil { + push["gateway_origin"] = Self.gatewayOrigin + json["push"] = push + } let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) let response = HTTPURLResponse( url: request.url!, diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index e7af8dc738a..12ab8012902 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -20,6 +20,7 @@ const _installationIdPattern = r'^[0-9a-f]{32}$'; class BuzzPushLeaseDescriptor { final String origin; + final String gatewayOrigin; final String executorKeyId; final String executorPubkey; final String transport; @@ -31,6 +32,7 @@ class BuzzPushLeaseDescriptor { const BuzzPushLeaseDescriptor({ required this.origin, + required this.gatewayOrigin, required this.executorKeyId, required this.executorPubkey, required this.transport, @@ -84,6 +86,7 @@ class BuzzPushLeaseDescriptor { push, required: const { 'origin', + 'gateway_origin', 'keys', 'app_profiles', 'push_kinds', @@ -93,6 +96,7 @@ class BuzzPushLeaseDescriptor { }, allowed: const { 'origin', + 'gateway_origin', 'keys', 'app_profiles', 'push_kinds', @@ -104,6 +108,10 @@ class BuzzPushLeaseDescriptor { ); final origin = _canonicalOrigin(push['origin']); + final gatewayOrigin = _canonicalHttpOrigin( + push['gateway_origin'], + name: 'gateway_origin', + ); final keys = _mapList(push['keys'], name: 'push.keys'); final keyIds = {}; final currentKeys = >[]; @@ -227,6 +235,7 @@ class BuzzPushLeaseDescriptor { } final maxStringLength = limitation['max_string_len'] as int; _checkStringLength(origin, maxStringLength, name: 'origin'); + _checkStringLength(gatewayOrigin, maxStringLength, name: 'gateway_origin'); _checkStringLength( currentKey['id'] as String, maxStringLength, @@ -239,6 +248,7 @@ class BuzzPushLeaseDescriptor { return BuzzPushLeaseDescriptor( origin: origin, + gatewayOrigin: gatewayOrigin, executorKeyId: currentKey['id'] as String, executorPubkey: currentKey['pubkey'] as String, transport: transport!, @@ -251,6 +261,21 @@ class BuzzPushLeaseDescriptor { } } +void validateBuzzPushGatewayOrigin({ + required BuzzPushLeaseDescriptor descriptor, + required String configuredGatewayUrl, +}) { + final configured = _canonicalHttpOrigin( + configuredGatewayUrl, + name: 'configured gateway origin', + ); + if (configured != descriptor.gatewayOrigin) { + throw StateError( + 'Configured push gateway does not match the relay NIP-11 descriptor', + ); + } +} + Future fetchBuzzPushLeaseDescriptor( String relayBaseUrl, { http.Client? client, @@ -561,6 +586,22 @@ String _canonicalOrigin(Object? value) { return origin; } +String _canonicalHttpOrigin(Object? value, {required String name}) { + final origin = _nonEmptyString(value, name: name); + final uri = Uri.tryParse(origin); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.path.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + uri.origin != origin) { + throw FormatException('$name must be a canonical HTTPS origin'); + } + return origin; +} + void _checkStringLength(String value, int maximum, {required String name}) { if (maximum <= 0 || utf8.encode(value).length > maximum) { throw FormatException('$name exceeds its advertised byte limit'); diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0e86745f92d..24693c72001 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -353,6 +353,10 @@ class BuzzPushBootstrap extends HookConsumerWidget { final state = community.pushSubscriptionState; final desired = state.desired; final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + validateBuzzPushGatewayOrigin( + descriptor: descriptor, + configuredGatewayUrl: Env.pushGatewayUrl, + ); final grant = await enrollBuzzPush( config.wsUrl, Env.pushGatewayUrl, diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index 713504c7dac..2a71532bcec 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -319,6 +319,23 @@ void main() { ); }); + test('descriptor requires a canonical HTTPS gateway origin', () { + final missing = _descriptorJson(relay.public); + (missing['push'] as Map).remove('gateway_origin'); + final malformed = _descriptorJson(relay.public); + (malformed['push'] as Map)['gateway_origin'] = + 'https://push.example/path'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(missing), + throwsA(isA()), + ); + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(malformed), + throwsA(isA()), + ); + }); + test('descriptor rejects unknown push fields', () { final information = _descriptorJson(relay.public); (information['push'] as Map)['future'] = true; @@ -328,6 +345,25 @@ void main() { throwsA(isA()), ); }); + + test('configured gateway must match relay descriptor', () { + final descriptor = _descriptor(relay.public); + + expect( + () => validateBuzzPushGatewayOrigin( + descriptor: descriptor, + configuredGatewayUrl: 'https://other-push.example', + ), + throwsStateError, + ); + expect( + () => validateBuzzPushGatewayOrigin( + descriptor: descriptor, + configuredGatewayUrl: 'https://push.example', + ), + returnsNormally, + ); + }); } class _UnauthenticatedAuthNotifier extends AuthNotifier { @@ -395,6 +431,7 @@ Map _descriptorJson(String relayPubkey) => { 'supported_extensions': ['nip-er', 'nip-pl'], 'push': { 'origin': 'wss://tenant.example:8443', + 'gateway_origin': 'https://push.example', 'keys': [ {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, ], diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 6380837bf6d..3596c255db1 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -191,6 +191,7 @@ BuzzPushLeaseDescriptor _descriptor({ required String pubkey, }) => BuzzPushLeaseDescriptor( origin: 'wss://relay.example', + gatewayOrigin: 'https://push.example', executorKeyId: keyId, executorPubkey: pubkey, transport: 'apns', diff --git a/mobile/test/shared/push/push_relay_capability_provider_test.dart b/mobile/test/shared/push/push_relay_capability_provider_test.dart index f6cc5874bac..a365f08f477 100644 --- a/mobile/test/shared/push/push_relay_capability_provider_test.dart +++ b/mobile/test/shared/push/push_relay_capability_provider_test.dart @@ -61,6 +61,7 @@ void main() { const _descriptor = BuzzPushLeaseDescriptor( origin: 'wss://relay.example', + gatewayOrigin: 'https://push.example', executorKeyId: 'relay-v1', executorPubkey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', From 236fd352bf1914e9f8049ef5bbc7daba8b44d1b3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 15:18:18 -0700 Subject: [PATCH 08/67] fix(push): preserve registered transcript audiences Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/config.rs | 30 +++++++++++-------- .../BuzzPushKit/BuzzPushTranscript.swift | 16 +++++----- .../BuzzDevPushEnrollmentDriverTests.swift | 7 ++--- .../BuzzPushTranscriptTests.swift | 4 +-- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index 95d34ab368c..1835468fee2 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -26,7 +26,7 @@ pub struct KeyConfig { pub struct Config { pub bind_addr: SocketAddr, pub health_addr: SocketAddr, - /// External gateway origin and every security-sensitive URL derived from it. + /// 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, @@ -43,7 +43,7 @@ pub struct Config { pub token_keys: Vec, } -/// Canonical gateway URLs derived from one explicitly configured origin. +/// Gateway transport URLs and registered NIP-PL v1 transcript audiences. #[derive(Debug, Clone)] pub struct GatewayUrls { /// External HTTPS origin serving the gateway. @@ -70,11 +70,15 @@ impl GatewayUrls { .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")) }; let delivery = derive("v1/deliveries/apns")?; - let enroll_audience = derive("v1/installations")?.to_string(); - let delegate_audience = derive("v1/delegations")?.to_string(); - let rotate_endpoint_audience = derive("v1/installations/endpoint")?.to_string(); - let revoke_delegation_audience = derive("v1/delegations/revoke")?.to_string(); - let revoke_installation_audience = derive("v1/installations/revoke")?.to_string(); + // 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, @@ -332,7 +336,7 @@ mod tests { } #[test] - fn gateway_urls_are_derived_from_the_configured_origin() { + 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!( @@ -341,23 +345,23 @@ mod tests { ); assert_eq!( config.gateway_urls.enroll_audience, - "https://push.example/v1/installations" + "https://push.buzz.xyz/v1/installations" ); assert_eq!( config.gateway_urls.delegate_audience, - "https://push.example/v1/delegations" + "https://push.buzz.xyz/v1/delegations" ); assert_eq!( config.gateway_urls.rotate_endpoint_audience, - "https://push.example/v1/installations/endpoint" + "https://push.buzz.xyz/v1/installations/endpoint" ); assert_eq!( config.gateway_urls.revoke_delegation_audience, - "https://push.example/v1/delegations/revoke" + "https://push.buzz.xyz/v1/delegations/revoke" ); assert_eq!( config.gateway_urls.revoke_installation_audience, - "https://push.example/v1/installations/revoke" + "https://push.buzz.xyz/v1/installations/revoke" ); } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift index ab6c8354067..c33c5fad286 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -29,9 +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 binds the transcript to the configured gateway origin -/// and one fixed protocol route. A transcript for one gateway or route cannot -/// be replayed against another. +/// 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 @@ -44,6 +44,9 @@ public enum BuzzPushTranscript { /// 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 @@ -202,11 +205,8 @@ public enum BuzzPushTranscript { } private static func audience(_ gatewayOrigin: URL, route: String) throws -> String { - let canonical = try canonicalGatewayOrigin(gatewayOrigin) - guard let value = URL(string: route, relativeTo: canonical.url)?.absoluteURL else { - throw BuzzPushTranscriptError.invalidGatewayOrigin - } - return value.absoluteString + _ = try canonicalGatewayOrigin(gatewayOrigin) + return "\(registeredAudienceOrigin)/\(route)" } /// Ordered compact JSON object writer. Emission order == call order; diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 90da089b7e1..279535875d2 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -127,20 +127,19 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { try assertMatchesVector( "enroll", actual: appAttest.clientData[0], - expectedSHA256: "8250aa191e62be0ad9d26ad7425ec99fb4df13ede580f01205fde057dacaea11", + expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", fixture: makeFixtureTranscript( name: "enroll", - replacements: [("https://push.buzz.xyz", Self.gatewayOrigin)] + replacements: [] ) ) try assertMatchesVector( "delegate", actual: appAttest.clientData[1], - expectedSHA256: "c4d1c03d89f044b6a00818069002a6318b5ee42c246a5dbcca3722ab6377df49", + expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", fixture: makeFixtureTranscript( name: "delegate", replacements: [ - ("https://push.buzz.xyz", Self.gatewayOrigin), (Self.firstChallengeId, Self.secondChallengeId) ] ) diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift index 3b3fb05343c..c08fe9c82c5 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -138,7 +138,7 @@ final class BuzzPushTranscriptTests: XCTestCase { ) } - func testConfiguredOriginBindsTranscriptAudience() throws { + func testConfiguredTransportOriginKeepsRegisteredTranscriptAudience() throws { let bytes = try BuzzPushTranscript.delegate( gatewayOrigin: URL(string: "https://push.example")!, challengeId: Self.challengeId, @@ -153,7 +153,7 @@ final class BuzzPushTranscriptTests: XCTestCase { XCTAssertTrue( String(decoding: bytes, as: UTF8.self) - .contains("\"audience\":\"https://push.example/v1/delegations\"") + .contains("\"audience\":\"https://push.buzz.xyz/v1/delegations\"") ) } From 38532e156b2a1e7dc379c80455b594014d30a395 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 15:29:56 -0700 Subject: [PATCH 09/67] fix(push): keep gateway config off the NIP wire Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-relay/src/nip11.rs | 52 +++---------------- .../BuzzDevPushEnrollmentDriver.swift | 7 --- .../BuzzDevPushEnrollmentDriverTests.swift | 29 ----------- mobile/lib/shared/push/dev_push_lease.dart | 41 --------------- mobile/lib/shared/push/push_bootstrap.dart | 4 -- .../test/shared/push/dev_push_lease_test.dart | 37 ------------- .../test/shared/push/push_bootstrap_test.dart | 1 - .../push_relay_capability_provider_test.dart | 1 - 8 files changed, 6 insertions(+), 166 deletions(-) diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 6dbd4e85207..e6b18cdd0f8 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -234,14 +234,12 @@ pub async fn relay_info_handler( fn push_descriptor( push_configured: bool, relay_url: &str, - gateway_origin: Option<&str>, executor_key_id: &str, relay_keypair: &nostr::Keys, tenant_host: Option<&str>, ) -> Option { let host = tenant_host?; push_configured.then_some(())?; - let gateway_origin = gateway_origin?; let scheme = if relay_url.starts_with("wss://") { "wss" } else { @@ -249,7 +247,6 @@ fn push_descriptor( }; Some(serde_json::json!({ "origin": format!("{scheme}://{host}"), - "gateway_origin": gateway_origin, "keys": [{ "id": executor_key_id, "pubkey": relay_keypair.public_key().to_hex(), @@ -304,15 +301,9 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st } else { None }; - let gateway_origin = state - .config - .push_gateway_delivery_url - .as_ref() - .map(|url| url.origin().ascii_serialization()); if let Some(push) = push_descriptor( state.config.push_enabled, &state.config.relay_url, - gateway_origin.as_deref(), &state.config.push_executor_key_id, &state.relay_keypair, tenant_host.as_deref(), @@ -415,44 +406,13 @@ mod tests { #[test] fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { let keys = nostr::Keys::generate(); - assert!(push_descriptor( - false, - "ws://relay", - Some("https://push.example"), - "key", - &keys, - Some("tenant.example") - ) - .is_none()); - assert!(push_descriptor( - true, - "ws://relay", - None, - "key", - &keys, - Some("tenant.example") - ) - .is_none()); - assert!(push_descriptor( - true, - "ws://relay", - Some("https://push.example"), - "key", - &keys, - None - ) - .is_none()); - let descriptor = push_descriptor( - true, - "ws://relay", - Some("https://push.example"), - "key", - &keys, - Some("tenant.example"), - ) - .expect("configured push descriptor"); + assert!( + push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none() + ); + assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none()); + let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example")) + .expect("configured push descriptor"); assert_eq!(descriptor["origin"], "ws://tenant.example"); - assert_eq!(descriptor["gateway_origin"], "https://push.example"); assert_eq!( descriptor["push_kinds"], serde_json::json!(crate::handlers::push_lease::PUSH_KINDS) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 3ccece20b5a..0ec4bc3c5dd 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -816,7 +816,6 @@ public final class BuzzDevPushEnrollmentDriver { } let current = document.push.keys.filter(\.current) guard current.count == 1, - document.push.gatewayOrigin == gatewayOrigin, Self.isLowercaseHexPubkey(current[0].pubkey) else { throw BuzzDevPushEnrollmentError.invalidRelayDescriptor @@ -1002,13 +1001,7 @@ private struct RelayInformation: Decodable { let pubkey: String let current: Bool } - let gatewayOrigin: String let keys: [Key] - - enum CodingKeys: String, CodingKey { - case gatewayOrigin = "gateway_origin" - case keys - } } let relaySelf: String? let push: Push diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 279535875d2..1fd39f8ad5a 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -910,30 +910,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 1) } - func testRejectsMismatchedGatewayOriginBeforeGatewayEnrollment() async throws { - let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) - URLProtocolStub.handler = { request in - Self.response( - request, - status: 200, - json: [ - "push": [ - "gateway_origin": "https://other-push.example", - "keys": [["pubkey": Self.relayPubkey, "current": true]], - ] - ] - ) - } - - do { - _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) - XCTFail("Expected an invalid relay descriptor") - } catch { - XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor) - } - XCTAssertEqual(URLProtocolStub.requests.count, 1) - } - func testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws { let pushPubkey = String(repeating: "b", count: 64) let oldMetadataPubkey = String(repeating: "c", count: 64) @@ -1193,11 +1169,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { status: Int, json: [String: Any] ) -> (HTTPURLResponse, Data) { - var json = json - if var push = json["push"] as? [String: Any], push["gateway_origin"] == nil { - push["gateway_origin"] = Self.gatewayOrigin - json["push"] = push - } let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) let response = HTTPURLResponse( url: request.url!, diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 12ab8012902..e7af8dc738a 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -20,7 +20,6 @@ const _installationIdPattern = r'^[0-9a-f]{32}$'; class BuzzPushLeaseDescriptor { final String origin; - final String gatewayOrigin; final String executorKeyId; final String executorPubkey; final String transport; @@ -32,7 +31,6 @@ class BuzzPushLeaseDescriptor { const BuzzPushLeaseDescriptor({ required this.origin, - required this.gatewayOrigin, required this.executorKeyId, required this.executorPubkey, required this.transport, @@ -86,7 +84,6 @@ class BuzzPushLeaseDescriptor { push, required: const { 'origin', - 'gateway_origin', 'keys', 'app_profiles', 'push_kinds', @@ -96,7 +93,6 @@ class BuzzPushLeaseDescriptor { }, allowed: const { 'origin', - 'gateway_origin', 'keys', 'app_profiles', 'push_kinds', @@ -108,10 +104,6 @@ class BuzzPushLeaseDescriptor { ); final origin = _canonicalOrigin(push['origin']); - final gatewayOrigin = _canonicalHttpOrigin( - push['gateway_origin'], - name: 'gateway_origin', - ); final keys = _mapList(push['keys'], name: 'push.keys'); final keyIds = {}; final currentKeys = >[]; @@ -235,7 +227,6 @@ class BuzzPushLeaseDescriptor { } final maxStringLength = limitation['max_string_len'] as int; _checkStringLength(origin, maxStringLength, name: 'origin'); - _checkStringLength(gatewayOrigin, maxStringLength, name: 'gateway_origin'); _checkStringLength( currentKey['id'] as String, maxStringLength, @@ -248,7 +239,6 @@ class BuzzPushLeaseDescriptor { return BuzzPushLeaseDescriptor( origin: origin, - gatewayOrigin: gatewayOrigin, executorKeyId: currentKey['id'] as String, executorPubkey: currentKey['pubkey'] as String, transport: transport!, @@ -261,21 +251,6 @@ class BuzzPushLeaseDescriptor { } } -void validateBuzzPushGatewayOrigin({ - required BuzzPushLeaseDescriptor descriptor, - required String configuredGatewayUrl, -}) { - final configured = _canonicalHttpOrigin( - configuredGatewayUrl, - name: 'configured gateway origin', - ); - if (configured != descriptor.gatewayOrigin) { - throw StateError( - 'Configured push gateway does not match the relay NIP-11 descriptor', - ); - } -} - Future fetchBuzzPushLeaseDescriptor( String relayBaseUrl, { http.Client? client, @@ -586,22 +561,6 @@ String _canonicalOrigin(Object? value) { return origin; } -String _canonicalHttpOrigin(Object? value, {required String name}) { - final origin = _nonEmptyString(value, name: name); - final uri = Uri.tryParse(origin); - if (uri == null || - uri.scheme != 'https' || - uri.host.isEmpty || - uri.userInfo.isNotEmpty || - uri.path.isNotEmpty || - uri.hasQuery || - uri.hasFragment || - uri.origin != origin) { - throw FormatException('$name must be a canonical HTTPS origin'); - } - return origin; -} - void _checkStringLength(String value, int maximum, {required String name}) { if (maximum <= 0 || utf8.encode(value).length > maximum) { throw FormatException('$name exceeds its advertised byte limit'); diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 24693c72001..0e86745f92d 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -353,10 +353,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { final state = community.pushSubscriptionState; final desired = state.desired; final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); - validateBuzzPushGatewayOrigin( - descriptor: descriptor, - configuredGatewayUrl: Env.pushGatewayUrl, - ); final grant = await enrollBuzzPush( config.wsUrl, Env.pushGatewayUrl, diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index 2a71532bcec..713504c7dac 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -319,23 +319,6 @@ void main() { ); }); - test('descriptor requires a canonical HTTPS gateway origin', () { - final missing = _descriptorJson(relay.public); - (missing['push'] as Map).remove('gateway_origin'); - final malformed = _descriptorJson(relay.public); - (malformed['push'] as Map)['gateway_origin'] = - 'https://push.example/path'; - - expect( - () => BuzzPushLeaseDescriptor.fromRelayInformation(missing), - throwsA(isA()), - ); - expect( - () => BuzzPushLeaseDescriptor.fromRelayInformation(malformed), - throwsA(isA()), - ); - }); - test('descriptor rejects unknown push fields', () { final information = _descriptorJson(relay.public); (information['push'] as Map)['future'] = true; @@ -345,25 +328,6 @@ void main() { throwsA(isA()), ); }); - - test('configured gateway must match relay descriptor', () { - final descriptor = _descriptor(relay.public); - - expect( - () => validateBuzzPushGatewayOrigin( - descriptor: descriptor, - configuredGatewayUrl: 'https://other-push.example', - ), - throwsStateError, - ); - expect( - () => validateBuzzPushGatewayOrigin( - descriptor: descriptor, - configuredGatewayUrl: 'https://push.example', - ), - returnsNormally, - ); - }); } class _UnauthenticatedAuthNotifier extends AuthNotifier { @@ -431,7 +395,6 @@ Map _descriptorJson(String relayPubkey) => { 'supported_extensions': ['nip-er', 'nip-pl'], 'push': { 'origin': 'wss://tenant.example:8443', - 'gateway_origin': 'https://push.example', 'keys': [ {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, ], diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 3596c255db1..6380837bf6d 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -191,7 +191,6 @@ BuzzPushLeaseDescriptor _descriptor({ required String pubkey, }) => BuzzPushLeaseDescriptor( origin: 'wss://relay.example', - gatewayOrigin: 'https://push.example', executorKeyId: keyId, executorPubkey: pubkey, transport: 'apns', diff --git a/mobile/test/shared/push/push_relay_capability_provider_test.dart b/mobile/test/shared/push/push_relay_capability_provider_test.dart index a365f08f477..f6cc5874bac 100644 --- a/mobile/test/shared/push/push_relay_capability_provider_test.dart +++ b/mobile/test/shared/push/push_relay_capability_provider_test.dart @@ -61,7 +61,6 @@ void main() { const _descriptor = BuzzPushLeaseDescriptor( origin: 'wss://relay.example', - gatewayOrigin: 'https://push.example', executorKeyId: 'relay-v1', executorPubkey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', From a61a89977c6a63253ec19f36235a77d907986272 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 15:41:26 -0700 Subject: [PATCH 10/67] fix(mobile): reject malformed push gateway origins Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- AGENTS.md | 2 +- deploy/charts/buzz-push-gateway/values.yaml | 4 +-- docs/push-gateway-deployment.md | 2 +- mobile/README.md | 2 +- mobile/android/app/build.gradle.kts | 30 ++++++++++++++----- mobile/scripts/require-push-gateway-origin.sh | 17 +++++++++-- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cbe27066ea..bea6ca95dd9 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/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index c7894f25b09..904cfbe7884 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -18,8 +18,8 @@ migration: resources: requests: {cpu: 50m, memory: 64Mi} limits: {cpu: 250m, memory: 128Mi} -# Exact externally reachable HTTPS origin. The gateway derives every protocol -# route and App Attest audience from this one value. +# 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: diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 008a933b868..e73b18839bd 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -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_GATEWAY_ORIGIN` | Exact externally reachable HTTPS origin. No credentials, port, path, query, or fragment. The gateway derives all routes and App Attest audiences from it. | +| `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. | diff --git a/mobile/README.md b/mobile/README.md index 7a8d0185dd2..5a43803a17e 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -160,7 +160,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 6f4218ee236..06882b2ecea 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,5 +1,6 @@ -import java.util.Properties +import java.net.URI import java.util.Base64 +import java.util.Properties plugins { id("com.android.application") @@ -23,18 +24,33 @@ val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrB val hasUploadSigning = missingUploadSigningValues.isEmpty() val dartDefines = providers.gradleProperty("dart-defines").orNull.orEmpty() val pushGatewayDefinePrefix = "BUZZ_PUSH_GATEWAY_URL=" -val hasPushGatewayOrigin = - dartDefines.split(',').any { encoded -> +val pushGatewayOrigins = + dartDefines.split(',').mapNotNull { encoded -> val define = runCatching { String(Base64.getDecoder().decode(encoded)) }.getOrNull() - define?.startsWith(pushGatewayDefinePrefix) == true && - define.length > pushGatewayDefinePrefix.length + define + ?.takeIf { it.startsWith(pushGatewayDefinePrefix) } + ?.removePrefix(pushGatewayDefinePrefix) } +fun isValidPushGatewayOrigin(value: String): Boolean { + val uri = runCatching { URI(value) }.getOrNull() ?: return false + val scheme = uri.scheme?.lowercase() + return (scheme == "http" || scheme == "https") && + !uri.host.isNullOrBlank() && + uri.rawUserInfo == null && + (uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") && + uri.rawQuery == null && + uri.rawFragment == null && + (uri.port == -1 || uri.port in 1..65535) +} +val hasValidPushGatewayOrigin = + pushGatewayOrigins.size == 1 && isValidPushGatewayOrigin(pushGatewayOrigins.single()) tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { doFirst { - if (!hasPushGatewayOrigin) { + if (!hasValidPushGatewayOrigin) { throw GradleException( - "BUZZ_PUSH_GATEWAY_URL must be supplied with --dart-define for every mobile build.", + "BUZZ_PUSH_GATEWAY_URL must be supplied as an HTTP(S) origin without " + + "credentials, path, query, or fragment for every mobile build.", ) } } diff --git a/mobile/scripts/require-push-gateway-origin.sh b/mobile/scripts/require-push-gateway-origin.sh index 0eee3036fe3..ecf66b49a77 100644 --- a/mobile/scripts/require-push-gateway-origin.sh +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -1,18 +1,29 @@ #!/bin/sh set -eu -configured=false +gateway_origin= 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=?*) configured=true ;; + BUZZ_PUSH_GATEWAY_URL=*) gateway_origin=${decoded#BUZZ_PUSH_GATEWAY_URL=} ;; esac done IFS=$old_ifs -if [ "$configured" != true ]; then +if [ -z "$gateway_origin" ]; then echo "error: BUZZ_PUSH_GATEWAY_URL must be supplied with --dart-define for every mobile build." >&2 exit 1 fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +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 +"$dart_bin" "$script_dir/validate_push_gateway_origin.dart" "$gateway_origin" From 86a90b67210f3f1e283e93e60bc1c9f7447cb1d6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 15:41:36 -0700 Subject: [PATCH 11/67] test(mobile): cover push gateway origin validation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../scripts/validate_push_gateway_origin.dart | 27 +++++++++++++++++ .../push_gateway_origin_validator_test.dart | 30 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 mobile/scripts/validate_push_gateway_origin.dart create mode 100644 mobile/test/shared/push/push_gateway_origin_validator_test.dart diff --git a/mobile/scripts/validate_push_gateway_origin.dart b/mobile/scripts/validate_push_gateway_origin.dart new file mode 100644 index 00000000000..9589a6a3630 --- /dev/null +++ b/mobile/scripts/validate_push_gateway_origin.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +bool isValidPushGatewayOrigin(String value) { + try { + final uri = Uri.parse(value); + if (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; + final port = uri.port; + return port >= 1 && port <= 65535; + } on FormatException { + return false; + } +} + +void main(List arguments) { + if (arguments.length == 1 && isValidPushGatewayOrigin(arguments.single)) { + return; + } + stderr.writeln( + 'error: BUZZ_PUSH_GATEWAY_URL must be an HTTP(S) origin without credentials, path, query, or fragment.', + ); + exitCode = 1; +} 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 00000000000..ea4ae2b744c --- /dev/null +++ b/mobile/test/shared/push/push_gateway_origin_validator_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../../scripts/validate_push_gateway_origin.dart'; + +void main() { + 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('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); + } + }); +} From c5a3764a93bf7efee6936e1c0d96622a2eff2acc Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 15:59:11 -0700 Subject: [PATCH 12/67] fix(push): revoke retired gateway installations Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 186 +++++++++++++++++- .../BuzzPushPendingEnrollmentRecord.swift | 18 ++ .../BuzzDevPushEnrollmentDriverTests.swift | 155 ++++++++++++++- .../ios/Runner/PushEndpointGrantStore.swift | 53 ++++- 4 files changed, 407 insertions(+), 5 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 0ec4bc3c5dd..de3ab930f03 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -60,10 +60,31 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { } } +/// Durable state retained while installations from an earlier gateway are revoked. +public struct BuzzPushGatewayCleanupState: Codable, Equatable, Sendable { + /// Gateway whose installation authority must be revoked. + public let gatewayOrigin: String + /// Grants that retain installation handles and endpoint epochs for revocation. + public var grants: [BuzzPushEndpointGrantRecord] + /// Crash-recovery journals, including response-loss enrollments that may need replay. + public var pendingEnrollments: [BuzzPushPendingEnrollmentRecord] + + /// Creates one durable cleanup snapshot for a retired gateway. + public init( + gatewayOrigin: String, + grants: [BuzzPushEndpointGrantRecord], + pendingEnrollments: [BuzzPushPendingEnrollmentRecord] + ) { + self.gatewayOrigin = gatewayOrigin + self.grants = grants + self.pendingEnrollments = pendingEnrollments + } +} + /// 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 { - /// Discards legacy records and records issued by any other gateway authority. + /// Discards legacy records and moves records from other gateways into the cleanup journal. func reset(forGatewayOrigin gatewayOrigin: String) throws func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws @@ -78,6 +99,12 @@ public protocol BuzzPushEndpointGrantStore { relayOrigin: String, appProfile: String ) throws + /// Returns every retired-gateway cleanup snapshot. + func gatewayCleanupStates() throws -> [BuzzPushGatewayCleanupState] + /// Atomically replaces one retired-gateway cleanup snapshot. + func saveGatewayCleanupState(_ state: BuzzPushGatewayCleanupState) throws + /// Deletes a cleanup snapshot only after its installations are terminal. + func removeGatewayCleanupState(gatewayOrigin: String) throws } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -359,6 +386,7 @@ public final class BuzzDevPushEnrollmentDriver { appAttest: BuzzDevAppAttesting, now: @escaping () -> Date, lifetimeSeconds: Int64, + resetStore: Bool = true, installationIdBytes: @escaping () throws -> Data = { try BuzzSecureRandom.bytes(count: 16) } @@ -372,7 +400,9 @@ public final class BuzzDevPushEnrollmentDriver { } catch { throw BuzzDevPushEnrollmentError.invalidGatewayURL } - try store.reset(forGatewayOrigin: canonical.text) + if resetStore { + try store.reset(forGatewayOrigin: canonical.text) + } self.gatewayBaseURL = canonical.url self.gatewayOrigin = canonical.text self.store = store @@ -392,6 +422,15 @@ public final class BuzzDevPushEnrollmentDriver { public func enroll( deviceToken: Data, relayURL: URL + ) async throws -> BuzzPushEndpointGrantRecord { + let record = try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) + await cleanStaleGateways(deviceToken: deviceToken) + return record + } + + private func enrollCurrent( + deviceToken: Data, + relayURL: URL ) async throws -> BuzzPushEndpointGrantRecord { precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") let relayOrigin = try Self.relayOrigin(relayURL) @@ -609,7 +648,7 @@ public final class BuzzDevPushEnrollmentDriver { relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) - return try await enroll(deviceToken: deviceToken, relayURL: relayURL) + return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) } pending = BuzzPushPendingEnrollmentRecord( gatewayOrigin: gatewayOrigin, @@ -714,6 +753,92 @@ public final class BuzzDevPushEnrollmentDriver { return record } + private func cleanStaleGateways(deviceToken: Data) async { + guard let states = try? store.gatewayCleanupStates() else { return } + for var state in states where state.gatewayOrigin != gatewayOrigin { + guard await cleanStaleGateway(&state, deviceToken: deviceToken) else { continue } + try? store.removeGatewayCleanupState(gatewayOrigin: state.gatewayOrigin) + } + } + + private func cleanStaleGateway( + _ state: inout BuzzPushGatewayCleanupState, + deviceToken: Data + ) async -> Bool { + guard let oldURL = URL(string: state.gatewayOrigin), + let oldDriver = try? BuzzDevPushEnrollmentDriver( + gatewayBaseURL: oldURL, + store: store, + session: session, + appAttest: appAttest, + now: now, + lifetimeSeconds: lifetimeSeconds, + resetStore: false, + installationIdBytes: installationIdBytes + ) + else { return false } + let nowSeconds = Int64(now().timeIntervalSince1970) + let endpoint = Self.lowercaseHex(deviceToken) + let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + var handles = [String: Int64]() + for grant in state.grants { + if grant.expiresAt <= nowSeconds { continue } + guard let handle = grant.gatewayInstallationHandle else { return false } + handles[handle] = max(handles[handle] ?? 0, grant.endpointEpoch) + } + for index in state.pendingEnrollments.indices { + var pending = state.pendingEnrollments[index] + if pending.expiresAt <= nowSeconds { continue } + if pending.gatewayInstallationHandle == nil { + guard pending.endpointHash == endpointHash, + let challengeId = pending.challengeId, + let challengeUUID = UUID(uuidString: challengeId), + challengeId == challengeUUID.uuidString.lowercased(), + let challenge = pending.challenge, + let keyId = pending.keyId, + let attestation = pending.attestation + else { return false } + do { + let installation = try await oldDriver.enrollInstallation( + challenge: Challenge(id: challengeUUID, value: challenge), + endpoint: endpoint, + expiresAt: pending.expiresAt, + attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) + ) + pending = pending.withGatewayInstallationHandle( + installation.uuidString.lowercased() + ) + state.pendingEnrollments[index] = pending + try store.saveGatewayCleanupState(state) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations", _, actual: 404, _ + ) { + continue + } catch { + return false + } + } + guard let handle = pending.gatewayInstallationHandle else { return false } + handles[handle] = max(handles[handle] ?? 0, Self.endpointEpoch) + } + for (handleText, endpointEpoch) in handles { + guard let handle = UUID(uuidString: handleText) else { return false } + do { + try await oldDriver.revokeInstallation( + installationHandle: handle, + endpointEpoch: endpointEpoch + ) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations/revoke", _, actual: 404, _ + ) { + continue + } catch { + return false + } + } + return true + } + private func makeInstallationId() throws -> String { let bytes = try installationIdBytes() precondition( @@ -802,6 +927,40 @@ public final class BuzzDevPushEnrollmentDriver { return response.endpointGrant } + private func revokeInstallation( + installationHandle: UUID, + endpointEpoch: Int64 + ) async throws { + let (newEndpointEpoch, overflow) = endpointEpoch.addingReportingOverflow(1) + guard !overflow else { throw BuzzDevPushEnrollmentError.generationExhausted } + let revokeChallenge = try await challenge() + let clientData = try BuzzPushTranscript.revokeInstallation( + gatewayOrigin: gatewayBaseURL, + challengeId: revokeChallenge.id, + challenge: revokeChallenge.value, + installationHandle: installationHandle, + endpointEpoch: endpointEpoch, + newEndpointEpoch: newEndpointEpoch + ) + let assertion = try await appAttest.assertion(clientData: clientData) + let response: MutationResponse = try await post( + route: "v1/installations/revoke", + expectedStatus: 200, + body: RevokeInstallationRequest( + v: 1, + challengeId: revokeChallenge.id.uuidString.lowercased(), + challenge: revokeChallenge.value, + installationHandle: installationHandle.uuidString.lowercased(), + endpointEpoch: endpointEpoch, + newEndpointEpoch: newEndpointEpoch, + assertion: assertion + ) + ) + guard response.status == "revoked" else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/revoke") + } + } + private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys { var request = URLRequest(url: relayOrigin) request.httpMethod = "GET" @@ -995,6 +1154,27 @@ private struct DelegationResponse: Decodable { let endpointGrant: String enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" } } +private struct RevokeInstallationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let installationHandle: String + let endpointEpoch: Int64 + let newEndpointEpoch: Int64 + let assertion: String + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case newEndpointEpoch = "new_endpoint_epoch" + case assertion + } +} +private struct MutationResponse: Decodable { + let status: String +} private struct RelayInformation: Decodable { struct Push: Decodable { struct Key: Decodable { diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift index 36d8807bb2d..49a385c3a7d 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -46,4 +46,22 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { self.attestation = attestation self.delegationGeneration = delegationGeneration } + + func withGatewayInstallationHandle(_ handle: String) -> Self { + Self( + gatewayOrigin: gatewayOrigin, + relayOrigin: relayOrigin, + relayPubkey: relayPubkey, + endpointHash: endpointHash, + appProfile: appProfile, + expiresAt: expiresAt, + installationId: installationId, + gatewayInstallationHandle: handle, + challengeId: challengeId, + challenge: challenge, + keyId: keyId, + attestation: attestation, + delegationGeneration: delegationGeneration + ) + } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 1fd39f8ad5a..f5d94296806 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -388,7 +388,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } - func testDriverDiscardsGrantAndPendingStateFromAnotherGateway() throws { + func testDriverMovesGrantAndPendingStateFromAnotherGatewayIntoCleanupJournal() throws { let record = BuzzPushEndpointGrantRecord( gatewayOrigin: "https://old-gateway.example", relayOrigin: "wss://relay.example", @@ -416,6 +416,136 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.saved.isEmpty) XCTAssertTrue(store.pending.isEmpty) + XCTAssertEqual( + store.cleanup, + [ + BuzzPushGatewayCleanupState( + gatewayOrigin: "https://old-gateway.example", + grants: [record], + pendingEnrollments: [pending] + ) + ] + ) + } + + func testSuccessfulEnrollmentRevokesAndDeletesStaleGatewayCleanup() async throws { + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + let current = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: Self.installationId, + endpointGrant: "current-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let staleHandle = "44444444-4444-4444-8444-444444444444" + let stale = BuzzPushEndpointGrantRecord( + gatewayOrigin: "http://old-gateway.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: staleHandle, + installationId: Self.installationId, + endpointGrant: "stale-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [current, stale]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + 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://old-gateway.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://old-gateway.example/v1/installations/revoke"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, staleHandle) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["new_endpoint_epoch"] as? Int, 2) + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(store.saved, [current]) + XCTAssertTrue(store.cleanup.isEmpty) + } + + func testFailedStaleGatewayRevocationKeepsCleanupJournal() async throws { + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + let current = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: Self.installationId, + endpointGrant: "current-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let stale = BuzzPushEndpointGrantRecord( + gatewayOrigin: "http://old-gateway.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: "44444444-4444-4444-8444-444444444444", + installationId: Self.installationId, + endpointGrant: "stale-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [current, stale]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + if request.url?.absoluteString == "https://relay.example/" { + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + } + return Self.response(request, status: 503, json: ["error": "unavailable"]) + } + + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(store.saved, [current]) + XCTAssertEqual(try XCTUnwrap(store.cleanup.first).grants, [stale]) } func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { @@ -1187,6 +1317,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] + var cleanup: [BuzzPushGatewayCleanupState] = [] var grantSaveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], @@ -1198,6 +1329,20 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { self.grantSaveFailuresRemaining = grantSaveFailuresRemaining } func reset(forGatewayOrigin gatewayOrigin: String) throws { + let origins = Set( + saved.filter { $0.gatewayOrigin != gatewayOrigin }.map(\.gatewayOrigin) + + pending.filter { $0.gatewayOrigin != gatewayOrigin }.map(\.gatewayOrigin) + ) + for origin in origins { + cleanup.removeAll { $0.gatewayOrigin == origin } + cleanup.append( + BuzzPushGatewayCleanupState( + gatewayOrigin: origin, + grants: saved.filter { $0.gatewayOrigin == origin }, + pendingEnrollments: pending.filter { $0.gatewayOrigin == origin } + ) + ) + } saved.removeAll { $0.gatewayOrigin != gatewayOrigin } pending.removeAll { $0.gatewayOrigin != gatewayOrigin } } @@ -1240,6 +1385,14 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { && $0.appProfile == appProfile } } + func gatewayCleanupStates() throws -> [BuzzPushGatewayCleanupState] { cleanup } + func saveGatewayCleanupState(_ state: BuzzPushGatewayCleanupState) throws { + cleanup.removeAll { $0.gatewayOrigin == state.gatewayOrigin } + cleanup.append(state) + } + func removeGatewayCleanupState(gatewayOrigin: String) throws { + cleanup.removeAll { $0.gatewayOrigin == gatewayOrigin } + } } private final class RecordingAppAttest: BuzzDevAppAttesting { diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index d30f042a468..6255e8d42b3 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -10,6 +10,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { private static let legacyPendingAccount = "pending-v1" private static let recordsAccount = "v2" private static let pendingAccount = "pending-v2" + private static let cleanupAccount = "gateway-cleanup-v1" private let accessGroup: String? @@ -22,12 +23,36 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try delete(account: Self.legacyPendingAccount) let allRecords = try records() + let allPending = try pendingEnrollments() + let staleRecords = allRecords.filter { $0.gatewayOrigin != gatewayOrigin } + let stalePending = allPending.filter { $0.gatewayOrigin != gatewayOrigin } + for origin in Set(staleRecords.map(\.gatewayOrigin) + stalePending.map(\.gatewayOrigin)) { + var state = try gatewayCleanupStates().first { $0.gatewayOrigin == origin } + ?? BuzzPushGatewayCleanupState( + gatewayOrigin: origin, + grants: [], + pendingEnrollments: [] + ) + for record in staleRecords where record.gatewayOrigin == origin { + state.grants.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + state.grants.append(record) + } + for pending in stalePending where pending.gatewayOrigin == origin { + state.pendingEnrollments.removeAll { + $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile + } + state.pendingEnrollments.append(pending) + } + // Persist cleanup authority before removing it from active state. + try saveGatewayCleanupState(state) + } let retainedRecords = allRecords.filter { $0.gatewayOrigin == gatewayOrigin } if retainedRecords.count != allRecords.count { try replace(retainedRecords, account: Self.recordsAccount) } - let allPending = try pendingEnrollments() let retainedPending = allPending.filter { $0.gatewayOrigin == gatewayOrigin } if retainedPending.count != allPending.count { try replace(retainedPending, account: Self.pendingAccount) @@ -99,6 +124,32 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replace(all, account: Self.pendingAccount) } + func gatewayCleanupStates() throws -> [BuzzPushGatewayCleanupState] { + var query = baseQuery(account: Self.cleanupAccount) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read gateway cleanup") + } + return try JSONDecoder().decode([BuzzPushGatewayCleanupState].self, from: data) + } + + func saveGatewayCleanupState(_ state: BuzzPushGatewayCleanupState) throws { + var states = try gatewayCleanupStates() + states.removeAll { $0.gatewayOrigin == state.gatewayOrigin } + states.append(state) + try replace(states, account: Self.cleanupAccount) + } + + func removeGatewayCleanupState(gatewayOrigin: String) throws { + var states = try gatewayCleanupStates() + states.removeAll { $0.gatewayOrigin == gatewayOrigin } + try replace(states, account: Self.cleanupAccount) + } + private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] { var query = baseQuery(account: Self.pendingAccount) query[kSecReturnData as String] = true From 1dda5fd809ee8690a7567c6a37a5db7cccb7eb34 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 15:59:20 -0700 Subject: [PATCH 13/67] fix(mobile): configure direct Xcode gateway builds Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/README.md | 4 +++- mobile/ios/Runner.xcodeproj/project.pbxproj | 2 +- mobile/scripts/require-push-gateway-origin.sh | 19 +++++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 5a43803a17e..02401fc1aef 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -70,7 +70,9 @@ 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. Direct Xcode builds and Runner tests also require a +`BUZZ_PUSH_GATEWAY_URL` scheme environment variable containing the origin to +use; the build phase validates and passes it through as a Flutter Dart define. For an Android debug build that must remain installed alongside other Buzz worktree builds, set an explicit launcher name and package suffix when invoking diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 288ef1ae3ce..d6211476347 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -503,7 +503,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n/bin/sh \"$SRCROOT/../scripts/require-push-gateway-origin.sh\"\n/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/scripts/require-push-gateway-origin.sh b/mobile/scripts/require-push-gateway-origin.sh index ecf66b49a77..1abc413e1be 100644 --- a/mobile/scripts/require-push-gateway-origin.sh +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -2,22 +2,37 @@ 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=*) gateway_origin=${decoded#BUZZ_PUSH_GATEWAY_URL=} ;; + BUZZ_PUSH_GATEWAY_URL=*) + 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 [ -z "$gateway_origin" ]; then echo "error: BUZZ_PUSH_GATEWAY_URL must be supplied with --dart-define for every mobile build." >&2 exit 1 fi -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +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) From de4a26a30c5122cb4031a9b8920a2257f869a58e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 16:09:39 -0700 Subject: [PATCH 14/67] fix(push): revoke before replacing attest key Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 18 ++++--- .../BuzzDevPushEnrollmentDriverTests.swift | 50 ++++++++----------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index de3ab930f03..8ca9a4b0b53 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -117,6 +117,7 @@ public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { case appAttestUnsupported case invalidAppAttestKeyId case generationExhausted + case retiredGatewayCleanupIncomplete public var errorDescription: String? { switch self { @@ -138,6 +139,8 @@ public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { return "The App Attest key identifier is missing or invalid." case .generationExhausted: return "The development push grant generation cannot advance further." + case .retiredGatewayCleanupIncomplete: + return "A retired push gateway installation could not be revoked yet." } } } @@ -423,9 +426,8 @@ public final class BuzzDevPushEnrollmentDriver { deviceToken: Data, relayURL: URL ) async throws -> BuzzPushEndpointGrantRecord { - let record = try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) - await cleanStaleGateways(deviceToken: deviceToken) - return record + try await cleanStaleGateways(deviceToken: deviceToken) + return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) } private func enrollCurrent( @@ -753,11 +755,13 @@ public final class BuzzDevPushEnrollmentDriver { return record } - private func cleanStaleGateways(deviceToken: Data) async { - guard let states = try? store.gatewayCleanupStates() else { return } + private func cleanStaleGateways(deviceToken: Data) async throws { + let states = try store.gatewayCleanupStates() for var state in states where state.gatewayOrigin != gatewayOrigin { - guard await cleanStaleGateway(&state, deviceToken: deviceToken) else { continue } - try? store.removeGatewayCleanupState(gatewayOrigin: state.gatewayOrigin) + guard await cleanStaleGateway(&state, deviceToken: deviceToken) else { + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } + try store.removeGatewayCleanupState(gatewayOrigin: state.gatewayOrigin) } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index f5d94296806..bc8dc801d64 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -500,19 +500,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testFailedStaleGatewayRevocationKeepsCleanupJournal() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) - let current = BuzzPushEndpointGrantRecord( - gatewayOrigin: Self.gatewayOrigin, - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - gatewayInstallationHandle: Self.installationHandle, - installationId: Self.installationId, - endpointGrant: "current-grant", - endpointHash: endpointHash, - appProfile: "buzz-ios-dogfood", - endpointEpoch: 1, - generation: 1, - expiresAt: Self.expiresAt - ) let stale = BuzzPushEndpointGrantRecord( gatewayOrigin: "http://old-gateway.example", relayOrigin: "wss://relay.example", @@ -526,26 +513,30 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { generation: 1, expiresAt: Self.expiresAt ) - let store = MemoryGrantStore(records: [current, stale]) - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + let store = MemoryGrantStore(records: [stale]) + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) URLProtocolStub.handler = { request in - if request.url?.absoluteString == "https://relay.example/" { - return Self.response( - request, - status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] - ) - } + XCTAssertNotEqual(request.url?.absoluteString, "https://relay.example/") return Self.response(request, status: 503, json: ["error": "unavailable"]) } - _ = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected retired gateway cleanup to block replacement enrollment") + } catch { + XCTAssertEqual( + error as? BuzzDevPushEnrollmentError, + .retiredGatewayCleanupIncomplete + ) + } - XCTAssertEqual(store.saved, [current]) + XCTAssertTrue(store.saved.isEmpty) XCTAssertEqual(try XCTUnwrap(store.cleanup.first).grants, [stale]) + XCTAssertTrue(appAttest.preparedAttestations.isEmpty) } func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { @@ -1397,12 +1388,15 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { private final class RecordingAppAttest: BuzzDevAppAttesting { var clientData: [Data] = [] + var preparedAttestations: [BuzzDevAttestation] = [] func prepareAttestation() async throws -> BuzzDevAttestation { - BuzzDevAttestation( + let prepared = BuzzDevAttestation( keyId: BuzzDevPushEnrollmentDriverTests.keyId, attestation: BuzzDevPushEnrollmentDriverTests.attestation ) + preparedAttestations.append(prepared) + return prepared } func attestation( From 4922af60f5d769f67b4cbd02e2a68ae7226ab5fd Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 16:09:47 -0700 Subject: [PATCH 15/67] docs(mobile): configure Xcode gateway build setting Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/README.md | 7 +++++-- mobile/scripts/require-push-gateway-origin.sh | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 02401fc1aef..30df6ad0b7a 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -71,8 +71,10 @@ 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. Direct Xcode builds and Runner tests also require a -`BUZZ_PUSH_GATEWAY_URL` scheme environment variable containing the origin to -use; the build phase validates and passes it through as a Flutter Dart define. +`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 @@ -131,6 +133,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 diff --git a/mobile/scripts/require-push-gateway-origin.sh b/mobile/scripts/require-push-gateway-origin.sh index 1abc413e1be..7fe3a8889e1 100644 --- a/mobile/scripts/require-push-gateway-origin.sh +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -24,7 +24,7 @@ if [ "$has_dart_define" = false ] && [ -n "${BUZZ_PUSH_GATEWAY_URL:-}" ]; then fi if [ -z "$gateway_origin" ]; then - echo "error: BUZZ_PUSH_GATEWAY_URL must be supplied with --dart-define for every mobile build." >&2 + echo "error: BUZZ_PUSH_GATEWAY_URL must be supplied as a Dart define or Xcode build setting for every mobile build." >&2 exit 1 fi From 711b50ab626d9a6dc208118f3d465e5fe18b1898 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 16:24:05 -0700 Subject: [PATCH 16/67] fix(push): bind cleanup to installation keys Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 69 +++++++--- .../BuzzPushGatewayStateReset.swift | 48 +++++++ .../BuzzDevPushEnrollmentDriverTests.swift | 119 ++++++++++++++---- .../ios/Runner/PushEndpointGrantStore.swift | 42 ++----- .../BuzzCommunicationNotificationTests.swift | 2 + 5 files changed, 209 insertions(+), 71 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 8ca9a4b0b53..8d8220cdc93 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -22,6 +22,8 @@ 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 public let installationId: String public let endpointGrant: String public let endpointHash: String @@ -36,6 +38,7 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { relayPubkey: String, relayMetadataPubkey: String? = nil, gatewayInstallationHandle: String? = nil, + appAttestKeyId: String, installationId: String, endpointGrant: String, endpointHash: String, @@ -50,6 +53,7 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { self.relayPubkey = relayPubkey self.relayMetadataPubkey = relayMetadataPubkey self.gatewayInstallationHandle = gatewayInstallationHandle + self.appAttestKeyId = appAttestKeyId self.installationId = installationId self.endpointGrant = endpointGrant self.endpointHash = endpointHash @@ -149,7 +153,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 { @@ -323,12 +327,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( @@ -483,6 +485,7 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: current.relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, gatewayInstallationHandle: current.gatewayInstallationHandle, + appAttestKeyId: current.appAttestKeyId, installationId: current.installationId, endpointGrant: current.endpointGrant, endpointHash: current.endpointHash, @@ -517,6 +520,7 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, + appAttestKeyId: sharedGrant.appAttestKeyId, installationId: try makeInstallationId(), endpointGrant: sharedGrant.endpointGrant, endpointHash: endpointHash, @@ -574,7 +578,8 @@ public final class BuzzDevPushEnrollmentDriver { 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 { @@ -721,7 +726,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, @@ -738,6 +749,7 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, gatewayInstallationHandle: installationHandle, + appAttestKeyId: appAttestKeyId, installationId: pending.installationId, endpointGrant: endpointGrant, endpointHash: endpointHash, @@ -784,11 +796,27 @@ public final class BuzzDevPushEnrollmentDriver { let nowSeconds = Int64(now().timeIntervalSince1970) let endpoint = Self.lowercaseHex(deviceToken) let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) - var handles = [String: Int64]() + var handles = [String: CleanupInstallation]() + func mergeHandle(_ handle: String, endpointEpoch: Int64, keyId: String) -> Bool { + if let existing = handles[handle] { + guard existing.keyId == keyId else { return false } + handles[handle] = CleanupInstallation( + endpointEpoch: max(existing.endpointEpoch, endpointEpoch), + keyId: keyId + ) + } else { + handles[handle] = CleanupInstallation(endpointEpoch: endpointEpoch, keyId: keyId) + } + return true + } for grant in state.grants { if grant.expiresAt <= nowSeconds { continue } guard let handle = grant.gatewayInstallationHandle else { return false } - handles[handle] = max(handles[handle] ?? 0, grant.endpointEpoch) + guard mergeHandle( + handle, + endpointEpoch: grant.endpointEpoch, + keyId: grant.appAttestKeyId + ) else { return false } } for index in state.pendingEnrollments.indices { var pending = state.pendingEnrollments[index] @@ -823,14 +851,19 @@ public final class BuzzDevPushEnrollmentDriver { } } guard let handle = pending.gatewayInstallationHandle else { return false } - handles[handle] = max(handles[handle] ?? 0, Self.endpointEpoch) + guard let keyId = pending.keyId, + mergeHandle(handle, endpointEpoch: Self.endpointEpoch, keyId: keyId) + else { + return false + } } - for (handleText, endpointEpoch) in handles { + for (handleText, installation) in handles { guard let handle = UUID(uuidString: handleText) else { return false } do { try await oldDriver.revokeInstallation( installationHandle: handle, - endpointEpoch: endpointEpoch + endpointEpoch: installation.endpointEpoch, + appAttestKeyId: installation.keyId ) } catch BuzzDevPushEnrollmentError.unexpectedStatus( route: "v1/installations/revoke", _, actual: 404, _ @@ -933,7 +966,8 @@ public final class BuzzDevPushEnrollmentDriver { private func revokeInstallation( installationHandle: UUID, - endpointEpoch: Int64 + endpointEpoch: Int64, + appAttestKeyId: String ) async throws { let (newEndpointEpoch, overflow) = endpointEpoch.addingReportingOverflow(1) guard !overflow else { throw BuzzDevPushEnrollmentError.generationExhausted } @@ -946,7 +980,10 @@ public final class BuzzDevPushEnrollmentDriver { endpointEpoch: endpointEpoch, newEndpointEpoch: newEndpointEpoch ) - let assertion = try await appAttest.assertion(clientData: clientData) + let assertion = try await appAttest.assertion( + keyId: appAttestKeyId, + clientData: clientData + ) let response: MutationResponse = try await post( route: "v1/installations/revoke", expectedStatus: 200, @@ -1179,6 +1216,10 @@ private struct RevokeInstallationRequest: Encodable { private struct MutationResponse: Decodable { let status: String } +private struct CleanupInstallation { + let endpointEpoch: Int64 + let keyId: String +} private struct RelayInformation: Decodable { struct Push: Decodable { struct Key: Decodable { diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift new file mode 100644 index 00000000000..700dda09f72 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift @@ -0,0 +1,48 @@ +/// Performs the ordered state transition when the configured push gateway changes. +public enum BuzzPushGatewayStateReset { + /// Journals every retired-gateway record before replacing active state. + public static func run( + gatewayOrigin: String, + records: [BuzzPushEndpointGrantRecord], + pendingEnrollments: [BuzzPushPendingEnrollmentRecord], + cleanupStates: [BuzzPushGatewayCleanupState], + saveCleanupState: (BuzzPushGatewayCleanupState) throws -> Void, + replaceRecords: ([BuzzPushEndpointGrantRecord]) throws -> Void, + replacePendingEnrollments: ([BuzzPushPendingEnrollmentRecord]) throws -> Void + ) throws { + let staleRecords = records.filter { $0.gatewayOrigin != gatewayOrigin } + let stalePending = pendingEnrollments.filter { $0.gatewayOrigin != gatewayOrigin } + let staleOrigins = Set(staleRecords.map(\.gatewayOrigin) + stalePending.map(\.gatewayOrigin)) + + for origin in staleOrigins.sorted() { + var state = cleanupStates.first { $0.gatewayOrigin == origin } + ?? BuzzPushGatewayCleanupState( + gatewayOrigin: origin, + grants: [], + pendingEnrollments: [] + ) + for record in staleRecords where record.gatewayOrigin == origin { + state.grants.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + state.grants.append(record) + } + for pending in stalePending where pending.gatewayOrigin == origin { + state.pendingEnrollments.removeAll { + $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile + } + state.pendingEnrollments.append(pending) + } + try saveCleanupState(state) + } + + if !staleRecords.isEmpty { + try replaceRecords(records.filter { $0.gatewayOrigin == gatewayOrigin }) + } + if !stalePending.isEmpty { + try replacePendingEnrollments( + pendingEnrollments.filter { $0.gatewayOrigin == gatewayOrigin } + ) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index bc8dc801d64..841a1c60040 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -152,6 +152,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { 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)))), @@ -388,11 +389,21 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } + 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 testDriverMovesGrantAndPendingStateFromAnotherGatewayIntoCleanupJournal() throws { let record = BuzzPushEndpointGrantRecord( gatewayOrigin: "https://old-gateway.example", relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "old-grant", endpointHash: String(repeating: "b", count: 64), @@ -416,6 +427,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.saved.isEmpty) XCTAssertTrue(store.pending.isEmpty) + XCTAssertEqual(store.resetOperations, ["cleanup:https://old-gateway.example", "records", "pending"]) XCTAssertEqual( store.cleanup, [ @@ -435,6 +447,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "current-grant", endpointHash: endpointHash, @@ -444,11 +457,13 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { expiresAt: Self.expiresAt ) let staleHandle = "44444444-4444-4444-8444-444444444444" + let staleKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() let stale = BuzzPushEndpointGrantRecord( gatewayOrigin: "http://old-gateway.example", relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, gatewayInstallationHandle: staleHandle, + appAttestKeyId: staleKeyId, installationId: Self.installationId, endpointGrant: "stale-grant", endpointHash: endpointHash, @@ -457,8 +472,25 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { generation: 1, expiresAt: Self.expiresAt ) - let store = MemoryGrantStore(records: [current, stale]) - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + let secondStaleHandle = "55555555-5555-4555-8555-555555555555" + let secondStaleKeyId = Data(repeating: 0xCC, count: 32).base64EncodedString() + let secondStale = BuzzPushEndpointGrantRecord( + gatewayOrigin: "http://old-gateway.example", + relayOrigin: "wss://second-relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: secondStaleHandle, + appAttestKeyId: secondStaleKeyId, + installationId: "101112131415161718191a1b1c1d1e1f", + endpointGrant: "second-stale-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 4, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [current, stale, secondStale]) + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://relay.example/"): @@ -479,9 +511,15 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) case ("POST", "http://old-gateway.example/v1/installations/revoke"): let body = try Self.body(request) - XCTAssertEqual(body["installation_handle"] as? String, staleHandle) - XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) - XCTAssertEqual(body["new_endpoint_epoch"] as? Int, 2) + let handle = body["installation_handle"] as? String + XCTAssertTrue([staleHandle, secondStaleHandle].contains(handle)) + if handle == staleHandle { + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["new_endpoint_epoch"] as? Int, 2) + } else { + XCTAssertEqual(body["endpoint_epoch"] as? Int, 4) + XCTAssertEqual(body["new_endpoint_epoch"] as? Int, 5) + } return Self.response(request, status: 200, json: ["status": "revoked"]) default: XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") @@ -496,6 +534,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.saved, [current]) XCTAssertTrue(store.cleanup.isEmpty) + XCTAssertEqual(Set(appAttest.assertionKeyIds.compactMap { $0 }), [staleKeyId, secondStaleKeyId]) } func testFailedStaleGatewayRevocationKeepsCleanupJournal() async throws { @@ -505,6 +544,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, gatewayInstallationHandle: "44444444-4444-4444-8444-444444444444", + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "stale-grant", endpointHash: endpointHash, @@ -578,13 +618,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]) @@ -595,6 +635,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", @@ -643,7 +699,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) @@ -776,6 +835,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { 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: Data((1...32).map(UInt8.init)))), @@ -818,6 +878,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { 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)))), @@ -864,6 +925,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { 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)))), @@ -935,6 +997,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { 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)))), @@ -1040,6 +1103,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { relayOrigin: "wss://relay.example", relayPubkey: pushPubkey, relayMetadataPubkey: oldMetadataPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -1082,6 +1146,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { 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)), @@ -1120,6 +1185,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { 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)), @@ -1309,6 +1375,7 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] var cleanup: [BuzzPushGatewayCleanupState] = [] + var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], @@ -1320,22 +1387,24 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { self.grantSaveFailuresRemaining = grantSaveFailuresRemaining } func reset(forGatewayOrigin gatewayOrigin: String) throws { - let origins = Set( - saved.filter { $0.gatewayOrigin != gatewayOrigin }.map(\.gatewayOrigin) - + pending.filter { $0.gatewayOrigin != gatewayOrigin }.map(\.gatewayOrigin) + try BuzzPushGatewayStateReset.run( + gatewayOrigin: gatewayOrigin, + records: saved, + pendingEnrollments: pending, + cleanupStates: cleanup, + saveCleanupState: { state in + self.resetOperations.append("cleanup:\(state.gatewayOrigin)") + try self.saveGatewayCleanupState(state) + }, + replaceRecords: { + self.resetOperations.append("records") + self.saved = $0 + }, + replacePendingEnrollments: { + self.resetOperations.append("pending") + self.pending = $0 + } ) - for origin in origins { - cleanup.removeAll { $0.gatewayOrigin == origin } - cleanup.append( - BuzzPushGatewayCleanupState( - gatewayOrigin: origin, - grants: saved.filter { $0.gatewayOrigin == origin }, - pendingEnrollments: pending.filter { $0.gatewayOrigin == origin } - ) - ) - } - saved.removeAll { $0.gatewayOrigin != gatewayOrigin } - pending.removeAll { $0.gatewayOrigin != gatewayOrigin } } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { @@ -1389,6 +1458,7 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { private final class RecordingAppAttest: BuzzDevAppAttesting { var clientData: [Data] = [] var preparedAttestations: [BuzzDevAttestation] = [] + var assertionKeyIds: [String] = [] func prepareAttestation() async throws -> BuzzDevAttestation { let prepared = BuzzDevAttestation( @@ -1407,8 +1477,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/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 6255e8d42b3..99e66163c37 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -24,39 +24,15 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { let allRecords = try records() let allPending = try pendingEnrollments() - let staleRecords = allRecords.filter { $0.gatewayOrigin != gatewayOrigin } - let stalePending = allPending.filter { $0.gatewayOrigin != gatewayOrigin } - for origin in Set(staleRecords.map(\.gatewayOrigin) + stalePending.map(\.gatewayOrigin)) { - var state = try gatewayCleanupStates().first { $0.gatewayOrigin == origin } - ?? BuzzPushGatewayCleanupState( - gatewayOrigin: origin, - grants: [], - pendingEnrollments: [] - ) - for record in staleRecords where record.gatewayOrigin == origin { - state.grants.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile - } - state.grants.append(record) - } - for pending in stalePending where pending.gatewayOrigin == origin { - state.pendingEnrollments.removeAll { - $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile - } - state.pendingEnrollments.append(pending) - } - // Persist cleanup authority before removing it from active state. - try saveGatewayCleanupState(state) - } - let retainedRecords = allRecords.filter { $0.gatewayOrigin == gatewayOrigin } - if retainedRecords.count != allRecords.count { - try replace(retainedRecords, account: Self.recordsAccount) - } - - let retainedPending = allPending.filter { $0.gatewayOrigin == gatewayOrigin } - if retainedPending.count != allPending.count { - try replace(retainedPending, account: Self.pendingAccount) - } + try BuzzPushGatewayStateReset.run( + gatewayOrigin: gatewayOrigin, + records: allRecords, + pendingEnrollments: allPending, + cleanupStates: gatewayCleanupStates(), + saveCleanupState: saveGatewayCleanupState, + replaceRecords: { try self.replace($0, account: Self.recordsAccount) }, + replacePendingEnrollments: { try self.replace($0, account: Self.pendingAccount) } + ) } func records() throws -> [BuzzPushEndpointGrantRecord] { diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 156aa615210..9ad815d5eb9 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -202,9 +202,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), From 6e8a0679b7800e6599571b18c42b1a04f215da6c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 16:31:25 -0700 Subject: [PATCH 17/67] fix(push): restore rollback gateway state Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzPushGatewayStateReset.swift | 38 +++++++++++++++---- .../BuzzDevPushEnrollmentDriverTests.swift | 30 +++++++++++++++ .../ios/Runner/PushEndpointGrantStore.swift | 1 + 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift index 700dda09f72..d5d902f47b4 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift @@ -1,17 +1,38 @@ /// Performs the ordered state transition when the configured push gateway changes. public enum BuzzPushGatewayStateReset { - /// Journals every retired-gateway record before replacing active state. + /// Restores current-gateway state and journals retired state before replacing active state. public static func run( gatewayOrigin: String, records: [BuzzPushEndpointGrantRecord], pendingEnrollments: [BuzzPushPendingEnrollmentRecord], cleanupStates: [BuzzPushGatewayCleanupState], saveCleanupState: (BuzzPushGatewayCleanupState) throws -> Void, + removeCleanupState: (String) throws -> Void, replaceRecords: ([BuzzPushEndpointGrantRecord]) throws -> Void, replacePendingEnrollments: ([BuzzPushPendingEnrollmentRecord]) throws -> Void ) throws { - let staleRecords = records.filter { $0.gatewayOrigin != gatewayOrigin } - let stalePending = pendingEnrollments.filter { $0.gatewayOrigin != gatewayOrigin } + var nextRecords = records + var nextPending = pendingEnrollments + let restoredState = cleanupStates.first { $0.gatewayOrigin == gatewayOrigin } + if let restoredState { + for record in restoredState.grants + where !nextRecords.contains(where: { + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile + }) { + nextRecords.append(record) + } + for pending in restoredState.pendingEnrollments + where !nextPending.contains(where: { + $0.gatewayOrigin == pending.gatewayOrigin && $0.relayOrigin == pending.relayOrigin + && $0.appProfile == pending.appProfile + }) { + nextPending.append(pending) + } + } + + let staleRecords = nextRecords.filter { $0.gatewayOrigin != gatewayOrigin } + let stalePending = nextPending.filter { $0.gatewayOrigin != gatewayOrigin } let staleOrigins = Set(staleRecords.map(\.gatewayOrigin) + stalePending.map(\.gatewayOrigin)) for origin in staleOrigins.sorted() { @@ -36,13 +57,16 @@ public enum BuzzPushGatewayStateReset { try saveCleanupState(state) } - if !staleRecords.isEmpty { - try replaceRecords(records.filter { $0.gatewayOrigin == gatewayOrigin }) + if !staleRecords.isEmpty || nextRecords.count != records.count { + try replaceRecords(nextRecords.filter { $0.gatewayOrigin == gatewayOrigin }) } - if !stalePending.isEmpty { + if !stalePending.isEmpty || nextPending.count != pendingEnrollments.count { try replacePendingEnrollments( - pendingEnrollments.filter { $0.gatewayOrigin == gatewayOrigin } + nextPending.filter { $0.gatewayOrigin == gatewayOrigin } ) } + if restoredState != nil { + try removeCleanupState(gatewayOrigin) + } } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 841a1c60040..d8def0e5bf4 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -440,6 +440,32 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) } + func testGatewayResetRestoresCurrentCleanupBeforeRemovingJournal() throws { + let record = BuzzPushEndpointGrantRecord( + gatewayOrigin: "https://gateway-a.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "gateway-a-grant", + endpointHash: String(repeating: "b", count: 64), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [record]) + + try store.reset(forGatewayOrigin: "https://gateway-b.example") + store.resetOperations.removeAll() + try store.reset(forGatewayOrigin: "https://gateway-a.example") + + XCTAssertEqual(store.saved, [record]) + XCTAssertTrue(store.cleanup.isEmpty) + XCTAssertEqual(store.resetOperations, ["records", "cleanup-removed:https://gateway-a.example"]) + } + func testSuccessfulEnrollmentRevokesAndDeletesStaleGatewayCleanup() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let current = BuzzPushEndpointGrantRecord( @@ -1396,6 +1422,10 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { self.resetOperations.append("cleanup:\(state.gatewayOrigin)") try self.saveGatewayCleanupState(state) }, + removeCleanupState: { + self.resetOperations.append("cleanup-removed:\($0)") + try self.removeGatewayCleanupState(gatewayOrigin: $0) + }, replaceRecords: { self.resetOperations.append("records") self.saved = $0 diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 99e66163c37..ba01debc8c9 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -30,6 +30,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { pendingEnrollments: allPending, cleanupStates: gatewayCleanupStates(), saveCleanupState: saveGatewayCleanupState, + removeCleanupState: removeGatewayCleanupState, replaceRecords: { try self.replace($0, account: Self.recordsAccount) }, replacePendingEnrollments: { try self.replace($0, account: Self.pendingAccount) } ) From 5925166e917193a588c855539cbe5c0a9b710bbd Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 16:40:39 -0700 Subject: [PATCH 18/67] fix(push): clean retired gateways during registration Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 8 +++- .../BuzzDevPushEnrollmentDriverTests.swift | 7 +-- mobile/ios/Runner/AppDelegate.swift | 45 ++++++++++++++++++- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 8d8220cdc93..69c4edc3256 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -428,10 +428,16 @@ public final class BuzzDevPushEnrollmentDriver { deviceToken: Data, relayURL: URL ) async throws -> BuzzPushEndpointGrantRecord { - try await cleanStaleGateways(deviceToken: deviceToken) + try await cleanRetiredGateways(deviceToken: deviceToken) return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) } + /// Revokes durable installations from gateways that are no longer configured. + public func cleanRetiredGateways(deviceToken: Data) async throws { + precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") + try await cleanStaleGateways(deviceToken: deviceToken) + } + private func enrollCurrent( deviceToken: Data, relayURL: URL diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index d8def0e5bf4..64fc89c9cd0 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -466,7 +466,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.resetOperations, ["records", "cleanup-removed:https://gateway-a.example"]) } - func testSuccessfulEnrollmentRevokesAndDeletesStaleGatewayCleanup() async throws { + func testCleanupRevokesAndDeletesStaleGatewaysWithoutRelayEnrollment() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let current = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, @@ -553,10 +553,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } } - _ = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) + try await driver.cleanRetiredGateways(deviceToken: Data((1...32).map(UInt8.init))) XCTAssertEqual(store.saved, [current]) XCTAssertTrue(store.cleanup.isEmpty) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 6fa9fbbfc32..eb185f35235 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -16,6 +16,8 @@ import os.log accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String ) private var enrollmentTask: Task? + private var gatewayCleanupTask: Task? + private var pushGatewayURL: URL? private var appGroupIdentifier: String? { Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String } @@ -280,6 +282,7 @@ import os.log ) { super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) apnsDeviceToken = deviceToken + scheduleRetiredGatewayCleanup() apnsRegistrationBuffer.recordToken(deviceToken) } @@ -359,8 +362,10 @@ import os.log return } do { - let gatewayOrigin = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayURL).text - try endpointGrantStore.reset(forGatewayOrigin: gatewayOrigin) + let gatewayOrigin = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayURL) + try endpointGrantStore.reset(forGatewayOrigin: gatewayOrigin.text) + pushGatewayURL = gatewayOrigin.url + scheduleRetiredGatewayCleanup() startPushRegistration(result: result) } catch { result( @@ -513,6 +518,9 @@ import os.log enrollmentTask = Task { [weak self] in defer { self?.enrollmentTask = nil } do { + if let cleanupTask = self?.gatewayCleanupTask { + await cleanupTask.value + } let record = try await driver.enroll( deviceToken: deviceToken, relayURL: relayURL @@ -541,6 +549,39 @@ import os.log } } + private func scheduleRetiredGatewayCleanup() { + guard gatewayCleanupTask == nil, + let deviceToken = apnsDeviceToken, + !deviceToken.isEmpty, + let gatewayURL = pushGatewayURL + else { return } + do { + let driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: pushKeychainAccessGroup + ) + gatewayCleanupTask = Task { [weak self] in + defer { self?.gatewayCleanupTask = nil } + do { + try await driver.cleanRetiredGateways(deviceToken: deviceToken) + } catch { + os_log( + "Retired push gateway cleanup remains queued: %{public}@", + type: .error, + error.localizedDescription + ) + } + } + } catch { + os_log( + "Retired push gateway cleanup could not start: %{public}@", + type: .error, + error.localizedDescription + ) + } + } + private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult From 134b104c7c4d8d80a847b491f5bda47d6c90c175 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 16:49:48 -0700 Subject: [PATCH 19/67] fix(push): retain endpoint for cleanup replay Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 47 ++++++++++++++-- .../BuzzPushPendingEnrollmentRecord.swift | 9 ++- .../BuzzDevPushEnrollmentDriverTests.swift | 56 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 69c4edc3256..59294ec7443 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -580,6 +580,7 @@ public final class BuzzDevPushEnrollmentDriver { gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, @@ -667,6 +668,7 @@ public final class BuzzDevPushEnrollmentDriver { gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -705,6 +707,7 @@ public final class BuzzDevPushEnrollmentDriver { gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -800,8 +803,8 @@ public final class BuzzDevPushEnrollmentDriver { ) else { return false } let nowSeconds = Int64(now().timeIntervalSince1970) - let endpoint = Self.lowercaseHex(deviceToken) - let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + let currentEndpoint = Self.lowercaseHex(deviceToken) + let currentEndpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) var handles = [String: CleanupInstallation]() func mergeHandle(_ handle: String, endpointEpoch: Int64, keyId: String) -> Bool { if let existing = handles[handle] { @@ -828,8 +831,18 @@ public final class BuzzDevPushEnrollmentDriver { var pending = state.pendingEnrollments[index] if pending.expiresAt <= nowSeconds { continue } if pending.gatewayInstallationHandle == nil { - guard pending.endpointHash == endpointHash, - let challengeId = pending.challengeId, + let replayEndpoint: String + if let protectedEndpoint = pending.endpoint { + guard Self.endpointHash(protectedEndpoint) == pending.endpointHash else { return false } + replayEndpoint = protectedEndpoint + } else if pending.endpointHash == currentEndpointHash { + replayEndpoint = currentEndpoint + } else { + // Pre-endpoint journals cannot be replayed after token rotation. They + // have no known handle to revoke, so this cleanup item is terminal. + continue + } + guard let challengeId = pending.challengeId, let challengeUUID = UUID(uuidString: challengeId), challengeId == challengeUUID.uuidString.lowercased(), let challenge = pending.challenge, @@ -839,7 +852,7 @@ public final class BuzzDevPushEnrollmentDriver { do { let installation = try await oldDriver.enrollInstallation( challenge: Challenge(id: challengeUUID, value: challenge), - endpoint: endpoint, + endpoint: replayEndpoint, expiresAt: pending.expiresAt, attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) ) @@ -1124,6 +1137,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 49a385c3a7d..e28c9614a15 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -1,11 +1,13 @@ /// 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 public let relayOrigin: String public let relayPubkey: String + /// Protected APNs endpoint needed to replay an enrollment without the current token. + public let endpoint: String? public let endpointHash: String public let appProfile: String public let expiresAt: Int64 @@ -21,6 +23,7 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { gatewayOrigin: String, relayOrigin: String, relayPubkey: String, + endpoint: String? = nil, endpointHash: String, appProfile: String, expiresAt: Int64, @@ -35,6 +38,7 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey + self.endpoint = endpoint self.endpointHash = endpointHash self.appProfile = appProfile self.expiresAt = expiresAt @@ -52,6 +56,7 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: appProfile, expiresAt: expiresAt, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 64fc89c9cd0..e3ba01e6f53 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -560,6 +560,62 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(Set(appAttest.assertionKeyIds.compactMap { $0 }), [staleKeyId, secondStaleKeyId]) } + func testCleanupReplaysProtectedEndpointAfterCurrentTokenChanges() async throws { + let oldToken = Data(repeating: 0x07, count: 32) + let newToken = Data(repeating: 0x08, count: 32) + let pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: "http://old-gateway.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + endpoint: Self.hex(oldToken), + endpointHash: Self.hex(SHA256.hash(data: oldToken)), + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + installationId: Self.installationId, + challengeId: Self.firstChallengeId, + challenge: Self.challenge, + keyId: Self.keyId, + attestation: Self.attestation + ) + let store = MemoryGrantStore(pending: [pending]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("POST", "http://old-gateway.example/v1/installations"): + let body = try Self.body(request) + XCTAssertEqual(body["endpoint"] as? String, Self.hex(oldToken)) + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://old-gateway.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://old-gateway.example/v1/installations/revoke"): + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + try await driver.cleanRetiredGateways(deviceToken: newToken) + + XCTAssertTrue(store.cleanup.isEmpty) + } + func testFailedStaleGatewayRevocationKeepsCleanupJournal() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let stale = BuzzPushEndpointGrantRecord( From 5dc1278ba0699e118d33020055a0a23f18e61837 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 16:56:50 -0700 Subject: [PATCH 20/67] fix(push): journal fresh enrollment endpoint Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 1 + .../BuzzDevPushEnrollmentDriverTests.swift | 68 +++++++++++-------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 59294ec7443..ea7befd0f50 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -614,6 +614,7 @@ public final class BuzzDevPushEnrollmentDriver { gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index e3ba01e6f53..ae1c05712c5 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -563,46 +563,50 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testCleanupReplaysProtectedEndpointAfterCurrentTokenChanges() async throws { let oldToken = Data(repeating: 0x07, count: 32) let newToken = Data(repeating: 0x08, count: 32) - let pending = BuzzPushPendingEnrollmentRecord( - gatewayOrigin: "http://old-gateway.example", - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - endpoint: Self.hex(oldToken), - endpointHash: Self.hex(SHA256.hash(data: oldToken)), - appProfile: "buzz-ios-dogfood", - expiresAt: Self.expiresAt, - installationId: Self.installationId, - challengeId: Self.firstChallengeId, - challenge: Self.challenge, - keyId: Self.keyId, - attestation: Self.attestation + let oldGatewayURL = URL(string: "http://old-gateway.example")! + let store = MemoryGrantStore() + let oldDriver = try makeDriver( + gatewayBaseURL: oldGatewayURL, + store: store, + appAttest: RecordingAppAttest() ) - let store = MemoryGrantStore(pending: [pending]) - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeRequests = 0 + var installationRequests = 0 URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { - case ("POST", "http://old-gateway.example/v1/installations"): - let body = try Self.body(request) - XCTAssertEqual(body["endpoint"] as? String, Self.hex(oldToken)) + case ("GET", "https://relay.example/"): return Self.response( request, - status: 201, - json: [ - "installation_handle": Self.installationHandle, - "endpoint_epoch": 1, - "expires_at": Self.expiresAt, - ] + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] ) case ("POST", "http://old-gateway.example/v1/installations/challenges"): + challengeRequests += 1 return Self.response( request, status: 200, json: [ - "challenge_id": Self.secondChallengeId, + "challenge_id": challengeRequests == 1 ? Self.firstChallengeId : Self.secondChallengeId, "challenge": Self.challenge, "expires_at": Self.now + 300, ] ) + case ("POST", "http://old-gateway.example/v1/installations"): + installationRequests += 1 + let body = try Self.body(request) + XCTAssertEqual(body["endpoint"] as? String, Self.hex(oldToken)) + if installationRequests == 1 { + throw URLError(.networkConnectionLost) + } + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) case ("POST", "http://old-gateway.example/v1/installations/revoke"): return Self.response(request, status: 200, json: ["status": "revoked"]) default: @@ -611,8 +615,17 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } } - try await driver.cleanRetiredGateways(deviceToken: newToken) + do { + _ = try await oldDriver.enroll(deviceToken: oldToken, relayURL: Self.relayURL) + XCTFail("Expected the committed enrollment response to be lost") + } catch { + XCTAssertEqual(store.pending.first?.endpoint, Self.hex(oldToken)) + } + + let currentDriver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + try await currentDriver.cleanRetiredGateways(deviceToken: newToken) + XCTAssertEqual(installationRequests, 2) XCTAssertTrue(store.cleanup.isEmpty) } @@ -1330,6 +1343,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } private func makeDriver( + gatewayBaseURL: URL = BuzzDevPushEnrollmentDriverTests.gatewayURL, store: BuzzPushEndpointGrantStore, appAttest: BuzzDevAppAttesting, installationIdBytes: @escaping () throws -> Data = { @@ -1339,7 +1353,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, From e99d3d52e5f66b6b1befb248c21362431e14f199 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 17:08:14 -0700 Subject: [PATCH 21/67] fix(push): initialize gateway cleanup at startup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 16 +++--- .../BuzzDevPushEnrollmentDriverTests.swift | 2 +- mobile/ios/Runner/AppDelegate.swift | 55 +++++++++++++++---- mobile/lib/shared/push/push_bootstrap.dart | 24 ++++++++ mobile/lib/shared/push/push_bridge.dart | 13 +++++ mobile/test/shared/push/push_bridge_test.dart | 15 +++++ 6 files changed, 106 insertions(+), 19 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index ea7befd0f50..6e2d2db2bd6 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -433,8 +433,8 @@ public final class BuzzDevPushEnrollmentDriver { } /// Revokes durable installations from gateways that are no longer configured. - public func cleanRetiredGateways(deviceToken: Data) async throws { - precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") + public func cleanRetiredGateways(deviceToken: Data? = nil) async throws { + precondition(deviceToken?.isEmpty != true, "The APNs device token must not be empty") try await cleanStaleGateways(deviceToken: deviceToken) } @@ -777,7 +777,7 @@ public final class BuzzDevPushEnrollmentDriver { return record } - private func cleanStaleGateways(deviceToken: Data) async throws { + private func cleanStaleGateways(deviceToken: Data?) async throws { let states = try store.gatewayCleanupStates() for var state in states where state.gatewayOrigin != gatewayOrigin { guard await cleanStaleGateway(&state, deviceToken: deviceToken) else { @@ -789,7 +789,7 @@ public final class BuzzDevPushEnrollmentDriver { private func cleanStaleGateway( _ state: inout BuzzPushGatewayCleanupState, - deviceToken: Data + deviceToken: Data? ) async -> Bool { guard let oldURL = URL(string: state.gatewayOrigin), let oldDriver = try? BuzzDevPushEnrollmentDriver( @@ -804,8 +804,10 @@ public final class BuzzDevPushEnrollmentDriver { ) else { return false } let nowSeconds = Int64(now().timeIntervalSince1970) - let currentEndpoint = Self.lowercaseHex(deviceToken) - let currentEndpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + let currentEndpoint = deviceToken.map(Self.lowercaseHex) + let currentEndpointHash = deviceToken.map { + Self.lowercaseHex(Data(SHA256.hash(data: $0))) + } var handles = [String: CleanupInstallation]() func mergeHandle(_ handle: String, endpointEpoch: Int64, keyId: String) -> Bool { if let existing = handles[handle] { @@ -836,7 +838,7 @@ public final class BuzzDevPushEnrollmentDriver { if let protectedEndpoint = pending.endpoint { guard Self.endpointHash(protectedEndpoint) == pending.endpointHash else { return false } replayEndpoint = protectedEndpoint - } else if pending.endpointHash == currentEndpointHash { + } else if let currentEndpoint, pending.endpointHash == currentEndpointHash { replayEndpoint = currentEndpoint } else { // Pre-endpoint journals cannot be replayed after token rotation. They diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index ae1c05712c5..dd86569ff71 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -553,7 +553,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } } - try await driver.cleanRetiredGateways(deviceToken: Data((1...32).map(UInt8.init))) + try await driver.cleanRetiredGateways() XCTAssertEqual(store.saved, [current]) XCTAssertTrue(store.cleanup.isEmpty) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index eb185f35235..786711bb50a 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -347,11 +347,31 @@ import os.log return } switch call.method { + case "initializeGateway": + guard let gatewayURL = gatewayURL(from: call) else { + result( + FlutterError( + code: "invalid_arguments", + message: "Push initialization requires gatewayUrl.", + details: nil + ) + ) + return + } + do { + try initializePushGateway(gatewayURL) + result(nil) + } catch { + result( + FlutterError( + code: "push_gateway_configuration_failed", + message: "Push gateway configuration is invalid.", + details: error.localizedDescription + ) + ) + } case "startRegistration": - guard let arguments = call.arguments as? [String: Any], - let gatewayText = arguments["gatewayUrl"] as? String, - let gatewayURL = URL(string: gatewayText) - else { + guard let gatewayURL = gatewayURL(from: call) else { result( FlutterError( code: "invalid_arguments", @@ -362,10 +382,7 @@ import os.log return } do { - let gatewayOrigin = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayURL) - try endpointGrantStore.reset(forGatewayOrigin: gatewayOrigin.text) - pushGatewayURL = gatewayOrigin.url - scheduleRetiredGatewayCleanup() + try initializePushGateway(gatewayURL) startPushRegistration(result: result) } catch { result( @@ -551,8 +568,6 @@ import os.log private func scheduleRetiredGatewayCleanup() { guard gatewayCleanupTask == nil, - let deviceToken = apnsDeviceToken, - !deviceToken.isEmpty, let gatewayURL = pushGatewayURL else { return } do { @@ -564,7 +579,7 @@ import os.log gatewayCleanupTask = Task { [weak self] in defer { self?.gatewayCleanupTask = nil } do { - try await driver.cleanRetiredGateways(deviceToken: deviceToken) + try await driver.cleanRetiredGateways(deviceToken: self?.apnsDeviceToken) } catch { os_log( "Retired push gateway cleanup remains queued: %{public}@", @@ -582,6 +597,24 @@ import os.log } } + private func gatewayURL(from call: FlutterMethodCall) -> URL? { + guard let arguments = call.arguments as? [String: Any], + let gatewayText = arguments["gatewayUrl"] as? String + else { return nil } + return URL(string: gatewayText) + } + + private func initializePushGateway(_ gatewayURL: URL) throws { + let gatewayOrigin = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayURL) + guard pushGatewayURL != gatewayOrigin.url else { + scheduleRetiredGatewayCleanup() + return + } + try endpointGrantStore.reset(forGatewayOrigin: gatewayOrigin.text) + pushGatewayURL = gatewayOrigin.url + scheduleRetiredGatewayCleanup() + } + private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0e86745f92d..ab8a44c612d 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -113,9 +113,11 @@ class BuzzPushBootstrap extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); + final gatewayInitializationAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); final registrationRetry = useState(0); + final gatewayInitializationRetry = useState(0); final publicationRetry = useState(0); final tombstoneRetry = useState(0); final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); @@ -141,9 +143,31 @@ class BuzzPushBootstrap extends HookConsumerWidget { return null; }, [revocationOutbox, session.status]); + useEffect(() { + const attempt = 'configured-gateway'; + if (!gatewayInitializationAttempt.tryBegin(attempt)) return null; + unawaited(() async { + try { + await initializeBuzzPushGateway(); + gatewayInitializationAttempt.complete(attempt); + } catch (error, stack) { + gatewayInitializationAttempt.failed( + attempt, + retry: () { + if (context.mounted) gatewayInitializationRetry.value += 1; + }, + ); + debugPrint('Push gateway initialization failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, [gatewayInitializationRetry.value]); + useEffect( () => () { registrationAttempt.dispose(); + gatewayInitializationAttempt.dispose(); publicationAttempt.dispose(); tombstoneAttempt.dispose(); }, diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 4f6122971a4..5752b92cbe4 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -136,6 +136,19 @@ Future syncPendingBuzzPushNotificationResponse() async { } } +/// Initializes native gateway migration and cleanup without requiring a relay, +/// notification authorization, or an APNs device token. +Future initializeBuzzPushGateway() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + await _channel.invokeMethod('initializeGateway', { + 'gatewayUrl': Env.pushGatewayUrl, + }); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + /// Starts the independent iOS notification-authorization and APNs-registration /// requests. Display authorization is intentionally not returned or persisted: /// APNs registration and enrollment remain valid while display is denied. diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index cdc26aaeac5..887596468e2 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -46,6 +46,21 @@ void main() { expect(apnsRegistrationError.value, isNull); }); + test( + 'initializes native gateway cleanup without starting APNs registration', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'initializeGateway'); + expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); + return null; + }); + + await initializeBuzzPushGateway(); + }, + ); + test( 'starts native permission and APNs registration without a result gate', () async { From 763517cc630d8595c608e6a44ae60cfe413ecf21 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 17:17:29 -0700 Subject: [PATCH 22/67] fix(push): retry startup gateway cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/ios/Runner/AppDelegate.swift | 74 +++++++++++++++++------------ 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 786711bb50a..619baf30ca9 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -16,7 +16,7 @@ import os.log accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String ) private var enrollmentTask: Task? - private var gatewayCleanupTask: Task? + private var gatewayCleanupTask: Task? private var pushGatewayURL: URL? private var appGroupIdentifier: String? { Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String @@ -358,17 +358,19 @@ import os.log ) return } - do { - try initializePushGateway(gatewayURL) - result(nil) - } catch { - result( - FlutterError( - code: "push_gateway_configuration_failed", - message: "Push gateway configuration is invalid.", - details: error.localizedDescription + Task { + do { + try await initializePushGateway(gatewayURL) + result(nil) + } catch { + result( + FlutterError( + code: "push_gateway_initialization_failed", + message: "Push gateway initialization failed.", + details: error.localizedDescription + ) ) - ) + } } case "startRegistration": guard let gatewayURL = gatewayURL(from: call) else { @@ -382,7 +384,8 @@ import os.log return } do { - try initializePushGateway(gatewayURL) + try configurePushGateway(gatewayURL) + scheduleRetiredGatewayCleanup() startPushRegistration(result: result) } catch { result( @@ -536,7 +539,7 @@ import os.log defer { self?.enrollmentTask = nil } do { if let cleanupTask = self?.gatewayCleanupTask { - await cleanupTask.value + try await cleanupTask.value } let record = try await driver.enroll( deviceToken: deviceToken, @@ -566,20 +569,28 @@ import os.log } } + private func retiredGatewayCleanupTask() throws -> Task? { + if let gatewayCleanupTask { return gatewayCleanupTask } + guard let gatewayURL = pushGatewayURL else { return nil } + let driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: pushKeychainAccessGroup + ) + let task = Task { [weak self] in + defer { self?.gatewayCleanupTask = nil } + try await driver.cleanRetiredGateways(deviceToken: self?.apnsDeviceToken) + } + gatewayCleanupTask = task + return task + } + private func scheduleRetiredGatewayCleanup() { - guard gatewayCleanupTask == nil, - let gatewayURL = pushGatewayURL - else { return } do { - let driver = try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: gatewayURL, - store: endpointGrantStore, - appAttestKeychainAccessGroup: pushKeychainAccessGroup - ) - gatewayCleanupTask = Task { [weak self] in - defer { self?.gatewayCleanupTask = nil } + guard let task = try retiredGatewayCleanupTask() else { return } + Task { do { - try await driver.cleanRetiredGateways(deviceToken: self?.apnsDeviceToken) + try await task.value } catch { os_log( "Retired push gateway cleanup remains queued: %{public}@", @@ -604,15 +615,18 @@ import os.log return URL(string: gatewayText) } - private func initializePushGateway(_ gatewayURL: URL) throws { + private func configurePushGateway(_ gatewayURL: URL) throws { let gatewayOrigin = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayURL) - guard pushGatewayURL != gatewayOrigin.url else { - scheduleRetiredGatewayCleanup() - return - } + guard pushGatewayURL != gatewayOrigin.url else { return } try endpointGrantStore.reset(forGatewayOrigin: gatewayOrigin.text) pushGatewayURL = gatewayOrigin.url - scheduleRetiredGatewayCleanup() + } + + private func initializePushGateway(_ gatewayURL: URL) async throws { + try configurePushGateway(gatewayURL) + if let cleanupTask = try retiredGatewayCleanupTask() { + try await cleanupTask.value + } } private func handleMediaUploadMethodCall( From 76e31cde5790a05683d4a76b224edd19cdacc380 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 17:27:38 -0700 Subject: [PATCH 23/67] fix(push): bound retired gateway retries Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 1 - .../BuzzDevPushEnrollmentDriverTests.swift | 17 +++++--- mobile/ios/Runner/AppDelegate.swift | 3 -- mobile/lib/shared/push/push_bootstrap.dart | 39 ++++++++++++++++--- .../test/shared/push/push_bootstrap_test.dart | 22 +++++++++++ 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 6e2d2db2bd6..b24b2de44d8 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -428,7 +428,6 @@ public final class BuzzDevPushEnrollmentDriver { deviceToken: Data, relayURL: URL ) async throws -> BuzzPushEndpointGrantRecord { - try await cleanRetiredGateways(deviceToken: deviceToken) return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index dd86569ff71..1fb651fc12c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -654,11 +654,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } do { - _ = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) - XCTFail("Expected retired gateway cleanup to block replacement enrollment") + try await driver.cleanRetiredGateways(deviceToken: Data((1...32).map(UInt8.init))) + XCTFail("Expected retired gateway cleanup to remain queued") } catch { XCTAssertEqual( error as? BuzzDevPushEnrollmentError, @@ -921,7 +918,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } } - func testReusesPersistedUnexpiredGrant() async throws { + func testEnrollmentContinuesWhileRetiredGatewayCleanupRemainsQueued() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", @@ -937,6 +934,13 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [existing]) + store.cleanup = [ + BuzzPushGatewayCleanupState( + gatewayOrigin: "http://retired-gateway.example", + grants: [], + pendingEnrollments: [] + ) + ] let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in guard request.httpMethod == "GET" else { @@ -960,6 +964,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record, existing) XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(store.cleanup.map(\.gatewayOrigin), ["http://retired-gateway.example"]) XCTAssertEqual(URLProtocolStub.requests.count, 1) } diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 619baf30ca9..b1072204b10 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -538,9 +538,6 @@ import os.log enrollmentTask = Task { [weak self] in defer { self?.enrollmentTask = nil } do { - if let cleanupTask = self?.gatewayCleanupTask { - try await cleanupTask.value - } let record = try await driver.enroll( deviceToken: deviceToken, relayURL: relayURL diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index ab8a44c612d..b1ff816d6a8 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -16,6 +16,18 @@ import 'push_relay_capability_provider.dart'; import 'push_subscription.dart'; const _pushBootstrapRetryDelay = Duration(seconds: 5); +const _maxGatewayInitializationRetries = 6; + +@visibleForTesting +Duration? buzzPushGatewayInitializationRetryDelay(int failureCount) { + if (failureCount < 1) { + throw ArgumentError.value(failureCount, 'failureCount', 'must be positive'); + } + if (failureCount > _maxGatewayInitializationRetries) return null; + return Duration( + seconds: _pushBootstrapRetryDelay.inSeconds << (failureCount - 1), + ); +} @visibleForTesting class BuzzPushAttemptGate { @@ -118,6 +130,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); final registrationRetry = useState(0); final gatewayInitializationRetry = useState(0); + final gatewayInitializationFailures = useRef(0); final publicationRetry = useState(0); final tombstoneRetry = useState(0); final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); @@ -149,15 +162,31 @@ class BuzzPushBootstrap extends HookConsumerWidget { unawaited(() async { try { await initializeBuzzPushGateway(); + gatewayInitializationFailures.value = 0; gatewayInitializationAttempt.complete(attempt); } catch (error, stack) { - gatewayInitializationAttempt.failed( - attempt, - retry: () { - if (context.mounted) gatewayInitializationRetry.value += 1; - }, + gatewayInitializationFailures.value += 1; + final retryDelay = buzzPushGatewayInitializationRetryDelay( + gatewayInitializationFailures.value, ); + if (retryDelay == null) { + gatewayInitializationAttempt.complete(attempt); + } else { + gatewayInitializationAttempt.retryAfter( + attempt, + delay: retryDelay, + retry: () { + if (context.mounted) gatewayInitializationRetry.value += 1; + }, + ); + } debugPrint('Push gateway initialization failed: $error'); + if (retryDelay == null) { + debugPrint( + 'Push gateway cleanup is deferred until the next app launch ' + 'or APNs registration callback.', + ); + } debugPrintStack(stackTrace: stack); } }()); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 6380837bf6d..180c22d57c6 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -5,6 +5,28 @@ import 'package:buzz/shared/push/push_subscription.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('gateway cleanup retries exponentially and then stops', () { + expect( + [ + for (var failure = 1; failure <= 7; failure++) + buzzPushGatewayInitializationRetryDelay(failure), + ], + const [ + Duration(seconds: 5), + Duration(seconds: 10), + Duration(seconds: 20), + Duration(seconds: 40), + Duration(seconds: 80), + Duration(seconds: 160), + null, + ], + ); + expect( + () => buzzPushGatewayInitializationRetryDelay(0), + throwsArgumentError, + ); + }); + test('failed bootstrap attempt becomes retryable after the delay', () async { final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); addTearDown(gate.dispose); From ce8d593a2bd236100c9a3ae770790a86994f2dcf Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 17:36:00 -0700 Subject: [PATCH 24/67] fix(push): continue retired gateway cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 15 +++- .../BuzzDevPushEnrollmentDriverTests.swift | 81 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index b24b2de44d8..92398a6ce49 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -778,11 +778,22 @@ public final class BuzzDevPushEnrollmentDriver { private func cleanStaleGateways(deviceToken: Data?) async throws { let states = try store.gatewayCleanupStates() + var cleanupIncomplete = false + var persistenceError: Error? for var state in states where state.gatewayOrigin != gatewayOrigin { guard await cleanStaleGateway(&state, deviceToken: deviceToken) else { - throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + cleanupIncomplete = true + continue } - try store.removeGatewayCleanupState(gatewayOrigin: state.gatewayOrigin) + do { + try store.removeGatewayCleanupState(gatewayOrigin: state.gatewayOrigin) + } catch { + if persistenceError == nil { persistenceError = error } + } + } + if let persistenceError { throw persistenceError } + if cleanupIncomplete { + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 1fb651fc12c..2657d86a31a 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -668,6 +668,87 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(appAttest.preparedAttestations.isEmpty) } + func testCleanupContinuesAfterAnEarlierRetiredGatewayFails() async throws { + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + func staleRecord(origin: String, handle: String) -> BuzzPushEndpointGrantRecord { + BuzzPushEndpointGrantRecord( + gatewayOrigin: origin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: handle, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "stale-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + } + let offlineOrigin = "http://offline-gateway.example" + let reachableOrigin = "http://reachable-gateway.example" + let offline = BuzzPushGatewayCleanupState( + gatewayOrigin: offlineOrigin, + grants: [ + staleRecord( + origin: offlineOrigin, + handle: "44444444-4444-4444-8444-444444444444" + ) + ], + pendingEnrollments: [] + ) + let reachable = BuzzPushGatewayCleanupState( + gatewayOrigin: reachableOrigin, + grants: [ + staleRecord( + origin: reachableOrigin, + handle: "55555555-5555-4555-8555-555555555555" + ) + ], + pendingEnrollments: [] + ) + let store = MemoryGrantStore() + store.cleanup = [offline, reachable] + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var reachableRevoked = false + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("POST", "http://offline-gateway.example/v1/installations/challenges"): + return Self.response(request, status: 503, json: ["error": "unavailable"]) + case ("POST", "http://reachable-gateway.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://reachable-gateway.example/v1/installations/revoke"): + reachableRevoked = true + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + try await driver.cleanRetiredGateways() + XCTFail("Expected the offline gateway cleanup to remain queued") + } catch { + XCTAssertEqual( + error as? BuzzDevPushEnrollmentError, + .retiredGatewayCleanupIncomplete + ) + } + + XCTAssertTrue(reachableRevoked) + XCTAssertEqual(store.cleanup, [offline]) + } + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { let service = RecordingDCAppAttestService(isSupported: false) let provider = BuzzDCAppAttestProvider( From 895d5fe272afe71b1e292a160e3ceb756cb9df25 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 17:45:51 -0700 Subject: [PATCH 25/67] fix(mobile): require HTTPS for release gateway Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/android/app/build.gradle.kts | 14 +++++++++----- mobile/scripts/require-push-gateway-origin.sh | 9 ++++++++- mobile/scripts/validate_push_gateway_origin.dart | 16 ++++++++++++---- .../push/push_gateway_origin_validator_test.dart | 11 +++++++++++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 06882b2ecea..358a1b355e2 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -31,10 +31,10 @@ val pushGatewayOrigins = ?.takeIf { it.startsWith(pushGatewayDefinePrefix) } ?.removePrefix(pushGatewayDefinePrefix) } -fun isValidPushGatewayOrigin(value: String): Boolean { +fun isValidPushGatewayOrigin(value: String, requireHttps: Boolean): Boolean { val uri = runCatching { URI(value) }.getOrNull() ?: return false val scheme = uri.scheme?.lowercase() - return (scheme == "http" || scheme == "https") && + return (scheme == "https" || (!requireHttps && scheme == "http")) && !uri.host.isNullOrBlank() && uri.rawUserInfo == null && (uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") && @@ -42,14 +42,18 @@ fun isValidPushGatewayOrigin(value: String): Boolean { uri.rawFragment == null && (uri.port == -1 || uri.port in 1..65535) } -val hasValidPushGatewayOrigin = - pushGatewayOrigins.size == 1 && isValidPushGatewayOrigin(pushGatewayOrigins.single()) tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { doFirst { + val requireHttps = !name.endsWith("Debug", ignoreCase = true) + val hasValidPushGatewayOrigin = + pushGatewayOrigins.size == 1 && + isValidPushGatewayOrigin(pushGatewayOrigins.single(), requireHttps) if (!hasValidPushGatewayOrigin) { throw GradleException( - "BUZZ_PUSH_GATEWAY_URL must be supplied as an HTTP(S) origin without " + + "BUZZ_PUSH_GATEWAY_URL must be supplied as an " + + (if (requireHttps) "HTTPS" else "HTTP(S)") + + " origin without " + "credentials, path, query, or fragment for every mobile build.", ) } diff --git a/mobile/scripts/require-push-gateway-origin.sh b/mobile/scripts/require-push-gateway-origin.sh index 7fe3a8889e1..0284db09fc3 100644 --- a/mobile/scripts/require-push-gateway-origin.sh +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -41,4 +41,11 @@ if [ -z "$dart_bin" ]; then echo "error: Dart is required to validate BUZZ_PUSH_GATEWAY_URL." >&2 exit 1 fi -"$dart_bin" "$script_dir/validate_push_gateway_origin.dart" "$gateway_origin" +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 index 9589a6a3630..fceb43ab6c6 100644 --- a/mobile/scripts/validate_push_gateway_origin.dart +++ b/mobile/scripts/validate_push_gateway_origin.dart @@ -1,9 +1,13 @@ import 'dart:io'; -bool isValidPushGatewayOrigin(String value) { +bool isValidPushGatewayOrigin(String value, {bool requireHttps = false}) { try { final uri = Uri.parse(value); - if (uri.scheme != 'http' && uri.scheme != 'https') return false; + if (requireHttps + ? uri.scheme != 'https' + : uri.scheme != 'http' && uri.scheme != 'https') { + return false; + } if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty) { return false; } @@ -17,11 +21,15 @@ bool isValidPushGatewayOrigin(String value) { } void main(List arguments) { - if (arguments.length == 1 && isValidPushGatewayOrigin(arguments.single)) { + 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; } stderr.writeln( - 'error: BUZZ_PUSH_GATEWAY_URL must be an HTTP(S) origin without credentials, path, query, or fragment.', + 'error: BUZZ_PUSH_GATEWAY_URL must be ${requireHttps ? 'an HTTPS' : 'an HTTP(S)'} origin without credentials, path, query, or fragment.', ); exitCode = 1; } diff --git a/mobile/test/shared/push/push_gateway_origin_validator_test.dart b/mobile/test/shared/push/push_gateway_origin_validator_test.dart index ea4ae2b744c..1cf223cbe7b 100644 --- a/mobile/test/shared/push/push_gateway_origin_validator_test.dart +++ b/mobile/test/shared/push/push_gateway_origin_validator_test.dart @@ -13,6 +13,17 @@ void main() { } }); + test('requires HTTPS for release and profile builds', () { + expect( + isValidPushGatewayOrigin('https://push.example', requireHttps: true), + isTrue, + ); + expect( + isValidPushGatewayOrigin('http://localhost:8080', requireHttps: true), + isFalse, + ); + }); + test('rejects malformed or non-origin gateway URLs', () { for (final value in [ '', From da10304f5eff9b8a9a983bd51c67e2137784cdfa Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 17:54:22 -0700 Subject: [PATCH 26/67] fix(push): reconcile restored enrollment journal Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 12 +++ .../BuzzDevPushEnrollmentDriverTests.swift | 89 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 92398a6ce49..7d77b0b95c4 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -463,11 +463,23 @@ public final class BuzzDevPushEnrollmentDriver { pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash || pending.expiresAt <= nowSeconds { + var cleanupState = BuzzPushGatewayCleanupState( + gatewayOrigin: gatewayOrigin, + grants: [], + pendingEnrollments: [pending] + ) + guard await cleanStaleGateway(&cleanupState, deviceToken: deviceToken) else { + if let reconciled = cleanupState.pendingEnrollments.first { + try store.savePendingEnrollment(reconciled) + } + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } try store.removePendingEnrollment( gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) + try store.removeGatewayCleanupState(gatewayOrigin: gatewayOrigin) pendingEnrollment = nil } if let current = storedForOrigin, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 2657d86a31a..ce515b2d06f 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -466,6 +466,95 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.resetOperations, ["records", "cleanup-removed:https://gateway-a.example"]) } + func testRestoredResponseLostEnrollmentIsRevokedBeforeReplacement() async throws { + let oldToken = Data(repeating: 0x07, count: 32) + let newToken = Data(repeating: 0x08, count: 32) + let pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + endpoint: Self.hex(oldToken), + endpointHash: Self.hex(SHA256.hash(data: oldToken)), + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + installationId: Self.installationId, + challengeId: Self.firstChallengeId, + challenge: Self.challenge, + keyId: Self.keyId, + attestation: Self.attestation + ) + let store = MemoryGrantStore() + store.cleanup = [ + BuzzPushGatewayCleanupState( + gatewayOrigin: Self.gatewayOrigin, + grants: [], + pendingEnrollments: [pending] + ) + ] + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeRequests = 0 + var replayedOldEndpoint = false + var revoked = false + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations"): + replayedOldEndpoint = try Self.body(request)["endpoint"] as? String == Self.hex(oldToken) + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeRequests += 1 + guard challengeRequests == 1 else { + return Self.response(request, status: 503, json: ["error": "injected"]) + } + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations/revoke"): + revoked = true + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll(deviceToken: newToken, relayURL: Self.relayURL) + XCTFail("Expected the injected replacement challenge failure") + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations/challenges", expected: 200, actual: 503, _ + ) { + // The old installation was reconciled before replacement began. + } + + XCTAssertTrue(replayedOldEndpoint) + XCTAssertTrue(revoked) + XCTAssertTrue(store.pending.isEmpty) + XCTAssertTrue(store.cleanup.isEmpty) + } + func testCleanupRevokesAndDeletesStaleGatewaysWithoutRelayEnrollment() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let current = BuzzPushEndpointGrantRecord( From 88561a9174ceb51445f4fe4dbfd36cb40fabb5e4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:05:32 -0700 Subject: [PATCH 27/67] fix(push): preserve shared installation during cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 103 ++++++++++++++++-- .../BuzzDevPushEnrollmentDriverTests.swift | 92 ++++++++++++++++ 2 files changed, 185 insertions(+), 10 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 7d77b0b95c4..eb280fb1750 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -463,23 +463,51 @@ public final class BuzzDevPushEnrollmentDriver { pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash || pending.expiresAt <= nowSeconds { - var cleanupState = BuzzPushGatewayCleanupState( - gatewayOrigin: gatewayOrigin, - grants: [], - pendingEnrollments: [pending] - ) - guard await cleanStaleGateway(&cleanupState, deviceToken: deviceToken) else { - if let reconciled = cleanupState.pendingEnrollments.first { - try store.savePendingEnrollment(reconciled) + let referencedInstallation = pending.gatewayInstallationHandle.flatMap { handle in + storedRecords.first { + $0.gatewayInstallationHandle == handle && $0.expiresAt > nowSeconds } - throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } + if let handleText = pending.gatewayInstallationHandle, + let handle = UUID(uuidString: handleText), + handleText == handle.uuidString.lowercased(), + let keyId = pending.keyId, + pending.endpointHash == endpointHash, + referencedInstallation != nil + { + if pending.delegationGeneration > 0 { + do { + try await revokeDelegation( + installationHandle: handle, + relayPubkey: pending.relayPubkey, + generation: pending.delegationGeneration, + appAttestKeyId: keyId + ) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/delegations/revoke", _, actual: 404, _ + ) { + // No committed delegation remains to clean up. + } + } + } else { + var cleanupState = BuzzPushGatewayCleanupState( + gatewayOrigin: gatewayOrigin, + grants: [], + pendingEnrollments: [pending] + ) + guard await cleanStaleGateway(&cleanupState, deviceToken: deviceToken) else { + if let reconciled = cleanupState.pendingEnrollments.first { + try store.savePendingEnrollment(reconciled) + } + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } + try store.removeGatewayCleanupState(gatewayOrigin: gatewayOrigin) } try store.removePendingEnrollment( gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) - try store.removeGatewayCleanupState(gatewayOrigin: gatewayOrigin) pendingEnrollment = nil } if let current = storedForOrigin, @@ -1046,6 +1074,43 @@ public final class BuzzDevPushEnrollmentDriver { } } + private func revokeDelegation( + installationHandle: UUID, + relayPubkey: String, + generation: Int64, + appAttestKeyId: String + ) async throws { + let revokeChallenge = try await challenge() + let clientData = try BuzzPushTranscript.revokeDelegation( + gatewayOrigin: gatewayBaseURL, + challengeId: revokeChallenge.id, + challenge: revokeChallenge.value, + installationHandle: installationHandle, + relayPubkey: relayPubkey, + generation: generation + ) + let assertion = try await appAttest.assertion( + keyId: appAttestKeyId, + clientData: clientData + ) + let response: MutationResponse = try await post( + route: "v1/delegations/revoke", + expectedStatus: 200, + body: RevokeDelegationRequest( + v: 1, + challengeId: revokeChallenge.id.uuidString.lowercased(), + challenge: revokeChallenge.value, + installationHandle: installationHandle.uuidString.lowercased(), + relayPubkey: relayPubkey, + generation: generation, + assertion: assertion + ) + ) + guard response.status == "revoked" else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/delegations/revoke") + } + } + private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys { var request = URLRequest(url: relayOrigin) request.httpMethod = "GET" @@ -1263,6 +1328,24 @@ private struct DelegationResponse: Decodable { let endpointGrant: String enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" } } +private struct RevokeDelegationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let installationHandle: String + let relayPubkey: String + let generation: Int64 + let assertion: String + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case installationHandle = "installation_handle" + case relayPubkey = "relay_pubkey" + case generation + case assertion + } +} private struct RevokeInstallationRequest: Encodable { let v: Int let challengeId: String diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index ce515b2d06f..77927fe94a0 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -555,6 +555,98 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.cleanup.isEmpty) } + func testRelayRotationRevokesPendingDelegationWithoutRevokingSharedInstallation() async throws { + let newRelayPubkey = String(repeating: "b", count: 64) + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + 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: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + endpoint: Self.endpoint, + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + installationId: Self.installationId, + gatewayInstallationHandle: Self.installationHandle, + keyId: Self.keyId, + delegationGeneration: 2 + ) + let store = MemoryGrantStore(records: [existing], pending: [pending]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeRequests = 0 + var delegationRevoked = false + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": newRelayPubkey, + "push": ["keys": [["pubkey": newRelayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeRequests += 1 + guard challengeRequests == 1 else { + return Self.response(request, status: 503, json: ["error": "injected"]) + } + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/delegations/revoke"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey) + XCTAssertEqual(body["generation"] as? Int, 2) + delegationRevoked = true + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected the injected replacement delegation challenge failure") + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations/challenges", expected: 200, actual: 503, _ + ) { + // The old delegation was revoked before replacement began. + } + + XCTAssertTrue(delegationRevoked) + XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(store.pending.count, 1) + XCTAssertEqual(store.pending.first?.relayPubkey, newRelayPubkey) + XCTAssertEqual(store.pending.first?.gatewayInstallationHandle, Self.installationHandle) + } + func testCleanupRevokesAndDeletesStaleGatewaysWithoutRelayEnrollment() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let current = BuzzPushEndpointGrantRecord( From 2c6041866f4cc8cdb8e3729ec577430f15b5ed73 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:05:44 -0700 Subject: [PATCH 28/67] fix(mobile): reject release gateway ports Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/android/app/build.gradle.kts | 4 ++-- mobile/scripts/validate_push_gateway_origin.dart | 8 +++++--- .../shared/push/push_gateway_origin_validator_test.dart | 4 ++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 358a1b355e2..16a7f0f8015 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -40,7 +40,7 @@ fun isValidPushGatewayOrigin(value: String, requireHttps: Boolean): Boolean { (uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") && uri.rawQuery == null && uri.rawFragment == null && - (uri.port == -1 || uri.port in 1..65535) + (if (requireHttps) uri.port == -1 else uri.port == -1 || uri.port in 1..65535) } tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { @@ -53,7 +53,7 @@ tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { throw GradleException( "BUZZ_PUSH_GATEWAY_URL must be supplied as an " + (if (requireHttps) "HTTPS" else "HTTP(S)") + - " origin without " + + " origin without " + (if (requireHttps) "an explicit port, " else "") + "credentials, path, query, or fragment for every mobile build.", ) } diff --git a/mobile/scripts/validate_push_gateway_origin.dart b/mobile/scripts/validate_push_gateway_origin.dart index fceb43ab6c6..97a8ed7aa4c 100644 --- a/mobile/scripts/validate_push_gateway_origin.dart +++ b/mobile/scripts/validate_push_gateway_origin.dart @@ -13,6 +13,7 @@ bool isValidPushGatewayOrigin(String value, {bool requireHttps = 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 { @@ -28,8 +29,9 @@ void main(List arguments) { isValidPushGatewayOrigin(values.single, requireHttps: requireHttps)) { return; } - stderr.writeln( - 'error: BUZZ_PUSH_GATEWAY_URL must be ${requireHttps ? 'an HTTPS' : 'an HTTP(S)'} origin without credentials, path, query, or fragment.', - ); + 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/shared/push/push_gateway_origin_validator_test.dart b/mobile/test/shared/push/push_gateway_origin_validator_test.dart index 1cf223cbe7b..6379a98f1b6 100644 --- a/mobile/test/shared/push/push_gateway_origin_validator_test.dart +++ b/mobile/test/shared/push/push_gateway_origin_validator_test.dart @@ -22,6 +22,10 @@ void main() { isValidPushGatewayOrigin('http://localhost:8080', requireHttps: true), isFalse, ); + expect( + isValidPushGatewayOrigin('https://push.example:8443', requireHttps: true), + isFalse, + ); }); test('rejects malformed or non-origin gateway URLs', () { From 59ce83016a40e94a6d9c19ee0faffd61debc41d4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:13:00 -0700 Subject: [PATCH 29/67] Checkpoint retired installation cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 13 +++- .../BuzzDevPushEnrollmentDriverTests.swift | 63 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index eb280fb1750..3439dccca32 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -929,7 +929,8 @@ public final class BuzzDevPushEnrollmentDriver { return false } } - for (handleText, installation) in handles { + for handleText in handles.keys.sorted() { + guard let installation = handles[handleText] else { return false } guard let handle = UUID(uuidString: handleText) else { return false } do { try await oldDriver.revokeInstallation( @@ -940,7 +941,15 @@ public final class BuzzDevPushEnrollmentDriver { } catch BuzzDevPushEnrollmentError.unexpectedStatus( route: "v1/installations/revoke", _, actual: 404, _ ) { - continue + // A missing installation is already terminal, so checkpoint it just + // like a successful revocation before attempting another handle. + } catch { + return false + } + state.grants.removeAll { $0.gatewayInstallationHandle == handleText } + state.pendingEnrollments.removeAll { $0.gatewayInstallationHandle == handleText } + do { + try store.saveGatewayCleanupState(state) } catch { return false } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 77927fe94a0..411be40cb0b 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -849,6 +849,69 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(appAttest.preparedAttestations.isEmpty) } + func testCleanupCheckpointsEachRevokedInstallationBeforeLaterFailure() async throws { + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + func staleRecord(handle: String, relayOrigin: String) -> BuzzPushEndpointGrantRecord { + BuzzPushEndpointGrantRecord( + gatewayOrigin: "http://old-gateway.example", + relayOrigin: relayOrigin, + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: handle, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "stale-grant-\(handle)", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + } + let revokedHandle = "44444444-4444-4444-8444-444444444444" + let failedHandle = "55555555-5555-4555-8555-555555555555" + let revoked = staleRecord(handle: revokedHandle, relayOrigin: "wss://first.example") + let failed = staleRecord(handle: failedHandle, relayOrigin: "wss://second.example") + let store = MemoryGrantStore(records: [revoked, failed]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("POST", "http://old-gateway.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://old-gateway.example/v1/installations/revoke"): + let body = try Self.body(request) + if body["installation_handle"] as? String == revokedHandle { + return Self.response(request, status: 200, json: ["status": "revoked"]) + } + XCTAssertEqual(body["installation_handle"] as? String, failedHandle) + return Self.response(request, status: 503, json: ["error": "unavailable"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + try await driver.cleanRetiredGateways() + XCTFail("Expected the later revocation failure to keep cleanup queued") + } catch { + XCTAssertEqual( + error as? BuzzDevPushEnrollmentError, + .retiredGatewayCleanupIncomplete + ) + } + + XCTAssertTrue(store.saved.isEmpty) + XCTAssertEqual(try XCTUnwrap(store.cleanup.first).grants, [failed]) + } + func testCleanupContinuesAfterAnEarlierRetiredGatewayFails() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) func staleRecord(origin: String, handle: String) -> BuzzPushEndpointGrantRecord { From 6a5c5cee6cf65dd807329369b72b1892027bef81 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:20:19 -0700 Subject: [PATCH 30/67] Retry ambiguous push revocation failures Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 5 -- .../BuzzDevPushEnrollmentDriverTests.swift | 52 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 3439dccca32..39e9721e2b4 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -938,11 +938,6 @@ public final class BuzzDevPushEnrollmentDriver { endpointEpoch: installation.endpointEpoch, appAttestKeyId: installation.keyId ) - } catch BuzzDevPushEnrollmentError.unexpectedStatus( - route: "v1/installations/revoke", _, actual: 404, _ - ) { - // A missing installation is already terminal, so checkpoint it just - // like a successful revocation before attempting another handle. } catch { return false } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 411be40cb0b..c914219ad28 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -912,6 +912,58 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(store.cleanup.first).grants, [failed]) } + func testCleanupKeepsJournalWhenRevocationReturnsAmbiguous404() async throws { + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + let stale = BuzzPushEndpointGrantRecord( + gatewayOrigin: "http://old-gateway.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: "44444444-4444-4444-8444-444444444444", + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "stale-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [stale]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("POST", "http://old-gateway.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://old-gateway.example/v1/installations/revoke"): + return Self.response(request, status: 404, json: ["error": "not_authorized"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + try await driver.cleanRetiredGateways() + XCTFail("Expected ambiguous revocation failure to keep cleanup queued") + } catch { + XCTAssertEqual( + error as? BuzzDevPushEnrollmentError, + .retiredGatewayCleanupIncomplete + ) + } + + XCTAssertTrue(store.saved.isEmpty) + XCTAssertEqual(try XCTUnwrap(store.cleanup.first).grants, [stale]) + } + func testCleanupContinuesAfterAnEarlierRetiredGatewayFails() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) func staleRecord(origin: String, handle: String) -> BuzzPushEndpointGrantRecord { From c559fc64921183272d29777a40c61b3eb5e7217c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:29:13 -0700 Subject: [PATCH 31/67] Quarantine push grants before revocation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 24 +++++- .../BuzzPushGatewayStateReset.swift | 30 +++++++- .../BuzzDevPushEnrollmentDriverTests.swift | 73 ++++++++++++++++++- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 39e9721e2b4..3a3c7a73771 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -72,16 +72,21 @@ public struct BuzzPushGatewayCleanupState: Codable, Equatable, Sendable { public var grants: [BuzzPushEndpointGrantRecord] /// Crash-recovery journals, including response-loss enrollments that may need replay. public var pendingEnrollments: [BuzzPushPendingEnrollmentRecord] + /// Handles whose revocation intent was persisted before the remote mutation. + /// These records must never be restored as usable grants after a gateway rollback. + public var revocationPendingInstallationHandles: [String]? /// Creates one durable cleanup snapshot for a retired gateway. public init( gatewayOrigin: String, grants: [BuzzPushEndpointGrantRecord], - pendingEnrollments: [BuzzPushPendingEnrollmentRecord] + pendingEnrollments: [BuzzPushPendingEnrollmentRecord], + revocationPendingInstallationHandles: [String]? = nil ) { self.gatewayOrigin = gatewayOrigin self.grants = grants self.pendingEnrollments = pendingEnrollments + self.revocationPendingInstallationHandles = revocationPendingInstallationHandles } } @@ -820,7 +825,7 @@ public final class BuzzDevPushEnrollmentDriver { let states = try store.gatewayCleanupStates() var cleanupIncomplete = false var persistenceError: Error? - for var state in states where state.gatewayOrigin != gatewayOrigin { + for var state in states { guard await cleanStaleGateway(&state, deviceToken: deviceToken) else { cleanupIncomplete = true continue @@ -932,6 +937,17 @@ public final class BuzzDevPushEnrollmentDriver { for handleText in handles.keys.sorted() { guard let installation = handles[handleText] else { return false } guard let handle = UUID(uuidString: handleText) else { return false } + if state.revocationPendingInstallationHandles?.contains(handleText) != true { + var pendingHandles = state.revocationPendingInstallationHandles ?? [] + pendingHandles.append(handleText) + pendingHandles.sort() + state.revocationPendingInstallationHandles = pendingHandles + do { + try store.saveGatewayCleanupState(state) + } catch { + return false + } + } do { try await oldDriver.revokeInstallation( installationHandle: handle, @@ -943,6 +959,10 @@ public final class BuzzDevPushEnrollmentDriver { } state.grants.removeAll { $0.gatewayInstallationHandle == handleText } state.pendingEnrollments.removeAll { $0.gatewayInstallationHandle == handleText } + state.revocationPendingInstallationHandles?.removeAll { $0 == handleText } + if state.revocationPendingInstallationHandles?.isEmpty == true { + state.revocationPendingInstallationHandles = nil + } do { try store.saveGatewayCleanupState(state) } catch { diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift index d5d902f47b4..48d664d4259 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift @@ -14,16 +14,21 @@ public enum BuzzPushGatewayStateReset { var nextRecords = records var nextPending = pendingEnrollments let restoredState = cleanupStates.first { $0.gatewayOrigin == gatewayOrigin } + let revocationPendingHandles = Set( + restoredState?.revocationPendingInstallationHandles ?? [] + ) if let restoredState { for record in restoredState.grants - where !nextRecords.contains(where: { + where record.gatewayInstallationHandle.map(revocationPendingHandles.contains) != true + && !nextRecords.contains(where: { $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile }) { nextRecords.append(record) } for pending in restoredState.pendingEnrollments - where !nextPending.contains(where: { + where pending.gatewayInstallationHandle.map(revocationPendingHandles.contains) != true + && !nextPending.contains(where: { $0.gatewayOrigin == pending.gatewayOrigin && $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile }) { @@ -65,8 +70,25 @@ public enum BuzzPushGatewayStateReset { nextPending.filter { $0.gatewayOrigin == gatewayOrigin } ) } - if restoredState != nil { - try removeCleanupState(gatewayOrigin) + if let restoredState { + let retainedGrants = restoredState.grants.filter { + $0.gatewayInstallationHandle.map(revocationPendingHandles.contains) == true + } + let retainedPending = restoredState.pendingEnrollments.filter { + $0.gatewayInstallationHandle.map(revocationPendingHandles.contains) == true + } + if retainedGrants.isEmpty && retainedPending.isEmpty { + try removeCleanupState(gatewayOrigin) + } else { + try saveCleanupState( + BuzzPushGatewayCleanupState( + gatewayOrigin: gatewayOrigin, + grants: retainedGrants, + pendingEnrollments: retainedPending, + revocationPendingInstallationHandles: Array(revocationPendingHandles).sorted() + ) + ) + } } } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index c914219ad28..e946d622f9e 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -964,6 +964,69 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(store.cleanup.first).grants, [stale]) } + func testRollbackCannotRestoreGrantAfterRevocationCheckpointFailure() async throws { + let stale = BuzzPushEndpointGrantRecord( + gatewayOrigin: "http://old-gateway.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: "44444444-4444-4444-8444-444444444444", + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "stale-grant", + endpointHash: String(repeating: "b", count: 64), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + // Reset journals the retired gateway on save 1, revocation intent is save + // 2, and save 3 is the injected post-revocation checkpoint failure. + let store = MemoryGrantStore(records: [stale], cleanupSaveFailureCalls: [3]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("POST", "http://old-gateway.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://old-gateway.example/v1/installations/revoke"): + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + try await driver.cleanRetiredGateways() + XCTFail("Expected the post-revocation checkpoint to fail") + } catch { + XCTAssertEqual( + error as? BuzzDevPushEnrollmentError, + .retiredGatewayCleanupIncomplete + ) + } + + let cleanup = try XCTUnwrap(store.cleanup.first) + XCTAssertEqual(cleanup.grants, [stale]) + XCTAssertEqual( + cleanup.revocationPendingInstallationHandles, + [try XCTUnwrap(stale.gatewayInstallationHandle)] + ) + + store.cleanupSaveFailureCalls = [] + try store.reset(forGatewayOrigin: stale.gatewayOrigin) + + XCTAssertTrue(store.saved.isEmpty) + XCTAssertEqual(store.cleanup.first?.grants, [stale]) + } + func testCleanupContinuesAfterAnEarlierRetiredGatewayFails() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) func staleRecord(origin: String, handle: String) -> BuzzPushEndpointGrantRecord { @@ -1852,14 +1915,18 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var cleanup: [BuzzPushGatewayCleanupState] = [] var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int + var cleanupSaveFailureCalls: Set + private var cleanupSaveCallCount = 0 init( records: [BuzzPushEndpointGrantRecord] = [], pending: [BuzzPushPendingEnrollmentRecord] = [], - grantSaveFailuresRemaining: Int = 0 + grantSaveFailuresRemaining: Int = 0, + cleanupSaveFailureCalls: Set = [] ) { saved = records self.pending = pending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining + self.cleanupSaveFailureCalls = cleanupSaveFailureCalls } func reset(forGatewayOrigin gatewayOrigin: String) throws { try BuzzPushGatewayStateReset.run( @@ -1926,6 +1993,10 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { } func gatewayCleanupStates() throws -> [BuzzPushGatewayCleanupState] { cleanup } func saveGatewayCleanupState(_ state: BuzzPushGatewayCleanupState) throws { + cleanupSaveCallCount += 1 + if cleanupSaveFailureCalls.contains(cleanupSaveCallCount) { + throw NSError(domain: "MemoryGrantStore", code: 2) + } cleanup.removeAll { $0.gatewayOrigin == state.gatewayOrigin } cleanup.append(state) } From d283152d2404f96a76ed05747cf1d2e1d5a43a71 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:36:58 -0700 Subject: [PATCH 32/67] Reconcile committed push delegation generation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 21 +++++++++++++---- .../BuzzDevPushEnrollmentDriverTests.swift | 23 ++++++++++++------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 3a3c7a73771..8048e16ad14 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -478,22 +478,35 @@ public final class BuzzDevPushEnrollmentDriver { handleText == handle.uuidString.lowercased(), let keyId = pending.keyId, pending.endpointHash == endpointHash, - referencedInstallation != nil + let referencedInstallation { - if pending.delegationGeneration > 0 { + let candidateGenerations = [ + pending.delegationGeneration, + referencedInstallation.generation, + ].filter { $0 > 0 }.reduce(into: [Int64]()) { generations, generation in + if !generations.contains(generation) { generations.append(generation) } + } + var revoked = false + for generation in candidateGenerations { do { try await revokeDelegation( installationHandle: handle, relayPubkey: pending.relayPubkey, - generation: pending.delegationGeneration, + generation: generation, appAttestKeyId: keyId ) + revoked = true + break } catch BuzzDevPushEnrollmentError.unexpectedStatus( route: "v1/delegations/revoke", _, actual: 404, _ ) { - // No committed delegation remains to clean up. + // The reserved generation may not have committed. Try the last + // generation known to have produced a durable grant as well. } } + guard revoked else { + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } } else { var cleanupState = BuzzPushGatewayCleanupState( gatewayOrigin: gatewayOrigin, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index e946d622f9e..1e29dc370b9 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -555,7 +555,9 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.cleanup.isEmpty) } - func testRelayRotationRevokesPendingDelegationWithoutRevokingSharedInstallation() async throws { + func testRelayRotationRevokesKnownCommittedGenerationWhenReservedGenerationDidNotCommit() + async throws + { let newRelayPubkey = String(repeating: "b", count: 64) let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let existing = BuzzPushEndpointGrantRecord( @@ -589,7 +591,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let store = MemoryGrantStore(records: [existing], pending: [pending]) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) var challengeRequests = 0 - var delegationRevoked = false + var revokedGenerations: [Int] = [] URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://relay.example/"): @@ -603,14 +605,15 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) case ("POST", "http://push.example/v1/installations/challenges"): challengeRequests += 1 - guard challengeRequests == 1 else { + guard challengeRequests <= 2 else { return Self.response(request, status: 503, json: ["error": "injected"]) } return Self.response( request, status: 200, json: [ - "challenge_id": Self.secondChallengeId, + "challenge_id": + challengeRequests == 1 ? Self.firstChallengeId : Self.secondChallengeId, "challenge": Self.challenge, "expires_at": Self.now + 300, ] @@ -619,8 +622,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let body = try Self.body(request) XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey) - XCTAssertEqual(body["generation"] as? Int, 2) - delegationRevoked = true + let generation = try XCTUnwrap(body["generation"] as? Int) + revokedGenerations.append(generation) + if generation == 2 { + return Self.response(request, status: 404, json: ["error": "not_authorized"]) + } + XCTAssertEqual(generation, 1) return Self.response(request, status: 200, json: ["status": "revoked"]) default: XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") @@ -637,10 +644,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } catch BuzzDevPushEnrollmentError.unexpectedStatus( route: "v1/installations/challenges", expected: 200, actual: 503, _ ) { - // The old delegation was revoked before replacement began. + // The known committed generation was revoked before replacement began. } - XCTAssertTrue(delegationRevoked) + XCTAssertEqual(revokedGenerations, [2, 1]) XCTAssertEqual(store.saved, [existing]) XCTAssertEqual(store.pending.count, 1) XCTAssertEqual(store.pending.first?.relayPubkey, newRelayPubkey) From 0074858b6d92d10bec80cf66a0f0d6178b3c9870 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:45:41 -0700 Subject: [PATCH 33/67] Scope push delegation generation fallback Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 12 +++++++--- .../BuzzDevPushEnrollmentDriverTests.swift | 22 +++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 8048e16ad14..ccc7c41de62 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -469,9 +469,15 @@ public final class BuzzDevPushEnrollmentDriver { || pending.expiresAt <= nowSeconds { let referencedInstallation = pending.gatewayInstallationHandle.flatMap { handle in - storedRecords.first { - $0.gatewayInstallationHandle == handle && $0.expiresAt > nowSeconds - } + storedRecords + .filter { + $0.gatewayOrigin == pending.gatewayOrigin + && $0.gatewayInstallationHandle == handle + && $0.relayPubkey == pending.relayPubkey + && $0.appProfile == pending.appProfile + && $0.expiresAt > nowSeconds + } + .max { $0.generation < $1.generation } } if let handleText = pending.gatewayInstallationHandle, let handle = UUID(uuidString: handleText), diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 1e29dc370b9..3f01cb56082 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -588,7 +588,25 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { keyId: Self.keyId, delegationGeneration: 2 ) - let store = MemoryGrantStore(records: [existing], pending: [pending]) + let unrelatedHigherGeneration = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://other-relay.example", + relayPubkey: newRelayPubkey, + relayMetadataPubkey: newRelayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: "101112131415161718191a1b1c1d1e1f", + endpointGrant: "other-relay-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 7, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore( + records: [unrelatedHigherGeneration, existing], + pending: [pending] + ) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) var challengeRequests = 0 var revokedGenerations: [Int] = [] @@ -648,7 +666,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } XCTAssertEqual(revokedGenerations, [2, 1]) - XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(store.saved, [unrelatedHigherGeneration, existing]) XCTAssertEqual(store.pending.count, 1) XCTAssertEqual(store.pending.first?.relayPubkey, newRelayPubkey) XCTAssertEqual(store.pending.first?.gatewayInstallationHandle, Self.installationHandle) From 0dff62c10f5cd47a01f6cc1c1f2fad5e9e219ad8 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 18:53:53 -0700 Subject: [PATCH 34/67] Make push delegation cleanup recoverable Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/authority.rs | 14 ++++++-- crates/buzz-push-gateway/src/postgres.rs | 7 ++-- .../BuzzDevPushEnrollmentDriver.swift | 14 ++++++-- .../BuzzDevPushEnrollmentDriverTests.swift | 35 +++++++++++++++---- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 1a7ef4b2a65..8e5d0673aa7 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -155,8 +155,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, @@ -449,9 +450,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(()) } @@ -838,6 +842,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()); diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 17fff2a2429..47044818843 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -334,9 +334,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(()) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index ccc7c41de62..85f44ea7e07 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -514,13 +514,21 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete } } else { - var cleanupState = BuzzPushGatewayCleanupState( + var cleanupState = try store.gatewayCleanupStates().first { + $0.gatewayOrigin == gatewayOrigin + } ?? BuzzPushGatewayCleanupState( gatewayOrigin: gatewayOrigin, grants: [], - pendingEnrollments: [pending] + pendingEnrollments: [] ) + cleanupState.pendingEnrollments.removeAll { + $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile + } + cleanupState.pendingEnrollments.append(pending) guard await cleanStaleGateway(&cleanupState, deviceToken: deviceToken) else { - if let reconciled = cleanupState.pendingEnrollments.first { + if let reconciled = cleanupState.pendingEnrollments.first(where: { + $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile + }) { try store.savePendingEnrollment(reconciled) } throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 3f01cb56082..0fda5855215 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -469,6 +469,21 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testRestoredResponseLostEnrollmentIsRevokedBeforeReplacement() async throws { let oldToken = Data(repeating: 0x07, count: 32) let newToken = Data(repeating: 0x08, count: 32) + let quarantinedHandle = "11111111-1111-4111-8111-111111111111" + let quarantined = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://other-relay.example", + relayPubkey: String(repeating: "b", count: 64), + gatewayInstallationHandle: quarantinedHandle, + appAttestKeyId: Self.keyId, + installationId: "101112131415161718191a1b1c1d1e1f", + endpointGrant: "quarantined-grant", + endpointHash: Self.hex(SHA256.hash(data: oldToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) let pending = BuzzPushPendingEnrollmentRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", @@ -487,14 +502,15 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { store.cleanup = [ BuzzPushGatewayCleanupState( gatewayOrigin: Self.gatewayOrigin, - grants: [], - pendingEnrollments: [pending] + grants: [quarantined], + pendingEnrollments: [pending], + revocationPendingInstallationHandles: [quarantinedHandle] ) ] let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) var challengeRequests = 0 var replayedOldEndpoint = false - var revoked = false + var revokedHandles = Set() URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://relay.example/"): @@ -519,7 +535,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) case ("POST", "http://push.example/v1/installations/challenges"): challengeRequests += 1 - guard challengeRequests == 1 else { + guard challengeRequests <= 2 else { return Self.response(request, status: 503, json: ["error": "injected"]) } return Self.response( @@ -532,7 +548,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ] ) case ("POST", "http://push.example/v1/installations/revoke"): - revoked = true + revokedHandles.insert(try XCTUnwrap(Self.body(request)["installation_handle"] as? String)) return Self.response(request, status: 200, json: ["status": "revoked"]) default: XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") @@ -550,7 +566,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } XCTAssertTrue(replayedOldEndpoint) - XCTAssertTrue(revoked) + XCTAssertEqual(revokedHandles, [quarantinedHandle, Self.installationHandle]) XCTAssertTrue(store.pending.isEmpty) XCTAssertTrue(store.cleanup.isEmpty) } @@ -1130,7 +1146,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } XCTAssertTrue(reachableRevoked) - XCTAssertEqual(store.cleanup, [offline]) + XCTAssertEqual(store.cleanup.map(\.gatewayOrigin), [offlineOrigin]) + XCTAssertEqual(store.cleanup.first?.grants, offline.grants) + XCTAssertEqual( + store.cleanup.first?.revocationPendingInstallationHandles, + ["44444444-4444-4444-8444-444444444444"] + ) } func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { From adf2bf03d20d693a878fa13d6f21e94712e36a7c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 19:06:16 -0700 Subject: [PATCH 35/67] Journal shared push installation grants Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 23 +++++ .../BuzzDevPushEnrollmentDriverTests.swift | 99 ++++++++++++++++++- .../ios/Runner/PushEndpointGrantStore.swift | 9 ++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 85f44ea7e07..05404f2bffc 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -97,6 +97,8 @@ public protocol BuzzPushEndpointGrantStore { func reset(forGatewayOrigin gatewayOrigin: String) throws func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws + /// Atomically removes every active grant backed by one installation. + func removeRecords(gatewayOrigin: String, installationHandle: String) throws func pendingEnrollment( gatewayOrigin: String, relayOrigin: String, @@ -468,6 +470,7 @@ public final class BuzzDevPushEnrollmentDriver { pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash || pending.expiresAt <= nowSeconds { + var revokedInstallation = false let referencedInstallation = pending.gatewayInstallationHandle.flatMap { handle in storedRecords .filter { @@ -525,6 +528,22 @@ public final class BuzzDevPushEnrollmentDriver { $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile } cleanupState.pendingEnrollments.append(pending) + if let installationHandle = pending.gatewayInstallationHandle { + for record in storedRecords + where record.gatewayOrigin == gatewayOrigin + && record.gatewayInstallationHandle == installationHandle + { + cleanupState.grants.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + cleanupState.grants.append(record) + } + try store.saveGatewayCleanupState(cleanupState) + try store.removeRecords( + gatewayOrigin: gatewayOrigin, + installationHandle: installationHandle + ) + } guard await cleanStaleGateway(&cleanupState, deviceToken: deviceToken) else { if let reconciled = cleanupState.pendingEnrollments.first(where: { $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile @@ -534,6 +553,7 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete } try store.removeGatewayCleanupState(gatewayOrigin: gatewayOrigin) + revokedInstallation = true } try store.removePendingEnrollment( gatewayOrigin: gatewayOrigin, @@ -541,6 +561,9 @@ public final class BuzzDevPushEnrollmentDriver { appProfile: Self.appProfile ) pendingEnrollment = nil + if revokedInstallation { + return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) + } } if let current = storedForOrigin, current.relayPubkey == relayPubkey, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 0fda5855215..12e87b292ca 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -425,7 +425,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { _ = try makeDriver(store: store, appAttest: RecordingAppAttest()) - XCTAssertTrue(store.saved.isEmpty) + XCTAssertEqual(store.saved, []) XCTAssertTrue(store.pending.isEmpty) XCTAssertEqual(store.resetOperations, ["cleanup:https://old-gateway.example", "records", "pending"]) XCTAssertEqual( @@ -567,6 +567,97 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(replayedOldEndpoint) XCTAssertEqual(revokedHandles, [quarantinedHandle, Self.installationHandle]) + XCTAssertEqual(store.saved, []) + XCTAssertTrue(store.pending.isEmpty) + XCTAssertTrue(store.cleanup.isEmpty) + } + + func testEndpointChangeJournalsEveryGrantSharingRevokedInstallation() async throws { + let oldToken = Data(repeating: 0x07, count: 32) + let newToken = Data(repeating: 0x08, count: 32) + let endpointHash = Self.hex(SHA256.hash(data: oldToken)) + func grant(relayOrigin: String, relayPubkey: String) -> BuzzPushEndpointGrantRecord { + BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: relayOrigin, + relayPubkey: relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "grant-\(relayOrigin)", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + } + let primary = grant(relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey) + let shared = grant( + relayOrigin: "wss://shared-relay.example", + relayPubkey: String(repeating: "c", count: 64) + ) + let pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: primary.relayOrigin, + relayPubkey: primary.relayPubkey, + endpoint: Self.hex(oldToken), + endpointHash: endpointHash, + appProfile: primary.appProfile, + expiresAt: Self.expiresAt, + installationId: primary.installationId, + gatewayInstallationHandle: Self.installationHandle, + keyId: Self.keyId, + delegationGeneration: 2 + ) + let store = MemoryGrantStore(records: [primary, shared], pending: [pending]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeRequests = 0 + var revoked = false + 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"): + challengeRequests += 1 + guard challengeRequests == 1 else { + return Self.response(request, status: 503, json: ["error": "injected"]) + } + 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/revoke"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + revoked = true + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll(deviceToken: newToken, relayURL: Self.relayURL) + XCTFail("Expected the injected replacement challenge failure") + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations/challenges", expected: 200, actual: 503, _ + ) { + // Every grant sharing the revoked installation was removed first. + } + + XCTAssertTrue(revoked) + XCTAssertTrue(store.saved.isEmpty) XCTAssertTrue(store.pending.isEmpty) XCTAssertTrue(store.cleanup.isEmpty) } @@ -2010,6 +2101,12 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { } saved.append(record) } + func removeRecords(gatewayOrigin: String, installationHandle: String) throws { + saved.removeAll { + $0.gatewayOrigin == gatewayOrigin + && $0.gatewayInstallationHandle == installationHandle + } + } func pendingEnrollment( gatewayOrigin: String, relayOrigin: String, diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index ba01debc8c9..e5785dba5be 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -67,6 +67,15 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replace(all, account: Self.recordsAccount) } + func removeRecords(gatewayOrigin: String, installationHandle: String) throws { + var all = try records() + all.removeAll { + $0.gatewayOrigin == gatewayOrigin + && $0.gatewayInstallationHandle == installationHandle + } + try replace(all, account: Self.recordsAccount) + } + func pendingEnrollment( gatewayOrigin: String, relayOrigin: String, From 39b66872933d5b9c9bdbd34a8886e076c83d4321 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 19:12:13 -0700 Subject: [PATCH 36/67] Retire grants for revoked push delegation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 11 ++++++ .../BuzzDevPushEnrollmentDriverTests.swift | 34 ++++++++++++++++--- .../ios/Runner/PushEndpointGrantStore.swift | 14 ++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 05404f2bffc..1b1bc8107f6 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -99,6 +99,12 @@ public protocol BuzzPushEndpointGrantStore { func save(_ record: BuzzPushEndpointGrantRecord) throws /// Atomically removes every active grant backed by one installation. func removeRecords(gatewayOrigin: String, installationHandle: String) throws + /// Atomically removes every active grant backed by one delegation. + func removeRecords( + gatewayOrigin: String, + installationHandle: String, + relayPubkey: String + ) throws func pendingEnrollment( gatewayOrigin: String, relayOrigin: String, @@ -516,6 +522,11 @@ public final class BuzzDevPushEnrollmentDriver { guard revoked else { throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete } + try store.removeRecords( + gatewayOrigin: gatewayOrigin, + installationHandle: handleText, + relayPubkey: pending.relayPubkey + ) } else { var cleanupState = try store.gatewayCleanupStates().first { $0.gatewayOrigin == gatewayOrigin diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 12e87b292ca..23f32420bc2 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -698,8 +698,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let unrelatedHigherGeneration = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://other-relay.example", - relayPubkey: newRelayPubkey, - relayMetadataPubkey: newRelayPubkey, + relayPubkey: String(repeating: "c", count: 64), + relayMetadataPubkey: String(repeating: "c", count: 64), gatewayInstallationHandle: Self.installationHandle, appAttestKeyId: Self.keyId, installationId: "101112131415161718191a1b1c1d1e1f", @@ -710,8 +710,23 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { generation: 7, expiresAt: Self.expiresAt ) + let siblingOrigin = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://sibling-relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: "303132333435363738393a3b3c3d3e3f", + endpointGrant: "sibling-origin-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) let store = MemoryGrantStore( - records: [unrelatedHigherGeneration, existing], + records: [unrelatedHigherGeneration, siblingOrigin, existing], pending: [pending] ) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) @@ -773,7 +788,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } XCTAssertEqual(revokedGenerations, [2, 1]) - XCTAssertEqual(store.saved, [unrelatedHigherGeneration, existing]) + XCTAssertEqual(store.saved, [unrelatedHigherGeneration]) XCTAssertEqual(store.pending.count, 1) XCTAssertEqual(store.pending.first?.relayPubkey, newRelayPubkey) XCTAssertEqual(store.pending.first?.gatewayInstallationHandle, Self.installationHandle) @@ -2107,6 +2122,17 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { && $0.gatewayInstallationHandle == installationHandle } } + func removeRecords( + gatewayOrigin: String, + installationHandle: String, + relayPubkey: String + ) throws { + saved.removeAll { + $0.gatewayOrigin == gatewayOrigin + && $0.gatewayInstallationHandle == installationHandle + && $0.relayPubkey == relayPubkey + } + } func pendingEnrollment( gatewayOrigin: String, relayOrigin: String, diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index e5785dba5be..375addcf0ec 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -76,6 +76,20 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replace(all, account: Self.recordsAccount) } + func removeRecords( + gatewayOrigin: String, + installationHandle: String, + relayPubkey: String + ) throws { + var all = try records() + all.removeAll { + $0.gatewayOrigin == gatewayOrigin + && $0.gatewayInstallationHandle == installationHandle + && $0.relayPubkey == relayPubkey + } + try replace(all, account: Self.recordsAccount) + } + func pendingEnrollment( gatewayOrigin: String, relayOrigin: String, From facee2422ea646205ebf4fa8a7d414ca755d67d4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 19:30:02 -0700 Subject: [PATCH 37/67] Checkpoint completed delegation cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- Justfile | 9 ++++-- .../BuzzDevPushEnrollmentDriver.swift | 16 ++++++++-- .../BuzzPushPendingEnrollmentRecord.swift | 29 +++++++++++++++++-- .../BuzzDevPushEnrollmentDriverTests.swift | 25 ++++++++++++++-- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/Justfile b/Justfile index 648d57c124a..24e5a19f4d3 100644 --- a/Justfile +++ b/Justfile @@ -826,10 +826,15 @@ mobile-dev: sleep 3 fi ./scripts/mobile-worktree-overrides.sh - test -n "${BUZZ_PUSH_GATEWAY_URL:-}" || { echo "BUZZ_PUSH_GATEWAY_URL is required" >&2; exit 1; } + 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 + test -n "$gateway_url" || { echo "BUZZ_PUSH_GATEWAY_URL is required in the environment or AppOverrides.xcconfig" >&2; exit 1; } cd {{mobile_dir}} unset GIT_DIR GIT_WORK_TREE - flutter run --dart-define="BUZZ_PUSH_GATEWAY_URL=${BUZZ_PUSH_GATEWAY_URL}" + flutter run --dart-define="BUZZ_PUSH_GATEWAY_URL=${gateway_url}" # Uninstall stale worktree-suffixed Buzz debug installs (production apps kept) mobile-clean: diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 1b1bc8107f6..241bef61641 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -488,7 +488,17 @@ public final class BuzzDevPushEnrollmentDriver { } .max { $0.generation < $1.generation } } - if let handleText = pending.gatewayInstallationHandle, + if pending.delegationRevoked == true, + let handleText = pending.gatewayInstallationHandle, + let handle = UUID(uuidString: handleText), + handleText == handle.uuidString.lowercased() + { + try store.removeRecords( + gatewayOrigin: gatewayOrigin, + installationHandle: handleText, + relayPubkey: pending.relayPubkey + ) + } else if let handleText = pending.gatewayInstallationHandle, let handle = UUID(uuidString: handleText), handleText == handle.uuidString.lowercased(), let keyId = pending.keyId, @@ -522,6 +532,7 @@ public final class BuzzDevPushEnrollmentDriver { guard revoked else { throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete } + try store.savePendingEnrollment(pending.withDelegationRevoked()) try store.removeRecords( gatewayOrigin: gatewayOrigin, installationHandle: handleText, @@ -784,7 +795,8 @@ public final class BuzzDevPushEnrollmentDriver { challenge: pending.challenge, keyId: pending.keyId, attestation: pending.attestation, - delegationGeneration: pending.delegationGeneration + delegationGeneration: pending.delegationGeneration, + delegationRevoked: pending.delegationRevoked ) try store.savePendingEnrollment(pending) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift index e28c9614a15..bb960048927 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -18,6 +18,8 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { public let keyId: String? public let attestation: String? public let delegationGeneration: Int64 + /// Durable marker that the journaled delegation revocation completed remotely. + public let delegationRevoked: Bool? public init( gatewayOrigin: String, @@ -33,7 +35,8 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { challenge: String? = nil, keyId: String? = nil, attestation: String? = nil, - delegationGeneration: Int64 = 0 + delegationGeneration: Int64 = 0, + delegationRevoked: Bool? = nil ) { self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin @@ -49,6 +52,7 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { self.keyId = keyId self.attestation = attestation self.delegationGeneration = delegationGeneration + self.delegationRevoked = delegationRevoked } func withGatewayInstallationHandle(_ handle: String) -> Self { @@ -66,7 +70,28 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { challenge: challenge, keyId: keyId, attestation: attestation, - delegationGeneration: delegationGeneration + delegationGeneration: delegationGeneration, + delegationRevoked: delegationRevoked + ) + } + + func withDelegationRevoked() -> Self { + Self( + gatewayOrigin: gatewayOrigin, + relayOrigin: relayOrigin, + relayPubkey: relayPubkey, + endpoint: endpoint, + endpointHash: endpointHash, + appProfile: appProfile, + expiresAt: expiresAt, + installationId: installationId, + gatewayInstallationHandle: gatewayInstallationHandle, + challengeId: challengeId, + challenge: challenge, + keyId: keyId, + attestation: attestation, + delegationGeneration: delegationGeneration, + delegationRevoked: true ) } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 23f32420bc2..f33bb0554e0 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -727,7 +727,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) let store = MemoryGrantStore( records: [unrelatedHigherGeneration, siblingOrigin, existing], - pending: [pending] + pending: [pending], + pendingRemoveFailuresRemaining: 1 ) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) var challengeRequests = 0 @@ -775,6 +776,19 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } } + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected the injected pending-journal deletion failure") + } catch let error as NSError where error.domain == "MemoryGrantStore" && error.code == 3 { + // The completed revocation remains journaled for a local-only retry. + } + + XCTAssertEqual(store.saved, [unrelatedHigherGeneration]) + XCTAssertEqual(store.pending.first?.delegationRevoked, true) + do { _ = try await driver.enroll( deviceToken: Data((1...32).map(UInt8.init)), @@ -2068,17 +2082,20 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int var cleanupSaveFailureCalls: Set + var pendingRemoveFailuresRemaining: Int private var cleanupSaveCallCount = 0 init( records: [BuzzPushEndpointGrantRecord] = [], pending: [BuzzPushPendingEnrollmentRecord] = [], grantSaveFailuresRemaining: Int = 0, - cleanupSaveFailureCalls: Set = [] + cleanupSaveFailureCalls: Set = [], + pendingRemoveFailuresRemaining: Int = 0 ) { saved = records self.pending = pending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining self.cleanupSaveFailureCalls = cleanupSaveFailureCalls + self.pendingRemoveFailuresRemaining = pendingRemoveFailuresRemaining } func reset(forGatewayOrigin gatewayOrigin: String) throws { try BuzzPushGatewayStateReset.run( @@ -2155,6 +2172,10 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { relayOrigin: String, appProfile: String ) throws { + if pendingRemoveFailuresRemaining > 0 { + pendingRemoveFailuresRemaining -= 1 + throw NSError(domain: "MemoryGrantStore", code: 3) + } pending.removeAll { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin && $0.appProfile == appProfile From bb75c43ac40889c0f2c4c54f5ca7b011c14b179c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 19:40:05 -0700 Subject: [PATCH 38/67] Reject ported push delivery URLs Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-relay/src/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e50774c93bb..5d831b3f651 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -455,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" @@ -462,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(), )); } @@ -2260,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", From bf39df6d592d06be3d032f25ac87940ac58f44c7 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 19:52:48 -0700 Subject: [PATCH 39/67] Recover legacy push authority state Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/authority.rs | 55 +++++++++++- crates/buzz-push-gateway/src/http.rs | 18 ++-- crates/buzz-push-gateway/src/postgres.rs | 38 ++++++++- .../BuzzPushLegacyStateMigration.swift | 82 ++++++++++++++++++ .../BuzzDevPushEnrollmentDriverTests.swift | 34 ++++++++ .../ios/Runner/PushEndpointGrantStore.swift | 84 ++++++++++++++++++- 6 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 8e5d0673aa7..db6fab0db07 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -140,6 +140,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, @@ -324,6 +331,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], @@ -362,7 +383,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; @@ -474,6 +495,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); } @@ -886,4 +910,33 @@ 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"); + assert_eq!( + store.revoke_installation(id, 2, 3).await, + Err(AuthorityError::Rejected) + ); + } } diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index c6899105e33..8e343c43a3c 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -283,15 +283,19 @@ async fn verify_installation_assertion( assertion: &str, domain: &str, signed: &T, + include_revoked: bool, ) -> Result<(), Response> { let now = (s.now)(); let challenge = 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")); } @@ -372,6 +376,7 @@ async fn delegate(State(s): State, body: Bytes) -> Response { &r.assertion, "buzz.push.delegate.v1", &t, + false, ) .await { @@ -464,6 +469,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { &r.assertion, "buzz.push.rotate-endpoint.v1", &t, + false, ) .await { @@ -523,6 +529,7 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { &r.assertion, "buzz.push.revoke-delegation.v1", &t, + false, ) .await { @@ -575,6 +582,7 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response &r.assertion, "buzz.push.revoke-installation.v1", &t, + true, ) .await { diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 47044818843..8a643bdb387 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -230,6 +230,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 +309,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); @@ -354,7 +385,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(()) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift new file mode 100644 index 00000000000..49b3be5d447 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Decodes the pre-gateway-origin Keychain schema into the canonical gateway profile. +public enum BuzzPushLegacyStateMigration { + private struct LegacyGrant: Decodable { + let relayOrigin: String + let relayPubkey: String + let relayMetadataPubkey: String? + let gatewayInstallationHandle: String? + let installationId: String + let endpointGrant: String + let endpointHash: String + let appProfile: String + let endpointEpoch: Int64 + let generation: Int64 + let expiresAt: Int64 + } + + private struct LegacyPending: Decodable { + let relayOrigin: String + let relayPubkey: String + let endpointHash: String + let appProfile: String + let expiresAt: Int64 + let installationId: String + let gatewayInstallationHandle: String? + let challengeId: String? + let challenge: String? + let keyId: String? + let attestation: String? + let delegationGeneration: Int64 + } + + /// Migrates legacy opaque grants without changing their canonical gateway authority. + public static func grants( + from data: Data, + gatewayOrigin: String, + appAttestKeyId: String + ) throws -> [BuzzPushEndpointGrantRecord] { + try JSONDecoder().decode([LegacyGrant].self, from: data).map { record in + BuzzPushEndpointGrantRecord( + gatewayOrigin: gatewayOrigin, + relayOrigin: record.relayOrigin, + relayPubkey: record.relayPubkey, + relayMetadataPubkey: record.relayMetadataPubkey, + gatewayInstallationHandle: record.gatewayInstallationHandle, + appAttestKeyId: appAttestKeyId, + installationId: record.installationId, + endpointGrant: record.endpointGrant, + endpointHash: record.endpointHash, + appProfile: record.appProfile, + endpointEpoch: record.endpointEpoch, + generation: record.generation, + expiresAt: record.expiresAt + ) + } + } + + /// Migrates legacy response-loss journals for later replay or cleanup. + public static func pendingEnrollments( + from data: Data, + gatewayOrigin: String + ) throws -> [BuzzPushPendingEnrollmentRecord] { + try JSONDecoder().decode([LegacyPending].self, from: data).map { pending in + BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, + relayPubkey: pending.relayPubkey, + endpointHash: pending.endpointHash, + appProfile: pending.appProfile, + expiresAt: pending.expiresAt, + installationId: pending.installationId, + gatewayInstallationHandle: pending.gatewayInstallationHandle, + challengeId: pending.challengeId, + challenge: pending.challenge, + keyId: pending.keyId, + attestation: pending.attestation, + delegationGeneration: pending.delegationGeneration + ) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index f33bb0554e0..7e499622c1c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -389,6 +389,40 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } + func testLegacyKeychainStateMigratesToCanonicalGatewayRecords() throws { + let grantData = Data( + """ + [{"relayOrigin":"wss://relay.example","relayPubkey":"\(Self.relayPubkey)","gatewayInstallationHandle":"\(Self.installationHandle)","installationId":"\(Self.installationId)","endpointGrant":"legacy-grant","endpointHash":"\(String(repeating: "b", count: 64))","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":\(Self.expiresAt)}] + """.utf8 + ) + let pendingData = Data( + """ + [{"relayOrigin":"wss://relay.example","relayPubkey":"\(Self.relayPubkey)","endpointHash":"\(String(repeating: "b", count: 64))","appProfile":"buzz-ios-dogfood","expiresAt":\(Self.expiresAt),"installationId":"\(Self.installationId)","gatewayInstallationHandle":"\(Self.installationHandle)","keyId":"\(Self.keyId)","delegationGeneration":2}] + """.utf8 + ) + + let grant = try XCTUnwrap( + BuzzPushLegacyStateMigration.grants( + from: grantData, + gatewayOrigin: "https://push.buzz.xyz", + appAttestKeyId: Self.keyId + ).first + ) + let pending = try XCTUnwrap( + BuzzPushLegacyStateMigration.pendingEnrollments( + from: pendingData, + gatewayOrigin: "https://push.buzz.xyz" + ).first + ) + + XCTAssertEqual(grant.gatewayOrigin, "https://push.buzz.xyz") + XCTAssertEqual(grant.appAttestKeyId, Self.keyId) + XCTAssertEqual(grant.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(pending.gatewayOrigin, "https://push.buzz.xyz") + XCTAssertEqual(pending.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(pending.delegationGeneration, 2) + } + 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}"# diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 375addcf0ec..c99543708f0 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -11,6 +11,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { private static let recordsAccount = "v2" private static let pendingAccount = "pending-v2" private static let cleanupAccount = "gateway-cleanup-v1" + private static let canonicalLegacyGatewayOrigin = "https://push.buzz.xyz" private let accessGroup: String? @@ -19,8 +20,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } func reset(forGatewayOrigin gatewayOrigin: String) throws { - try delete(account: Self.legacyRecordsAccount) - try delete(account: Self.legacyPendingAccount) + try migrateLegacyState() let allRecords = try records() let allPending = try pendingEnrollments() @@ -36,6 +36,51 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { ) } + private func migrateLegacyState() throws { + let legacyGrantData = try data(account: Self.legacyRecordsAccount) + let legacyPendingData = try data(account: Self.legacyPendingAccount) + if let legacyGrantData { + let keyId = try appAttestKeyId() + let legacyGrants = try BuzzPushLegacyStateMigration.grants( + from: legacyGrantData, + gatewayOrigin: Self.canonicalLegacyGatewayOrigin, + appAttestKeyId: keyId ?? "" + ) + guard legacyGrants.isEmpty || keyId != nil else { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Legacy grants require their App Attest key"] + ) + } + var migrated = try records() + for record in legacyGrants { + migrated.removeAll { + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile + } + migrated.append(record) + } + try replace(migrated, account: Self.recordsAccount) + } + if let legacyPendingData { + var migrated = try pendingEnrollments() + for pending in try BuzzPushLegacyStateMigration.pendingEnrollments( + from: legacyPendingData, + gatewayOrigin: Self.canonicalLegacyGatewayOrigin + ) { + migrated.removeAll { + $0.gatewayOrigin == pending.gatewayOrigin && $0.relayOrigin == pending.relayOrigin + && $0.appProfile == pending.appProfile + } + migrated.append(pending) + } + try replace(migrated, account: Self.pendingAccount) + } + try delete(account: Self.legacyRecordsAccount) + try delete(account: Self.legacyPendingAccount) + } + func records() throws -> [BuzzPushEndpointGrantRecord] { var query = baseQuery(account: Self.recordsAccount) query[kSecReturnData as String] = true @@ -171,6 +216,41 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } + private func data(account: String) throws -> Data? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read legacy state") + } + return data + } + + private func appAttestKeyId() throws -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "buzz.push.app-attest", + kSecAttrAccount as String: "key-id-v1", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data, + let keyId = String(data: data, encoding: .utf8), !keyId.isEmpty + else { + throw keychainError(status, operation: "read legacy App Attest key") + } + return keyId + } + private func replace(_ values: [T], account: String) throws { let data = try JSONEncoder().encode(values) let updateStatus = SecItemUpdate( From 048e88c33a6467c8db5d180af635b1b71ad7fa6f Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 20:01:52 -0700 Subject: [PATCH 40/67] Preserve configured legacy push origin Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzPushKit/BuzzPushLegacyStateMigration.swift | 4 ++-- .../BuzzDevPushEnrollmentDriverTests.swift | 10 +++++----- mobile/ios/Runner/PushEndpointGrantStore.swift | 9 ++++----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift index 49b3be5d447..8b234b85fc3 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift @@ -1,6 +1,6 @@ import Foundation -/// Decodes the pre-gateway-origin Keychain schema into the canonical gateway profile. +/// Decodes the pre-gateway-origin Keychain schema using the artifact's configured gateway. public enum BuzzPushLegacyStateMigration { private struct LegacyGrant: Decodable { let relayOrigin: String @@ -31,7 +31,7 @@ public enum BuzzPushLegacyStateMigration { let delegationGeneration: Int64 } - /// Migrates legacy opaque grants without changing their canonical gateway authority. + /// Migrates legacy opaque grants under the gateway configured by the upgrading artifact. public static func grants( from data: Data, gatewayOrigin: String, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 7e499622c1c..ef02fc26c2a 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -389,7 +389,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } - func testLegacyKeychainStateMigratesToCanonicalGatewayRecords() throws { + func testLegacyKeychainStateMigratesToConfiguredGatewayRecords() throws { let grantData = Data( """ [{"relayOrigin":"wss://relay.example","relayPubkey":"\(Self.relayPubkey)","gatewayInstallationHandle":"\(Self.installationHandle)","installationId":"\(Self.installationId)","endpointGrant":"legacy-grant","endpointHash":"\(String(repeating: "b", count: 64))","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":\(Self.expiresAt)}] @@ -404,21 +404,21 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let grant = try XCTUnwrap( BuzzPushLegacyStateMigration.grants( from: grantData, - gatewayOrigin: "https://push.buzz.xyz", + gatewayOrigin: "http://localhost:8080", appAttestKeyId: Self.keyId ).first ) let pending = try XCTUnwrap( BuzzPushLegacyStateMigration.pendingEnrollments( from: pendingData, - gatewayOrigin: "https://push.buzz.xyz" + gatewayOrigin: "http://localhost:8080" ).first ) - XCTAssertEqual(grant.gatewayOrigin, "https://push.buzz.xyz") + XCTAssertEqual(grant.gatewayOrigin, "http://localhost:8080") XCTAssertEqual(grant.appAttestKeyId, Self.keyId) XCTAssertEqual(grant.gatewayInstallationHandle, Self.installationHandle) - XCTAssertEqual(pending.gatewayOrigin, "https://push.buzz.xyz") + XCTAssertEqual(pending.gatewayOrigin, "http://localhost:8080") XCTAssertEqual(pending.gatewayInstallationHandle, Self.installationHandle) XCTAssertEqual(pending.delegationGeneration, 2) } diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index c99543708f0..117627985f6 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -11,7 +11,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { private static let recordsAccount = "v2" private static let pendingAccount = "pending-v2" private static let cleanupAccount = "gateway-cleanup-v1" - private static let canonicalLegacyGatewayOrigin = "https://push.buzz.xyz" private let accessGroup: String? @@ -20,7 +19,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } func reset(forGatewayOrigin gatewayOrigin: String) throws { - try migrateLegacyState() + try migrateLegacyState(gatewayOrigin: gatewayOrigin) let allRecords = try records() let allPending = try pendingEnrollments() @@ -36,14 +35,14 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { ) } - private func migrateLegacyState() throws { + private func migrateLegacyState(gatewayOrigin: String) throws { let legacyGrantData = try data(account: Self.legacyRecordsAccount) let legacyPendingData = try data(account: Self.legacyPendingAccount) if let legacyGrantData { let keyId = try appAttestKeyId() let legacyGrants = try BuzzPushLegacyStateMigration.grants( from: legacyGrantData, - gatewayOrigin: Self.canonicalLegacyGatewayOrigin, + gatewayOrigin: gatewayOrigin, appAttestKeyId: keyId ?? "" ) guard legacyGrants.isEmpty || keyId != nil else { @@ -67,7 +66,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { var migrated = try pendingEnrollments() for pending in try BuzzPushLegacyStateMigration.pendingEnrollments( from: legacyPendingData, - gatewayOrigin: Self.canonicalLegacyGatewayOrigin + gatewayOrigin: gatewayOrigin ) { migrated.removeAll { $0.gatewayOrigin == pending.gatewayOrigin && $0.relayOrigin == pending.relayOrigin From b0d04356e847a7e43b659ad2695dad5f2b3a37f1 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 20:09:27 -0700 Subject: [PATCH 41/67] Wait for APNs before legacy push cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 1 + .../BuzzDevPushEnrollmentDriverTests.swift | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 241bef61641..cea01d68d05 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -969,6 +969,7 @@ public final class BuzzDevPushEnrollmentDriver { } else if let currentEndpoint, pending.endpointHash == currentEndpointHash { replayEndpoint = currentEndpoint } else { + guard currentEndpoint != nil else { return false } // Pre-endpoint journals cannot be replayed after token rotation. They // have no known handle to revoke, so this cleanup item is terminal. continue diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index ef02fc26c2a..5a315b3ffd4 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -1005,6 +1005,81 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.cleanup.isEmpty) } + func testLegacyCleanupWaitsForAPNsTokenBeforeReplayingHashedEndpoint() async throws { + let token = Data(repeating: 0x07, count: 32) + let oldOrigin = "http://old-gateway.example" + let pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: oldOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + endpointHash: Self.hex(SHA256.hash(data: token)), + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + installationId: Self.installationId, + challengeId: Self.firstChallengeId, + challenge: Self.challenge, + keyId: Self.keyId, + attestation: Self.attestation + ) + let state = BuzzPushGatewayCleanupState( + gatewayOrigin: oldOrigin, + grants: [], + pendingEnrollments: [pending] + ) + let store = MemoryGrantStore() + store.cleanup = [state] + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + XCTFail("Cleanup must wait for APNs before requesting \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + + do { + try await driver.cleanRetiredGateways() + XCTFail("Expected endpoint-less legacy cleanup to remain queued") + } catch { + XCTAssertEqual( + error as? BuzzDevPushEnrollmentError, + .retiredGatewayCleanupIncomplete + ) + } + XCTAssertEqual(store.cleanup, [state]) + + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("POST", "http://old-gateway.example/v1/installations"): + XCTAssertEqual(try Self.body(request)["endpoint"] as? String, Self.hex(token)) + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://old-gateway.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://old-gateway.example/v1/installations/revoke"): + return Self.response(request, status: 200, json: ["status": "revoked"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + try await driver.cleanRetiredGateways(deviceToken: token) + XCTAssertTrue(store.cleanup.isEmpty) + } + func testFailedStaleGatewayRevocationKeepsCleanupJournal() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let stale = BuzzPushEndpointGrantRecord( From 9e08172a4903d37525d87b682d7ddacacce9f2db Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 2 Sep 2026 20:26:01 -0700 Subject: [PATCH 42/67] Migrate push leases before gateway cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzPushLegacyStateMigration.swift | 82 ----------- .../BuzzDevPushEnrollmentDriverTests.swift | 43 +----- mobile/ios/Runner/AppDelegate.swift | 56 ++++---- .../ios/Runner/PushEndpointGrantStore.swift | 81 +---------- mobile/lib/shared/push/push_bootstrap.dart | 133 +++++++++++++++++- mobile/lib/shared/push/push_bridge.dart | 30 +++- .../test/shared/push/push_bootstrap_test.dart | 27 ++++ mobile/test/shared/push/push_bridge_test.dart | 22 ++- 8 files changed, 241 insertions(+), 233 deletions(-) delete mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift deleted file mode 100644 index 8b234b85fc3..00000000000 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyStateMigration.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -/// Decodes the pre-gateway-origin Keychain schema using the artifact's configured gateway. -public enum BuzzPushLegacyStateMigration { - private struct LegacyGrant: Decodable { - let relayOrigin: String - let relayPubkey: String - let relayMetadataPubkey: String? - let gatewayInstallationHandle: String? - let installationId: String - let endpointGrant: String - let endpointHash: String - let appProfile: String - let endpointEpoch: Int64 - let generation: Int64 - let expiresAt: Int64 - } - - private struct LegacyPending: Decodable { - let relayOrigin: String - let relayPubkey: String - let endpointHash: String - let appProfile: String - let expiresAt: Int64 - let installationId: String - let gatewayInstallationHandle: String? - let challengeId: String? - let challenge: String? - let keyId: String? - let attestation: String? - let delegationGeneration: Int64 - } - - /// Migrates legacy opaque grants under the gateway configured by the upgrading artifact. - public static func grants( - from data: Data, - gatewayOrigin: String, - appAttestKeyId: String - ) throws -> [BuzzPushEndpointGrantRecord] { - try JSONDecoder().decode([LegacyGrant].self, from: data).map { record in - BuzzPushEndpointGrantRecord( - gatewayOrigin: gatewayOrigin, - relayOrigin: record.relayOrigin, - relayPubkey: record.relayPubkey, - relayMetadataPubkey: record.relayMetadataPubkey, - gatewayInstallationHandle: record.gatewayInstallationHandle, - appAttestKeyId: appAttestKeyId, - installationId: record.installationId, - endpointGrant: record.endpointGrant, - endpointHash: record.endpointHash, - appProfile: record.appProfile, - endpointEpoch: record.endpointEpoch, - generation: record.generation, - expiresAt: record.expiresAt - ) - } - } - - /// Migrates legacy response-loss journals for later replay or cleanup. - public static func pendingEnrollments( - from data: Data, - gatewayOrigin: String - ) throws -> [BuzzPushPendingEnrollmentRecord] { - try JSONDecoder().decode([LegacyPending].self, from: data).map { pending in - BuzzPushPendingEnrollmentRecord( - gatewayOrigin: gatewayOrigin, - relayOrigin: pending.relayOrigin, - relayPubkey: pending.relayPubkey, - endpointHash: pending.endpointHash, - appProfile: pending.appProfile, - expiresAt: pending.expiresAt, - installationId: pending.installationId, - gatewayInstallationHandle: pending.gatewayInstallationHandle, - challengeId: pending.challengeId, - challenge: pending.challenge, - keyId: pending.keyId, - attestation: pending.attestation, - delegationGeneration: pending.delegationGeneration - ) - } - } -} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 5a315b3ffd4..f4c02a4870c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -389,40 +389,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } - func testLegacyKeychainStateMigratesToConfiguredGatewayRecords() throws { - let grantData = Data( - """ - [{"relayOrigin":"wss://relay.example","relayPubkey":"\(Self.relayPubkey)","gatewayInstallationHandle":"\(Self.installationHandle)","installationId":"\(Self.installationId)","endpointGrant":"legacy-grant","endpointHash":"\(String(repeating: "b", count: 64))","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":\(Self.expiresAt)}] - """.utf8 - ) - let pendingData = Data( - """ - [{"relayOrigin":"wss://relay.example","relayPubkey":"\(Self.relayPubkey)","endpointHash":"\(String(repeating: "b", count: 64))","appProfile":"buzz-ios-dogfood","expiresAt":\(Self.expiresAt),"installationId":"\(Self.installationId)","gatewayInstallationHandle":"\(Self.installationHandle)","keyId":"\(Self.keyId)","delegationGeneration":2}] - """.utf8 - ) - - let grant = try XCTUnwrap( - BuzzPushLegacyStateMigration.grants( - from: grantData, - gatewayOrigin: "http://localhost:8080", - appAttestKeyId: Self.keyId - ).first - ) - let pending = try XCTUnwrap( - BuzzPushLegacyStateMigration.pendingEnrollments( - from: pendingData, - gatewayOrigin: "http://localhost:8080" - ).first - ) - - XCTAssertEqual(grant.gatewayOrigin, "http://localhost:8080") - XCTAssertEqual(grant.appAttestKeyId, Self.keyId) - XCTAssertEqual(grant.gatewayInstallationHandle, Self.installationHandle) - XCTAssertEqual(pending.gatewayOrigin, "http://localhost:8080") - XCTAssertEqual(pending.gatewayInstallationHandle, Self.installationHandle) - XCTAssertEqual(pending.delegationGeneration, 2) - } - 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}"# @@ -461,7 +427,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.saved, []) XCTAssertTrue(store.pending.isEmpty) - XCTAssertEqual(store.resetOperations, ["cleanup:https://old-gateway.example", "records", "pending"]) + XCTAssertEqual( + store.resetOperations, ["cleanup:https://old-gateway.example", "records", "pending"]) XCTAssertEqual( store.cleanup, [ @@ -933,7 +900,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.saved, [current]) XCTAssertTrue(store.cleanup.isEmpty) - XCTAssertEqual(Set(appAttest.assertionKeyIds.compactMap { $0 }), [staleKeyId, secondStaleKeyId]) + XCTAssertEqual( + Set(appAttest.assertionKeyIds.compactMap { $0 }), [staleKeyId, secondStaleKeyId]) } func testCleanupReplaysProtectedEndpointAfterCurrentTokenChanges() async throws { @@ -1030,7 +998,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { store.cleanup = [state] let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in - XCTFail("Cleanup must wait for APNs before requesting \(request.url?.absoluteString ?? "nil")") + XCTFail( + "Cleanup must wait for APNs before requesting \(request.url?.absoluteString ?? "nil")") return Self.response(request, status: 500, json: [:]) } diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index b1072204b10..fdfab6fcdd6 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -282,7 +282,6 @@ import os.log ) { super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) apnsDeviceToken = deviceToken - scheduleRetiredGatewayCleanup() apnsRegistrationBuffer.recordToken(deviceToken) } @@ -360,8 +359,7 @@ import os.log } Task { do { - try await initializePushGateway(gatewayURL) - result(nil) + result(try initializePushGateway(gatewayURL)) } catch { result( FlutterError( @@ -372,6 +370,23 @@ import os.log ) } } + case "completeGatewayMigration": + Task { + do { + if let cleanupTask = try retiredGatewayCleanupTask() { + try await cleanupTask.value + } + result(nil) + } catch { + result( + FlutterError( + code: "push_gateway_cleanup_failed", + message: "Retired push gateway cleanup failed.", + details: error.localizedDescription + ) + ) + } + } case "startRegistration": guard let gatewayURL = gatewayURL(from: call) else { result( @@ -385,7 +400,6 @@ import os.log } do { try configurePushGateway(gatewayURL) - scheduleRetiredGatewayCleanup() startPushRegistration(result: result) } catch { result( @@ -582,29 +596,6 @@ import os.log return task } - private func scheduleRetiredGatewayCleanup() { - do { - guard let task = try retiredGatewayCleanupTask() else { return } - Task { - do { - try await task.value - } catch { - os_log( - "Retired push gateway cleanup remains queued: %{public}@", - type: .error, - error.localizedDescription - ) - } - } - } catch { - os_log( - "Retired push gateway cleanup could not start: %{public}@", - type: .error, - error.localizedDescription - ) - } - } - private func gatewayURL(from call: FlutterMethodCall) -> URL? { guard let arguments = call.arguments as? [String: Any], let gatewayText = arguments["gatewayUrl"] as? String @@ -619,11 +610,14 @@ import os.log pushGatewayURL = gatewayOrigin.url } - private func initializePushGateway(_ gatewayURL: URL) async throws { + private func initializePushGateway(_ gatewayURL: URL) throws -> [String] { try configurePushGateway(gatewayURL) - if let cleanupTask = try retiredGatewayCleanupTask() { - try await cleanupTask.value - } + return Array( + Set( + try endpointGrantStore.gatewayCleanupStates() + .flatMap { $0.grants.map(\.relayOrigin) + $0.pendingEnrollments.map(\.relayOrigin) } + ) + ).sorted() } private func handleMediaUploadMethodCall( diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 117627985f6..005380aad93 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -19,8 +19,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } func reset(forGatewayOrigin gatewayOrigin: String) throws { - try migrateLegacyState(gatewayOrigin: gatewayOrigin) - let allRecords = try records() let allPending = try pendingEnrollments() try BuzzPushGatewayStateReset.run( @@ -35,49 +33,13 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { ) } - private func migrateLegacyState(gatewayOrigin: String) throws { - let legacyGrantData = try data(account: Self.legacyRecordsAccount) - let legacyPendingData = try data(account: Self.legacyPendingAccount) - if let legacyGrantData { - let keyId = try appAttestKeyId() - let legacyGrants = try BuzzPushLegacyStateMigration.grants( - from: legacyGrantData, - gatewayOrigin: gatewayOrigin, - appAttestKeyId: keyId ?? "" - ) - guard legacyGrants.isEmpty || keyId != nil else { - throw NSError( - domain: "BuzzPushEndpointGrantStore", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Legacy grants require their App Attest key"] - ) - } - var migrated = try records() - for record in legacyGrants { - migrated.removeAll { - $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin - && $0.appProfile == record.appProfile - } - migrated.append(record) - } - try replace(migrated, account: Self.recordsAccount) - } - if let legacyPendingData { - var migrated = try pendingEnrollments() - for pending in try BuzzPushLegacyStateMigration.pendingEnrollments( - from: legacyPendingData, - gatewayOrigin: gatewayOrigin - ) { - migrated.removeAll { - $0.gatewayOrigin == pending.gatewayOrigin && $0.relayOrigin == pending.relayOrigin - && $0.appProfile == pending.appProfile - } - migrated.append(pending) - } - try replace(migrated, account: Self.pendingAccount) - } - try delete(account: Self.legacyRecordsAccount) - try delete(account: Self.legacyPendingAccount) + /// Legacy records do not identify their gateway origin, and grants do not + /// identify the App Attest key that created their installation. Keep those + /// Keychain accounts as a durable quarantine rather than inventing authority + /// that could revoke an unrelated installation after a gateway change. + func hasQuarantinedLegacyState() throws -> Bool { + try data(account: Self.legacyRecordsAccount) != nil + || data(account: Self.legacyPendingAccount) != nil } func records() throws -> [BuzzPushEndpointGrantRecord] { @@ -228,28 +190,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { return data } - private func appAttestKeyId() throws -> String? { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "buzz.push.app-attest", - kSecAttrAccount as String: "key-id-v1", - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - if let accessGroup, !accessGroup.isEmpty { - query[kSecAttrAccessGroup as String] = accessGroup - } - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data, - let keyId = String(data: data, encoding: .utf8), !keyId.isEmpty - else { - throw keychainError(status, operation: "read legacy App Attest key") - } - return keyId - } - private func replace(_ values: [T], account: String) throws { let data = try JSONEncoder().encode(values) let updateStatus = SecItemUpdate( @@ -270,13 +210,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } - private func delete(account: String) throws { - let status = SecItemDelete(baseQuery(account: account) as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw keychainError(status, operation: "delete legacy state") - } - } - private func baseQuery(account: String) -> [String: Any] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index b1ff816d6a8..03aaa3f5836 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -102,6 +102,26 @@ bool buzzPushLifecycleEnabled({ required BuzzPushLeaseDescriptor? descriptor, }) => community?.pushNotificationsEnabled == true && descriptor != null; +@visibleForTesting +List buzzPushCommunitiesRequiringGatewayMigration({ + required List communities, + required Set retiredRelayOrigins, +}) => communities + .where( + (community) => + community.pushNotificationsEnabled && + retiredRelayOrigins.contains( + buzzPushRelayWebSocketOrigin(community.relayUrl), + ), + ) + .toList(); + +@visibleForTesting +String buzzPushRelayWebSocketOrigin(String relayUrl) { + final uri = Uri.parse(RelayConfig(baseUrl: relayUrl).wsUrl); + return uri.replace(path: '', query: null, fragment: null).toString(); +} + @visibleForTesting Future publishBuzzPushLeaseRecoverably({ required Future Function() reserveGeneration, @@ -124,18 +144,22 @@ class BuzzPushBootstrap extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); + useListenable(retiredBuzzPushRelayOrigins); final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); final gatewayInitializationAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); + final gatewayMigrationAttempt = useMemoized(BuzzPushAttemptGate.new); final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); final registrationRetry = useState(0); final gatewayInitializationRetry = useState(0); final gatewayInitializationFailures = useRef(0); final publicationRetry = useState(0); + final gatewayMigrationRetry = useState(0); final tombstoneRetry = useState(0); final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); final session = ref.watch(relaySessionProvider); - final communities = ref.watch(communityListProvider).value ?? const []; + final communitiesAsync = ref.watch(communityListProvider); + final communities = communitiesAsync.value ?? const []; final config = ref.watch(relayConfigProvider); final community = ref.watch(activeCommunityProvider).value; final memberPubkey = ref.watch(myPubkeyProvider); @@ -183,8 +207,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { debugPrint('Push gateway initialization failed: $error'); if (retryDelay == null) { debugPrint( - 'Push gateway cleanup is deferred until the next app launch ' - 'or APNs registration callback.', + 'Push gateway migration is deferred until the next app launch.', ); } debugPrintStack(stackTrace: stack); @@ -198,6 +221,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { registrationAttempt.dispose(); gatewayInitializationAttempt.dispose(); publicationAttempt.dispose(); + gatewayMigrationAttempt.dispose(); tombstoneAttempt.dispose(); }, const [], @@ -305,6 +329,52 @@ class BuzzPushBootstrap extends HookConsumerWidget { ); final token = apnsDeviceToken.value; + final retiredRelayOrigins = retiredBuzzPushRelayOrigins.value; + useEffect( + () { + if (token == null || + retiredRelayOrigins.isEmpty || + !communitiesAsync.hasValue) { + return null; + } + final attempt = [ + token, + ...retiredRelayOrigins.toList()..sort(), + ].join('|'); + if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; + unawaited(() async { + try { + for (final candidate + in buzzPushCommunitiesRequiringGatewayMigration( + communities: communities, + retiredRelayOrigins: retiredRelayOrigins, + )) { + await _publishCommunityReplacement(ref, candidate, communities); + } + await completeBuzzPushGatewayMigration(); + gatewayMigrationAttempt.complete(attempt); + } catch (error, stack) { + gatewayMigrationAttempt.failed( + attempt, + retry: () { + if (context.mounted) gatewayMigrationRetry.value += 1; + }, + ); + debugPrint('Push gateway migration failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [ + token, + retiredRelayOrigins, + communitiesAsync.hasValue, + communities, + gatewayMigrationRetry.value, + ], + ); + useEffect( () { if (!_ready(session, config, community, memberPubkey) || @@ -312,7 +382,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { community: community, descriptor: descriptor, ) || - token == null) { + token == null || + retiredRelayOrigins.isNotEmpty) { return null; } final activeCommunity = community!; @@ -376,6 +447,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { memberPubkey, descriptor, token, + retiredRelayOrigins, publicationRetry.value, ], ); @@ -437,6 +509,59 @@ class BuzzPushBootstrap extends HookConsumerWidget { ); return grant; } + + static Future _publishCommunityReplacement( + WidgetRef ref, + Community community, + List communities, + ) async { + final config = RelayConfig( + baseUrl: community.relayUrl, + nsec: community.nsec, + ); + final nsec = config.nsec; + final memberPubkey = pubkeyFromNsec(nsec); + if (nsec == null || nsec.isEmpty || memberPubkey == null) { + throw StateError( + 'Cannot migrate push for ${community.id}: signing key is unavailable', + ); + } + final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final grant = await enrollBuzzPush( + config.wsUrl, + Env.pushGatewayUrl, + communitiesForSnapshotRefresh: communities, + ); + final notifier = ref.read(communityListProvider.notifier); + await publishBuzzPushLeaseRecoverably( + reserveGeneration: () => + notifier.reservePushLeaseGeneration(community.id), + publish: (generation) => publishBuzzDevPushLease( + grant: grant, + leaseInstallationId: community.pushLeaseInstallationId, + leaseGeneration: generation, + descriptor: descriptor, + nsec: nsec, + memberPubkey: memberPubkey, + subscriptions: community.pushSubscriptionState.desired, + submit: ({required kind, required content, required tags, createdAt}) => + submitSignedEventOnce( + wsUrl: config.wsUrl, + nsec: nsec, + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + ), + ), + markAccepted: (generation) => notifier.markPushLeaseAccepted( + community.id, + subscriptions: community.pushSubscriptionState.desired, + generation: generation, + ), + ); + return grant; + } } void _runRevocationOutbox(Future Function() operation) { diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 5752b92cbe4..d811717e453 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -94,6 +94,7 @@ final apnsRegistrationError = ValueNotifier(null); final pushEndpointGrants = ValueNotifier>([]); final pushEndpointGrantError = ValueNotifier(null); +final retiredBuzzPushRelayOrigins = ValueNotifier>(const {}); /// The most recent notification response waiting for app navigation. /// @@ -136,14 +137,35 @@ Future syncPendingBuzzPushNotificationResponse() async { } } -/// Initializes native gateway migration and cleanup without requiring a relay, -/// notification authorization, or an APNs device token. -Future initializeBuzzPushGateway() async { +/// Inventories durable retired-gateway state without revoking it. +/// +/// Replacement publication and the explicit cleanup completion seam remain +/// separate so every enabled community can migrate first. +Future> initializeBuzzPushGateway() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return const {}; + try { + final origins = await _channel.invokeListMethod( + 'initializeGateway', + {'gatewayUrl': Env.pushGatewayUrl}, + ); + final pending = origins?.toSet() ?? const {}; + retiredBuzzPushRelayOrigins.value = pending; + return pending; + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + return const {}; + } +} + +/// Revokes retired gateway authority after every affected enabled community +/// has durably published its replacement lease. +Future completeBuzzPushGatewayMigration() async { if (defaultTargetPlatform != TargetPlatform.iOS) return; try { - await _channel.invokeMethod('initializeGateway', { + await _channel.invokeMethod('completeGatewayMigration', { 'gatewayUrl': Env.pushGatewayUrl, }); + retiredBuzzPushRelayOrigins.value = const {}; } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. } diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 180c22d57c6..9ba508b6633 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -138,6 +138,33 @@ void main() { ); }); + test('gateway migration includes inactive enabled communities', () { + final active = Community.create( + name: 'Active', + relayUrl: 'https://active.example/path', + ).copyWith(pushNotificationsEnabled: true); + final inactive = Community.create( + name: 'Inactive', + relayUrl: 'wss://inactive.example', + ).copyWith(pushNotificationsEnabled: true); + final disabled = Community.create( + name: 'Disabled', + relayUrl: 'wss://disabled.example', + ); + + expect( + buzzPushCommunitiesRequiringGatewayMigration( + communities: [active, inactive, disabled], + retiredRelayOrigins: const { + 'wss://active.example', + 'wss://inactive.example', + 'wss://disabled.example', + }, + ).map((community) => community.name), + ['Active', 'Inactive'], + ); + }); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 887596468e2..c0e195190ff 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -21,6 +21,7 @@ void main() { apnsRegistrationError.value = null; pushEndpointGrants.value = const []; pushEndpointGrantError.value = null; + retiredBuzzPushRelayOrigins.value = const {}; pushCommunitySnapshotError.value = null; pendingPushNotificationLink.value = null; installBuzzPushMethodHandler(); @@ -54,10 +55,29 @@ void main() { .setMockMethodCallHandler(_channel, (call) async { expect(call.method, 'initializeGateway'); expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); + return ['wss://old-relay.example']; + }); + + expect(await initializeBuzzPushGateway(), {'wss://old-relay.example'}); + expect(retiredBuzzPushRelayOrigins.value, {'wss://old-relay.example'}); + }, + ); + + test( + 'completes retired gateway cleanup only through the explicit seam', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + retiredBuzzPushRelayOrigins.value = {'wss://old-relay.example'}; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'completeGatewayMigration'); + expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); return null; }); - await initializeBuzzPushGateway(); + await completeBuzzPushGatewayMigration(); + + expect(retiredBuzzPushRelayOrigins.value, isEmpty); }, ); From 338c706fd86ba67fcdf16112d2d0a9c1aa305590 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 08:18:11 -0700 Subject: [PATCH 43/67] Checkpoint push migration retries Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 110 +++++++++------- .../BuzzDevPushEnrollmentDriverTests.swift | 120 +++++++++++++++--- .../ios/Runner/PushEndpointGrantStore.swift | 9 ++ .../shared/community/community_provider.dart | 2 + mobile/lib/shared/push/push_bootstrap.dart | 51 +++++++- mobile/lib/shared/push/push_subscription.dart | 25 ++++ .../test/shared/push/push_bootstrap_test.dart | 26 ++++ .../shared/push/push_subscription_test.dart | 7 +- 8 files changed, 283 insertions(+), 67 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index cea01d68d05..7b6e030c829 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -93,10 +93,12 @@ public struct BuzzPushGatewayCleanupState: 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 { - /// Discards legacy records and moves records from other gateways into the cleanup journal. + /// Moves records from other gateways into the cleanup journal. func reset(forGatewayOrigin gatewayOrigin: String) throws func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws + /// Atomically removes one relay-origin grant without touching sibling origins. + func removeRecord(gatewayOrigin: String, relayOrigin: String, appProfile: String) throws /// Atomically removes every active grant backed by one installation. func removeRecords(gatewayOrigin: String, installationHandle: String) throws /// Atomically removes every active grant backed by one delegation. @@ -505,47 +507,67 @@ public final class BuzzDevPushEnrollmentDriver { pending.endpointHash == endpointHash, let referencedInstallation { - let candidateGenerations = [ - pending.delegationGeneration, - referencedInstallation.generation, - ].filter { $0 > 0 }.reduce(into: [Int64]()) { generations, generation in - if !generations.contains(generation) { generations.append(generation) } + let siblingDelegationRecords = storedRecords.filter { + $0.gatewayOrigin == gatewayOrigin + && $0.gatewayInstallationHandle == handleText + && $0.relayPubkey == pending.relayPubkey + && $0.appProfile == pending.appProfile + && $0.relayOrigin != pending.relayOrigin } - var revoked = false - for generation in candidateGenerations { - do { - try await revokeDelegation( - installationHandle: handle, - relayPubkey: pending.relayPubkey, - generation: generation, - appAttestKeyId: keyId - ) - revoked = true - break - } catch BuzzDevPushEnrollmentError.unexpectedStatus( - route: "v1/delegations/revoke", _, actual: 404, _ - ) { - // The reserved generation may not have committed. Try the last - // generation known to have produced a durable grant as well. + if !siblingDelegationRecords.isEmpty { + // Delegation authority is shared by relay key, installation, and app + // profile. Keep it alive while another relay origin still uses it; + // only the rotating origin's obsolete grant is removed. + try store.removeRecord( + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, + appProfile: pending.appProfile + ) + } else { + let candidateGenerations = [ + pending.delegationGeneration, + referencedInstallation.generation, + ].filter { $0 > 0 }.reduce(into: [Int64]()) { generations, generation in + if !generations.contains(generation) { generations.append(generation) } } + var revoked = false + for generation in candidateGenerations { + do { + try await revokeDelegation( + installationHandle: handle, + relayPubkey: pending.relayPubkey, + generation: generation, + appAttestKeyId: keyId + ) + revoked = true + break + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/delegations/revoke", _, actual: 404, _ + ) { + // The reserved generation may not have committed. Try the last + // generation known to have produced a durable grant as well. + } + } + guard revoked else { + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } + try store.savePendingEnrollment(pending.withDelegationRevoked()) + try store.removeRecords( + gatewayOrigin: gatewayOrigin, + installationHandle: handleText, + relayPubkey: pending.relayPubkey + ) } - guard revoked else { - throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete - } - try store.savePendingEnrollment(pending.withDelegationRevoked()) - try store.removeRecords( - gatewayOrigin: gatewayOrigin, - installationHandle: handleText, - relayPubkey: pending.relayPubkey - ) } else { - var cleanupState = try store.gatewayCleanupStates().first { - $0.gatewayOrigin == gatewayOrigin - } ?? BuzzPushGatewayCleanupState( - gatewayOrigin: gatewayOrigin, - grants: [], - pendingEnrollments: [] - ) + var cleanupState = + try store.gatewayCleanupStates().first { + $0.gatewayOrigin == gatewayOrigin + } + ?? BuzzPushGatewayCleanupState( + gatewayOrigin: gatewayOrigin, + grants: [], + pendingEnrollments: [] + ) cleanupState.pendingEnrollments.removeAll { $0.relayOrigin == pending.relayOrigin && $0.appProfile == pending.appProfile } @@ -952,11 +974,13 @@ public final class BuzzDevPushEnrollmentDriver { for grant in state.grants { if grant.expiresAt <= nowSeconds { continue } guard let handle = grant.gatewayInstallationHandle else { return false } - guard mergeHandle( - handle, - endpointEpoch: grant.endpointEpoch, - keyId: grant.appAttestKeyId - ) else { return false } + guard + mergeHandle( + handle, + endpointEpoch: grant.endpointEpoch, + keyId: grant.appAttestKeyId + ) + else { return false } } for index in state.pendingEnrollments.indices { var pending = state.pendingEnrollments[index] diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index f4c02a4870c..45931c22cc7 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -711,23 +711,8 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { generation: 7, expiresAt: Self.expiresAt ) - let siblingOrigin = BuzzPushEndpointGrantRecord( - gatewayOrigin: Self.gatewayOrigin, - relayOrigin: "wss://sibling-relay.example", - relayPubkey: Self.relayPubkey, - relayMetadataPubkey: Self.relayPubkey, - gatewayInstallationHandle: Self.installationHandle, - appAttestKeyId: Self.keyId, - installationId: "303132333435363738393a3b3c3d3e3f", - endpointGrant: "sibling-origin-grant", - endpointHash: endpointHash, - appProfile: "buzz-ios-dogfood", - endpointEpoch: 1, - generation: 1, - expiresAt: Self.expiresAt - ) let store = MemoryGrantStore( - records: [unrelatedHigherGeneration, siblingOrigin, existing], + records: [unrelatedHigherGeneration, existing], pending: [pending], pendingRemoveFailuresRemaining: 1 ) @@ -809,6 +794,103 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.pending.first?.gatewayInstallationHandle, Self.installationHandle) } + func testRelayRotationPreservesDelegationUsedBySiblingOrigin() async throws { + let newRelayPubkey = String(repeating: "b", count: 64) + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let sibling = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://sibling.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: "303132333435363738393a3b3c3d3e3f", + endpointGrant: "sibling-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + endpoint: Self.endpoint, + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + installationId: Self.installationId, + gatewayInstallationHandle: Self.installationHandle, + keyId: Self.keyId, + delegationGeneration: 2 + ) + let store = MemoryGrantStore(records: [sibling, existing], pending: [pending]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": newRelayPubkey, + "push": ["keys": [["pubkey": newRelayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["relay_pubkey"] as? String, newRelayPubkey) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "replacement-grant"] + ) + case ("POST", "http://push.example/v1/delegations/revoke"): + XCTFail("A delegation still used by a sibling origin must not be revoked") + return Self.response(request, status: 500, json: [:]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let replacement = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(replacement.relayPubkey, newRelayPubkey) + XCTAssertTrue(store.saved.contains(sibling)) + XCTAssertEqual( + store.saved.filter { $0.relayOrigin == "wss://relay.example" }.map(\.relayPubkey), + [newRelayPubkey] + ) + } + func testCleanupRevokesAndDeletesStaleGatewaysWithoutRelayEnrollment() async throws { let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) let current = BuzzPushEndpointGrantRecord( @@ -2211,6 +2293,12 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { } saved.append(record) } + func removeRecord(gatewayOrigin: String, relayOrigin: String, appProfile: String) throws { + saved.removeAll { + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile + } + } func removeRecords(gatewayOrigin: String, installationHandle: String) throws { saved.removeAll { $0.gatewayOrigin == gatewayOrigin diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 005380aad93..1de881515c3 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -73,6 +73,15 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replace(all, account: Self.recordsAccount) } + func removeRecord(gatewayOrigin: String, relayOrigin: String, appProfile: String) throws { + var all = try records() + all.removeAll { + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile + } + try replace(all, account: Self.recordsAccount) + } + func removeRecords(gatewayOrigin: String, installationHandle: String) throws { var all = try records() all.removeAll { diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 7788082745e..0f0b9eeecd4 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -416,6 +416,7 @@ class CommunityListNotifier extends AsyncNotifier> { String id, { required List subscriptions, required int generation, + String? gatewayOrigin, }) => _serializePushMutation(() async { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); @@ -432,6 +433,7 @@ class CommunityListNotifier extends AsyncNotifier> { pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, generation: generation, + gatewayOrigin: gatewayOrigin, ), ); await storage.save(updated); diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 03aaa3f5836..78835a90d9e 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -106,13 +106,16 @@ bool buzzPushLifecycleEnabled({ List buzzPushCommunitiesRequiringGatewayMigration({ required List communities, required Set retiredRelayOrigins, + required String targetGatewayOrigin, }) => communities .where( (community) => community.pushNotificationsEnabled && retiredRelayOrigins.contains( buzzPushRelayWebSocketOrigin(community.relayUrl), - ), + ) && + community.pushSubscriptionState.acceptedGatewayOrigin != + targetGatewayOrigin, ) .toList(); @@ -122,6 +125,12 @@ String buzzPushRelayWebSocketOrigin(String relayUrl) { return uri.replace(path: '', query: null, fragment: null).toString(); } +@visibleForTesting +String buzzPushGatewayOrigin(String gatewayUrl) { + final uri = Uri.parse(gatewayUrl); + return uri.replace(path: '', query: null, fragment: null).toString(); +} + @visibleForTesting Future publishBuzzPushLeaseRecoverably({ required Future Function() reserveGeneration, @@ -155,6 +164,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { final gatewayInitializationFailures = useRef(0); final publicationRetry = useState(0); final gatewayMigrationRetry = useState(0); + final gatewayMigrationFailures = useRef(0); final tombstoneRetry = useState(0); final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); final session = ref.watch(relaySessionProvider); @@ -344,23 +354,47 @@ class BuzzPushBootstrap extends HookConsumerWidget { if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; unawaited(() async { try { + final targetGatewayOrigin = buzzPushGatewayOrigin( + Env.pushGatewayUrl, + ); for (final candidate in buzzPushCommunitiesRequiringGatewayMigration( communities: communities, retiredRelayOrigins: retiredRelayOrigins, + targetGatewayOrigin: targetGatewayOrigin, )) { - await _publishCommunityReplacement(ref, candidate, communities); + await _publishCommunityReplacement( + ref, + candidate, + communities, + targetGatewayOrigin, + ); } await completeBuzzPushGatewayMigration(); + gatewayMigrationFailures.value = 0; gatewayMigrationAttempt.complete(attempt); } catch (error, stack) { - gatewayMigrationAttempt.failed( - attempt, - retry: () { - if (context.mounted) gatewayMigrationRetry.value += 1; - }, + gatewayMigrationFailures.value += 1; + final retryDelay = buzzPushGatewayInitializationRetryDelay( + gatewayMigrationFailures.value, ); + if (retryDelay == null) { + gatewayMigrationAttempt.complete(attempt); + } else { + gatewayMigrationAttempt.retryAfter( + attempt, + delay: retryDelay, + retry: () { + if (context.mounted) gatewayMigrationRetry.value += 1; + }, + ); + } debugPrint('Push gateway migration failed: $error'); + if (retryDelay == null) { + debugPrint( + 'Push gateway migration remains durably queued for the next app launch.', + ); + } debugPrintStack(stackTrace: stack); } }()); @@ -505,6 +539,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { community.id, subscriptions: desired, generation: leaseGeneration, + gatewayOrigin: buzzPushGatewayOrigin(Env.pushGatewayUrl), ), ); return grant; @@ -514,6 +549,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { WidgetRef ref, Community community, List communities, + String targetGatewayOrigin, ) async { final config = RelayConfig( baseUrl: community.relayUrl, @@ -558,6 +594,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { community.id, subscriptions: community.pushSubscriptionState.desired, generation: generation, + gatewayOrigin: targetGatewayOrigin, ), ); return grant; diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 41ef1808fec..284b8b123f4 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -185,6 +185,9 @@ class BuzzPushLeaseSubscriptionState { /// Monotonic generation of the relay-facing kind-30350 lease. final int? acceptedGeneration; + /// Canonical gateway origin whose endpoint grant backs the accepted lease. + final String? acceptedGatewayOrigin; + /// Highest lease generation durably reserved by the client. This advances /// before relay publication so a relay commit followed by a local failure /// cannot make the next retry reuse a stale generation. @@ -201,6 +204,7 @@ class BuzzPushLeaseSubscriptionState { this.desired = const [], this.accepted, this.acceptedGeneration, + this.acceptedGatewayOrigin, this.generationCursor, this.pendingTombstoneGeneration, }) : assert( @@ -215,6 +219,7 @@ class BuzzPushLeaseSubscriptionState { required Iterable desired, required Iterable acceptedSubscriptions, required this.acceptedGeneration, + this.acceptedGatewayOrigin, this.generationCursor, this.pendingTombstoneGeneration, }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, @@ -260,6 +265,7 @@ class BuzzPushLeaseSubscriptionState { desired: updated, accepted: accepted, acceptedGeneration: acceptedGeneration, + acceptedGatewayOrigin: acceptedGatewayOrigin, generationCursor: generationCursor, pendingTombstoneGeneration: pendingTombstoneGeneration, ), @@ -268,6 +274,7 @@ class BuzzPushLeaseSubscriptionState { desired: updated, acceptedSubscriptions: accepted!, acceptedGeneration: acceptedGeneration, + acceptedGatewayOrigin: acceptedGatewayOrigin, generationCursor: generationCursor, pendingTombstoneGeneration: pendingTombstoneGeneration, ), @@ -277,10 +284,12 @@ class BuzzPushLeaseSubscriptionState { BuzzPushLeaseSubscriptionState withAccepted({ required Iterable subscriptions, required int generation, + String? gatewayOrigin, }) => BuzzPushLeaseSubscriptionState.accepted( desired: desired, acceptedSubscriptions: subscriptions, acceptedGeneration: generation, + acceptedGatewayOrigin: gatewayOrigin ?? acceptedGatewayOrigin, generationCursor: generationCursor == null || generation > generationCursor! ? generation : generationCursor, @@ -303,6 +312,7 @@ class BuzzPushLeaseSubscriptionState { desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration, + acceptedGatewayOrigin: acceptedGatewayOrigin, generationCursor: generation, pendingTombstoneGeneration: pendingTombstoneGeneration, ), @@ -311,6 +321,7 @@ class BuzzPushLeaseSubscriptionState { desired: desired, acceptedSubscriptions: accepted!, acceptedGeneration: acceptedGeneration, + acceptedGatewayOrigin: acceptedGatewayOrigin, generationCursor: generation, pendingTombstoneGeneration: pendingTombstoneGeneration, ), @@ -327,6 +338,7 @@ class BuzzPushLeaseSubscriptionState { desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration, + acceptedGatewayOrigin: acceptedGatewayOrigin, generationCursor: generation, pendingTombstoneGeneration: generation, ); @@ -343,6 +355,7 @@ class BuzzPushLeaseSubscriptionState { desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration, + acceptedGatewayOrigin: acceptedGatewayOrigin, generationCursor: generation, pendingTombstoneGeneration: generation, ); @@ -356,6 +369,7 @@ class BuzzPushLeaseSubscriptionState { return BuzzPushLeaseSubscriptionState.desired( desired: desired, acceptedGeneration: generation, + acceptedGatewayOrigin: acceptedGatewayOrigin, generationCursor: generationCursor == null || generation > generationCursor! ? generation @@ -369,6 +383,8 @@ class BuzzPushLeaseSubscriptionState { if (accepted != null) 'accepted': [for (final subscription in accepted!) subscription.toJson()], if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, + if (acceptedGatewayOrigin != null) + 'acceptedGatewayOrigin': acceptedGatewayOrigin, if (generationCursor != null) 'generationCursor': generationCursor, if (pendingTombstoneGeneration != null) 'pendingTombstoneGeneration': pendingTombstoneGeneration, @@ -380,6 +396,7 @@ class BuzzPushLeaseSubscriptionState { 'desired', 'accepted', 'acceptedGeneration', + 'acceptedGatewayOrigin', 'generationCursor', 'pendingTombstoneGeneration', }, 'push subscription state'); @@ -394,6 +411,7 @@ class BuzzPushLeaseSubscriptionState { ? null : _subscriptionList(acceptedRaw, 'accepted'); final acceptedGeneration = json['acceptedGeneration']; + final acceptedGatewayOrigin = json['acceptedGatewayOrigin']; final generationCursor = json['generationCursor']; final pendingTombstoneGeneration = json['pendingTombstoneGeneration']; if (acceptedGeneration != null && acceptedGeneration is! int) { @@ -401,6 +419,11 @@ class BuzzPushLeaseSubscriptionState { 'Accepted push lease generation must be an integer.', ); } + if (acceptedGatewayOrigin != null && acceptedGatewayOrigin is! String) { + throw const FormatException( + 'Accepted push gateway origin must be a string.', + ); + } if (generationCursor != null && generationCursor is! int) { throw const FormatException( 'Push lease generation cursor must be an integer.', @@ -426,6 +449,7 @@ class BuzzPushLeaseSubscriptionState { desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration as int?, + acceptedGatewayOrigin: acceptedGatewayOrigin as String?, generationCursor: generationCursor as int?, pendingTombstoneGeneration: pendingTombstoneGeneration as int?, ), @@ -434,6 +458,7 @@ class BuzzPushLeaseSubscriptionState { desired: desired, acceptedSubscriptions: accepted, acceptedGeneration: acceptedGeneration, + acceptedGatewayOrigin: acceptedGatewayOrigin as String?, generationCursor: generationCursor as int?, pendingTombstoneGeneration: pendingTombstoneGeneration as int?, ), diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 9ba508b6633..fa6f9318f56 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -160,11 +160,37 @@ void main() { 'wss://inactive.example', 'wss://disabled.example', }, + targetGatewayOrigin: 'https://push.example', ).map((community) => community.name), ['Active', 'Inactive'], ); }); + test('gateway migration skips a durably checkpointed replacement', () { + final community = + Community.create( + name: 'Migrated', + relayUrl: 'wss://relay.example', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.accepted( + desired: const [], + acceptedSubscriptions: const [], + acceptedGeneration: 2, + acceptedGatewayOrigin: 'https://push.example', + ), + ); + + expect( + buzzPushCommunitiesRequiringGatewayMigration( + communities: [community], + retiredRelayOrigins: const {'wss://relay.example'}, + targetGatewayOrigin: 'https://push.example', + ), + isEmpty, + ); + }); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 3ef21938e2a..1365f9cdc41 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -47,12 +47,17 @@ void main() { final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; final state = BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) - .withAccepted(subscriptions: [subscription], generation: 9) + .withAccepted( + subscriptions: [subscription], + generation: 9, + gatewayOrigin: 'https://push.example', + ) .withReservedGeneration(10); final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); expect(decoded.acceptedGeneration, 9); expect(decoded.generationCursor, 10); + expect(decoded.acceptedGatewayOrigin, 'https://push.example'); expect(decoded.toJson(), state.toJson()); }); From 2f8ad3f2d4325e60f42e60e0f6a24d6332ea9b05 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 08:37:37 -0700 Subject: [PATCH 44/67] Scope push migration publication fence Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/push/push_bootstrap.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 78835a90d9e..9210723bd57 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -340,6 +340,14 @@ class BuzzPushBootstrap extends HookConsumerWidget { final token = apnsDeviceToken.value; final retiredRelayOrigins = retiredBuzzPushRelayOrigins.value; + final targetGatewayOrigin = buzzPushGatewayOrigin(Env.pushGatewayUrl); + final activeCommunityAwaitingGatewayMigration = + community != null && + buzzPushCommunitiesRequiringGatewayMigration( + communities: [community], + retiredRelayOrigins: retiredRelayOrigins, + targetGatewayOrigin: targetGatewayOrigin, + ).isNotEmpty; useEffect( () { if (token == null || @@ -354,9 +362,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; unawaited(() async { try { - final targetGatewayOrigin = buzzPushGatewayOrigin( - Env.pushGatewayUrl, - ); for (final candidate in buzzPushCommunitiesRequiringGatewayMigration( communities: communities, @@ -417,7 +422,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { descriptor: descriptor, ) || token == null || - retiredRelayOrigins.isNotEmpty) { + activeCommunityAwaitingGatewayMigration) { return null; } final activeCommunity = community!; @@ -481,7 +486,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { memberPubkey, descriptor, token, - retiredRelayOrigins, + activeCommunityAwaitingGatewayMigration, publicationRetry.value, ], ); From 239623eb5fe74d12f5a85be75bd93360439f69ef Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 08:54:11 -0700 Subject: [PATCH 45/67] Preserve push replacements across endpoint rotation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 17 +++ .../BuzzDevPushEnrollmentDriverTests.swift | 10 ++ mobile/ios/Runner/AppDelegate.swift | 10 +- .../ios/Runner/PushEndpointGrantStore.swift | 15 +++ mobile/lib/shared/push/push_bootstrap.dart | 114 ++++++++++-------- mobile/lib/shared/push/push_bridge.dart | 6 + .../test/shared/push/push_bootstrap_test.dart | 31 +++++ mobile/test/shared/push/push_bridge_test.dart | 6 +- 8 files changed, 158 insertions(+), 51 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 7b6e030c829..5f6c0df903c 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -124,6 +124,13 @@ public protocol BuzzPushEndpointGrantStore { func saveGatewayCleanupState(_ state: BuzzPushGatewayCleanupState) throws /// Deletes a cleanup snapshot only after its installations are terminal. func removeGatewayCleanupState(gatewayOrigin: String) throws + /// Relay origins whose leases must be republished after shared installation + /// authority was revoked. The queue is persisted before the remote mutation. + func replacementRelayOrigins() throws -> [String] + /// Atomically merges relay origins into the durable replacement queue. + func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws + /// Clears the queue only after replacement publication has completed. + func clearReplacementRelayOrigins() throws } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -559,6 +566,16 @@ public final class BuzzDevPushEnrollmentDriver { ) } } else { + let replacementRelayOrigins: [String] = storedRecords.compactMap { record in + guard record.gatewayOrigin == gatewayOrigin, + record.gatewayInstallationHandle == pending.gatewayInstallationHandle + else { return nil } + return record.relayOrigin + } + // Revoking an installation invalidates every lease backed by its + // grants. Queue those relay origins before either local or remote + // authority is removed so a crash cannot strand sibling communities. + try store.queueReplacementRelayOrigins(replacementRelayOrigins) var cleanupState = try store.gatewayCleanupStates().first { $0.gatewayOrigin == gatewayOrigin diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 45931c22cc7..0cfdf467f3b 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -661,6 +661,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.saved.isEmpty) XCTAssertTrue(store.pending.isEmpty) XCTAssertTrue(store.cleanup.isEmpty) + XCTAssertEqual( + store.replacementOrigins, + ["wss://relay.example", "wss://shared-relay.example"] + ) } func testRelayRotationRevokesKnownCommittedGenerationWhenReservedGenerationDidNotCommit() @@ -2239,6 +2243,7 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] var cleanup: [BuzzPushGatewayCleanupState] = [] + var replacementOrigins: [String] = [] var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int var cleanupSaveFailureCalls: Set @@ -2359,6 +2364,11 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { func removeGatewayCleanupState(gatewayOrigin: String) throws { cleanup.removeAll { $0.gatewayOrigin == gatewayOrigin } } + func replacementRelayOrigins() throws -> [String] { replacementOrigins } + func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws { + replacementOrigins = Array(Set(replacementOrigins + relayOrigins)).sorted() + } + func clearReplacementRelayOrigins() throws { replacementOrigins = [] } } private final class RecordingAppAttest: BuzzDevAppAttesting { diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 939cfc00903..cadf41ae443 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -376,6 +376,7 @@ import os.log if let cleanupTask = try retiredGatewayCleanupTask() { try await cleanupTask.value } + try endpointGrantStore.clearReplacementRelayOrigins() result(nil) } catch { result( @@ -556,7 +557,9 @@ import os.log deviceToken: deviceToken, relayURL: relayURL ) - await MainActor.run { result(record.flutterArguments) } + var arguments = record.flutterArguments + arguments["migrationRelayOrigins"] = try self?.pushGatewayMigrationRelayOrigins() ?? [] + await MainActor.run { result(arguments) } } catch { await MainActor.run { result( @@ -612,10 +615,15 @@ import os.log private func initializePushGateway(_ gatewayURL: URL) throws -> [String] { try configurePushGateway(gatewayURL) + return try pushGatewayMigrationRelayOrigins() + } + + private func pushGatewayMigrationRelayOrigins() throws -> [String] { return Array( Set( try endpointGrantStore.gatewayCleanupStates() .flatMap { $0.grants.map(\.relayOrigin) + $0.pendingEnrollments.map(\.relayOrigin) } + + endpointGrantStore.replacementRelayOrigins() ) ).sorted() } diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 1de881515c3..aee3994aad8 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -11,6 +11,7 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { private static let recordsAccount = "v2" private static let pendingAccount = "pending-v2" private static let cleanupAccount = "gateway-cleanup-v1" + private static let replacementRelaysAccount = "replacement-relays-v1" private let accessGroup: String? @@ -165,6 +166,20 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replace(states, account: Self.cleanupAccount) } + func replacementRelayOrigins() throws -> [String] { + guard let data = try data(account: Self.replacementRelaysAccount) else { return [] } + return try JSONDecoder().decode([String].self, from: data) + } + + func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws { + let merged = Array(Set(try replacementRelayOrigins() + relayOrigins)).sorted() + try replace(merged, account: Self.replacementRelaysAccount) + } + + func clearReplacementRelayOrigins() throws { + try replace([String](), account: Self.replacementRelaysAccount) + } + private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] { var query = baseQuery(account: Self.pendingAccount) query[kSecReturnData as String] = true diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 9210723bd57..9b844c20eba 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -119,6 +119,51 @@ List buzzPushCommunitiesRequiringGatewayMigration({ ) .toList(); +/// Owns the APNs-registration side effect so migration-triggered registration +/// is exercised through the same production boundary as active-community +/// registration. +@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 String buzzPushRelayWebSocketOrigin(String relayUrl) { final uri = Uri.parse(RelayConfig(baseUrl: relayUrl).wsUrl); @@ -154,12 +199,10 @@ class BuzzPushBootstrap extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); useListenable(retiredBuzzPushRelayOrigins); - final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); final gatewayInitializationAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); final gatewayMigrationAttempt = useMemoized(BuzzPushAttemptGate.new); final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); - final registrationRetry = useState(0); final gatewayInitializationRetry = useState(0); final gatewayInitializationFailures = useRef(0); final publicationRetry = useState(0); @@ -174,6 +217,17 @@ class BuzzPushBootstrap extends HookConsumerWidget { final community = ref.watch(activeCommunityProvider).value; final memberPubkey = ref.watch(myPubkeyProvider); final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + final token = apnsDeviceToken.value; + final retiredRelayOrigins = retiredBuzzPushRelayOrigins.value; + final targetGatewayOrigin = buzzPushGatewayOrigin(Env.pushGatewayUrl); + final migrationCommunities = buzzPushCommunitiesRequiringGatewayMigration( + communities: communities, + retiredRelayOrigins: retiredRelayOrigins, + targetGatewayOrigin: targetGatewayOrigin, + ); + final activeLifecycleReady = + _ready(session, config, community, memberPubkey) && + buzzPushLifecycleEnabled(community: community, descriptor: descriptor); useEffect(() { final listener = AppLifecycleListener( @@ -228,7 +282,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { useEffect( () => () { - registrationAttempt.dispose(); gatewayInitializationAttempt.dispose(); publicationAttempt.dispose(); gatewayMigrationAttempt.dispose(); @@ -296,51 +349,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; - final retiredRelayOrigins = retiredBuzzPushRelayOrigins.value; - final targetGatewayOrigin = buzzPushGatewayOrigin(Env.pushGatewayUrl); final activeCommunityAwaitingGatewayMigration = community != null && buzzPushCommunitiesRequiringGatewayMigration( @@ -491,7 +499,15 @@ class BuzzPushBootstrap extends HookConsumerWidget { ], ); - return child; + return BuzzPushRegistrationBootstrap( + shouldRegister: activeLifecycleReady || migrationCommunities.isNotEmpty, + attemptKey: [ + if (activeLifecycleReady) 'active:${community!.id}|${config.baseUrl}', + if (migrationCommunities.isNotEmpty) + 'migration:${migrationCommunities.map((candidate) => candidate.id).join(',')}', + ].join('|'), + child: child, + ); } static bool _ready( diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index d811717e453..1f35a467084 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -257,6 +257,12 @@ Future enrollBuzzPush( if (raw == null) { throw StateError('Native push enrollment returned no grant.'); } + final migrationRelayOrigins = raw['migrationRelayOrigins']; + if (migrationRelayOrigins is List) { + retiredBuzzPushRelayOrigins.value = migrationRelayOrigins + .cast() + .toSet(); + } final grant = BuzzPushEndpointGrant.fromMap(raw); await readBuzzPushEndpointGrants(); if (communitiesForSnapshotRefresh != null) { diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index fa6f9318f56..f440961344d 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -2,6 +2,7 @@ import 'package:buzz/shared/push/dev_push_lease.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/push/push_bootstrap.dart'; import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -166,6 +167,36 @@ void main() { ); }); + testWidgets( + 'inactive migration work starts APNs registration through production boundary', + (tester) async { + final inactive = Community.create( + name: 'Inactive', + relayUrl: 'wss://inactive.example', + ).copyWith(pushNotificationsEnabled: true); + final migrationCommunities = buzzPushCommunitiesRequiringGatewayMigration( + communities: [inactive], + retiredRelayOrigins: const {'wss://inactive.example'}, + targetGatewayOrigin: 'https://push.example', + ); + var registrations = 0; + + await tester.pumpWidget( + MaterialApp( + home: BuzzPushRegistrationBootstrap( + shouldRegister: migrationCommunities.isNotEmpty, + attemptKey: 'migration:${inactive.id}', + startRegistration: () async => registrations += 1, + child: const SizedBox(), + ), + ), + ); + await tester.pump(); + + expect(registrations, 1); + }, + ); + test('gateway migration skips a durably checkpointed replacement', () { final community = Community.create( diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index c0e195190ff..75125e59024 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -213,7 +213,10 @@ void main() { methods.add(call.method); if (call.method == 'enrollPush') { enrollmentArguments.add(call.arguments); - return _grantMap('new-grant'); + return { + ..._grantMap('new-grant'), + 'migrationRelayOrigins': ['wss://sibling.example'], + }; } if (call.method == 'endpointGrants') { return [_grantMap('new-grant')]; @@ -246,6 +249,7 @@ void main() { expect(firstGrant.endpointGrant, 'new-grant'); expect(secondGrant.endpointGrant, 'new-grant'); + expect(retiredBuzzPushRelayOrigins.value, {'wss://sibling.example'}); expect(enrollmentArguments, [ { 'relayUrl': 'wss://relay.example/', From 76b2e329df166be2f54d9250e5f0e951cd277e28 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 09:02:55 -0700 Subject: [PATCH 46/67] Distinguish same-gateway push replacements Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/ios/Runner/AppDelegate.swift | 17 +++++++---- mobile/lib/shared/push/push_bootstrap.dart | 29 ++++++++++++++----- mobile/lib/shared/push/push_bridge.dart | 27 ++++++++++------- .../test/shared/push/push_bootstrap_test.dart | 26 +++++++++++++++++ mobile/test/shared/push/push_bridge_test.dart | 21 +++++++++++--- 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index cadf41ae443..c702de26147 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -558,7 +558,9 @@ import os.log relayURL: relayURL ) var arguments = record.flutterArguments - arguments["migrationRelayOrigins"] = try self?.pushGatewayMigrationRelayOrigins() ?? [] + if let inventory = try self?.pushGatewayMigrationInventory() { + arguments.merge(inventory) { _, latest in latest } + } await MainActor.run { result(arguments) } } catch { await MainActor.run { @@ -613,19 +615,22 @@ import os.log pushGatewayURL = gatewayOrigin.url } - private func initializePushGateway(_ gatewayURL: URL) throws -> [String] { + private func initializePushGateway(_ gatewayURL: URL) throws -> [String: Any] { try configurePushGateway(gatewayURL) - return try pushGatewayMigrationRelayOrigins() + return try pushGatewayMigrationInventory() } - private func pushGatewayMigrationRelayOrigins() throws -> [String] { - return Array( + private func pushGatewayMigrationInventory() throws -> [String: Any] { + let retiredRelayOrigins = Array( Set( try endpointGrantStore.gatewayCleanupStates() .flatMap { $0.grants.map(\.relayOrigin) + $0.pendingEnrollments.map(\.relayOrigin) } - + endpointGrantStore.replacementRelayOrigins() ) ).sorted() + return [ + "retiredRelayOrigins": retiredRelayOrigins, + "replacementRelayOrigins": try endpointGrantStore.replacementRelayOrigins(), + ] } private func handleMediaUploadMethodCall( diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 9b844c20eba..0fb087be552 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -106,6 +106,7 @@ bool buzzPushLifecycleEnabled({ List buzzPushCommunitiesRequiringGatewayMigration({ required List communities, required Set retiredRelayOrigins, + Set replacementRelayOrigins = const {}, required String targetGatewayOrigin, }) => communities .where( @@ -114,8 +115,11 @@ List buzzPushCommunitiesRequiringGatewayMigration({ retiredRelayOrigins.contains( buzzPushRelayWebSocketOrigin(community.relayUrl), ) && - community.pushSubscriptionState.acceptedGatewayOrigin != - targetGatewayOrigin, + (replacementRelayOrigins.contains( + buzzPushRelayWebSocketOrigin(community.relayUrl), + ) || + community.pushSubscriptionState.acceptedGatewayOrigin != + targetGatewayOrigin), ) .toList(); @@ -199,6 +203,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); useListenable(retiredBuzzPushRelayOrigins); + useListenable(replacementBuzzPushRelayOrigins); final gatewayInitializationAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); final gatewayMigrationAttempt = useMemoized(BuzzPushAttemptGate.new); @@ -219,10 +224,15 @@ class BuzzPushBootstrap extends HookConsumerWidget { final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; final token = apnsDeviceToken.value; final retiredRelayOrigins = retiredBuzzPushRelayOrigins.value; + final replacementRelayOrigins = replacementBuzzPushRelayOrigins.value; + final migrationRelayOrigins = retiredRelayOrigins.union( + replacementRelayOrigins, + ); final targetGatewayOrigin = buzzPushGatewayOrigin(Env.pushGatewayUrl); final migrationCommunities = buzzPushCommunitiesRequiringGatewayMigration( communities: communities, - retiredRelayOrigins: retiredRelayOrigins, + retiredRelayOrigins: migrationRelayOrigins, + replacementRelayOrigins: replacementRelayOrigins, targetGatewayOrigin: targetGatewayOrigin, ); final activeLifecycleReady = @@ -353,19 +363,20 @@ class BuzzPushBootstrap extends HookConsumerWidget { community != null && buzzPushCommunitiesRequiringGatewayMigration( communities: [community], - retiredRelayOrigins: retiredRelayOrigins, + retiredRelayOrigins: migrationRelayOrigins, + replacementRelayOrigins: replacementRelayOrigins, targetGatewayOrigin: targetGatewayOrigin, ).isNotEmpty; useEffect( () { if (token == null || - retiredRelayOrigins.isEmpty || + migrationRelayOrigins.isEmpty || !communitiesAsync.hasValue) { return null; } final attempt = [ token, - ...retiredRelayOrigins.toList()..sort(), + ...migrationRelayOrigins.toList()..sort(), ].join('|'); if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; unawaited(() async { @@ -373,7 +384,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { for (final candidate in buzzPushCommunitiesRequiringGatewayMigration( communities: communities, - retiredRelayOrigins: retiredRelayOrigins, + retiredRelayOrigins: migrationRelayOrigins, + replacementRelayOrigins: replacementRelayOrigins, targetGatewayOrigin: targetGatewayOrigin, )) { await _publishCommunityReplacement( @@ -415,7 +427,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { }, [ token, - retiredRelayOrigins, + migrationRelayOrigins, + replacementRelayOrigins, communitiesAsync.hasValue, communities, gatewayMigrationRetry.value, diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 1f35a467084..227cba0cd99 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -95,6 +95,7 @@ final apnsRegistrationError = ValueNotifier(null); final pushEndpointGrants = ValueNotifier>([]); final pushEndpointGrantError = ValueNotifier(null); final retiredBuzzPushRelayOrigins = ValueNotifier>(const {}); +final replacementBuzzPushRelayOrigins = ValueNotifier>(const {}); /// The most recent notification response waiting for app navigation. /// @@ -144,13 +145,15 @@ Future syncPendingBuzzPushNotificationResponse() async { Future> initializeBuzzPushGateway() async { if (defaultTargetPlatform != TargetPlatform.iOS) return const {}; try { - final origins = await _channel.invokeListMethod( + final inventory = await _channel.invokeMapMethod( 'initializeGateway', {'gatewayUrl': Env.pushGatewayUrl}, ); - final pending = origins?.toSet() ?? const {}; - retiredBuzzPushRelayOrigins.value = pending; - return pending; + final retired = _relayOriginSet(inventory?['retiredRelayOrigins']); + final replacements = _relayOriginSet(inventory?['replacementRelayOrigins']); + retiredBuzzPushRelayOrigins.value = retired; + replacementBuzzPushRelayOrigins.value = replacements; + return retired.union(replacements); } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. return const {}; @@ -166,6 +169,7 @@ Future completeBuzzPushGatewayMigration() async { 'gatewayUrl': Env.pushGatewayUrl, }); retiredBuzzPushRelayOrigins.value = const {}; + replacementBuzzPushRelayOrigins.value = const {}; } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. } @@ -257,12 +261,12 @@ Future enrollBuzzPush( if (raw == null) { throw StateError('Native push enrollment returned no grant.'); } - final migrationRelayOrigins = raw['migrationRelayOrigins']; - if (migrationRelayOrigins is List) { - retiredBuzzPushRelayOrigins.value = migrationRelayOrigins - .cast() - .toSet(); - } + retiredBuzzPushRelayOrigins.value = _relayOriginSet( + raw['retiredRelayOrigins'], + ); + replacementBuzzPushRelayOrigins.value = _relayOriginSet( + raw['replacementRelayOrigins'], + ); final grant = BuzzPushEndpointGrant.fromMap(raw); await readBuzzPushEndpointGrants(); if (communitiesForSnapshotRefresh != null) { @@ -276,6 +280,9 @@ Future enrollBuzzPush( return grant; } +Set _relayOriginSet(Object? value) => + value is List ? value.cast().toSet() : const {}; + /// Latest failure to export the community snapshot used by the iOS /// notification service extension. Snapshot export is push enrichment and must /// never gate authentication or community persistence. diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index f440961344d..93ce6bb4069 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -222,6 +222,32 @@ void main() { ); }); + test('same-gateway rotation forces a durably checkpointed replacement', () { + final community = + Community.create( + name: 'Rotated', + relayUrl: 'wss://relay.example', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.accepted( + desired: const [], + acceptedSubscriptions: const [], + acceptedGeneration: 2, + acceptedGatewayOrigin: 'https://push.example', + ), + ); + + expect( + buzzPushCommunitiesRequiringGatewayMigration( + communities: [community], + retiredRelayOrigins: const {'wss://relay.example'}, + replacementRelayOrigins: const {'wss://relay.example'}, + targetGatewayOrigin: 'https://push.example', + ), + [community], + ); + }); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 75125e59024..ef82177c537 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -22,6 +22,7 @@ void main() { pushEndpointGrants.value = const []; pushEndpointGrantError.value = null; retiredBuzzPushRelayOrigins.value = const {}; + replacementBuzzPushRelayOrigins.value = const {}; pushCommunitySnapshotError.value = null; pendingPushNotificationLink.value = null; installBuzzPushMethodHandler(); @@ -55,11 +56,20 @@ void main() { .setMockMethodCallHandler(_channel, (call) async { expect(call.method, 'initializeGateway'); expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); - return ['wss://old-relay.example']; + return { + 'retiredRelayOrigins': ['wss://old-relay.example'], + 'replacementRelayOrigins': ['wss://rotated-relay.example'], + }; }); - expect(await initializeBuzzPushGateway(), {'wss://old-relay.example'}); + expect(await initializeBuzzPushGateway(), { + 'wss://old-relay.example', + 'wss://rotated-relay.example', + }); expect(retiredBuzzPushRelayOrigins.value, {'wss://old-relay.example'}); + expect(replacementBuzzPushRelayOrigins.value, { + 'wss://rotated-relay.example', + }); }, ); @@ -78,6 +88,7 @@ void main() { await completeBuzzPushGatewayMigration(); expect(retiredBuzzPushRelayOrigins.value, isEmpty); + expect(replacementBuzzPushRelayOrigins.value, isEmpty); }, ); @@ -215,7 +226,8 @@ void main() { enrollmentArguments.add(call.arguments); return { ..._grantMap('new-grant'), - 'migrationRelayOrigins': ['wss://sibling.example'], + 'retiredRelayOrigins': ['wss://retired.example'], + 'replacementRelayOrigins': ['wss://sibling.example'], }; } if (call.method == 'endpointGrants') { @@ -249,7 +261,8 @@ void main() { expect(firstGrant.endpointGrant, 'new-grant'); expect(secondGrant.endpointGrant, 'new-grant'); - expect(retiredBuzzPushRelayOrigins.value, {'wss://sibling.example'}); + expect(retiredBuzzPushRelayOrigins.value, {'wss://retired.example'}); + expect(replacementBuzzPushRelayOrigins.value, {'wss://sibling.example'}); expect(enrollmentArguments, [ { 'relayUrl': 'wss://relay.example/', From 7b874a619bc957d1062ebde686879433f6e76d88 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 09:11:57 -0700 Subject: [PATCH 47/67] Fence push cleanup against newer migration work Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 9 +++++++-- .../BuzzDevPushEnrollmentDriverTests.swift | 1 + mobile/lib/shared/push/push_bootstrap.dart | 18 +++++++++++++++++- .../test/shared/push/push_bootstrap_test.dart | 2 ++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 5f6c0df903c..154b9436b9a 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -523,8 +523,13 @@ public final class BuzzDevPushEnrollmentDriver { } if !siblingDelegationRecords.isEmpty { // Delegation authority is shared by relay key, installation, and app - // profile. Keep it alive while another relay origin still uses it; - // only the rotating origin's obsolete grant is removed. + // profile. A response-lost higher generation may already have + // invalidated every sibling grant, so queue their relay origins + // before discarding the journal. Keep the delegation alive and + // remove only the rotating origin's obsolete grant. + try store.queueReplacementRelayOrigins( + siblingDelegationRecords.map(\.relayOrigin) + ) try store.removeRecord( gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 0cfdf467f3b..a9f1f408138 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -889,6 +889,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(replacement.relayPubkey, newRelayPubkey) XCTAssertTrue(store.saved.contains(sibling)) + XCTAssertEqual(store.replacementOrigins, ["wss://sibling.example"]) XCTAssertEqual( store.saved.filter { $0.relayOrigin == "wss://relay.example" }.map(\.relayPubkey), [newRelayPubkey] diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0fb087be552..d8eacced23a 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -45,6 +46,8 @@ class BuzzPushAttemptGate { return true; } + bool isCurrent(String attempt) => _attempt == attempt; + void failed(String attempt, {required VoidCallback retry}) { if (_attempt != attempt) return; _attempt = null; @@ -376,7 +379,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { } final attempt = [ token, - ...migrationRelayOrigins.toList()..sort(), + 'retired:${(retiredRelayOrigins.toList()..sort()).join(',')}', + 'replacement:${(replacementRelayOrigins.toList()..sort()).join(',')}', ].join('|'); if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; unawaited(() async { @@ -395,6 +399,18 @@ class BuzzPushBootstrap extends HookConsumerWidget { targetGatewayOrigin, ); } + final latestRetiredRelayOrigins = retiredBuzzPushRelayOrigins.value; + final latestReplacementRelayOrigins = + replacementBuzzPushRelayOrigins.value; + if (!gatewayMigrationAttempt.isCurrent(attempt) || + token != apnsDeviceToken.value || + !setEquals(retiredRelayOrigins, latestRetiredRelayOrigins) || + !setEquals( + replacementRelayOrigins, + latestReplacementRelayOrigins, + )) { + return; + } await completeBuzzPushGatewayMigration(); gatewayMigrationFailures.value = 0; gatewayMigrationAttempt.complete(attempt); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 93ce6bb4069..51402ab102a 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -52,6 +52,8 @@ void main() { await Future.delayed(Duration.zero); expect(retries, 0); + expect(gate.isCurrent('old'), isFalse); + expect(gate.isCurrent('new'), isTrue); expect(gate.tryBegin('new'), isFalse); }); From 29779e999c55084184c88c990c90d8c0f41f6ebf Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 09:22:57 -0700 Subject: [PATCH 48/67] Serialize push cleanup with enrollment Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 23 +++++ .../BuzzDevPushEnrollmentDriverTests.swift | 89 +++++++++++++++++++ mobile/ios/Runner/AppDelegate.swift | 64 +++++++++---- 3 files changed, 161 insertions(+), 15 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 154b9436b9a..ebda9ab7e30 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -824,6 +824,29 @@ public final class BuzzDevPushEnrollmentDriver { appProfile: Self.appProfile ) return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) + } catch let error as BuzzDevPushEnrollmentError { + guard + case .unexpectedStatus( + route: "v1/installations", _, actual: 404, _ + ) = error + else { throw error } + let cleanupStates = try store.gatewayCleanupStates() + guard !cleanupStates.isEmpty else { throw error } + let affectedRelayOrigins = cleanupStates.flatMap { + $0.grants.map(\.relayOrigin) + $0.pendingEnrollments.map(\.relayOrigin) + } + // A gateway origin can change while retaining the same backing + // authority store. Its live installation then conflicts with the new + // origin's enrollment. Preserve replacement work before retiring that + // authority, then retry against the released APNs token. + try store.queueReplacementRelayOrigins(affectedRelayOrigins) + try await cleanStaleGateways(deviceToken: deviceToken) + try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) } pending = BuzzPushPendingEnrollmentRecord( gatewayOrigin: gatewayOrigin, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index a9f1f408138..e2d12d5597f 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -1739,6 +1739,95 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 1) } + func testEnrollmentRetiresConflictingInstallationFromRenamedGateway() async throws { + let oldGatewayOrigin = "http://old-gateway.example" + let newGatewayURL = try XCTUnwrap(URL(string: "http://new-gateway.example")) + let stale = BuzzPushEndpointGrantRecord( + gatewayOrigin: oldGatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "stale-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() + store.cleanup = [ + BuzzPushGatewayCleanupState( + gatewayOrigin: oldGatewayOrigin, + grants: [stale], + pendingEnrollments: [] + ) + ] + let driver = try makeDriver( + gatewayBaseURL: newGatewayURL, + store: store, + appAttest: RecordingAppAttest() + ) + var installationAttempts = 0 + var revokedOldInstallation = false + 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://new-gateway.example/v1/installations/challenges"), + ("POST", "http://old-gateway.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://new-gateway.example/v1/installations"): + installationAttempts += 1 + if installationAttempts == 1 { + return Self.response(request, status: 404, json: ["error": "not_authorized"]) + } + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://old-gateway.example/v1/installations/revoke"): + revokedOldInstallation = true + return Self.response(request, status: 200, json: ["status": "revoked"]) + case ("POST", "http://new-gateway.example/v1/delegations"): + return Self.response(request, status: 201, json: ["endpoint_grant": "new-grant"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(installationAttempts, 2) + XCTAssertTrue(revokedOldInstallation) + XCTAssertTrue(store.cleanup.isEmpty) + XCTAssertEqual(store.replacementOrigins, ["wss://relay.example"]) + XCTAssertEqual(record.gatewayOrigin, "http://new-gateway.example") + XCTAssertEqual(record.endpointGrant, "new-grant") + } + func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c702de26147..d5c471597ea 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -371,22 +371,42 @@ import os.log } } case "completeGatewayMigration": - Task { - do { - if let cleanupTask = try retiredGatewayCleanupTask() { - try await cleanupTask.value - } - try endpointGrantStore.clearReplacementRelayOrigins() - result(nil) - } catch { - result( - FlutterError( - code: "push_gateway_cleanup_failed", - message: "Retired push gateway cleanup failed.", - details: error.localizedDescription - ) + guard enrollmentTask == nil else { + result( + FlutterError( + code: "push_enrollment_in_progress", + message: "Push enrollment must finish before gateway cleanup.", + details: nil ) + ) + return + } + do { + let cleanupTask = try retiredGatewayCleanupTask() + Task { + do { + if let cleanupTask { + try await cleanupTask.value + } + result(nil) + } catch { + result( + FlutterError( + code: "push_gateway_cleanup_failed", + message: "Retired push gateway cleanup failed.", + details: error.localizedDescription + ) + ) + } } + } catch { + result( + FlutterError( + code: "push_gateway_cleanup_failed", + message: "Retired push gateway cleanup failed.", + details: error.localizedDescription + ) + ) } case "startRegistration": guard let gatewayURL = gatewayURL(from: call) else { @@ -506,6 +526,16 @@ import os.log ) return } + guard gatewayCleanupTask == nil else { + result( + FlutterError( + code: "gateway_cleanup_in_progress", + message: "Gateway cleanup must finish before push enrollment.", + details: nil + ) + ) + return + } guard let deviceToken = apnsDeviceToken else { result( FlutterError( @@ -594,10 +624,14 @@ import os.log appAttestKeychainAccessGroup: pushKeychainAccessGroup ) let task = Task { [weak self] in - defer { self?.gatewayCleanupTask = nil } try await driver.cleanRetiredGateways(deviceToken: self?.apnsDeviceToken) + try self?.endpointGrantStore.clearReplacementRelayOrigins() } gatewayCleanupTask = task + Task { [weak self] in + _ = await task.result + self?.gatewayCleanupTask = nil + } return task } From 923544e759c0aad1985d40d7e20595eec1798094 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 09:37:14 -0700 Subject: [PATCH 49/67] Disambiguate push installation conflicts Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/authority.rs | 6 ++++-- crates/buzz-push-gateway/src/http.rs | 20 ++++++++++++++++++- crates/buzz-push-gateway/src/postgres.rs | 6 +++--- .../BuzzDevPushEnrollmentDriver.swift | 2 +- .../BuzzDevPushEnrollmentDriverTests.swift | 2 +- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index db6fab0db07..c87af649918 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")] @@ -291,7 +293,7 @@ impl AuthorityStore for MemoryAuthorityStore { .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); } for id in replaced { if let Some(old) = s.installations.remove(&id) { @@ -786,7 +788,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) diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 8e343c43a3c..ed24dc755c5 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -90,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()) @@ -234,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), } @@ -672,6 +676,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"); @@ -946,6 +955,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/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 8a643bdb387..c02070884b8 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -187,7 +187,7 @@ impl AuthorityStore for PostgresAuthorityStore { _ => true, } }) { - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } let replaced = existing .iter() @@ -208,7 +208,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(()) @@ -946,7 +946,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) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index ebda9ab7e30..75372e7791b 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -827,7 +827,7 @@ public final class BuzzDevPushEnrollmentDriver { } catch let error as BuzzDevPushEnrollmentError { guard case .unexpectedStatus( - route: "v1/installations", _, actual: 404, _ + route: "v1/installations", _, actual: 409, _ ) = error else { throw error } let cleanupStates = try store.gatewayCleanupStates() diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index e2d12d5597f..cdd0dafb7d1 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -1793,7 +1793,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { case ("POST", "http://new-gateway.example/v1/installations"): installationAttempts += 1 if installationAttempts == 1 { - return Self.response(request, status: 404, json: ["error": "not_authorized"]) + return Self.response(request, status: 409, json: ["error": "installation_conflict"]) } return Self.response( request, From 532c37d698ac63f4120e1f9f80a1d2fabec3bb8a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 09:54:38 -0700 Subject: [PATCH 50/67] Checkpoint completed push replacement origins Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 19 +++++- .../BuzzDevPushEnrollmentDriverTests.swift | 22 ++++++- mobile/ios/Runner/AppDelegate.swift | 35 ++++++++++- .../ios/Runner/PushEndpointGrantStore.swift | 55 +++++++++++++++--- mobile/lib/shared/push/push_bootstrap.dart | 58 ++++++++++++++----- mobile/lib/shared/push/push_bridge.dart | 29 ++++++++++ mobile/test/shared/push/push_bridge_test.dart | 51 ++++++++++++++++ 7 files changed, 244 insertions(+), 25 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 75372e7791b..be2c12ebe3d 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -90,6 +90,18 @@ public struct BuzzPushGatewayCleanupState: Codable, Equatable, Sendable { } } +/// Durable same-gateway lease replacement inventory with a compare-and-swap +/// generation that fences checkpoints from newer endpoint mutations. +public struct BuzzPushReplacementQueueState: Codable, Equatable, Sendable { + public let generation: Int64 + public var relayOrigins: [String] + + public init(generation: Int64, relayOrigins: [String]) { + self.generation = generation + self.relayOrigins = relayOrigins + } +} + /// 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 { @@ -126,9 +138,14 @@ public protocol BuzzPushEndpointGrantStore { func removeGatewayCleanupState(gatewayOrigin: String) throws /// Relay origins whose leases must be republished after shared installation /// authority was revoked. The queue is persisted before the remote mutation. - func replacementRelayOrigins() throws -> [String] + func replacementQueueState() throws -> BuzzPushReplacementQueueState /// Atomically merges relay origins into the durable replacement queue. func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws + /// Removes one relay origin after all of its community leases are durable. + func checkpointReplacementRelayOrigin( + _ relayOrigin: String, + expectedGeneration: Int64 + ) throws -> Bool /// Clears the queue only after replacement publication has completed. func clearReplacementRelayOrigins() throws } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index cdd0dafb7d1..dccb6103f9f 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -2334,6 +2334,7 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var pending: [BuzzPushPendingEnrollmentRecord] = [] var cleanup: [BuzzPushGatewayCleanupState] = [] var replacementOrigins: [String] = [] + var replacementGeneration: Int64 = 0 var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int var cleanupSaveFailureCalls: Set @@ -2454,11 +2455,28 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { func removeGatewayCleanupState(gatewayOrigin: String) throws { cleanup.removeAll { $0.gatewayOrigin == gatewayOrigin } } - func replacementRelayOrigins() throws -> [String] { replacementOrigins } + func replacementQueueState() throws -> BuzzPushReplacementQueueState { + BuzzPushReplacementQueueState( + generation: replacementGeneration, + relayOrigins: replacementOrigins + ) + } func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws { + replacementGeneration += 1 replacementOrigins = Array(Set(replacementOrigins + relayOrigins)).sorted() } - func clearReplacementRelayOrigins() throws { replacementOrigins = [] } + func checkpointReplacementRelayOrigin( + _ relayOrigin: String, + expectedGeneration: Int64 + ) throws -> Bool { + guard replacementGeneration == expectedGeneration else { return false } + replacementOrigins.removeAll { $0 == relayOrigin } + return true + } + func clearReplacementRelayOrigins() throws { + replacementGeneration += 1 + replacementOrigins = [] + } } private final class RecordingAppAttest: BuzzDevAppAttesting { diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index d5c471597ea..305ecc6f09a 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -408,6 +408,37 @@ import os.log ) ) } + case "checkpointGatewayReplacement": + guard let arguments = call.arguments as? [String: Any], + let relayOrigin = arguments["relayOrigin"] as? String, + !relayOrigin.isEmpty, + let generation = (arguments["generation"] as? NSNumber)?.int64Value + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Gateway replacement checkpoint requires relayOrigin.", + details: nil + ) + ) + return + } + do { + result( + try endpointGrantStore.checkpointReplacementRelayOrigin( + relayOrigin, + expectedGeneration: generation + ) + ) + } catch { + result( + FlutterError( + code: "push_gateway_checkpoint_failed", + message: "Push gateway replacement checkpoint failed.", + details: error.localizedDescription + ) + ) + } case "startRegistration": guard let gatewayURL = gatewayURL(from: call) else { result( @@ -661,9 +692,11 @@ import os.log .flatMap { $0.grants.map(\.relayOrigin) + $0.pendingEnrollments.map(\.relayOrigin) } ) ).sorted() + let replacementState = try endpointGrantStore.replacementQueueState() return [ "retiredRelayOrigins": retiredRelayOrigins, - "replacementRelayOrigins": try endpointGrantStore.replacementRelayOrigins(), + "replacementRelayOrigins": replacementState.relayOrigins, + "replacementGeneration": replacementState.generation, ] } diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index aee3994aad8..cebe109fa09 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -166,18 +166,55 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replace(states, account: Self.cleanupAccount) } - func replacementRelayOrigins() throws -> [String] { - guard let data = try data(account: Self.replacementRelaysAccount) else { return [] } - return try JSONDecoder().decode([String].self, from: data) + func replacementQueueState() throws -> BuzzPushReplacementQueueState { + guard let data = try data(account: Self.replacementRelaysAccount) else { + return BuzzPushReplacementQueueState(generation: 0, relayOrigins: []) + } + return try JSONDecoder().decode(BuzzPushReplacementQueueState.self, from: data) } func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws { - let merged = Array(Set(try replacementRelayOrigins() + relayOrigins)).sorted() - try replace(merged, account: Self.replacementRelaysAccount) + let current = try replacementQueueState() + let (generation, overflow) = current.generation.addingReportingOverflow(1) + guard !overflow else { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Replacement queue generation exhausted."] + ) + } + let state = BuzzPushReplacementQueueState( + generation: generation, + relayOrigins: Array(Set(current.relayOrigins + relayOrigins)).sorted() + ) + try replaceValue(state, account: Self.replacementRelaysAccount) + } + + func checkpointReplacementRelayOrigin( + _ relayOrigin: String, + expectedGeneration: Int64 + ) throws -> Bool { + var state = try replacementQueueState() + guard state.generation == expectedGeneration else { return false } + state.relayOrigins.removeAll { $0 == relayOrigin } + try replaceValue(state, account: Self.replacementRelaysAccount) + return true } func clearReplacementRelayOrigins() throws { - try replace([String](), account: Self.replacementRelaysAccount) + let current = try replacementQueueState() + let (generation, overflow) = current.generation.addingReportingOverflow(1) + guard !overflow else { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Replacement queue generation exhausted."] + ) + } + try replaceValue( + BuzzPushReplacementQueueState(generation: generation, relayOrigins: []), + account: Self.replacementRelaysAccount + ) } private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] { @@ -215,7 +252,11 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } private func replace(_ values: [T], account: String) throws { - let data = try JSONEncoder().encode(values) + try replaceValue(values, account: account) + } + + 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/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index d8eacced23a..0d91f41e286 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -207,6 +207,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { useListenable(apnsDeviceToken); useListenable(retiredBuzzPushRelayOrigins); useListenable(replacementBuzzPushRelayOrigins); + useListenable(replacementBuzzPushGeneration); final gatewayInitializationAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); final gatewayMigrationAttempt = useMemoized(BuzzPushAttemptGate.new); @@ -228,6 +229,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { final token = apnsDeviceToken.value; final retiredRelayOrigins = retiredBuzzPushRelayOrigins.value; final replacementRelayOrigins = replacementBuzzPushRelayOrigins.value; + final replacementGeneration = replacementBuzzPushGeneration.value; final migrationRelayOrigins = retiredRelayOrigins.union( replacementRelayOrigins, ); @@ -381,23 +383,49 @@ class BuzzPushBootstrap extends HookConsumerWidget { token, 'retired:${(retiredRelayOrigins.toList()..sort()).join(',')}', 'replacement:${(replacementRelayOrigins.toList()..sort()).join(',')}', + 'replacement-generation:$replacementGeneration', ].join('|'); if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; unawaited(() async { try { - for (final candidate - in buzzPushCommunitiesRequiringGatewayMigration( - communities: communities, - retiredRelayOrigins: migrationRelayOrigins, - replacementRelayOrigins: replacementRelayOrigins, - targetGatewayOrigin: targetGatewayOrigin, - )) { - await _publishCommunityReplacement( - ref, - candidate, - communities, - targetGatewayOrigin, - ); + final candidates = buzzPushCommunitiesRequiringGatewayMigration( + communities: communities, + retiredRelayOrigins: migrationRelayOrigins, + replacementRelayOrigins: replacementRelayOrigins, + targetGatewayOrigin: targetGatewayOrigin, + ); + final candidateOrigins = + candidates + .map( + (candidate) => + buzzPushRelayWebSocketOrigin(candidate.relayUrl), + ) + .toSet() + .toList() + ..sort(); + for (final relayOrigin in candidateOrigins) { + for (final candidate in candidates.where( + (candidate) => + buzzPushRelayWebSocketOrigin(candidate.relayUrl) == + relayOrigin, + )) { + await _publishCommunityReplacement( + ref, + candidate, + communities, + targetGatewayOrigin, + ); + } + if (replacementRelayOrigins.contains(relayOrigin)) { + await checkpointBuzzPushGatewayReplacement( + relayOrigin, + replacementGeneration, + ); + // The checkpoint updates the listenable replacement inventory. + // Let the resulting rebuild own the next origin so two attempts + // cannot publish the remaining work concurrently. + return; + } } final latestRetiredRelayOrigins = retiredBuzzPushRelayOrigins.value; final latestReplacementRelayOrigins = @@ -408,7 +436,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { !setEquals( replacementRelayOrigins, latestReplacementRelayOrigins, - )) { + ) || + replacementGeneration != replacementBuzzPushGeneration.value) { return; } await completeBuzzPushGatewayMigration(); @@ -445,6 +474,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { token, migrationRelayOrigins, replacementRelayOrigins, + replacementGeneration, communitiesAsync.hasValue, communities, gatewayMigrationRetry.value, diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 227cba0cd99..224dce8d42a 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -96,6 +96,7 @@ final pushEndpointGrants = ValueNotifier>([]); final pushEndpointGrantError = ValueNotifier(null); final retiredBuzzPushRelayOrigins = ValueNotifier>(const {}); final replacementBuzzPushRelayOrigins = ValueNotifier>(const {}); +final replacementBuzzPushGeneration = ValueNotifier(0); /// The most recent notification response waiting for app navigation. /// @@ -153,6 +154,8 @@ Future> initializeBuzzPushGateway() async { final replacements = _relayOriginSet(inventory?['replacementRelayOrigins']); retiredBuzzPushRelayOrigins.value = retired; replacementBuzzPushRelayOrigins.value = replacements; + replacementBuzzPushGeneration.value = + inventory?['replacementGeneration'] as int? ?? 0; return retired.union(replacements); } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. @@ -170,6 +173,30 @@ Future completeBuzzPushGatewayMigration() async { }); retiredBuzzPushRelayOrigins.value = const {}; replacementBuzzPushRelayOrigins.value = const {}; + replacementBuzzPushGeneration.value = 0; + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +/// Removes one same-gateway relay origin only after all of its community +/// replacement leases have been durably accepted. +Future checkpointBuzzPushGatewayReplacement( + String relayOrigin, + int generation, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final checkpointed = await _channel.invokeMethod( + 'checkpointGatewayReplacement', + {'relayOrigin': relayOrigin, 'generation': generation}, + ); + if (checkpointed != true) { + throw StateError('Push replacement inventory changed before checkpoint.'); + } + replacementBuzzPushRelayOrigins.value = { + ...replacementBuzzPushRelayOrigins.value, + }..remove(relayOrigin); } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. } @@ -267,6 +294,8 @@ Future enrollBuzzPush( replacementBuzzPushRelayOrigins.value = _relayOriginSet( raw['replacementRelayOrigins'], ); + replacementBuzzPushGeneration.value = + raw['replacementGeneration'] as int? ?? 0; final grant = BuzzPushEndpointGrant.fromMap(raw); await readBuzzPushEndpointGrants(); if (communitiesForSnapshotRefresh != null) { diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index ef82177c537..7feaed890cf 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -23,6 +23,7 @@ void main() { pushEndpointGrantError.value = null; retiredBuzzPushRelayOrigins.value = const {}; replacementBuzzPushRelayOrigins.value = const {}; + replacementBuzzPushGeneration.value = 0; pushCommunitySnapshotError.value = null; pendingPushNotificationLink.value = null; installBuzzPushMethodHandler(); @@ -59,6 +60,7 @@ void main() { return { 'retiredRelayOrigins': ['wss://old-relay.example'], 'replacementRelayOrigins': ['wss://rotated-relay.example'], + 'replacementGeneration': 4, }; }); @@ -70,6 +72,7 @@ void main() { expect(replacementBuzzPushRelayOrigins.value, { 'wss://rotated-relay.example', }); + expect(replacementBuzzPushGeneration.value, 4); }, ); @@ -89,9 +92,55 @@ void main() { expect(retiredBuzzPushRelayOrigins.value, isEmpty); expect(replacementBuzzPushRelayOrigins.value, isEmpty); + expect(replacementBuzzPushGeneration.value, 0); }, ); + test('checkpoints one completed same-gateway relay origin', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + replacementBuzzPushRelayOrigins.value = { + 'wss://done.example', + 'wss://pending.example', + }; + replacementBuzzPushGeneration.value = 7; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'checkpointGatewayReplacement'); + expect(call.arguments, { + 'relayOrigin': 'wss://done.example', + 'generation': 7, + }); + return true; + }); + + await checkpointBuzzPushGatewayReplacement('wss://done.example', 7); + + expect(replacementBuzzPushRelayOrigins.value, {'wss://pending.example'}); + }); + + test('preserves replacement inventory after a stale checkpoint', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + replacementBuzzPushRelayOrigins.value = {'wss://pending.example'}; + replacementBuzzPushGeneration.value = 7; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'checkpointGatewayReplacement'); + expect(call.arguments, { + 'relayOrigin': 'wss://pending.example', + 'generation': 7, + }); + return false; + }); + + await expectLater( + checkpointBuzzPushGatewayReplacement('wss://pending.example', 7), + throwsStateError, + ); + + expect(replacementBuzzPushRelayOrigins.value, {'wss://pending.example'}); + expect(replacementBuzzPushGeneration.value, 7); + }); + test( 'starts native permission and APNs registration without a result gate', () async { @@ -228,6 +277,7 @@ void main() { ..._grantMap('new-grant'), 'retiredRelayOrigins': ['wss://retired.example'], 'replacementRelayOrigins': ['wss://sibling.example'], + 'replacementGeneration': 8, }; } if (call.method == 'endpointGrants') { @@ -263,6 +313,7 @@ void main() { expect(secondGrant.endpointGrant, 'new-grant'); expect(retiredBuzzPushRelayOrigins.value, {'wss://retired.example'}); expect(replacementBuzzPushRelayOrigins.value, {'wss://sibling.example'}); + expect(replacementBuzzPushGeneration.value, 8); expect(enrollmentArguments, [ { 'relayUrl': 'wss://relay.example/', From c36ba66b21bbeed3fa9532bbc2a3f92a8519e90b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 12:37:13 -0700 Subject: [PATCH 51/67] Fence push replacement checkpoints Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzDevPushEnrollmentDriver.swift | 33 +++++++-- .../BuzzDevPushEnrollmentDriverTests.swift | 69 ++++++++++++++++++ mobile/ios/Runner/AppDelegate.swift | 15 +++- mobile/lib/shared/push/push_bootstrap.dart | 70 +++++++++++++------ mobile/lib/shared/push/push_bridge.dart | 9 ++- .../test/shared/push/push_bootstrap_test.dart | 17 +++++ mobile/test/shared/push/push_bridge_test.dart | 17 ++++- 7 files changed, 197 insertions(+), 33 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index be2c12ebe3d..bff615be5fe 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -465,9 +465,14 @@ public final class BuzzDevPushEnrollmentDriver { /// delegates to that key, and durably saves the resulting opaque grant. public func enroll( deviceToken: Data, - relayURL: URL + relayURL: URL, + forceDelegationRenewal: Bool = false ) async throws -> BuzzPushEndpointGrantRecord { - return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) + return try await enrollCurrent( + deviceToken: deviceToken, + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal + ) } /// Revokes durable installations from gateways that are no longer configured. @@ -478,7 +483,8 @@ public final class BuzzDevPushEnrollmentDriver { private func enrollCurrent( deviceToken: Data, - relayURL: URL + relayURL: URL, + forceDelegationRenewal: Bool ) async throws -> BuzzPushEndpointGrantRecord { precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") let relayOrigin = try Self.relayOrigin(relayURL) @@ -645,10 +651,15 @@ public final class BuzzDevPushEnrollmentDriver { ) pendingEnrollment = nil if revokedInstallation { - return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) + return try await enrollCurrent( + deviceToken: deviceToken, + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal + ) } } - if let current = storedForOrigin, + if !forceDelegationRenewal, + let current = storedForOrigin, current.relayPubkey == relayPubkey, current.endpointHash == endpointHash, current.endpointEpoch == Self.endpointEpoch, @@ -840,7 +851,11 @@ public final class BuzzDevPushEnrollmentDriver { relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) - return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) + return try await enrollCurrent( + deviceToken: deviceToken, + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal + ) } catch let error as BuzzDevPushEnrollmentError { guard case .unexpectedStatus( @@ -863,7 +878,11 @@ public final class BuzzDevPushEnrollmentDriver { relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) - return try await enrollCurrent(deviceToken: deviceToken, relayURL: relayURL) + return try await enrollCurrent( + deviceToken: deviceToken, + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal + ) } pending = BuzzPushPendingEnrollmentRecord( gatewayOrigin: gatewayOrigin, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index dccb6103f9f..16825fea1b3 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -1947,6 +1947,75 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(store.saved.count, 2) } + func testForcedRenewalBypassesReusableSiblingGrant() 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: "invalidated-sibling-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 4, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["generation"] as? Int, 5) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "replacement-grant"] + ) + case ("POST", "http://push.example/v1/installations"): + XCTFail("Forced delegation renewal must reuse the existing installation") + return Self.response(request, status: 500, json: [:]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL, + forceDelegationRenewal: true + ) + + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertEqual(record.generation, 5) + XCTAssertEqual(record.endpointGrant, "replacement-grant") + } + func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 305ecc6f09a..02b2c340949 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -412,7 +412,9 @@ import os.log guard let arguments = call.arguments as? [String: Any], let relayOrigin = arguments["relayOrigin"] as? String, !relayOrigin.isEmpty, - let generation = (arguments["generation"] as? NSNumber)?.int64Value + let generation = (arguments["generation"] as? NSNumber)?.int64Value, + let expectedDeviceToken = arguments["deviceToken"] as? String, + !expectedDeviceToken.isEmpty else { result( FlutterError( @@ -423,6 +425,13 @@ import os.log ) return } + guard + apnsDeviceToken?.map({ String(format: "%02x", $0) }).joined() + == expectedDeviceToken + else { + result(false) + return + } do { result( try endpointGrantStore.checkpointReplacementRelayOrigin( @@ -602,6 +611,7 @@ import os.log ) return } + let forceDelegationRenewal = arguments["forceDelegationRenewal"] as? Bool ?? false do { let driver = try BuzzDevPushEnrollmentDriver( @@ -616,7 +626,8 @@ import os.log do { let record = try await driver.enroll( deviceToken: deviceToken, - relayURL: relayURL + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal ) var arguments = record.flutterArguments if let inventory = try self?.pushGatewayMigrationInventory() { diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0d91f41e286..fd17a9daa74 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -126,6 +126,24 @@ List buzzPushCommunitiesRequiringGatewayMigration({ ) .toList(); +@visibleForTesting +bool buzzPushGatewayMigrationAttemptIsCurrent({ + required bool attemptIsCurrent, + required String token, + required String? liveToken, + required Set retiredRelayOrigins, + required Set liveRetiredRelayOrigins, + required Set replacementRelayOrigins, + required Set liveReplacementRelayOrigins, + required int replacementGeneration, + required int liveReplacementGeneration, +}) => + attemptIsCurrent && + token == liveToken && + setEquals(retiredRelayOrigins, liveRetiredRelayOrigins) && + setEquals(replacementRelayOrigins, liveReplacementRelayOrigins) && + replacementGeneration == liveReplacementGeneration; + /// Owns the APNs-registration side effect so migration-triggered registration /// is exercised through the same production boundary as active-community /// registration. @@ -388,6 +406,18 @@ class BuzzPushBootstrap extends HookConsumerWidget { if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; unawaited(() async { try { + bool attemptIsCurrent() => buzzPushGatewayMigrationAttemptIsCurrent( + attemptIsCurrent: gatewayMigrationAttempt.isCurrent(attempt), + token: token, + liveToken: apnsDeviceToken.value, + retiredRelayOrigins: retiredRelayOrigins, + liveRetiredRelayOrigins: retiredBuzzPushRelayOrigins.value, + replacementRelayOrigins: replacementRelayOrigins, + liveReplacementRelayOrigins: + replacementBuzzPushRelayOrigins.value, + replacementGeneration: replacementGeneration, + liveReplacementGeneration: replacementBuzzPushGeneration.value, + ); final candidates = buzzPushCommunitiesRequiringGatewayMigration( communities: communities, retiredRelayOrigins: migrationRelayOrigins, @@ -404,22 +434,30 @@ class BuzzPushBootstrap extends HookConsumerWidget { .toList() ..sort(); for (final relayOrigin in candidateOrigins) { - for (final candidate in candidates.where( - (candidate) => - buzzPushRelayWebSocketOrigin(candidate.relayUrl) == - relayOrigin, - )) { + final originCandidates = candidates + .where( + (candidate) => + buzzPushRelayWebSocketOrigin(candidate.relayUrl) == + relayOrigin, + ) + .toList(); + for (var index = 0; index < originCandidates.length; index += 1) { await _publishCommunityReplacement( ref, - candidate, + originCandidates[index], communities, targetGatewayOrigin, + forceDelegationRenewal: + replacementRelayOrigins.contains(relayOrigin) && + index == 0, ); } if (replacementRelayOrigins.contains(relayOrigin)) { + if (!attemptIsCurrent()) return; await checkpointBuzzPushGatewayReplacement( relayOrigin, replacementGeneration, + token, ); // The checkpoint updates the listenable replacement inventory. // Let the resulting rebuild own the next origin so two attempts @@ -427,19 +465,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { return; } } - final latestRetiredRelayOrigins = retiredBuzzPushRelayOrigins.value; - final latestReplacementRelayOrigins = - replacementBuzzPushRelayOrigins.value; - if (!gatewayMigrationAttempt.isCurrent(attempt) || - token != apnsDeviceToken.value || - !setEquals(retiredRelayOrigins, latestRetiredRelayOrigins) || - !setEquals( - replacementRelayOrigins, - latestReplacementRelayOrigins, - ) || - replacementGeneration != replacementBuzzPushGeneration.value) { - return; - } + if (!attemptIsCurrent()) return; await completeBuzzPushGatewayMigration(); gatewayMigrationFailures.value = 0; gatewayMigrationAttempt.complete(attempt); @@ -629,8 +655,9 @@ class BuzzPushBootstrap extends HookConsumerWidget { WidgetRef ref, Community community, List communities, - String targetGatewayOrigin, - ) async { + String targetGatewayOrigin, { + bool forceDelegationRenewal = false, + }) async { final config = RelayConfig( baseUrl: community.relayUrl, nsec: community.nsec, @@ -647,6 +674,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { config.wsUrl, Env.pushGatewayUrl, communitiesForSnapshotRefresh: communities, + forceDelegationRenewal: forceDelegationRenewal, ); final notifier = ref.read(communityListProvider.notifier); await publishBuzzPushLeaseRecoverably( diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 224dce8d42a..9381d3aa473 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -184,12 +184,17 @@ Future completeBuzzPushGatewayMigration() async { Future checkpointBuzzPushGatewayReplacement( String relayOrigin, int generation, + String deviceToken, ) async { if (defaultTargetPlatform != TargetPlatform.iOS) return; try { final checkpointed = await _channel.invokeMethod( 'checkpointGatewayReplacement', - {'relayOrigin': relayOrigin, 'generation': generation}, + { + 'relayOrigin': relayOrigin, + 'generation': generation, + 'deviceToken': deviceToken, + }, ); if (checkpointed != true) { throw StateError('Push replacement inventory changed before checkpoint.'); @@ -280,10 +285,12 @@ Future enrollBuzzPush( String relayUrl, String gatewayUrl, { List? communitiesForSnapshotRefresh, + bool forceDelegationRenewal = false, }) async { final raw = await _channel.invokeMapMethod('enrollPush', { 'relayUrl': relayUrl, 'gatewayUrl': gatewayUrl, + 'forceDelegationRenewal': forceDelegationRenewal, }); if (raw == null) { throw StateError('Native push enrollment returned no grant.'); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 51402ab102a..bc48eb09af1 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -250,6 +250,23 @@ void main() { ); }); + test('gateway migration rejects a stale APNs token before checkpoint', () { + expect( + buzzPushGatewayMigrationAttemptIsCurrent( + attemptIsCurrent: true, + token: 'old-token', + liveToken: 'new-token', + retiredRelayOrigins: const {'wss://relay.example'}, + liveRetiredRelayOrigins: const {'wss://relay.example'}, + replacementRelayOrigins: const {'wss://relay.example'}, + liveReplacementRelayOrigins: const {'wss://relay.example'}, + replacementGeneration: 7, + liveReplacementGeneration: 7, + ), + isFalse, + ); + }); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 7feaed890cf..c844971de59 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -109,11 +109,16 @@ void main() { expect(call.arguments, { 'relayOrigin': 'wss://done.example', 'generation': 7, + 'deviceToken': 'device-token', }); return true; }); - await checkpointBuzzPushGatewayReplacement('wss://done.example', 7); + await checkpointBuzzPushGatewayReplacement( + 'wss://done.example', + 7, + 'device-token', + ); expect(replacementBuzzPushRelayOrigins.value, {'wss://pending.example'}); }); @@ -128,12 +133,17 @@ void main() { expect(call.arguments, { 'relayOrigin': 'wss://pending.example', 'generation': 7, + 'deviceToken': 'stale-token', }); return false; }); await expectLater( - checkpointBuzzPushGatewayReplacement('wss://pending.example', 7), + checkpointBuzzPushGatewayReplacement( + 'wss://pending.example', + 7, + 'stale-token', + ), throwsStateError, ); @@ -297,6 +307,7 @@ void main() { final secondGrant = await enrollBuzzPush( 'wss://relay.example/', 'https://gateway-two.example/', + forceDelegationRenewal: true, communitiesForSnapshotRefresh: [ Community( id: 'community-id', @@ -318,10 +329,12 @@ void main() { { 'relayUrl': 'wss://relay.example/', 'gatewayUrl': 'https://gateway-one.example/', + 'forceDelegationRenewal': false, }, { 'relayUrl': 'wss://relay.example/', 'gatewayUrl': 'https://gateway-two.example/', + 'forceDelegationRenewal': true, }, ]); expect(methods, [ From 44ce7fb472fda97b7a5b2421ccdd05cda1917955 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 12:47:00 -0700 Subject: [PATCH 52/67] Confirm push lease acceptance before cleanup Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../lib/shared/community/community_provider.dart | 7 ++++--- mobile/lib/shared/push/push_bootstrap.dart | 6 ++++-- .../shared/community/community_provider_test.dart | 11 +++++++---- mobile/test/shared/push/push_bootstrap_test.dart | 14 +++++++++++++- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 0f0b9eeecd4..756b1d3a981 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -412,7 +412,7 @@ class CommunityListNotifier extends AsyncNotifier> { }); } - Future markPushLeaseAccepted( + Future markPushLeaseAccepted( String id, { required List subscriptions, required int generation, @@ -421,14 +421,14 @@ 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; final updated = community.copyWith( pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, @@ -440,6 +440,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 fd17a9daa74..c5ec5ed4cb9 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -205,11 +205,13 @@ String buzzPushGatewayOrigin(String gatewayUrl) { 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; } diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index 345226e84d1..ceaa02ec5ca 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -204,10 +204,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; diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index bc48eb09af1..77a83f26756 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -307,12 +307,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( @@ -335,6 +336,17 @@ void main() { expect(acceptedGeneration, 2); }, ); + + test('superseded lease acceptance fails the publication attempt', () async { + await expectLater( + publishBuzzPushLeaseRecoverably( + reserveGeneration: () async => 3, + publish: (_) async {}, + markAccepted: (_) async => false, + ), + throwsStateError, + ); + }); } BuzzPushLeaseDescriptor _descriptor({ From c645d9c255e86c59f18295b4e7c1da1d3c264674 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 12:57:26 -0700 Subject: [PATCH 53/67] Retain push revocation tombstones through expiry Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/postgres.rs | 68 +++++++++++++++++++----- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index c02070884b8..19ed218200f 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -521,26 +521,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(()) } @@ -643,7 +645,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()); @@ -683,32 +685,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 @@ -718,6 +742,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); From bb75bec995b19b1a881032622f62b8175a2257b0 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 13:06:39 -0700 Subject: [PATCH 54/67] Document Android push gateway input Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/README.md b/mobile/README.md index 30df6ad0b7a..60bf3ca70fe 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -81,6 +81,7 @@ 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 From 2341ccb21b0138b33e323e46e72a9b0b5b304b34 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 13:20:24 -0700 Subject: [PATCH 55/67] Checkpoint shared push authorities atomically Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/README.md | 2 +- .../BuzzDevPushEnrollmentDriver.swift | 7 ++- .../BuzzDevPushEnrollmentDriverTests.swift | 7 ++- mobile/ios/Runner/AppDelegate.swift | 13 ++-- .../ios/Runner/PushEndpointGrantStore.swift | 7 ++- mobile/lib/shared/push/push_bootstrap.dart | 60 +++++++++++++++---- mobile/lib/shared/push/push_bridge.dart | 24 ++++---- .../test/shared/push/push_bootstrap_test.dart | 41 +++++++++++++ mobile/test/shared/push/push_bridge_test.dart | 22 ++++--- 9 files changed, 134 insertions(+), 49 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 60bf3ca70fe..b711a61cd8f 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 diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index bff615be5fe..9cd88cf8e4a 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -141,9 +141,10 @@ public protocol BuzzPushEndpointGrantStore { func replacementQueueState() throws -> BuzzPushReplacementQueueState /// Atomically merges relay origins into the durable replacement queue. func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws - /// Removes one relay origin after all of its community leases are durable. - func checkpointReplacementRelayOrigin( - _ relayOrigin: String, + /// Atomically removes relay origins sharing delegation authority after all + /// of their community leases are durable. + func checkpointReplacementRelayOrigins( + _ relayOrigins: [String], expectedGeneration: Int64 ) throws -> Bool /// Clears the queue only after replacement publication has completed. diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 16825fea1b3..2865497bd8c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -2534,12 +2534,13 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { replacementGeneration += 1 replacementOrigins = Array(Set(replacementOrigins + relayOrigins)).sorted() } - func checkpointReplacementRelayOrigin( - _ relayOrigin: String, + func checkpointReplacementRelayOrigins( + _ relayOrigins: [String], expectedGeneration: Int64 ) throws -> Bool { guard replacementGeneration == expectedGeneration else { return false } - replacementOrigins.removeAll { $0 == relayOrigin } + let completedOrigins = Set(relayOrigins) + replacementOrigins.removeAll { completedOrigins.contains($0) } return true } func clearReplacementRelayOrigins() throws { diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 02b2c340949..20be9e28260 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -408,10 +408,11 @@ import os.log ) ) } - case "checkpointGatewayReplacement": + case "checkpointGatewayReplacements": guard let arguments = call.arguments as? [String: Any], - let relayOrigin = arguments["relayOrigin"] as? String, - !relayOrigin.isEmpty, + let relayOrigins = arguments["relayOrigins"] as? [String], + !relayOrigins.isEmpty, + relayOrigins.allSatisfy({ !$0.isEmpty }), let generation = (arguments["generation"] as? NSNumber)?.int64Value, let expectedDeviceToken = arguments["deviceToken"] as? String, !expectedDeviceToken.isEmpty @@ -419,7 +420,7 @@ import os.log result( FlutterError( code: "invalid_arguments", - message: "Gateway replacement checkpoint requires relayOrigin.", + message: "Gateway replacement checkpoint requires relayOrigins.", details: nil ) ) @@ -434,8 +435,8 @@ import os.log } do { result( - try endpointGrantStore.checkpointReplacementRelayOrigin( - relayOrigin, + try endpointGrantStore.checkpointReplacementRelayOrigins( + relayOrigins, expectedGeneration: generation ) ) diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index cebe109fa09..9ab3392a84d 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -190,13 +190,14 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replaceValue(state, account: Self.replacementRelaysAccount) } - func checkpointReplacementRelayOrigin( - _ relayOrigin: String, + func checkpointReplacementRelayOrigins( + _ relayOrigins: [String], expectedGeneration: Int64 ) throws -> Bool { var state = try replacementQueueState() guard state.generation == expectedGeneration else { return false } - state.relayOrigins.removeAll { $0 == relayOrigin } + let completedOrigins = Set(relayOrigins) + state.relayOrigins.removeAll { completedOrigins.contains($0) } try replaceValue(state, account: Self.replacementRelaysAccount) return true } diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index c5ec5ed4cb9..c639e01d507 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -144,6 +144,24 @@ bool buzzPushGatewayMigrationAttemptIsCurrent({ setEquals(replacementRelayOrigins, liveReplacementRelayOrigins) && replacementGeneration == liveReplacementGeneration; +typedef BuzzPushGatewayMigrationTarget = ({ + Community community, + String relayOrigin, + BuzzPushLeaseDescriptor descriptor, +}); + +@visibleForTesting +Map> +buzzPushGroupGatewayMigrationsByDelegationAuthority( + Iterable targets, +) { + final groups = >{}; + for (final target in targets) { + groups.putIfAbsent(target.descriptor.executorPubkey, () => []).add(target); + } + return groups; +} + /// Owns the APNs-registration side effect so migration-triggered registration /// is exercised through the same production boundary as active-community /// registration. @@ -435,6 +453,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { .toSet() .toList() ..sort(); + final targets = []; for (final relayOrigin in candidateOrigins) { final originCandidates = candidates .where( @@ -443,27 +462,46 @@ class BuzzPushBootstrap extends HookConsumerWidget { relayOrigin, ) .toList(); - for (var index = 0; index < originCandidates.length; index += 1) { + for (final originCandidate in originCandidates) { + final descriptor = await fetchBuzzPushLeaseDescriptor( + originCandidate.relayUrl, + ); + targets.add(( + community: originCandidate, + relayOrigin: relayOrigin, + descriptor: descriptor, + )); + } + } + final authorityGroups = + buzzPushGroupGatewayMigrationsByDelegationAuthority(targets); + for (final authorityTargets in authorityGroups.values) { + final queuedOrigins = authorityTargets + .map((target) => target.relayOrigin) + .where(replacementRelayOrigins.contains) + .toSet(); + for (var index = 0; index < authorityTargets.length; index += 1) { + final target = authorityTargets[index]; await _publishCommunityReplacement( ref, - originCandidates[index], + target.community, communities, targetGatewayOrigin, + descriptor: target.descriptor, forceDelegationRenewal: - replacementRelayOrigins.contains(relayOrigin) && - index == 0, + queuedOrigins.isNotEmpty && index == 0, ); } - if (replacementRelayOrigins.contains(relayOrigin)) { + if (queuedOrigins.isNotEmpty) { if (!attemptIsCurrent()) return; - await checkpointBuzzPushGatewayReplacement( - relayOrigin, + await checkpointBuzzPushGatewayReplacements( + queuedOrigins, replacementGeneration, token, ); - // The checkpoint updates the listenable replacement inventory. - // Let the resulting rebuild own the next origin so two attempts - // cannot publish the remaining work concurrently. + // The checkpoint atomically removes every origin whose grants + // share this delegation authority. Let the resulting rebuild + // own the next authority so attempts cannot overlap. return; } } @@ -658,6 +696,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { Community community, List communities, String targetGatewayOrigin, { + required BuzzPushLeaseDescriptor descriptor, bool forceDelegationRenewal = false, }) async { final config = RelayConfig( @@ -671,7 +710,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { 'Cannot migrate push for ${community.id}: signing key is unavailable', ); } - final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); final grant = await enrollBuzzPush( config.wsUrl, Env.pushGatewayUrl, diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 9381d3aa473..99ec2e478c3 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -179,29 +179,27 @@ Future completeBuzzPushGatewayMigration() async { } } -/// Removes one same-gateway relay origin only after all of its community -/// replacement leases have been durably accepted. -Future checkpointBuzzPushGatewayReplacement( - String relayOrigin, +/// Atomically removes same-gateway relay origins only after all community +/// replacement leases sharing their delegation authority are durable. +Future checkpointBuzzPushGatewayReplacements( + Set relayOrigins, int generation, String deviceToken, ) async { if (defaultTargetPlatform != TargetPlatform.iOS) return; try { - final checkpointed = await _channel.invokeMethod( - 'checkpointGatewayReplacement', - { - 'relayOrigin': relayOrigin, - 'generation': generation, - 'deviceToken': deviceToken, - }, - ); + final checkpointed = await _channel + .invokeMethod('checkpointGatewayReplacements', { + 'relayOrigins': relayOrigins.toList()..sort(), + 'generation': generation, + 'deviceToken': deviceToken, + }); if (checkpointed != true) { throw StateError('Push replacement inventory changed before checkpoint.'); } replacementBuzzPushRelayOrigins.value = { ...replacementBuzzPushRelayOrigins.value, - }..remove(relayOrigin); + }..removeAll(relayOrigins); } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. } diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 77a83f26756..5a2199cad83 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -267,6 +267,47 @@ void main() { ); }); + test('queued origins sharing delegation authority migrate atomically', () { + final first = Community.create( + name: 'First', + relayUrl: 'wss://first.example', + ); + final second = Community.create( + name: 'Second', + relayUrl: 'wss://second.example', + ); + final independent = Community.create( + name: 'Independent', + relayUrl: 'wss://independent.example', + ); + + final groups = buzzPushGroupGatewayMigrationsByDelegationAuthority([ + ( + community: first, + relayOrigin: 'wss://first.example', + descriptor: _descriptor(keyId: 'first', pubkey: _hex('a')), + ), + ( + community: second, + relayOrigin: 'wss://second.example', + descriptor: _descriptor(keyId: 'second', pubkey: _hex('a')), + ), + ( + community: independent, + relayOrigin: 'wss://independent.example', + descriptor: _descriptor(keyId: 'third', pubkey: _hex('b')), + ), + ]); + + expect(groups[_hex('a')]!.map((target) => target.relayOrigin), [ + 'wss://first.example', + 'wss://second.example', + ]); + expect(groups[_hex('b')]!.map((target) => target.relayOrigin), [ + 'wss://independent.example', + ]); + }); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index c844971de59..ec5272da59d 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -96,7 +96,7 @@ void main() { }, ); - test('checkpoints one completed same-gateway relay origin', () async { + test('atomically checkpoints origins sharing delegation authority', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; replacementBuzzPushRelayOrigins.value = { 'wss://done.example', @@ -105,17 +105,21 @@ void main() { replacementBuzzPushGeneration.value = 7; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_channel, (call) async { - expect(call.method, 'checkpointGatewayReplacement'); + expect(call.method, 'checkpointGatewayReplacements'); expect(call.arguments, { - 'relayOrigin': 'wss://done.example', + 'relayOrigins': ['wss://also-done.example', 'wss://done.example'], 'generation': 7, 'deviceToken': 'device-token', }); return true; }); - await checkpointBuzzPushGatewayReplacement( - 'wss://done.example', + replacementBuzzPushRelayOrigins.value = { + 'wss://also-done.example', + ...replacementBuzzPushRelayOrigins.value, + }; + await checkpointBuzzPushGatewayReplacements( + {'wss://done.example', 'wss://also-done.example'}, 7, 'device-token', ); @@ -129,9 +133,9 @@ void main() { replacementBuzzPushGeneration.value = 7; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_channel, (call) async { - expect(call.method, 'checkpointGatewayReplacement'); + expect(call.method, 'checkpointGatewayReplacements'); expect(call.arguments, { - 'relayOrigin': 'wss://pending.example', + 'relayOrigins': ['wss://pending.example'], 'generation': 7, 'deviceToken': 'stale-token', }); @@ -139,8 +143,8 @@ void main() { }); await expectLater( - checkpointBuzzPushGatewayReplacement( - 'wss://pending.example', + checkpointBuzzPushGatewayReplacements( + {'wss://pending.example'}, 7, 'stale-token', ), From 22b73b2bcce21c4beb95464648a151dadf31cdff Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 13:33:58 -0700 Subject: [PATCH 56/67] Preserve shared push recovery authority Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/authority.rs | 36 ++++++- crates/buzz-push-gateway/src/postgres.rs | 67 +++++++++++- ...0045_retain_push_revocation_tombstones.sql | 11 ++ .../BuzzDevPushEnrollmentDriver.swift | 81 +++++--------- .../BuzzDevPushEnrollmentDriverTests.swift | 67 ++++++++++++ mobile/lib/shared/push/push_bootstrap.dart | 101 ++++++++++++------ .../test/shared/push/push_bootstrap_test.dart | 25 +++++ schema/schema.sql | 10 +- 8 files changed, 303 insertions(+), 95 deletions(-) create mode 100644 migrations/0045_retain_push_revocation_tombstones.sql diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index c87af649918..9f71f6ee69b 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -277,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| { @@ -287,7 +287,7 @@ 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) @@ -295,6 +295,14 @@ impl AuthorityStore for MemoryAuthorityStore { // App identity and token possession never supersede a live installation. 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)); @@ -936,6 +944,30 @@ mod tests { .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/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 19ed218200f..bce2e6f3290 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -191,16 +191,25 @@ impl AuthorityStore for PostgresAuthorityStore { } 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)?; @@ -1009,6 +1018,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/migrations/0045_retain_push_revocation_tombstones.sql b/migrations/0045_retain_push_revocation_tombstones.sql new file mode 100644 index 00000000000..0f480f574b9 --- /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/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 9cd88cf8e4a..66c566ac894 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -659,70 +659,41 @@ public final class BuzzDevPushEnrollmentDriver { ) } } - if !forceDelegationRenewal, - let current = storedForOrigin, - current.relayPubkey == relayPubkey, - current.endpointHash == endpointHash, - current.endpointEpoch == Self.endpointEpoch, - current.expiresAt > nowSeconds + 300 - { - guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { - try store.removePendingEnrollment( - gatewayOrigin: gatewayOrigin, - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile - ) - return current + let reusableCurrent = storedForOrigin.flatMap { current in + current.relayPubkey == relayPubkey + && current.endpointHash == endpointHash + && current.endpointEpoch == Self.endpointEpoch + && current.expiresAt > nowSeconds + 300 ? current : nil + } + 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 } + let newerSiblingGrant = reusableCurrent.flatMap { current in + newestSharedGrant.flatMap { shared in + shared.generation > current.generation ? shared : nil } - let refreshed = BuzzPushEndpointGrantRecord( - gatewayOrigin: gatewayOrigin, - relayOrigin: current.relayOrigin, - relayPubkey: current.relayPubkey, - relayMetadataPubkey: relayKeys.metadataPubkey, - gatewayInstallationHandle: current.gatewayInstallationHandle, - appAttestKeyId: current.appAttestKeyId, - 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( - gatewayOrigin: gatewayOrigin, - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile - ) - return refreshed } - - // 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.gatewayOrigin == gatewayOrigin && $0.relayPubkey == relayPubkey - && $0.appProfile == Self.appProfile - && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch - && $0.expiresAt > nowSeconds + 300 - }) - { + if let reusableGrant = forceDelegationRenewal ? newerSiblingGrant : newestSharedGrant { + // Delegation authority is shared by installation and relay key. When a + // sibling origin has already renewed it, adopt that newest opaque grant + // even if this origin is still durably queued for forced replacement. let record = BuzzPushEndpointGrantRecord( gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, - gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, - appAttestKeyId: sharedGrant.appAttestKeyId, - installationId: try makeInstallationId(), - endpointGrant: sharedGrant.endpointGrant, + gatewayInstallationHandle: reusableGrant.gatewayInstallationHandle, + appAttestKeyId: reusableGrant.appAttestKeyId, + installationId: try reusableCurrent?.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( diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 2865497bd8c..8583646238b 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -2016,6 +2016,73 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record.endpointGrant, "replacement-grant") } + func testForcedReplacementAdoptsNewerSiblingGrantWithoutRenewingAgain() async throws { + let endpointHash = Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))) + let stale = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "stale-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 4, + expiresAt: Self.expiresAt + ) + let renewed = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://sibling.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + installationId: "sibling-lease-address", + endpointGrant: "renewed-shared-grant", + endpointHash: endpointHash, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 5, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [stale, renewed]) + let driver = try makeDriver( + store: store, + appAttest: RecordingAppAttest(), + installationIdBytes: { + XCTFail("A queued sibling must retain its existing lease address") + return Data(repeating: 0xFF, count: 16) + } + ) + URLProtocolStub.handler = { request in + guard request.httpMethod == "GET" else { + XCTFail("A newer sibling grant must prevent another gateway renewal") + 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, + forceDelegationRenewal: true + ) + + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertEqual(record.generation, 5) + XCTAssertEqual(record.endpointGrant, "renewed-shared-grant") + } + func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index c639e01d507..0ccd0b594ac 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -150,6 +150,66 @@ typedef BuzzPushGatewayMigrationTarget = ({ BuzzPushLeaseDescriptor descriptor, }); +@visibleForTesting +class BuzzPushGatewayMigrationResolution { + BuzzPushGatewayMigrationResolution({ + required this.targets, + required this.blockedOrigins, + this.firstError, + this.firstStack, + }); + + final List targets; + final Set blockedOrigins; + final Object? firstError; + final StackTrace? firstStack; + + void throwIfFailed() { + if (firstError != null) { + Error.throwWithStackTrace(firstError!, firstStack!); + } + } +} + +@visibleForTesting +Future +resolveBuzzPushGatewayMigrationTargets({ + required Iterable communities, + required Future Function(String relayUrl) + fetchDescriptor, +}) async { + final ordered = communities.toList() + ..sort( + (left, right) => buzzPushRelayWebSocketOrigin( + left.relayUrl, + ).compareTo(buzzPushRelayWebSocketOrigin(right.relayUrl)), + ); + final targets = []; + final blockedOrigins = {}; + Object? firstError; + StackTrace? firstStack; + for (final community in ordered) { + final relayOrigin = buzzPushRelayWebSocketOrigin(community.relayUrl); + try { + targets.add(( + community: community, + relayOrigin: relayOrigin, + descriptor: await fetchDescriptor(community.relayUrl), + )); + } catch (error, stack) { + blockedOrigins.add(relayOrigin); + firstError ??= error; + firstStack ??= stack; + } + } + return BuzzPushGatewayMigrationResolution( + targets: targets, + blockedOrigins: blockedOrigins, + firstError: firstError, + firstStack: firstStack, + ); +} + @visibleForTesting Map> buzzPushGroupGatewayMigrationsByDelegationAuthority( @@ -444,41 +504,21 @@ class BuzzPushBootstrap extends HookConsumerWidget { replacementRelayOrigins: replacementRelayOrigins, targetGatewayOrigin: targetGatewayOrigin, ); - final candidateOrigins = - candidates - .map( - (candidate) => - buzzPushRelayWebSocketOrigin(candidate.relayUrl), - ) - .toSet() - .toList() - ..sort(); - final targets = []; - for (final relayOrigin in candidateOrigins) { - final originCandidates = candidates - .where( - (candidate) => - buzzPushRelayWebSocketOrigin(candidate.relayUrl) == - relayOrigin, - ) - .toList(); - for (final originCandidate in originCandidates) { - final descriptor = await fetchBuzzPushLeaseDescriptor( - originCandidate.relayUrl, - ); - targets.add(( - community: originCandidate, - relayOrigin: relayOrigin, - descriptor: descriptor, - )); - } - } + final resolution = await resolveBuzzPushGatewayMigrationTargets( + communities: candidates, + fetchDescriptor: fetchBuzzPushLeaseDescriptor, + ); final authorityGroups = - buzzPushGroupGatewayMigrationsByDelegationAuthority(targets); + buzzPushGroupGatewayMigrationsByDelegationAuthority( + resolution.targets, + ); for (final authorityTargets in authorityGroups.values) { final queuedOrigins = authorityTargets .map((target) => target.relayOrigin) .where(replacementRelayOrigins.contains) + .where( + (origin) => !resolution.blockedOrigins.contains(origin), + ) .toSet(); for (var index = 0; index < authorityTargets.length; index += 1) { final target = authorityTargets[index]; @@ -505,6 +545,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { return; } } + resolution.throwIfFailed(); if (!attemptIsCurrent()) return; await completeBuzzPushGatewayMigration(); gatewayMigrationFailures.value = 0; diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 5a2199cad83..a3e7e550f4d 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -308,6 +308,31 @@ void main() { ]); }); + test('descriptor resolution preserves reachable migration work', () async { + final reachable = Community.create( + name: 'Reachable', + relayUrl: 'wss://reachable.example', + ); + final offline = Community.create( + name: 'Offline', + relayUrl: 'wss://offline.example', + ); + + final resolution = await resolveBuzzPushGatewayMigrationTargets( + communities: [offline, reachable], + fetchDescriptor: (relayUrl) async { + if (relayUrl == offline.relayUrl) { + throw StateError('offline'); + } + return _descriptor(keyId: 'reachable', pubkey: _hex('a')); + }, + ); + + expect(resolution.targets.map((target) => target.community), [reachable]); + expect(resolution.blockedOrigins, {'wss://offline.example'}); + expect(resolution.throwIfFailed, throwsStateError); + }); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), diff --git a/schema/schema.sql b/schema/schema.sql index 09508125622..0b4b6896660 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'); - From 3373281a9094deccebb6b74ed0a9b3d814dcae8b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 13:36:06 -0700 Subject: [PATCH 57/67] Update embedded migration inventory Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-db/src/runtime/migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 59015125042..ecd48bef598 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] From ad0e67f50b0ab3bf0b3b9a56b1a1b9bc367f1e48 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 13:46:55 -0700 Subject: [PATCH 58/67] Continue independent push migration groups Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/push/push_bootstrap.dart | 83 +++++++++++++------ .../test/shared/push/push_bootstrap_test.dart | 21 +++++ 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0ccd0b594ac..b1b9ac81c77 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -222,6 +222,29 @@ buzzPushGroupGatewayMigrationsByDelegationAuthority( return groups; } +@visibleForTesting +Future processBuzzPushGatewayMigrationGroups({ + required Iterable groups, + required Future Function(T group) process, + Object? initialError, + StackTrace? initialStack, +}) async { + Object? firstError = initialError; + StackTrace? firstStack = initialStack; + for (final group in groups) { + try { + if (await process(group)) return true; + } catch (error, stack) { + firstError ??= error; + firstStack ??= stack; + } + } + if (firstError != null) { + Error.throwWithStackTrace(firstError, firstStack!); + } + return false; +} + /// Owns the APNs-registration side effect so migration-triggered registration /// is exercised through the same production boundary as active-community /// registration. @@ -512,28 +535,36 @@ class BuzzPushBootstrap extends HookConsumerWidget { buzzPushGroupGatewayMigrationsByDelegationAuthority( resolution.targets, ); - for (final authorityTargets in authorityGroups.values) { - final queuedOrigins = authorityTargets - .map((target) => target.relayOrigin) - .where(replacementRelayOrigins.contains) - .where( - (origin) => !resolution.blockedOrigins.contains(origin), - ) - .toSet(); - for (var index = 0; index < authorityTargets.length; index += 1) { - final target = authorityTargets[index]; - await _publishCommunityReplacement( - ref, - target.community, - communities, - targetGatewayOrigin, - descriptor: target.descriptor, - forceDelegationRenewal: - queuedOrigins.isNotEmpty && index == 0, - ); - } - if (queuedOrigins.isNotEmpty) { - if (!attemptIsCurrent()) return; + final stopped = await processBuzzPushGatewayMigrationGroups( + groups: authorityGroups.values, + initialError: resolution.firstError, + initialStack: resolution.firstStack, + process: (authorityTargets) async { + final queuedOrigins = authorityTargets + .map((target) => target.relayOrigin) + .where(replacementRelayOrigins.contains) + .where( + (origin) => !resolution.blockedOrigins.contains(origin), + ) + .toSet(); + for ( + var index = 0; + index < authorityTargets.length; + index += 1 + ) { + final target = authorityTargets[index]; + await _publishCommunityReplacement( + ref, + target.community, + communities, + targetGatewayOrigin, + descriptor: target.descriptor, + forceDelegationRenewal: + queuedOrigins.isNotEmpty && index == 0, + ); + } + if (queuedOrigins.isEmpty) return false; + if (!attemptIsCurrent()) return true; await checkpointBuzzPushGatewayReplacements( queuedOrigins, replacementGeneration, @@ -542,10 +573,10 @@ class BuzzPushBootstrap extends HookConsumerWidget { // The checkpoint atomically removes every origin whose grants // share this delegation authority. Let the resulting rebuild // own the next authority so attempts cannot overlap. - return; - } - } - resolution.throwIfFailed(); + return true; + }, + ); + if (stopped) return; if (!attemptIsCurrent()) return; await completeBuzzPushGatewayMigration(); gatewayMigrationFailures.value = 0; diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index a3e7e550f4d..2a1b7c5b662 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -333,6 +333,27 @@ void main() { expect(resolution.throwIfFailed, throwsStateError); }); + test( + 'migration processes later authority groups before propagating', + () async { + final processed = []; + + await expectLater( + processBuzzPushGatewayMigrationGroups( + groups: const ['unavailable', 'reachable'], + process: (group) async { + processed.add(group); + if (group == 'unavailable') throw StateError('rejected'); + return false; + }, + ), + throwsStateError, + ); + + expect(processed, ['unavailable', 'reachable']); + }, + ); + test('pending opt-out tombstone keeps active push lifecycle disabled', () { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), From 2fcf5648e9111b3c555896b13029b2bcf09e7620 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 14:09:52 -0700 Subject: [PATCH 59/67] Recover quarantined push installations Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/authority.rs | 127 +++++++++++++++ crates/buzz-push-gateway/src/http.rs | 122 ++++++++++++++ crates/buzz-push-gateway/src/model.rs | 11 ++ crates/buzz-push-gateway/src/postgres.rs | 151 ++++++++++++++++++ docs/nips/NIP-PL.md | 10 ++ .../BuzzDevPushEnrollmentDriver.swift | 62 ++++++- .../BuzzPushKit/BuzzPushLegacyRecovery.swift | 67 ++++++++ .../BuzzDevPushEnrollmentDriverTests.swift | 63 ++++++++ .../BuzzPushLegacyRecoveryTests.swift | 41 +++++ mobile/ios/Runner/AppDelegate.swift | 1 + .../ios/Runner/PushEndpointGrantStore.swift | 32 +++- .../shared/community/community_provider.dart | 6 + .../community/community_provider_test.dart | 48 ++++++ 13 files changed, 733 insertions(+), 8 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 9f71f6ee69b..aa26bec82ff 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -179,6 +179,20 @@ pub trait AuthorityStore: Send + Sync { expected_epoch: i64, new_epoch: i64, ) -> Result<(), AuthorityError>; + /// Revoke the live installation named by a gateway-issued delegation only + /// when it still owns the submitted APNs token. This is the recovery seam + /// for clients whose legacy state omitted gateway and App Attest identity. + #[allow(clippy::too_many_arguments)] + async fn recover_installation( + &self, + delegation_id: Uuid, + relay_pubkey: &str, + endpoint_epoch: i64, + generation: i64, + profile: AppProfile, + token_fingerprint: [u8; 32], + now: i64, + ) -> Result<(), AuthorityError>; /// Atomically validate and lock installation then delegation authority, /// reserve quota/replay state, and commit. That durable commit is the /// delivery send-begin linearization point seen by revocation. @@ -516,6 +530,64 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(()) } + async fn recover_installation( + &self, + delegation_id: Uuid, + relay_pubkey: &str, + endpoint_epoch: i64, + generation: i64, + profile: AppProfile, + token_fingerprint: [u8; 32], + now: i64, + ) -> Result<(), AuthorityError> { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let (installation_id, stored_relay) = s + .delegation_ids + .get(&delegation_id) + .cloned() + .ok_or(AuthorityError::Rejected)?; + if stored_relay != relay_pubkey { + return Err(AuthorityError::Rejected); + } + let delegation = s + .delegations + .get(&(installation_id, stored_relay)) + .ok_or(AuthorityError::Rejected)?; + if delegation.revoked + || delegation.endpoint_epoch != endpoint_epoch + || delegation.generation != generation + || delegation.expires_at < now + { + return Err(AuthorityError::Rejected); + } + let installation = s + .installations + .get_mut(&installation_id) + .ok_or(AuthorityError::Rejected)?; + let revoked_epoch = endpoint_epoch + .checked_add(1) + .ok_or(AuthorityError::Rejected)?; + if installation.revoked + && installation.endpoint_epoch == revoked_epoch + && installation.expires_at >= now + && installation.profile == profile + && installation.token_fingerprint == token_fingerprint + { + return Ok(()); + } + if installation.revoked + || installation.expires_at < now + || installation.profile != profile + || installation.token_fingerprint != token_fingerprint + || installation.endpoint_epoch != endpoint_epoch + { + return Err(AuthorityError::Rejected); + } + installation.endpoint_epoch = revoked_epoch; + installation.revoked = true; + Ok(()) + } + async fn authorize_delivery( &self, delegation_id: Uuid, @@ -973,4 +1045,59 @@ mod tests { Err(AuthorityError::Rejected) ); } + + #[tokio::test] + async fn legacy_grant_and_matching_token_recover_only_the_named_installation() { + let store = store().await; + + assert_eq!( + store + .recover_installation( + Uuid::from_u128(2), + &"11".repeat(32), + 1, + 1, + AppProfile::BuzzIosDogfood, + [9; 32], + 1_000, + ) + .await, + Err(AuthorityError::Rejected), + "an opaque grant cannot recover a different APNs token" + ); + assert!(store.installation(Uuid::from_u128(1), 1_000).await.is_ok()); + + store + .recover_installation( + Uuid::from_u128(2), + &"11".repeat(32), + 1, + 1, + AppProfile::BuzzIosDogfood, + [4; 32], + 1_000, + ) + .await + .expect("the gateway-issued grant and matching token identify legacy authority"); + store + .recover_installation( + Uuid::from_u128(2), + &"11".repeat(32), + 1, + 1, + AppProfile::BuzzIosDogfood, + [4; 32], + 1_000, + ) + .await + .expect("an exact recovery retry is idempotent after response loss"); + assert!(store.installation(Uuid::from_u128(1), 1_000).await.is_err()); + assert!( + store + .installation_for_revocation(Uuid::from_u128(1), 1_000) + .await + .expect("recovery retains the installation tombstone") + .revoked + ); + } } diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index ed24dc755c5..5b30bd0d178 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -279,6 +279,48 @@ async fn enroll(State(s): State, body: Bytes) -> Response { .into_response() } +async fn recover_installation(State(s): State, body: Bytes) -> Response { + let r: RecoverInstallationRequest = match crate::strict_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let token = match endpoint_bytes(&r.endpoint) { + Some(token) => token, + None => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let grant = match s.grant_keyring.open(&r.endpoint_grant) { + Ok(grant) => grant, + Err(_) => return error(StatusCode::NOT_FOUND, "not_authorized"), + }; + let now = (s.now)(); + if r.v != WIRE_VERSION + || grant.v != WIRE_VERSION + || grant.app_profile != r.app_profile + || !valid_relay_pubkey(&grant.relay_pubkey) + || grant.endpoint_epoch < 1 + || grant.generation < 1 + || grant.expires_at < now + { + return error(StatusCode::NOT_FOUND, "not_authorized"); + } + if let Err(e) = s + .authority + .recover_installation( + grant.delegation_id, + &grant.relay_pubkey, + grant.endpoint_epoch, + grant.generation, + r.app_profile, + endpoint_fingerprint(r.app_profile, &token), + now, + ) + .await + { + return authority_error(e); + } + (StatusCode::OK, Json(MutationResponse { status: "revoked" })).into_response() +} + async fn verify_installation_assertion( s: &AppState, installation_id: uuid::Uuid, @@ -812,6 +854,7 @@ pub fn router_with_metrics( .layer(RequestBodyLimitLayer::new(MAX_ENROLL_REQUEST_BYTES)); let standard_requests = Router::new() .route("/v1/installations/challenges", post(challenge)) + .route("/v1/installations/recover", post(recover_installation)) .route("/v1/delegations", post(delegate)) .route("/v1/delegations/revoke", post(revoke_delegation)) .route("/v1/installations/endpoint", post(rotate_endpoint)) @@ -955,6 +998,85 @@ mod request_limit_tests { assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } + #[tokio::test] + async fn legacy_recovery_requires_gateway_grant_and_matching_endpoint() { + let authority = Arc::new(MemoryAuthorityStore::default()); + let endpoint = vec![7; 32]; + let fingerprint = endpoint_fingerprint(AppProfile::BuzzIosDogfood, &endpoint); + authority + .create_installation( + NewInstallation { + id: uuid::Uuid::from_u128(1), + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: fingerprint, + endpoint_epoch: 1, + expires_at: fixed_now() + 60, + }, + fixed_now(), + ) + .await + .unwrap(); + let delegation_id = uuid::Uuid::from_u128(2); + let relay_pubkey = "11".repeat(32); + authority + .upsert_delegation(Delegation { + id: delegation_id, + installation_id: uuid::Uuid::from_u128(1), + relay_pubkey: relay_pubkey.clone(), + endpoint_epoch: 1, + generation: 1, + not_before: fixed_now() - 1, + expires_at: fixed_now() + 60, + revoked: false, + }) + .await + .unwrap(); + let grant_keyring = + Arc::new(GrantKeyring::new(vec![GrantKey::new("test", &[1; 32]).unwrap()]).unwrap()); + let endpoint_grant = grant_keyring + .issue(&EndpointGrant { + v: WIRE_VERSION, + delegation_id, + relay_pubkey, + app_profile: AppProfile::BuzzIosDogfood, + endpoint_epoch: 1, + generation: 1, + expires_at: fixed_now() + 60, + }) + .unwrap(); + let mut app_state = state(); + app_state.authority = authority.clone(); + app_state.grant_keyring = grant_keyring; + let (public, _) = router(app_state); + let body = serde_json::to_vec(&RecoverInstallationRequest { + v: WIRE_VERSION, + endpoint_grant, + app_profile: AppProfile::BuzzIosDogfood, + endpoint: hex::encode(endpoint), + }) + .unwrap(); + + let response = public + .oneshot( + Request::post("/v1/installations/recover") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(authority + .installation(uuid::Uuid::from_u128(1), fixed_now()) + .await + .is_err()); + } + #[test] fn live_installation_conflict_has_an_unambiguous_status() { assert_eq!( diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 390f665d8ab..0147a40e917 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -94,6 +94,17 @@ pub struct InstallationEnrollResponse { pub expires_at: i64, } +/// Recovery proof for the gateway-neutral legacy client schema. The opaque +/// grant proves which gateway and delegation own the submitted APNs token. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecoverInstallationRequest { + pub v: u8, + pub endpoint_grant: String, + pub app_profile: AppProfile, + pub endpoint: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DelegationRequest { diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index bce2e6f3290..54ef8443799 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -401,6 +401,67 @@ impl AuthorityStore for PostgresAuthorityStore { } Ok(()) } + async fn recover_installation( + &self, + delegation_id: Uuid, + relay_pubkey: &str, + endpoint_epoch: i64, + generation: i64, + profile: AppProfile, + token_fingerprint: [u8; 32], + now: i64, + ) -> Result<(), AuthorityError> { + let relay = hex::decode(relay_pubkey).map_err(|_| AuthorityError::Rejected)?; + let revoked_epoch = endpoint_epoch + .checked_add(1) + .ok_or(AuthorityError::Rejected)?; + let result = sqlx::query( + "UPDATE push_gateway_installations AS i + SET endpoint_epoch=i.endpoint_epoch+1,revoked_at=now(),updated_at=now() + FROM push_gateway_delegations AS d + WHERE d.id=$1 AND d.installation_id=i.id AND d.relay_pubkey=$2 + AND d.endpoint_epoch=$3 AND d.generation=$4 AND d.revoked_at IS NULL + AND d.expires_at >= $5 AND i.revoked_at IS NULL AND i.expires_at >= $5 + AND i.endpoint_epoch=$3 AND i.app_profile=$6 AND i.token_fingerprint=$7", + ) + .bind(delegation_id) + .bind(&relay) + .bind(endpoint_epoch) + .bind(generation) + .bind(at(now)?) + .bind(profile.as_str()) + .bind(token_fingerprint.to_vec()) + .execute(&self.pool) + .await + .map_err(db)?; + if result.rows_affected() != 1 { + let already_recovered: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM push_gateway_installations AS i + JOIN push_gateway_delegations AS d ON d.installation_id=i.id + WHERE d.id=$1 AND d.relay_pubkey=$2 AND d.endpoint_epoch=$3 + AND d.generation=$4 AND d.expires_at >= $5 + AND i.revoked_at IS NOT NULL AND i.expires_at >= $5 + AND i.endpoint_epoch=$6 AND i.app_profile=$7 + AND i.token_fingerprint=$8)", + ) + .bind(delegation_id) + .bind(&relay) + .bind(endpoint_epoch) + .bind(generation) + .bind(at(now)?) + .bind(revoked_epoch) + .bind(profile.as_str()) + .bind(token_fingerprint.to_vec()) + .fetch_one(&self.pool) + .await + .map_err(db)?; + if !already_recovered { + return Err(AuthorityError::Rejected); + } + } + Ok(()) + } async fn authorize_delivery( &self, did: Uuid, @@ -1018,6 +1079,96 @@ mod postgres_tests { drop_schema(&schema).await; } + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn legacy_recovery_revokes_only_the_grant_and_token_owner() { + let (pool, schema) = full_schema(1).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let installation_id = Uuid::from_u128(1); + let delegation_id = Uuid::from_u128(2); + store + .create_installation( + NewInstallation { + id: installation_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 + 1_000, + }, + now, + ) + .await + .expect("create legacy installation"); + store + .upsert_delegation(Delegation { + id: delegation_id, + installation_id, + relay_pubkey: RELAY_HEX.to_owned(), + endpoint_epoch: 1, + generation: 1, + not_before: now, + expires_at: now + 1_000, + revoked: false, + }) + .await + .expect("create legacy delegation"); + + assert_eq!( + store + .recover_installation( + delegation_id, + RELAY_HEX, + 1, + 1, + AppProfile::BuzzIosDogfood, + [9; 32], + now, + ) + .await, + Err(AuthorityError::Rejected) + ); + store + .recover_installation( + delegation_id, + RELAY_HEX, + 1, + 1, + AppProfile::BuzzIosDogfood, + [4; 32], + now, + ) + .await + .expect("matching gateway grant and token revoke legacy authority"); + store + .recover_installation( + delegation_id, + RELAY_HEX, + 1, + 1, + AppProfile::BuzzIosDogfood, + [4; 32], + now, + ) + .await + .expect("exact recovery retry is idempotent"); + assert!(store.installation(installation_id, now).await.is_err()); + assert!( + store + .installation_for_revocation(installation_id, now) + .await + .expect("recovery preserves tombstone") + .revoked + ); + + pool.close().await; + drop_schema(&schema).await; + } + #[tokio::test] #[ignore = "requires PostgreSQL"] async fn replacement_installation_preserves_unexpired_revocation_tombstone() { diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 6575c98bfb3..8d6d9201eb0 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -331,6 +331,16 @@ The client MUST durably journal the exact attested enrollment request before its 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. +### Gateway-neutral legacy recovery + +`POST /v1/installations/recover` + +```json +{"v":1,"endpoint_grant":"","app_profile":"buzz-ios-dogfood","endpoint":""} +``` + +This narrowly recovers clients whose older durable schema retained a gateway-issued capability and APNs token but omitted the gateway origin and App Attest key associated with the installation. The gateway MUST decrypt the capability itself and atomically revoke only the live installation named by its current delegation when the capability profile, relay key, endpoint epoch, generation, expiry, and the installation's `(app_profile, SHA-256(token))` fingerprint all match. Thus neither a capability from another gateway nor a capability paired with another token grants recovery authority. The client MUST durably retain every affected relay origin for replacement publication before calling this route and MUST retain the legacy record until a replacement grant is durable. Success, including an exact retry after response loss, is `200 {"status":"revoked"}`; invalid, expired, or mismatched proof is `404 not_authorized`. + ### Relay delegation and capability issuance `POST /v1/delegations` diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 66c566ac894..00a55869a66 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -149,6 +149,13 @@ public protocol BuzzPushEndpointGrantStore { ) throws -> Bool /// Clears the queue only after replacement publication has completed. func clearReplacementRelayOrigins() throws + /// Opaque grants retained from the gateway-neutral legacy schema. A gateway + /// may open one as proof that it owns the conflicting installation. + func quarantinedLegacyEndpointGrants() throws -> [String] +} + +extension BuzzPushEndpointGrantStore { + public func quarantinedLegacyEndpointGrants() throws -> [String] { [] } } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -835,7 +842,20 @@ public final class BuzzDevPushEnrollmentDriver { ) = error else { throw error } let cleanupStates = try store.gatewayCleanupStates() - guard !cleanupStates.isEmpty else { throw error } + if cleanupStates.isEmpty { + let recovered = try await recoverQuarantinedLegacyInstallation(endpoint: endpoint) + guard recovered else { throw error } + try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return try await enrollCurrent( + deviceToken: deviceToken, + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal + ) + } let affectedRelayOrigins = cleanupStates.flatMap { $0.grants.map(\.relayOrigin) + $0.pendingEnrollments.map(\.relayOrigin) } @@ -1180,6 +1200,34 @@ public final class BuzzDevPushEnrollmentDriver { return installation } + private func recoverQuarantinedLegacyInstallation(endpoint: String) async throws -> Bool { + for endpointGrant in try store.quarantinedLegacyEndpointGrants() { + do { + let response: MutationResponse = try await post( + route: "v1/installations/recover", + expectedStatus: 200, + body: RecoverInstallationRequest( + v: 1, + endpointGrant: endpointGrant, + appProfile: Self.appProfile, + endpoint: endpoint + ) + ) + guard response.status == "revoked" else { + throw BuzzDevPushEnrollmentError.invalidResponse( + route: "v1/installations/recover" + ) + } + return true + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations/recover", _, actual: 404, _ + ) { + continue + } + } + return false + } + private func delegate( challenge: Challenge, installationHandle: UUID, @@ -1475,6 +1523,18 @@ private struct InstallationResponse: Decodable { case expiresAt = "expires_at" } } +private struct RecoverInstallationRequest: Encodable { + let v: Int + let endpointGrant: String + let appProfile: String + let endpoint: String + enum CodingKeys: String, CodingKey { + case v + case endpointGrant = "endpoint_grant" + case appProfile = "app_profile" + case endpoint + } +} private struct DelegationRequest: Encodable { let v: Int let challengeId: String diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift new file mode 100644 index 00000000000..e87911ea51b --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Gateway-neutral recovery material retained by the pre-gateway-origin schema. +public struct BuzzPushLegacyRecoveryInventory: Equatable { + public let relayOrigins: [String] + public let endpointGrants: [String] + + public init(relayOrigins: [String], endpointGrants: [String]) { + self.relayOrigins = relayOrigins + self.endpointGrants = endpointGrants + } + + private struct LegacyGrant: Decodable { + let relayOrigin: String + let endpointGrant: String + } + + private struct LegacyPending: Decodable { + let relayOrigin: String + } + + /// Extracts only gateway-neutral relay origins and opaque gateway proofs. + /// No gateway origin or App Attest key is inferred for legacy records. + public static func decode(grants: Data?, pending: Data?) throws -> Self { + let legacyGrants = + try grants.map { + try JSONDecoder().decode([LegacyGrant].self, from: $0) + } ?? [] + let legacyPending = + try pending.map { + try JSONDecoder().decode([LegacyPending].self, from: $0) + } ?? [] + let relayOrigins = try (legacyGrants.map(\.relayOrigin) + legacyPending.map(\.relayOrigin)) + .map { origin -> String in + guard origin.utf8.count <= 2_048, + var components = URLComponents(string: origin), + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { + throw CocoaError(.coderInvalidValue) + } + switch components.scheme?.lowercased() { + case "https": components.scheme = "wss" + case "http": components.scheme = "ws" + case "wss", "ws": break + default: throw CocoaError(.coderInvalidValue) + } + components.path = "" + guard let canonical = components.string, canonical.utf8.count <= 2_048 else { + throw CocoaError(.coderInvalidValue) + } + return canonical + } + let endpointGrants = legacyGrants.map(\.endpointGrant) + guard endpointGrants.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 4_096 }) else { + throw CocoaError(.coderInvalidValue) + } + return Self( + relayOrigins: Array(Set(relayOrigins)).sorted(), + endpointGrants: Array(Set(endpointGrants)).sorted() + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 8583646238b..a6f01e06582 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -1828,6 +1828,67 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record.endpointGrant, "new-grant") } + func testEnrollmentRecoversQuarantinedLegacyInstallationWithOpaqueGrant() async throws { + let store = MemoryGrantStore() + store.legacyEndpointGrants = ["legacy-grant"] + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var installationAttempts = 0 + var recoveryAttempts = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + 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"): + installationAttempts += 1 + if installationAttempts == 1 { + return Self.response(request, status: 409, json: ["error": "installation_conflict"]) + } + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/installations/recover"): + recoveryAttempts += 1 + let body = try Self.body(request) + XCTAssertEqual(body["endpoint_grant"] as? String, "legacy-grant") + return Self.response(request, status: 200, json: ["status": "revoked"]) + case ("POST", "http://push.example/v1/delegations"): + return Self.response(request, status: 201, json: ["endpoint_grant": "new-grant"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(installationAttempts, 2) + XCTAssertEqual(recoveryAttempts, 1) + XCTAssertEqual(record.endpointGrant, "new-grant") + } + func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, @@ -2471,6 +2532,7 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var cleanup: [BuzzPushGatewayCleanupState] = [] var replacementOrigins: [String] = [] var replacementGeneration: Int64 = 0 + var legacyEndpointGrants: [String] = [] var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int var cleanupSaveFailureCalls: Set @@ -2614,6 +2676,7 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { replacementGeneration += 1 replacementOrigins = [] } + func quarantinedLegacyEndpointGrants() throws -> [String] { legacyEndpointGrants } } private final class RecordingAppAttest: BuzzDevAppAttesting { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift new file mode 100644 index 00000000000..efa83da1e22 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +struct BuzzPushLegacyRecoveryTests { + @Test func extractsGatewayNeutralRecoveryMaterial() throws { + let grants = try #require( + """ + [{"relayOrigin":"wss://relay.example/","endpointGrant":"opaque-grant","ignored":true}] + """.data(using: .utf8) + ) + let pending = try #require( + """ + [{"relayOrigin":"https://pending.example"}] + """.data(using: .utf8) + ) + + let inventory = try BuzzPushLegacyRecoveryInventory.decode( + grants: grants, + pending: pending + ) + + #expect( + inventory.relayOrigins == ["wss://pending.example", "wss://relay.example"] + ) + #expect(inventory.endpointGrants == ["opaque-grant"]) + } + + @Test func rejectsInvalidRelayOriginsWithoutAssigningGatewayAuthority() throws { + let grants = try #require( + """ + [{"relayOrigin":"wss://relay.example/path","endpointGrant":"opaque-grant"}] + """.data(using: .utf8) + ) + + #expect(throws: (any Error).self) { + try BuzzPushLegacyRecoveryInventory.decode(grants: grants, pending: nil) + } + } +} diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 20be9e28260..ef2e8d6b61c 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -630,6 +630,7 @@ import os.log relayURL: relayURL, forceDelegationRenewal: forceDelegationRenewal ) + try self?.endpointGrantStore.clearQuarantinedLegacyState() var arguments = record.flutterArguments if let inventory = try self?.pushGatewayMigrationInventory() { arguments.merge(inventory) { _, latest in latest } diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 9ab3392a84d..17bd81743ee 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -20,6 +20,10 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } func reset(forGatewayOrigin gatewayOrigin: String) throws { + let legacyInventory = try quarantinedLegacyInventory() + if !legacyInventory.relayOrigins.isEmpty { + try queueReplacementRelayOrigins(legacyInventory.relayOrigins) + } let allRecords = try records() let allPending = try pendingEnrollments() try BuzzPushGatewayStateReset.run( @@ -34,13 +38,20 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { ) } - /// Legacy records do not identify their gateway origin, and grants do not - /// identify the App Attest key that created their installation. Keep those - /// Keychain accounts as a durable quarantine rather than inventing authority - /// that could revoke an unrelated installation after a gateway change. - func hasQuarantinedLegacyState() throws -> Bool { - try data(account: Self.legacyRecordsAccount) != nil - || data(account: Self.legacyPendingAccount) != nil + func quarantinedLegacyEndpointGrants() throws -> [String] { + try quarantinedLegacyInventory().endpointGrants + } + + func clearQuarantinedLegacyState() throws { + try delete(account: Self.legacyRecordsAccount) + try delete(account: Self.legacyPendingAccount) + } + + private func quarantinedLegacyInventory() throws -> BuzzPushLegacyRecoveryInventory { + try BuzzPushLegacyRecoveryInventory.decode( + grants: data(account: Self.legacyRecordsAccount), + pending: data(account: Self.legacyPendingAccount) + ) } func records() throws -> [BuzzPushEndpointGrantRecord] { @@ -256,6 +267,13 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try replaceValue(values, account: account) } + private func delete(account: String) throws { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw keychainError(status, operation: "delete legacy state") + } + } + private func replaceValue(_ value: T, account: String) throws { let data = try JSONEncoder().encode(value) let updateStatus = SecItemUpdate( diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 756b1d3a981..624d00c3d81 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -429,6 +429,12 @@ class CommunityListNotifier extends AsyncNotifier> { final generationCursor = community.pushSubscriptionState.generationCursor ?? 0; 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, diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index ceaa02ec5ca..cbbad6afaff 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -221,6 +221,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); From 49d8ac3c2491241e4647b16e0a9ce31b5b19765c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 14:17:40 -0700 Subject: [PATCH 60/67] Recover response-lost legacy enrollment Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- docs/nips/NIP-PL.md | 2 +- .../BuzzDevPushEnrollmentDriver.swift | 50 +++++++++++++ .../BuzzPushKit/BuzzPushLegacyRecovery.swift | 47 ++++++++++-- .../BuzzDevPushEnrollmentDriverTests.swift | 72 +++++++++++++++++++ .../BuzzPushLegacyRecoveryTests.swift | 3 +- .../ios/Runner/PushEndpointGrantStore.swift | 6 ++ 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 8d6d9201eb0..4e375c1336d 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -339,7 +339,7 @@ Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge o {"v":1,"endpoint_grant":"","app_profile":"buzz-ios-dogfood","endpoint":""} ``` -This narrowly recovers clients whose older durable schema retained a gateway-issued capability and APNs token but omitted the gateway origin and App Attest key associated with the installation. The gateway MUST decrypt the capability itself and atomically revoke only the live installation named by its current delegation when the capability profile, relay key, endpoint epoch, generation, expiry, and the installation's `(app_profile, SHA-256(token))` fingerprint all match. Thus neither a capability from another gateway nor a capability paired with another token grants recovery authority. The client MUST durably retain every affected relay origin for replacement publication before calling this route and MUST retain the legacy record until a replacement grant is durable. Success, including an exact retry after response loss, is `200 {"status":"revoked"}`; invalid, expired, or mismatched proof is `404 not_authorized`. +This narrowly recovers clients whose older durable schema retained a gateway-issued capability and APNs token but omitted the gateway origin and App Attest key associated with the installation. The gateway MUST decrypt the capability itself and atomically revoke only the live installation named by its current delegation when the capability profile, relay key, endpoint epoch, generation, expiry, and the installation's `(app_profile, SHA-256(token))` fingerprint all match. Thus neither a capability from another gateway nor a capability paired with another token grants recovery authority. When legacy state contains only a response-loss enrollment journal, the client may instead replay that exact attested request to candidate gateways and use the returned handle only on the gateway that cryptographically accepts it. The client MUST durably retain every affected relay origin for replacement publication before either recovery path and MUST retain the legacy record until a replacement grant is durable. Success, including an exact retry after response loss, is `200 {"status":"revoked"}`; invalid, expired, or mismatched proof is `404 not_authorized`. ### Relay delegation and capability issuance diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 00a55869a66..23a00dc6b3f 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -152,10 +152,15 @@ public protocol BuzzPushEndpointGrantStore { /// Opaque grants retained from the gateway-neutral legacy schema. A gateway /// may open one as proof that it owns the conflicting installation. func quarantinedLegacyEndpointGrants() throws -> [String] + func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] } extension BuzzPushEndpointGrantStore { public func quarantinedLegacyEndpointGrants() throws -> [String] { [] } + public func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] + { [] } } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -1225,6 +1230,51 @@ public final class BuzzDevPushEnrollmentDriver { continue } } + let endpointHash = Self.endpointHash(endpoint) + for pending in try store.quarantinedLegacyPendingEnrollments() { + guard pending.appProfile == Self.appProfile, + pending.endpointHash == endpointHash, + pending.expiresAt > Int64(now().timeIntervalSince1970), + let keyId = pending.keyId, + BuzzAppAttestKeyId.isValid(keyId) + else { continue } + do { + let installation: UUID + if let handle = pending.gatewayInstallationHandle, + let existing = UUID(uuidString: handle), + handle == existing.uuidString.lowercased() + { + installation = existing + } else { + guard let challengeId = pending.challengeId, + let challengeUUID = UUID(uuidString: challengeId), + challengeId == challengeUUID.uuidString.lowercased(), + let challenge = pending.challenge, + Self.isBase64URLChallenge(challenge), + let attestation = pending.attestation, + !attestation.isEmpty, + attestation.utf8.count <= 24_000 + else { continue } + installation = try await enrollInstallation( + challenge: Challenge(id: challengeUUID, value: challenge), + endpoint: endpoint, + expiresAt: pending.expiresAt, + attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) + ) + } + try await revokeInstallation( + installationHandle: installation, + endpointEpoch: Self.endpointEpoch, + appAttestKeyId: keyId + ) + return true + } catch BuzzDevPushEnrollmentError.unexpectedStatus(_, _, actual: 401, _), + BuzzDevPushEnrollmentError.unexpectedStatus(_, _, actual: 404, _), + BuzzDevPushEnrollmentError.unexpectedStatus(_, _, actual: 409, _) + { + continue + } + } return false } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift index e87911ea51b..14a105b86c5 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift @@ -4,10 +4,16 @@ import Foundation public struct BuzzPushLegacyRecoveryInventory: Equatable { public let relayOrigins: [String] public let endpointGrants: [String] + public let pendingEnrollments: [BuzzPushLegacyPendingRecovery] - public init(relayOrigins: [String], endpointGrants: [String]) { + public init( + relayOrigins: [String], + endpointGrants: [String], + pendingEnrollments: [BuzzPushLegacyPendingRecovery] + ) { self.relayOrigins = relayOrigins self.endpointGrants = endpointGrants + self.pendingEnrollments = pendingEnrollments } private struct LegacyGrant: Decodable { @@ -15,8 +21,38 @@ public struct BuzzPushLegacyRecoveryInventory: Equatable { let endpointGrant: String } - private struct LegacyPending: Decodable { - let relayOrigin: String + public struct BuzzPushLegacyPendingRecovery: Decodable, Equatable { + public let relayOrigin: String + public let endpointHash: String + public let appProfile: String + public let expiresAt: Int64 + public let gatewayInstallationHandle: String? + public let challengeId: String? + public let challenge: String? + public let keyId: String? + public let attestation: String? + + public init( + relayOrigin: String, + endpointHash: String, + appProfile: String, + expiresAt: Int64, + gatewayInstallationHandle: String? = nil, + challengeId: String? = nil, + challenge: String? = nil, + keyId: String? = nil, + attestation: String? = nil + ) { + self.relayOrigin = relayOrigin + self.endpointHash = endpointHash + self.appProfile = appProfile + self.expiresAt = expiresAt + self.gatewayInstallationHandle = gatewayInstallationHandle + self.challengeId = challengeId + self.challenge = challenge + self.keyId = keyId + self.attestation = attestation + } } /// Extracts only gateway-neutral relay origins and opaque gateway proofs. @@ -28,7 +64,7 @@ public struct BuzzPushLegacyRecoveryInventory: Equatable { } ?? [] let legacyPending = try pending.map { - try JSONDecoder().decode([LegacyPending].self, from: $0) + try JSONDecoder().decode([BuzzPushLegacyPendingRecovery].self, from: $0) } ?? [] let relayOrigins = try (legacyGrants.map(\.relayOrigin) + legacyPending.map(\.relayOrigin)) .map { origin -> String in @@ -61,7 +97,8 @@ public struct BuzzPushLegacyRecoveryInventory: Equatable { } return Self( relayOrigins: Array(Set(relayOrigins)).sorted(), - endpointGrants: Array(Set(endpointGrants)).sorted() + endpointGrants: Array(Set(endpointGrants)).sorted(), + pendingEnrollments: legacyPending ) } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index a6f01e06582..59c75a1fa97 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -1889,6 +1889,74 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record.endpointGrant, "new-grant") } + func testEnrollmentRecoversResponseLostLegacyPendingInstallation() async throws { + let token = Data((1...32).map(UInt8.init)) + let store = MemoryGrantStore() + store.legacyPendingEnrollments = [ + BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery( + relayOrigin: "wss://relay.example", + endpointHash: Self.hex(SHA256.hash(data: token)), + appProfile: "buzz-ios-dogfood", + expiresAt: Self.expiresAt, + challengeId: Self.firstChallengeId, + challenge: Self.challenge, + keyId: Self.keyId, + attestation: Self.attestation + ) + ] + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var installationAttempts = 0 + var revokeAttempts = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + 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"): + installationAttempts += 1 + if installationAttempts == 1 { + return Self.response(request, status: 409, json: ["error": "installation_conflict"]) + } + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/installations/revoke"): + revokeAttempts += 1 + return Self.response(request, status: 200, json: ["status": "revoked"]) + case ("POST", "http://push.example/v1/delegations"): + return Self.response(request, status: 201, json: ["endpoint_grant": "new-grant"]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll(deviceToken: token, relayURL: Self.relayURL) + + XCTAssertEqual(installationAttempts, 3) + XCTAssertEqual(revokeAttempts, 1) + XCTAssertEqual(record.endpointGrant, "new-grant") + } + func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, @@ -2533,6 +2601,7 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var replacementOrigins: [String] = [] var replacementGeneration: Int64 = 0 var legacyEndpointGrants: [String] = [] + var legacyPendingEnrollments: [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] = [] var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int var cleanupSaveFailureCalls: Set @@ -2677,6 +2746,9 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { replacementOrigins = [] } func quarantinedLegacyEndpointGrants() throws -> [String] { legacyEndpointGrants } + func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] + { legacyPendingEnrollments } } private final class RecordingAppAttest: BuzzDevAppAttesting { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift index efa83da1e22..211fb60eb1e 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift @@ -12,7 +12,7 @@ struct BuzzPushLegacyRecoveryTests { ) let pending = try #require( """ - [{"relayOrigin":"https://pending.example"}] + [{"relayOrigin":"https://pending.example","endpointHash":"abc","appProfile":"buzz-ios-dogfood","expiresAt":99}] """.data(using: .utf8) ) @@ -25,6 +25,7 @@ struct BuzzPushLegacyRecoveryTests { inventory.relayOrigins == ["wss://pending.example", "wss://relay.example"] ) #expect(inventory.endpointGrants == ["opaque-grant"]) + #expect(inventory.pendingEnrollments.map(\.endpointHash) == ["abc"]) } @Test func rejectsInvalidRelayOriginsWithoutAssigningGatewayAuthority() throws { diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 17bd81743ee..8cdda51bcea 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -42,6 +42,12 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { try quarantinedLegacyInventory().endpointGrants } + func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] + { + try quarantinedLegacyInventory().pendingEnrollments + } + func clearQuarantinedLegacyState() throws { try delete(account: Self.legacyRecordsAccount) try delete(account: Self.legacyPendingAccount) From 843170db4cbd08e7a42d53fdcf6b2ad73440a28e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 14:20:57 -0700 Subject: [PATCH 61/67] Migrate gateway tombstone indexes Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../0005_retain_revocation_tombstones.sql | 15 ++++++++++++ crates/buzz-push-gateway/src/postgres.rs | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 crates/buzz-push-gateway/migrations/0005_retain_revocation_tombstones.sql 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 00000000000..bb00d1db13e --- /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/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 54ef8443799..149af270793 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -682,6 +682,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) From 838d0dc34f1b6d680c01c0cac4ea7af853d59035 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 14:30:12 -0700 Subject: [PATCH 62/67] Fence push migration acceptance Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/push/push_bootstrap.dart | 27 ++++++++++++++----- .../test/shared/push/push_bootstrap_test.dart | 19 +++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index b1b9ac81c77..e4bb6f72c9b 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -144,6 +144,15 @@ bool buzzPushGatewayMigrationAttemptIsCurrent({ setEquals(replacementRelayOrigins, liveReplacementRelayOrigins) && replacementGeneration == liveReplacementGeneration; +@visibleForTesting +Future markBuzzPushGatewayMigrationAcceptedIfCurrent({ + required bool Function() attemptIsCurrent, + required Future Function() markAccepted, +}) { + if (!attemptIsCurrent()) return Future.value(false); + return markAccepted(); +} + typedef BuzzPushGatewayMigrationTarget = ({ Community community, String relayOrigin, @@ -561,6 +570,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { descriptor: target.descriptor, forceDelegationRenewal: queuedOrigins.isNotEmpty && index == 0, + attemptIsCurrent: attemptIsCurrent, ); } if (queuedOrigins.isEmpty) return false; @@ -770,6 +780,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { String targetGatewayOrigin, { required BuzzPushLeaseDescriptor descriptor, bool forceDelegationRenewal = false, + required bool Function() attemptIsCurrent, }) async { final config = RelayConfig( baseUrl: community.relayUrl, @@ -810,12 +821,16 @@ class BuzzPushBootstrap extends HookConsumerWidget { createdAt: createdAt, ), ), - markAccepted: (generation) => notifier.markPushLeaseAccepted( - community.id, - subscriptions: community.pushSubscriptionState.desired, - generation: generation, - gatewayOrigin: targetGatewayOrigin, - ), + markAccepted: (generation) => + markBuzzPushGatewayMigrationAcceptedIfCurrent( + attemptIsCurrent: attemptIsCurrent, + markAccepted: () => notifier.markPushLeaseAccepted( + community.id, + subscriptions: community.pushSubscriptionState.desired, + generation: generation, + gatewayOrigin: targetGatewayOrigin, + ), + ), ); return grant; } diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 2a1b7c5b662..5a0970e3d86 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -267,6 +267,25 @@ void main() { ); }); + test( + 'gateway migration rejects a stale APNs token before acceptance', + () async { + var accepted = false; + + expect( + await markBuzzPushGatewayMigrationAcceptedIfCurrent( + attemptIsCurrent: () => false, + markAccepted: () async { + accepted = true; + return true; + }, + ), + isFalse, + ); + expect(accepted, isFalse); + }, + ); + test('queued origins sharing delegation authority migrate atomically', () { final first = Community.create( name: 'First', From 4b59a1d019320e9360ed7c6a7683d74dcddfc6a5 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 14:42:17 -0700 Subject: [PATCH 63/67] Fence push cleanup against token rotation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- docs/nips/NIP-PL.md | 4 +- .../BuzzPushCleanupTokenFence.swift | 20 ++++++++++ .../BuzzPushCleanupTokenFenceTests.swift | 37 +++++++++++++++++++ mobile/ios/Runner/AppDelegate.swift | 13 ++++++- 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushCleanupTokenFence.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushCleanupTokenFenceTests.swift diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 4e375c1336d..a79fbe58a5a 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`; clients use this distinct authenticated result to discover and revoke response-loss legacy enrollment before retrying. A fresh verified enrollment may replace expired or revoked ownership so an app that missed its renewal window can recover. ### Gateway-neutral legacy recovery diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushCleanupTokenFence.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushCleanupTokenFence.swift new file mode 100644 index 00000000000..8e824a03a6b --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushCleanupTokenFence.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Prevents cleanup from checkpointing work produced for an obsolete APNs token. +public enum BuzzPushCleanupTokenFence { + /// Runs `checkpoint` only when a nonempty captured token is still current. + @discardableResult + public static func checkpointIfCurrent( + capturedDeviceToken: Data?, + liveDeviceToken: Data?, + checkpoint: () throws -> Void + ) rethrows -> Bool { + guard let capturedDeviceToken, !capturedDeviceToken.isEmpty, + capturedDeviceToken == liveDeviceToken + else { + return false + } + try checkpoint() + return true + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushCleanupTokenFenceTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushCleanupTokenFenceTests.swift new file mode 100644 index 00000000000..42779e0573f --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushCleanupTokenFenceTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +struct BuzzPushCleanupTokenFenceTests { + @Test + func checkpointsWhenDeviceTokenIsStillCurrent() { + let token = Data([0x01, 0x02]) + var checkpointed = false + + let didCheckpoint = BuzzPushCleanupTokenFence.checkpointIfCurrent( + capturedDeviceToken: token, + liveDeviceToken: token + ) { + checkpointed = true + } + + #expect(didCheckpoint) + #expect(checkpointed) + } + + @Test + func retainsWorkWhenDeviceTokenRotates() { + var checkpointed = false + + let didCheckpoint = BuzzPushCleanupTokenFence.checkpointIfCurrent( + capturedDeviceToken: Data([0x01, 0x02]), + liveDeviceToken: Data([0x03, 0x04]) + ) { + checkpointed = true + } + + #expect(!didCheckpoint) + #expect(!checkpointed) + } +} diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index ef2e8d6b61c..cd3c2c8a08b 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -667,9 +667,18 @@ import os.log store: endpointGrantStore, appAttestKeychainAccessGroup: pushKeychainAccessGroup ) + let cleanupDeviceToken = apnsDeviceToken let task = Task { [weak self] in - try await driver.cleanRetiredGateways(deviceToken: self?.apnsDeviceToken) - try self?.endpointGrantStore.clearReplacementRelayOrigins() + try await driver.cleanRetiredGateways(deviceToken: cleanupDeviceToken) + try await MainActor.run { [weak self] in + guard let self else { return } + try BuzzPushCleanupTokenFence.checkpointIfCurrent( + capturedDeviceToken: cleanupDeviceToken, + liveDeviceToken: self.apnsDeviceToken + ) { + try self.endpointGrantStore.clearReplacementRelayOrigins() + } + } } gatewayCleanupTask = task Task { [weak self] in From deab86b83e412b034b2bef17bdb44a3158170933 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 14:53:27 -0700 Subject: [PATCH 64/67] Preserve push migration work after rotation Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- crates/buzz-push-gateway/src/http.rs | 40 ++++++++++++------- mobile/ios/Runner/AppDelegate.swift | 2 +- mobile/lib/shared/push/push_bridge.dart | 18 ++++++--- mobile/test/shared/push/push_bridge_test.dart | 33 ++++++++++++++- 4 files changed, 71 insertions(+), 22 deletions(-) diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 5b30bd0d178..8c2f8bca511 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -321,18 +321,22 @@ async fn recover_installation(State(s): State, body: Bytes) -> Respons (StatusCode::OK, Json(MutationResponse { status: "revoked" })).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 = if include_revoked { s.authority @@ -355,12 +359,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 @@ -417,8 +421,10 @@ 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, @@ -510,8 +516,10 @@ 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, @@ -570,8 +578,10 @@ 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, @@ -623,8 +633,10 @@ 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, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index cd3c2c8a08b..63bf0cc20ac 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -388,7 +388,7 @@ import os.log if let cleanupTask { try await cleanupTask.value } - result(nil) + result(try pushGatewayMigrationInventory()) } catch { result( FlutterError( diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 99ec2e478c3..c23bc406b53 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -168,12 +168,18 @@ Future> initializeBuzzPushGateway() async { Future completeBuzzPushGatewayMigration() async { if (defaultTargetPlatform != TargetPlatform.iOS) return; try { - await _channel.invokeMethod('completeGatewayMigration', { - 'gatewayUrl': Env.pushGatewayUrl, - }); - retiredBuzzPushRelayOrigins.value = const {}; - replacementBuzzPushRelayOrigins.value = const {}; - replacementBuzzPushGeneration.value = 0; + final inventory = await _channel.invokeMapMethod( + 'completeGatewayMigration', + {'gatewayUrl': Env.pushGatewayUrl}, + ); + retiredBuzzPushRelayOrigins.value = _relayOriginSet( + inventory?['retiredRelayOrigins'], + ); + replacementBuzzPushRelayOrigins.value = _relayOriginSet( + inventory?['replacementRelayOrigins'], + ); + replacementBuzzPushGeneration.value = + inventory?['replacementGeneration'] as int? ?? 0; } on MissingPluginException { // Flutter tests and non-Runner embeddings do not install the native bridge. } diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index ec5272da59d..e82ead17a86 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -85,7 +85,11 @@ void main() { .setMockMethodCallHandler(_channel, (call) async { expect(call.method, 'completeGatewayMigration'); expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); - return null; + return { + 'retiredRelayOrigins': [], + 'replacementRelayOrigins': [], + 'replacementGeneration': 0, + }; }); await completeBuzzPushGatewayMigration(); @@ -96,6 +100,33 @@ void main() { }, ); + test( + 'retains native migration inventory when cleanup token becomes stale', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + retiredBuzzPushRelayOrigins.value = {'wss://old-relay.example'}; + replacementBuzzPushRelayOrigins.value = {'wss://old-relay.example'}; + replacementBuzzPushGeneration.value = 4; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'completeGatewayMigration'); + return { + 'retiredRelayOrigins': [], + 'replacementRelayOrigins': ['wss://old-relay.example'], + 'replacementGeneration': 4, + }; + }); + + await completeBuzzPushGatewayMigration(); + + expect(retiredBuzzPushRelayOrigins.value, isEmpty); + expect(replacementBuzzPushRelayOrigins.value, { + 'wss://old-relay.example', + }); + expect(replacementBuzzPushGeneration.value, 4); + }, + ); + test('atomically checkpoints origins sharing delegation authority', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; replacementBuzzPushRelayOrigins.value = { From 18d4a07a49fe927782c693293a602a383b50883a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 15:06:13 -0700 Subject: [PATCH 65/67] Journal push migration authority groups Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/ios/Runner/AppDelegate.swift | 27 +++++++ mobile/lib/shared/push/push_bootstrap.dart | 81 +++++++++++++++---- mobile/lib/shared/push/push_bridge.dart | 22 +++++ .../test/shared/push/push_bootstrap_test.dart | 31 +++++++ mobile/test/shared/push/push_bridge_test.dart | 34 ++++++++ 5 files changed, 179 insertions(+), 16 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 63bf0cc20ac..9b6cf12c4f5 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -408,6 +408,33 @@ import os.log ) ) } + case "queueGatewayReplacements": + guard let arguments = call.arguments as? [String: Any], + let relayOrigins = arguments["relayOrigins"] as? [String], + !relayOrigins.isEmpty, + relayOrigins.allSatisfy({ !$0.isEmpty }) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Gateway replacement queue requires relayOrigins.", + details: nil + ) + ) + return + } + do { + try endpointGrantStore.queueReplacementRelayOrigins(relayOrigins) + result(try pushGatewayMigrationInventory()) + } catch { + result( + FlutterError( + code: "push_gateway_queue_failed", + message: "Push gateway replacement queue failed.", + details: error.localizedDescription + ) + ) + } case "checkpointGatewayReplacements": guard let arguments = call.arguments as? [String: Any], let relayOrigins = arguments["relayOrigins"] as? [String], diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index e4bb6f72c9b..60f692cf983 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -83,6 +83,26 @@ class BuzzPushAttemptGate { void dispose() => _retryTimer?.cancel(); } +@visibleForTesting +class BuzzPushAttemptFailureBudget { + String? _attempt; + int _failureCount = 0; + + int recordFailure(String attempt) { + if (_attempt != attempt) { + _attempt = attempt; + _failureCount = 0; + } + return ++_failureCount; + } + + void clear(String attempt) { + if (_attempt != attempt) return; + _attempt = null; + _failureCount = 0; + } +} + @visibleForTesting String buzzPushPublicationAttemptKey({ required String communityId, @@ -231,6 +251,19 @@ buzzPushGroupGatewayMigrationsByDelegationAuthority( return groups; } +@visibleForTesting +Set buzzPushGatewayMigrationGroupOriginsToQueue({ + required Iterable targets, + required Set replacementRelayOrigins, +}) { + final groupOrigins = targets.map((target) => target.relayOrigin).toSet(); + if (groupOrigins.intersection(replacementRelayOrigins).isEmpty || + replacementRelayOrigins.containsAll(groupOrigins)) { + return const {}; + } + return groupOrigins; +} + @visibleForTesting Future processBuzzPushGatewayMigrationGroups({ required Iterable groups, @@ -346,7 +379,9 @@ class BuzzPushBootstrap extends HookConsumerWidget { final gatewayInitializationFailures = useRef(0); final publicationRetry = useState(0); final gatewayMigrationRetry = useState(0); - final gatewayMigrationFailures = useRef(0); + final gatewayMigrationFailures = useMemoized( + BuzzPushAttemptFailureBudget.new, + ); final tombstoneRetry = useState(0); final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); final session = ref.watch(relaySessionProvider); @@ -517,19 +552,18 @@ class BuzzPushBootstrap extends HookConsumerWidget { ].join('|'); if (!gatewayMigrationAttempt.tryBegin(attempt)) return null; unawaited(() async { + bool attemptIsCurrent() => buzzPushGatewayMigrationAttemptIsCurrent( + attemptIsCurrent: gatewayMigrationAttempt.isCurrent(attempt), + token: token, + liveToken: apnsDeviceToken.value, + retiredRelayOrigins: retiredRelayOrigins, + liveRetiredRelayOrigins: retiredBuzzPushRelayOrigins.value, + replacementRelayOrigins: replacementRelayOrigins, + liveReplacementRelayOrigins: replacementBuzzPushRelayOrigins.value, + replacementGeneration: replacementGeneration, + liveReplacementGeneration: replacementBuzzPushGeneration.value, + ); try { - bool attemptIsCurrent() => buzzPushGatewayMigrationAttemptIsCurrent( - attemptIsCurrent: gatewayMigrationAttempt.isCurrent(attempt), - token: token, - liveToken: apnsDeviceToken.value, - retiredRelayOrigins: retiredRelayOrigins, - liveRetiredRelayOrigins: retiredBuzzPushRelayOrigins.value, - replacementRelayOrigins: replacementRelayOrigins, - liveReplacementRelayOrigins: - replacementBuzzPushRelayOrigins.value, - replacementGeneration: replacementGeneration, - liveReplacementGeneration: replacementBuzzPushGeneration.value, - ); final candidates = buzzPushCommunitiesRequiringGatewayMigration( communities: communities, retiredRelayOrigins: migrationRelayOrigins, @@ -549,6 +583,18 @@ class BuzzPushBootstrap extends HookConsumerWidget { initialError: resolution.firstError, initialStack: resolution.firstStack, process: (authorityTargets) async { + final originsToQueue = + buzzPushGatewayMigrationGroupOriginsToQueue( + targets: authorityTargets, + replacementRelayOrigins: replacementRelayOrigins, + ); + if (originsToQueue.isNotEmpty) { + if (!attemptIsCurrent()) return true; + await queueBuzzPushGatewayReplacements(originsToQueue); + // Queueing advances the inventory generation. Let the + // resulting rebuild process the fully journaled group. + return true; + } final queuedOrigins = authorityTargets .map((target) => target.relayOrigin) .where(replacementRelayOrigins.contains) @@ -589,12 +635,15 @@ class BuzzPushBootstrap extends HookConsumerWidget { if (stopped) return; if (!attemptIsCurrent()) return; await completeBuzzPushGatewayMigration(); - gatewayMigrationFailures.value = 0; + gatewayMigrationFailures.clear(attempt); gatewayMigrationAttempt.complete(attempt); } catch (error, stack) { - gatewayMigrationFailures.value += 1; + if (!attemptIsCurrent()) return; + final failureCount = gatewayMigrationFailures.recordFailure( + attempt, + ); final retryDelay = buzzPushGatewayInitializationRetryDelay( - gatewayMigrationFailures.value, + failureCount, ); if (retryDelay == null) { gatewayMigrationAttempt.complete(attempt); diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index c23bc406b53..a7c14a4d771 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -185,6 +185,28 @@ Future completeBuzzPushGatewayMigration() async { } } +/// Durably queues every relay origin sharing delegation authority before any +/// member is renewed, so a partial group cannot lose replacement work. +Future queueBuzzPushGatewayReplacements(Set relayOrigins) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final inventory = await _channel.invokeMapMethod( + 'queueGatewayReplacements', + {'relayOrigins': relayOrigins.toList()..sort()}, + ); + retiredBuzzPushRelayOrigins.value = _relayOriginSet( + inventory?['retiredRelayOrigins'], + ); + replacementBuzzPushRelayOrigins.value = _relayOriginSet( + inventory?['replacementRelayOrigins'], + ); + replacementBuzzPushGeneration.value = + inventory?['replacementGeneration'] as int? ?? 0; + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + /// Atomically removes same-gateway relay origins only after all community /// replacement leases sharing their delegation authority are durable. Future checkpointBuzzPushGatewayReplacements( diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 5a0970e3d86..6e49a78ab41 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -325,6 +325,37 @@ void main() { expect(groups[_hex('b')]!.map((target) => target.relayOrigin), [ 'wss://independent.example', ]); + + expect( + buzzPushGatewayMigrationGroupOriginsToQueue( + targets: groups[_hex('a')]!, + replacementRelayOrigins: const {'wss://first.example'}, + ), + {'wss://first.example', 'wss://second.example'}, + ); + expect( + buzzPushGatewayMigrationGroupOriginsToQueue( + targets: groups[_hex('a')]!, + replacementRelayOrigins: const { + 'wss://first.example', + 'wss://second.example', + }, + ), + isEmpty, + ); + }); + + test('migration retry budget resets for a new attempt generation', () { + final budget = BuzzPushAttemptFailureBudget(); + + expect(budget.recordFailure('generation-1'), 1); + expect(budget.recordFailure('generation-1'), 2); + expect(budget.recordFailure('generation-2'), 1); + expect(budget.recordFailure('generation-2'), 2); + budget.clear('generation-1'); + expect(budget.recordFailure('generation-2'), 3); + budget.clear('generation-2'); + expect(budget.recordFailure('generation-2'), 1); }); test('descriptor resolution preserves reachable migration work', () async { diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index e82ead17a86..52e5a3d2121 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -127,6 +127,40 @@ void main() { }, ); + test('durably queues a complete delegation authority group', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'queueGatewayReplacements'); + expect(call.arguments, { + 'relayOrigins': [ + 'wss://already-queued.example', + 'wss://retired-only.example', + ], + }); + return { + 'retiredRelayOrigins': ['wss://retired-only.example'], + 'replacementRelayOrigins': [ + 'wss://already-queued.example', + 'wss://retired-only.example', + ], + 'replacementGeneration': 5, + }; + }); + + await queueBuzzPushGatewayReplacements({ + 'wss://retired-only.example', + 'wss://already-queued.example', + }); + + expect(retiredBuzzPushRelayOrigins.value, {'wss://retired-only.example'}); + expect(replacementBuzzPushRelayOrigins.value, { + 'wss://already-queued.example', + 'wss://retired-only.example', + }); + expect(replacementBuzzPushGeneration.value, 5); + }); + test('atomically checkpoints origins sharing delegation authority', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; replacementBuzzPushRelayOrigins.value = { From ea811c27be1536ee98477ef07b0287eb2ef46747 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 15:14:26 -0700 Subject: [PATCH 66/67] Fence stale push lease publication Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/push/push_bootstrap.dart | 8 ++++ .../test/shared/push/push_bootstrap_test.dart | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 60f692cf983..be227500e24 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -349,8 +349,15 @@ Future publishBuzzPushLeaseRecoverably({ required Future Function() reserveGeneration, required Future Function(int generation) publish, required Future Function(int generation) markAccepted, + bool Function()? operationIsCurrent, }) async { + if (operationIsCurrent?.call() == false) { + throw StateError('Push lease publication attempt is obsolete.'); + } final generation = await reserveGeneration(); + if (operationIsCurrent?.call() == false) { + throw StateError('Push lease publication attempt is obsolete.'); + } await publish(generation); if (!await markAccepted(generation)) { throw StateError('A newer push lease superseded the published generation.'); @@ -852,6 +859,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { await publishBuzzPushLeaseRecoverably( reserveGeneration: () => notifier.reservePushLeaseGeneration(community.id), + operationIsCurrent: attemptIsCurrent, publish: (generation) => publishBuzzDevPushLease( grant: grant, leaseInstallationId: community.pushLeaseInstallationId, diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 6e49a78ab41..0d108ae0a08 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -484,6 +484,48 @@ void main() { throwsStateError, ); }); + + test( + 'stale migration cannot reserve or publish a lease generation', + () async { + var reserved = false; + var published = false; + + await expectLater( + publishBuzzPushLeaseRecoverably( + operationIsCurrent: () => false, + reserveGeneration: () async { + reserved = true; + return 3; + }, + publish: (_) async => published = true, + markAccepted: (_) async => true, + ), + throwsStateError, + ); + expect(reserved, isFalse); + expect(published, isFalse); + }, + ); + + test('migration becoming stale during reservation cannot publish', () async { + var current = true; + var published = false; + + await expectLater( + publishBuzzPushLeaseRecoverably( + operationIsCurrent: () => current, + reserveGeneration: () async { + current = false; + return 3; + }, + publish: (_) async => published = true, + markAccepted: (_) async => true, + ), + throwsStateError, + ); + expect(published, isFalse); + }); } BuzzPushLeaseDescriptor _descriptor({ From 14d38ce88a590bf467dc608b4e2c2dba1d38c1e7 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 3 Sep 2026 15:23:36 -0700 Subject: [PATCH 67/67] Fence stale push enrollment Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/shared/push/push_bootstrap.dart | 26 +++++++++++++++---- .../test/shared/push/push_bootstrap_test.dart | 15 +++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index be227500e24..f0dba0dce42 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -173,6 +173,19 @@ Future markBuzzPushGatewayMigrationAcceptedIfCurrent({ return markAccepted(); } +@visibleForTesting +Future runBuzzPushGatewayMigrationMutationIfCurrent({ + required bool Function() attemptIsCurrent, + required Future Function() mutate, +}) { + if (!attemptIsCurrent()) { + return Future.error( + StateError('Push gateway migration attempt is obsolete.'), + ); + } + return mutate(); +} + typedef BuzzPushGatewayMigrationTarget = ({ Community community, String relayOrigin, @@ -849,11 +862,14 @@ class BuzzPushBootstrap extends HookConsumerWidget { 'Cannot migrate push for ${community.id}: signing key is unavailable', ); } - final grant = await enrollBuzzPush( - config.wsUrl, - Env.pushGatewayUrl, - communitiesForSnapshotRefresh: communities, - forceDelegationRenewal: forceDelegationRenewal, + final grant = await runBuzzPushGatewayMigrationMutationIfCurrent( + attemptIsCurrent: attemptIsCurrent, + mutate: () => enrollBuzzPush( + config.wsUrl, + Env.pushGatewayUrl, + communitiesForSnapshotRefresh: communities, + forceDelegationRenewal: forceDelegationRenewal, + ), ); final notifier = ref.read(communityListProvider.notifier); await publishBuzzPushLeaseRecoverably( diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 0d108ae0a08..fcd5be1772a 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -286,6 +286,21 @@ void main() { }, ); + test('stale migration cannot start an authority mutation', () async { + var mutated = false; + + await expectLater( + runBuzzPushGatewayMigrationMutationIfCurrent( + attemptIsCurrent: () => false, + mutate: () async { + mutated = true; + }, + ), + throwsStateError, + ); + expect(mutated, isFalse); + }); + test('queued origins sharing delegation authority migrate atomically', () { final first = Community.create( name: 'First',