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/AGENTS.md b/AGENTS.md index 9d1a6afd429..01ee082135b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -691,7 +691,7 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. cd mobile dart format --output=none --set-exit-if-changed . flutter analyze -flutter test +flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example ``` Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests). diff --git a/Justfile b/Justfile index c81adb2381b..24e5a19f4d3 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,15 @@ mobile-dev: sleep 3 fi ./scripts/mobile-worktree-overrides.sh + gateway_url="${BUZZ_PUSH_GATEWAY_URL:-}" + overrides_file="{{mobile_dir}}/ios/Flutter/AppOverrides.xcconfig" + if [[ -z "$gateway_url" && -f "$overrides_file" ]]; then + gateway_url="$(sed -nE 's/^[[:space:]]*BUZZ_PUSH_GATEWAY_URL[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$overrides_file" | tail -n 1 | sed 's/\$()//g')" + fi + 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 + 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/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] 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/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 1a7ef4b2a65..aa26bec82ff 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -103,6 +103,8 @@ pub enum DeliveryDisposition { pub enum AuthorityError { #[error("authority state rejected the request")] Rejected, + #[error("installation authority is already live")] + Conflict, #[error("authority request rate exceeded")] RateLimited, #[error("authority store unavailable")] @@ -140,6 +142,13 @@ pub trait AuthorityStore: Send + Sync { now: i64, ) -> Result, AuthorityError>; async fn installation(&self, id: Uuid, now: i64) -> Result; + /// Load an unexpired installation for authenticated revocation retry, + /// including its terminal tombstone. + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result; async fn advance_assertion_counter( &self, installation_id: Uuid, @@ -155,8 +164,9 @@ pub trait AuthorityStore: Send + Sync { token_ciphertext: Vec, token_fingerprint: [u8; 32], ) -> Result<(), AuthorityError>; - /// Revoke an active delegation only when `expected_generation` is current, - /// retaining that generation as the replacement watermark. + /// Revoke a delegation only when `expected_generation` is current, + /// retaining that generation as the replacement watermark. Repeating the + /// exact revocation succeeds so clients can recover from a lost response. async fn revoke_delegation( &self, installation_id: Uuid, @@ -169,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. @@ -267,7 +291,7 @@ impl AuthorityStore for MemoryAuthorityStore { if s.installations.contains_key(&n.id) { return Err(AuthorityError::Rejected); } - let replaced = s + let matching = s .installations .values() .filter(|installation| { @@ -277,14 +301,22 @@ impl AuthorityStore for MemoryAuthorityStore { }) .map(|installation| installation.id) .collect::>(); - if replaced.iter().any(|id| { + if matching.iter().any(|id| { s.installations .get(id) .is_some_and(|installation| !installation.revoked && installation.expires_at >= now) }) { // App identity and token possession never supersede a live installation. - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } + let replaced = matching + .into_iter() + .filter(|id| { + s.installations + .get(id) + .is_some_and(|installation| installation.expires_at < now) + }) + .collect::>(); for id in replaced { if let Some(old) = s.installations.remove(&id) { s.token_owners.remove(&(old.profile, old.token_fingerprint)); @@ -323,6 +355,20 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(i.clone()) } + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result { + let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let i = s + .installations + .get(&id) + .filter(|i| i.expires_at >= now) + .ok_or(AuthorityError::Rejected)?; + Ok(i.clone()) + } + async fn matching_installation( &self, key_id: &[u8], @@ -361,7 +407,7 @@ impl AuthorityStore for MemoryAuthorityStore { .installations .get_mut(&id) .ok_or(AuthorityError::Rejected)?; - if i.revoked || i.assertion_counter != previous { + if i.assertion_counter != previous { return Err(AuthorityError::Rejected); } i.assertion_counter = next; @@ -449,9 +495,12 @@ impl AuthorityStore for MemoryAuthorityStore { .delegations .get_mut(&key) .ok_or(AuthorityError::Rejected)?; - if old.revoked || expected_generation != old.generation { + if expected_generation != old.generation { return Err(AuthorityError::Rejected); } + if old.revoked { + return Ok(()); + } old.revoked = true; Ok(()) } @@ -470,6 +519,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); } @@ -478,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, @@ -758,7 +868,7 @@ mod tests { store .create_installation(replacement(Uuid::from_u128(5)), 1_999) .await, - Err(AuthorityError::Rejected) + Err(AuthorityError::Conflict) ); store .create_installation(replacement(Uuid::from_u128(5)), 2_001) @@ -838,6 +948,10 @@ mod tests { .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) .await .expect("the current generation can be revoked"); + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) + .await + .expect("the exact revocation is idempotent after response loss"); assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4()) .await .is_err()); @@ -878,4 +992,112 @@ mod tests { .await .expect("generation 2 authority is active"); } + + #[tokio::test] + async fn installation_revocation_is_authenticated_and_idempotent() { + let store = store().await; + let id = Uuid::from_u128(1); + let before = store.installation(id, 1_000).await.unwrap(); + + store + .revoke_installation(id, 1, 2) + .await + .expect("the current endpoint epoch can be revoked"); + let tombstone = store + .installation_for_revocation(id, 1_000) + .await + .expect("revocation retry can authenticate against the tombstone"); + assert!(tombstone.revoked); + store + .advance_assertion_counter(id, before.assertion_counter, before.assertion_counter + 1) + .await + .expect("an authenticated tombstone retry can advance its assertion counter"); + store + .revoke_installation(id, 1, 2) + .await + .expect("the exact installation revocation is idempotent"); + store + .create_installation( + NewInstallation { + id: Uuid::from_u128(5), + app_attest_key_id: vec![1], + app_attest_public_key: vec![5; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![6], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 3_000, + }, + 1_001, + ) + .await + .expect("a replacement can reuse ownership without deleting the tombstone"); + assert!( + store + .installation_for_revocation(id, 1_001) + .await + .expect("replacement preserves the prior revocation tombstone") + .revoked + ); + assert_eq!( + store.revoke_installation(id, 2, 3).await, + Err(AuthorityError::Rejected) + ); + } + + #[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/config.rs b/crates/buzz-push-gateway/src/config.rs index f8485a628de..1835468fee2 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -26,7 +26,8 @@ pub struct KeyConfig { pub struct Config { pub bind_addr: SocketAddr, pub health_addr: SocketAddr, - pub public_delivery_url: url::Url, + /// External gateway origin, delivery URL, and registered transcript audiences. + pub gateway_urls: GatewayUrls, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -41,6 +42,54 @@ pub struct Config { /// externally presented delivery capabilities. pub token_keys: Vec, } + +/// Gateway transport URLs and registered NIP-PL v1 transcript audiences. +#[derive(Debug, Clone)] +pub struct GatewayUrls { + /// External HTTPS origin serving the gateway. + pub origin: url::Url, + /// Exact NIP-98 delivery endpoint used by relays. + pub delivery: url::Url, + /// App Attest audience for installation enrollment. + pub enroll_audience: String, + /// App Attest audience for relay delegation. + pub delegate_audience: String, + /// App Attest audience for endpoint rotation. + pub rotate_endpoint_audience: String, + /// App Attest audience for delegation revocation. + pub revoke_delegation_audience: String, + /// App Attest audience for installation revocation. + pub revoke_installation_audience: String, +} + +impl GatewayUrls { + pub(crate) fn from_origin(origin: url::Url) -> Result { + let derive = |path: &str| { + origin + .join(path) + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")) + }; + let delivery = derive("v1/deliveries/apns")?; + // NIP-PL v1 registers these exact audience strings. The configurable + // origin controls transport only; changing transcript bytes requires + // a separately versioned protocol profile. + let enroll_audience = "https://push.buzz.xyz/v1/installations".to_owned(); + let delegate_audience = "https://push.buzz.xyz/v1/delegations".to_owned(); + let rotate_endpoint_audience = "https://push.buzz.xyz/v1/installations/endpoint".to_owned(); + let revoke_delegation_audience = "https://push.buzz.xyz/v1/delegations/revoke".to_owned(); + let revoke_installation_audience = + "https://push.buzz.xyz/v1/installations/revoke".to_owned(); + Ok(Self { + origin, + delivery, + enroll_audience, + delegate_audience, + rotate_endpoint_audience, + revoke_delegation_audience, + revoke_installation_audience, + }) + } +} #[derive(Debug, Error)] pub enum ConfigError { #[error("missing required environment variable {0}")] @@ -132,20 +181,21 @@ impl Config { }) { return Err(ConfigError::Invalid("BUZZ_PUSH_TOKEN_KEYS")); } - let public_delivery_url = req(e, "BUZZ_PUSH_PUBLIC_DELIVERY_URL")? + let gateway_origin = req(e, "BUZZ_PUSH_GATEWAY_ORIGIN")? .parse::() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL"))?; - if public_delivery_url.scheme() != "https" - || public_delivery_url.host_str() != Some("push.buzz.xyz") - || public_delivery_url.port().is_some() - || public_delivery_url.path() != "/v1/deliveries/apns" - || public_delivery_url.query().is_some() - || public_delivery_url.fragment().is_some() - || !public_delivery_url.username().is_empty() - || public_delivery_url.password().is_some() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN"))?; + if gateway_origin.scheme() != "https" + || gateway_origin.host().is_none() + || gateway_origin.port().is_some() + || gateway_origin.path() != "/" + || gateway_origin.query().is_some() + || gateway_origin.fragment().is_some() + || !gateway_origin.username().is_empty() + || gateway_origin.password().is_some() { - return Err(ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL")); + return Err(ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")); } + let gateway_urls = GatewayUrls::from_origin(gateway_origin)?; let max_grant_lifetime_seconds = req(e, "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS")? .parse::() .ok() @@ -191,7 +241,7 @@ impl Config { Ok(Self { bind_addr, health_addr, - public_delivery_url, + gateway_urls, max_grant_lifetime_seconds, max_installation_lifetime_seconds, endpoint_quota_window_seconds, @@ -227,8 +277,8 @@ mod tests { ), ), ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL".into(), - "https://push.buzz.xyz/v1/deliveries/apns".into(), + "BUZZ_PUSH_GATEWAY_ORIGIN".into(), + "https://push.example".into(), ), ( "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(), @@ -285,6 +335,36 @@ mod tests { } } + #[test] + fn gateway_transport_uses_configured_origin_and_transcript_audiences_stay_registered() { + let config = Config::from_map(&base()).unwrap(); + assert_eq!(config.gateway_urls.origin.as_str(), "https://push.example/"); + assert_eq!( + config.gateway_urls.delivery.as_str(), + "https://push.example/v1/deliveries/apns" + ); + assert_eq!( + config.gateway_urls.enroll_audience, + "https://push.buzz.xyz/v1/installations" + ); + assert_eq!( + config.gateway_urls.delegate_audience, + "https://push.buzz.xyz/v1/delegations" + ); + assert_eq!( + config.gateway_urls.rotate_endpoint_audience, + "https://push.buzz.xyz/v1/installations/endpoint" + ); + assert_eq!( + config.gateway_urls.revoke_delegation_audience, + "https://push.buzz.xyz/v1/delegations/revoke" + ); + assert_eq!( + config.gateway_urls.revoke_installation_audience, + "https://push.buzz.xyz/v1/installations/revoke" + ); + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); @@ -298,14 +378,10 @@ mod tests { #[test] fn malformed_security_configuration_fails_startup() { for (key, value) in [ - ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL", - "http://push.example/v1/deliveries/apns", - ), - ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL", - "https://push.example/v1/deliveries/apns", - ), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "http://push.example"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example/path"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example?token=x"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://user@push.example"), ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 9a6c66a519a..8c2f8bca511 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -5,6 +5,7 @@ use crate::{ authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, + config::GatewayUrls, grant::GrantKeyring, model::*, token::TokenKeyring, @@ -46,7 +47,8 @@ pub struct AppState { /// Server-owned dogfood application identity and APNs transport. The wire /// profile selector is fixed and App Attest verifies the configured app ID. pub profile: Arc, - pub delivery_url: url::Url, + /// Security-sensitive endpoints and audiences derived from one gateway origin. + pub gateway_urls: Arc, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -88,12 +90,16 @@ fn decode_challenge(value: &str) -> Option<[u8; 32]> { fn authority_error(e: AuthorityError) -> Response { match e { AuthorityError::Rejected => error(StatusCode::NOT_FOUND, "not_authorized"), + AuthorityError::Conflict => installation_conflict(), AuthorityError::RateLimited => error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"), AuthorityError::Unavailable => { error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable") } } } +fn installation_conflict() -> Response { + error(StatusCode::CONFLICT, "installation_conflict") +} fn endpoint_bytes(endpoint: &str) -> Option> { valid_endpoint(endpoint) .then(|| hex::decode(endpoint).ok()) @@ -151,7 +157,7 @@ async fn challenge(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct EnrollTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, key_id: &'a str, @@ -186,7 +192,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { }; let t = EnrollTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations", + audience: &s.gateway_urls.enroll_audience, challenge_id: r.challenge_id, challenge: &r.challenge, key_id: &r.key_id, @@ -232,7 +238,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { ) .into_response(); } - Ok(Some(_)) => return error(StatusCode::NOT_FOUND, "not_authorized"), + Ok(Some(_)) => return installation_conflict(), Ok(None) => {} Err(e) => return authority_error(e), } @@ -273,23 +279,73 @@ 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() +} + +struct AssertionChallenge<'a> { + id: uuid::Uuid, + text: &'a str, +} + async fn verify_installation_assertion( s: &AppState, installation_id: uuid::Uuid, - challenge_id: uuid::Uuid, - challenge_text: &str, + challenge: AssertionChallenge<'_>, assertion: &str, domain: &str, signed: &T, + include_revoked: bool, ) -> Result<(), Response> { let now = (s.now)(); - let challenge = decode_challenge(challenge_text) + let challenge_bytes = decode_challenge(challenge.text) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; - let installation = s - .authority - .installation(installation_id, now) - .await - .map_err(authority_error)?; + let installation = if include_revoked { + s.authority + .installation_for_revocation(installation_id, now) + .await + } else { + s.authority.installation(installation_id, now).await + } + .map_err(authority_error)?; if installation.profile != AppProfile::BuzzIosDogfood { return Err(error(StatusCode::NOT_FOUND, "not_authorized")); } @@ -303,12 +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 @@ -324,7 +380,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 +408,7 @@ async fn delegate(State(s): State, body: Bytes) -> Response { } let t = DelegateTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/delegations", + audience: &s.gateway_urls.delegate_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -365,11 +421,14 @@ async fn delegate(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.delegate.v1", &t, + false, ) .await { @@ -413,7 +472,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 +505,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { }; let t = RotateTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations/endpoint", + audience: &s.gateway_urls.rotate_endpoint_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -457,11 +516,14 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.rotate-endpoint.v1", &t, + false, ) .await { @@ -489,7 +551,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 +568,7 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { } let t = RevokeDelegationTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/delegations/revoke", + audience: &s.gateway_urls.revoke_delegation_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -516,11 +578,14 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.revoke-delegation.v1", &t, + false, ) .await { @@ -538,7 +603,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 +623,7 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response } let t = RevokeInstallationTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations/revoke", + audience: &s.gateway_urls.revoke_installation_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -568,11 +633,14 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.revoke-installation.v1", &t, + true, ) .await { @@ -613,7 +681,7 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> }; let relay = match verify_auth_header( auth, - &s.delivery_url, + &s.gateway_urls.delivery, HttpMethod::POST, Timestamp::now(), Some(&body), @@ -662,6 +730,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"); @@ -793,6 +866,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)) @@ -875,7 +949,9 @@ mod request_limit_tests { app_attest: Arc::new(app_attest), transport: Arc::new(NeverTransport), }), - delivery_url: "https://push.buzz.xyz/v1/deliveries/apns".parse().unwrap(), + gateway_urls: Arc::new( + GatewayUrls::from_origin("https://push.example".parse().unwrap()).unwrap(), + ), max_grant_lifetime_seconds: 86_400, max_installation_lifetime_seconds: 86_400, endpoint_quota_window_seconds: 60, @@ -934,6 +1010,94 @@ 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!( + authority_error(AuthorityError::Conflict).status(), + StatusCode::CONFLICT + ); + assert_eq!(installation_conflict().status(), StatusCode::CONFLICT); + } + #[test] fn ambiguous_apns_profile_failures_remain_retryable_at_the_relay_boundary() { for reason in ["BadDeviceToken", "DeviceTokenNotForTopic"] { diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index 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-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 17fff2a2429..149af270793 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -187,20 +187,29 @@ impl AuthorityStore for PostgresAuthorityStore { _ => true, } }) { - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } let replaced = existing .iter() - .map(|row| row.try_get::("id").map_err(db)) + .map(|row| { + Ok(( + row.try_get::("id").map_err(db)?, + row.try_get::, _>("expires_at").map_err(db)?, + )) + }) .collect::, _>>()?; - if !replaced.is_empty() { + let expired = replaced + .into_iter() + .filter_map(|(id, expires_at)| (expires_at < now_at).then_some(id)) + .collect::>(); + if !expired.is_empty() { sqlx::query("DELETE FROM push_gateway_delegations WHERE installation_id = ANY($1)") - .bind(&replaced) + .bind(&expired) .execute(&mut *tx) .await .map_err(db)?; sqlx::query("DELETE FROM push_gateway_installations WHERE id = ANY($1)") - .bind(&replaced) + .bind(&expired) .execute(&mut *tx) .await .map_err(db)?; @@ -208,7 +217,7 @@ impl AuthorityStore for PostgresAuthorityStore { let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING") .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&mut *tx).await.map_err(db)?; if result.rows_affected() != 1 { - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } tx.commit().await.map_err(db)?; Ok(()) @@ -230,6 +239,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 +318,7 @@ impl AuthorityStore for PostgresAuthorityStore { if next <= previous { return Err(AuthorityError::Rejected); } - let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2 AND revoked_at IS NULL") + let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2") .bind(id).bind(i64::from(previous)).bind(i64::from(next)).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); @@ -334,9 +374,12 @@ impl AuthorityStore for PostgresAuthorityStore { expected_generation: i64, ) -> Result<(), AuthorityError> { let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; - let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; + let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(&relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { - return Err(AuthorityError::Rejected); + let already_revoked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM push_gateway_delegations WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NOT NULL)").bind(id).bind(&relay).bind(expected_generation).fetch_one(&self.pool).await.map_err(db)?; + if !already_revoked { + return Err(AuthorityError::Rejected); + } } Ok(()) } @@ -351,7 +394,71 @@ 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(()) + } + 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(()) } @@ -484,26 +591,28 @@ impl AuthorityStore for PostgresAuthorityStore { .execute(&mut *tx) .await .map_err(db)?; - // A parent may become retention-eligible before an otherwise-active - // child. Parent eligibility must therefore reap every child first; - // otherwise the installation delete violates the delegation FK and - // rolls back all cleanup in this transaction. + // Revoked rows are idempotency tombstones for cleanup retries, so keep + // them for the full authority lifetime. A parent may expire before an + // otherwise-active child; reap every child of an expired parent first + // so the installation delete cannot violate the delegation FK. sqlx::query( "DELETE FROM push_gateway_delegations d WHERE d.expires_at < $1 - OR d.revoked_at < $1 - interval '1 day' OR EXISTS ( SELECT 1 FROM push_gateway_installations i WHERE i.id = d.installation_id - AND (i.expires_at < $1 OR i.revoked_at < $1 - interval '1 day') + AND i.expires_at < $1 )", ) .bind(at(now)?) .execute(&mut *tx) .await .map_err(db)?; - sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1 OR revoked_at < $1 - interval '1 day'") - .bind(at(now)?).execute(&mut *tx).await.map_err(db)?; + sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1") + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; tx.commit().await.map_err(db)?; Ok(()) } @@ -573,6 +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) @@ -606,7 +739,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires PostgreSQL"] - async fn reaper_deletes_active_child_of_retention_eligible_revoked_installation() { + async fn reaper_retains_revocation_tombstones_until_authority_expiry() { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_owned()); @@ -646,32 +779,54 @@ mod postgres_tests { .expect("create authority retention tables"); let now = Utc::now(); - let installation_id = Uuid::new_v4(); + let revoked_installation_id = Uuid::new_v4(); sqlx::query( "INSERT INTO push_gateway_installations(id, expires_at, revoked_at) VALUES ($1, $2, $3)", ) - .bind(installation_id) + .bind(revoked_installation_id) .bind(now + chrono::Duration::days(30)) .bind(now - chrono::Duration::days(2)) .execute(&pool) .await - .expect("insert retention-eligible revoked installation"); + .expect("insert revoked installation tombstone"); sqlx::query( "INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at) VALUES ($1, $2, $3, NULL)", ) .bind(Uuid::new_v4()) - .bind(installation_id) + .bind(revoked_installation_id) .bind(now + chrono::Duration::days(7)) .execute(&pool) .await .expect("insert active future-expiring child delegation"); + let active_installation_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO push_gateway_installations(id, expires_at, revoked_at) + VALUES ($1, $2, NULL)", + ) + .bind(active_installation_id) + .bind(now + chrono::Duration::days(30)) + .execute(&pool) + .await + .expect("insert active installation"); + sqlx::query( + "INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at) + VALUES ($1, $2, $3, $4)", + ) + .bind(Uuid::new_v4()) + .bind(active_installation_id) + .bind(now + chrono::Duration::days(30)) + .bind(now - chrono::Duration::days(2)) + .execute(&pool) + .await + .expect("insert revoked delegation tombstone"); + PostgresAuthorityStore::new(pool.clone()) .reap_expired(now.timestamp()) .await - .expect("reaper must delete the child before its revoked parent"); + .expect("reap before authority expiry"); let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") .fetch_one(&pool) .await @@ -681,6 +836,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); @@ -909,7 +1080,7 @@ mod postgres_tests { store .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 999,) .await, - Err(AuthorityError::Rejected) + Err(AuthorityError::Conflict) ); store .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 1_001) @@ -932,6 +1103,146 @@ 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() { + let (pool, schema) = full_schema(1).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let installation = |id| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: now + 2_592_000, + }; + let original_id = Uuid::from_u128(1); + + store + .create_installation(installation(original_id), now) + .await + .expect("create original installation"); + store + .revoke_installation(original_id, 1, 2) + .await + .expect("revoke original installation"); + store + .create_installation(installation(Uuid::from_u128(2)), now + 1) + .await + .expect("create replacement with the same key and token"); + + assert!( + store + .installation_for_revocation(original_id, now + 1) + .await + .expect("the original tombstone remains retryable") + .revoked + ); + let installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count original tombstone and replacement"); + assert_eq!(installations, 2); + + pool.close().await; + drop_schema(&schema).await; + } + fn admit<'a>( store: &'a PostgresAuthorityStore, event_hex: &'a str, diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e035752ec3a..5d831b3f651 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -347,8 +347,8 @@ pub struct Config { /// Descriptor key identifier accepted in kind:30350 `exec` tags. pub push_executor_key_id: String, /// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities. - /// An absent setting selects the canonical Buzz gateway. An explicitly - /// empty setting is allowed only while push is disabled. + /// Required while push is enabled. An explicitly empty setting is allowed + /// only while push is disabled. pub push_gateway_delivery_url: Option, /// Hard timeout for one gateway delivery request. pub push_gateway_timeout: Duration, @@ -447,8 +447,6 @@ fn parse_operator_api_origin(raw: &str) -> Result { Ok(raw.trim_end_matches('/').to_string()) } -const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns"; - fn parse_push_gateway_delivery_url(raw: &str) -> Result { let url = url::Url::parse(raw.trim()).map_err(|e| { ConfigError::InvalidValue(format!( @@ -457,6 +455,7 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { })?; if url.scheme() != "https" || url.host().is_none() + || url.port().is_some() || !url.username().is_empty() || url.password().is_some() || url.path() != "/v1/deliveries/apns" @@ -464,7 +463,7 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without credentials, query, or fragment" + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without an explicit port, credentials, query, or fragment" .to_string(), )); } @@ -978,9 +977,18 @@ impl Config { } Ok(raw) if raw.trim().is_empty() => None, Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?), - Err(_) => Some(parse_push_gateway_delivery_url( - DEFAULT_PUSH_GATEWAY_DELIVERY_URL, - )?), + Err(std::env::VarError::NotPresent) if push_enabled => { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be configured when BUZZ_PUSH_ENABLED=true" + .to_string(), + )); + } + Err(std::env::VarError::NotPresent) => None, + Err(error) => { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be valid UTF-8: {error}" + ))); + } }; let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") { Ok(raw) => raw @@ -2174,7 +2182,7 @@ mod tests { } #[test] - fn push_is_opt_in_and_gateway_defaults_to_buzz() { + fn push_is_opt_in_and_gateway_is_required_when_enabled() { let _guard = ENV_MUTEX.lock().unwrap(); let previous_enabled = std::env::var_os("BUZZ_PUSH_ENABLED"); let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); @@ -2182,15 +2190,20 @@ mod tests { std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); let config = Config::from_env().expect("default config"); assert!(!config.push_enabled); - assert_eq!( - config - .push_gateway_delivery_url - .as_ref() - .map(url::Url::as_str), - Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) - ); + assert!(config.push_gateway_delivery_url.is_none()); std::env::set_var("BUZZ_PUSH_ENABLED", "true"); + let result = Config::from_env(); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must be configured") + )); + + std::env::set_var( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL", + "https://push.example/v1/deliveries/apns", + ); let config = Config::from_env().expect("enabled push config"); assert!(config.push_enabled); assert_eq!( @@ -2198,7 +2211,7 @@ mod tests { .push_gateway_delivery_url .as_ref() .map(url::Url::as_str), - Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) + Some("https://push.example/v1/deliveries/apns") ); std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", ""); @@ -2248,6 +2261,7 @@ mod tests { assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); for invalid in [ "http://push.example/v1/deliveries/apns", + "https://push.example:8443/v1/deliveries/apns", "https://push.example/v1/deliveries/apns/", "https://push.example/v1/deliveries/apns?token=x", "https://user@push.example/v1/deliveries/apns", diff --git a/deploy/charts/buzz-push-gateway/ci/ci-values.yaml b/deploy/charts/buzz-push-gateway/ci/ci-values.yaml new file mode 100644 index 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 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..904cfbe7884 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -18,7 +18,9 @@ migration: resources: requests: {cpu: 50m, memory: 64Mi} limits: {cpu: 250m, memory: 128Mi} -publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns +# Exact externally reachable HTTPS origin. The gateway derives transport routes +# from this value; NIP-PL v1 App Attest audiences remain registered constants. +gatewayOrigin: "" maxGrantLifetimeSeconds: 2592000 profiles: dogfood: @@ -41,7 +43,6 @@ httpRoute: # existing ingress or service mesh route should keep this disabled. enabled: false parentRefs: [] - hostnames: [push.buzz.xyz] resources: requests: {cpu: 100m, memory: 128Mi} limits: {cpu: "1", memory: 512Mi} diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 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..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,17 @@ 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 + +`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. 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/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index c4151a677ce..e73b18839bd 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -1,10 +1,10 @@ # Buzz Push Gateway deployment -`buzz-push-gateway` is the standalone public APNs last hop intended for `push.buzz.xyz`. Build it with `Dockerfile.push-gateway`; do not run it in the relay image or give relays APNs credentials. +`buzz-push-gateway` is a standalone APNs last hop. Build it with `Dockerfile.push-gateway`; do not run it in the relay image or give relays APNs credentials. ## Network and health -- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route `https://push.buzz.xyz` to this port. +- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route the configured `BUZZ_PUSH_GATEWAY_ORIGIN` to this port. - Private health listener: `BUZZ_PUSH_HEALTH_ADDR` (default `0.0.0.0:8081`). Probe `/_liveness` and `/_readiness`; do not expose this port publicly. The chart has no pod-ingress allowance for 8081; Kubernetes node/kubelet-origin probe traffic is exempt from NetworkPolicy. Add a narrowly selected monitoring source only if the target CNI requires pod-origin health scraping. - Readiness fails when PostgreSQL authority is unavailable. Graceful shutdown stops accepting new requests before draining in-flight APNs calls. @@ -13,7 +13,7 @@ | Variable | Purpose | |---|---| | `DATABASE_URL` | PostgreSQL authority/admission store. Runtime credentials need DML on the six gateway tables, not DDL. | -| `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | +| `BUZZ_PUSH_GATEWAY_ORIGIN` | Exact externally reachable HTTPS origin. No credentials, port, path, query, or fragment. The gateway derives its transport routes from it; NIP-PL v1 App Attest audiences remain the registered `https://push.buzz.xyz/v1/...` constants. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | @@ -24,7 +24,7 @@ | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | -The canonical `push.buzz.xyz` MVP serves the dogfood application identity +The current MVP serves the dogfood application identity (`xyz.block.buzz.dogfood.mobile`). App Attest must cryptographically validate the configured application ID before enrollment. Assertions and delivery use the server-owned APNs topic, certificate-backed connection pool, and @@ -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/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/README.md b/mobile/README.md index c108dcece25..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 @@ -70,13 +70,18 @@ For direct Xcode / Android Studio / `flutter run` development, run switch to refresh the display label (the install identity never changes); the persisted files are then picked up by any subsequent build. In the main checkout the script is a no-op that removes stale override files, restoring -the plain `Buzz` identity. +the plain `Buzz` identity. Direct Xcode builds and Runner tests also require a +`BUZZ_PUSH_GATEWAY_URL` build setting in the gitignored +`mobile/ios/Flutter/AppOverrides.xcconfig`; the build phase validates and +passes it through as a Flutter Dart define. Since `//` begins an xcconfig +comment, spell the origin as `BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example`. For an Android debug build that must remain installed alongside other Buzz worktree builds, set an explicit launcher name and package suffix when invoking the generator or a recipe that invokes it: ```bash +BUZZ_PUSH_GATEWAY_URL="https://push.example" \ BUZZ_ANDROID_DEBUG_APP_NAME="Buzz Huddles" \ BUZZ_ANDROID_DEBUG_ID_SUFFIX=".huddles_829c" \ ./bin/just mobile-build-android @@ -104,6 +109,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, @@ -117,6 +134,7 @@ BUNDLE_IDENTIFIER = xyz.block.buzz.mobile BUZZ_DEVELOPMENT_TEAM = EYF346PHUG BUZZ_IOS_PUSH_ENVIRONMENT = development BUZZ_APP_ATTEST_ENVIRONMENT = development +BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example ``` This exercises the client, extension, relay, and gateway integration without @@ -148,7 +166,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 d0ae877e9dd..16a7f0f8015 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI +import java.util.Base64 import java.util.Properties plugins { @@ -20,6 +22,43 @@ val uploadSigningValues = ) val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrBlank() }.keys val hasUploadSigning = missingUploadSigningValues.isEmpty() +val dartDefines = providers.gradleProperty("dart-defines").orNull.orEmpty() +val pushGatewayDefinePrefix = "BUZZ_PUSH_GATEWAY_URL=" +val pushGatewayOrigins = + dartDefines.split(',').mapNotNull { encoded -> + val define = runCatching { String(Base64.getDecoder().decode(encoded)) }.getOrNull() + define + ?.takeIf { it.startsWith(pushGatewayDefinePrefix) } + ?.removePrefix(pushGatewayDefinePrefix) + } +fun isValidPushGatewayOrigin(value: String, requireHttps: Boolean): Boolean { + val uri = runCatching { URI(value) }.getOrNull() ?: return false + val scheme = uri.scheme?.lowercase() + return (scheme == "https" || (!requireHttps && scheme == "http")) && + !uri.host.isNullOrBlank() && + uri.rawUserInfo == null && + (uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") && + uri.rawQuery == null && + uri.rawFragment == null && + (if (requireHttps) uri.port == -1 else uri.port == -1 || uri.port in 1..65535) +} + +tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { + doFirst { + val requireHttps = !name.endsWith("Debug", ignoreCase = true) + val hasValidPushGatewayOrigin = + pushGatewayOrigins.size == 1 && + isValidPushGatewayOrigin(pushGatewayOrigins.single(), requireHttps) + if (!hasValidPushGatewayOrigin) { + throw GradleException( + "BUZZ_PUSH_GATEWAY_URL must be supplied as an " + + (if (requireHttps) "HTTPS" else "HTTP(S)") + + " origin without " + (if (requireHttps) "an explicit port, " else "") + + "credentials, path, query, or fragment 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..23a00dc6b3f 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 @@ -20,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 @@ -29,10 +33,12 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public let expiresAt: Int64 public init( + gatewayOrigin: String, relayOrigin: String, relayPubkey: String, relayMetadataPubkey: String? = nil, gatewayInstallationHandle: String? = nil, + appAttestKeyId: String, installationId: String, endpointGrant: String, endpointHash: String, @@ -42,10 +48,12 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { expiresAt: Int64 ) { precondition(generation > 0, "Endpoint grant generation must be positive") + self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey self.relayMetadataPubkey = relayMetadataPubkey self.gatewayInstallationHandle = gatewayInstallationHandle + self.appAttestKeyId = appAttestKeyId self.installationId = installationId self.endpointGrant = endpointGrant self.endpointHash = endpointHash @@ -56,17 +64,103 @@ 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] + /// 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], + revocationPendingInstallationHandles: [String]? = nil + ) { + self.gatewayOrigin = gatewayOrigin + self.grants = grants + self.pendingEnrollments = pendingEnrollments + self.revocationPendingInstallationHandles = revocationPendingInstallationHandles + } +} + +/// 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 { + /// 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. + func removeRecords( + gatewayOrigin: String, + installationHandle: String, + relayPubkey: String + ) 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 + /// 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 + /// Relay origins whose leases must be republished after shared installation + /// authority was revoked. The queue is persisted before the remote mutation. + func replacementQueueState() throws -> BuzzPushReplacementQueueState + /// Atomically merges relay origins into the durable replacement queue. + func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws + /// 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. + 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] + func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] +} + +extension BuzzPushEndpointGrantStore { + public func quarantinedLegacyEndpointGrants() throws -> [String] { [] } + public func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] + { [] } } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -79,6 +173,7 @@ public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { case appAttestUnsupported case invalidAppAttestKeyId case generationExhausted + case retiredGatewayCleanupIncomplete public var errorDescription: String? { switch self { @@ -100,6 +195,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." } } } @@ -108,7 +205,7 @@ protocol BuzzDevAppAttesting { func prepareAttestation() async throws -> BuzzDevAttestation func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws -> BuzzDevAttestation - func assertion(clientData: Data) async throws -> String + func assertion(keyId: String, clientData: Data) async throws -> String } struct BuzzDevAttestation: Equatable { @@ -282,12 +379,10 @@ struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { ) } - func assertion(clientData: Data) async throws -> String { + func assertion(keyId: String, clientData: Data) async throws -> String { precondition(!clientData.isEmpty, "Delegation client data must not be empty") try requireSupportedService() - guard let keyId = try keyIdStore.keyId(), - BuzzAppAttestKeyId.isValid(keyId) - else { + guard BuzzAppAttestKeyId.isValid(keyId) else { throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId } let object = try await service.generateAssertion( @@ -310,6 +405,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 @@ -347,14 +443,25 @@ public final class BuzzDevPushEnrollmentDriver { appAttest: BuzzDevAppAttesting, now: @escaping () -> Date, lifetimeSeconds: Int64, + resetStore: Bool = true, installationIdBytes: @escaping () throws -> Data = { 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 + if resetStore { + try store.reset(forGatewayOrigin: canonical.text) + } + self.gatewayBaseURL = canonical.url + self.gatewayOrigin = canonical.text self.store = store self.session = session self.appAttest = appAttest @@ -371,7 +478,26 @@ 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, + forceDelegationRenewal: forceDelegationRenewal + ) + } + + /// Revokes durable installations from gateways that are no longer configured. + 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) + } + + private func enrollCurrent( + deviceToken: Data, + relayURL: URL, + forceDelegationRenewal: Bool ) async throws -> BuzzPushEndpointGrantRecord { precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") let relayOrigin = try Self.relayOrigin(relayURL) @@ -383,9 +509,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 ) @@ -393,71 +521,195 @@ 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 { + $0.gatewayOrigin == pending.gatewayOrigin + && $0.gatewayInstallationHandle == handle + && $0.relayPubkey == pending.relayPubkey + && $0.appProfile == pending.appProfile + && $0.expiresAt > nowSeconds + } + .max { $0.generation < $1.generation } + } + 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, + pending.endpointHash == endpointHash, + let referencedInstallation + { + let siblingDelegationRecords = storedRecords.filter { + $0.gatewayOrigin == gatewayOrigin + && $0.gatewayInstallationHandle == handleText + && $0.relayPubkey == pending.relayPubkey + && $0.appProfile == pending.appProfile + && $0.relayOrigin != pending.relayOrigin + } + if !siblingDelegationRecords.isEmpty { + // Delegation authority is shared by relay key, installation, and app + // 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, + 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 + ) + } + } 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 + } + ?? BuzzPushGatewayCleanupState( + gatewayOrigin: gatewayOrigin, + grants: [], + pendingEnrollments: [] + ) + cleanupState.pendingEnrollments.removeAll { + $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 + }) { + try store.savePendingEnrollment(reconciled) + } + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } + try store.removeGatewayCleanupState(gatewayOrigin: gatewayOrigin) + revokedInstallation = true + } try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) pendingEnrollment = nil - } - if let current = storedForOrigin, - current.relayPubkey == relayPubkey, - current.endpointHash == endpointHash, - current.endpointEpoch == Self.endpointEpoch, - current.expiresAt > nowSeconds + 300 - { - guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { - try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile + if revokedInstallation { + return try await enrollCurrent( + deviceToken: deviceToken, + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal ) - return current } - let refreshed = BuzzPushEndpointGrantRecord( - relayOrigin: current.relayOrigin, - relayPubkey: current.relayPubkey, - relayMetadataPubkey: relayKeys.metadataPubkey, - gatewayInstallationHandle: current.gatewayInstallationHandle, - installationId: current.installationId, - endpointGrant: current.endpointGrant, - endpointHash: current.endpointHash, - appProfile: current.appProfile, - endpointEpoch: current.endpointEpoch, - generation: current.generation, - expiresAt: current.expiresAt - ) - try store.save(refreshed) - try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile - ) - return refreshed } - - // One gateway delegation is scoped to an installation and relay key, not - // to a Buzz community. A second origin served by the same relay therefore - // gets a fresh unlinkable NIP-PL address while reusing the opaque grant. - if storedForOrigin == nil, - let sharedGrant = storedRecords.first(where: { - $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile - && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch - && $0.expiresAt > nowSeconds + 300 - }) - { + let 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 + } + } + 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, - 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( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -469,7 +721,8 @@ public final class BuzzDevPushEnrollmentDriver { // without attempting duplicate APNs-token enrollment. An installation in // its final five minutes is renewed by the authenticated delegation. let reusableInstallation = storedRecords.first { record in - guard record.appProfile == Self.appProfile, + guard record.gatewayOrigin == gatewayOrigin, + record.appProfile == Self.appProfile, record.endpointHash == endpointHash, record.endpointEpoch == Self.endpointEpoch, record.expiresAt > nowSeconds, @@ -496,13 +749,16 @@ public final class BuzzDevPushEnrollmentDriver { ? reusableInstallation.expiresAt : renewedExpiration pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, installationId: try storedForOrigin?.installationId ?? makeInstallationId(), - gatewayInstallationHandle: existing.uuidString.lowercased() + gatewayInstallationHandle: existing.uuidString.lowercased(), + keyId: reusableInstallation.appAttestKeyId ) try store.savePendingEnrollment(pending) } else { @@ -510,6 +766,7 @@ public final class BuzzDevPushEnrollmentDriver { let enrollmentChallenge = try await challenge() let preparedAttestation = try await appAttest.prepareAttestation() let enrollmentClientData = try BuzzPushTranscript.enroll( + gatewayOrigin: gatewayBaseURL, challengeId: enrollmentChallenge.id, challenge: enrollmentChallenge.value, keyId: preparedAttestation.keyId, @@ -526,8 +783,10 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, @@ -572,14 +831,61 @@ 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) + return try await enrollCurrent( + deviceToken: deviceToken, + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal + ) + } catch let error as BuzzDevPushEnrollmentError { + guard + case .unexpectedStatus( + route: "v1/installations", _, actual: 409, _ + ) = error + else { throw error } + let cleanupStates = try store.gatewayCleanupStates() + 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) + } + // 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, + forceDelegationRenewal: forceDelegationRenewal + ) } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -589,7 +895,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) } @@ -615,8 +922,10 @@ public final class BuzzDevPushEnrollmentDriver { generation = 1 } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -634,6 +943,7 @@ public final class BuzzDevPushEnrollmentDriver { let delegationChallenge = try await challenge() let delegationClientData = try BuzzPushTranscript.delegate( + gatewayOrigin: gatewayBaseURL, challengeId: delegationChallenge.id, challenge: delegationChallenge.value, installationHandle: installation, @@ -643,7 +953,13 @@ public final class BuzzDevPushEnrollmentDriver { notBefore: nowSeconds, expiresAt: pending.expiresAt ) - let assertion = try await appAttest.assertion(clientData: delegationClientData) + guard let appAttestKeyId = pending.keyId else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let assertion = try await appAttest.assertion( + keyId: appAttestKeyId, + clientData: delegationClientData + ) let endpointGrant = try await delegate( challenge: delegationChallenge, installationHandle: installation, @@ -655,10 +971,12 @@ public final class BuzzDevPushEnrollmentDriver { ) let record = BuzzPushEndpointGrantRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, gatewayInstallationHandle: installationHandle, + appAttestKeyId: appAttestKeyId, installationId: pending.installationId, endpointGrant: endpointGrant, endpointHash: endpointHash, @@ -669,12 +987,167 @@ public final class BuzzDevPushEnrollmentDriver { ) try store.save(record) try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) return record } + private func cleanStaleGateways(deviceToken: Data?) async throws { + let states = try store.gatewayCleanupStates() + var cleanupIncomplete = false + var persistenceError: Error? + for var state in states { + guard await cleanStaleGateway(&state, deviceToken: deviceToken) else { + cleanupIncomplete = true + continue + } + do { + try store.removeGatewayCleanupState(gatewayOrigin: state.gatewayOrigin) + } catch { + if persistenceError == nil { persistenceError = error } + } + } + if let persistenceError { throw persistenceError } + if cleanupIncomplete { + throw BuzzDevPushEnrollmentError.retiredGatewayCleanupIncomplete + } + } + + 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 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] { + 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 } + guard + mergeHandle( + handle, + endpointEpoch: grant.endpointEpoch, + keyId: grant.appAttestKeyId + ) + else { return false } + } + for index in state.pendingEnrollments.indices { + var pending = state.pendingEnrollments[index] + if pending.expiresAt <= nowSeconds { continue } + if pending.gatewayInstallationHandle == nil { + let replayEndpoint: String + if let protectedEndpoint = pending.endpoint { + guard Self.endpointHash(protectedEndpoint) == pending.endpointHash else { return false } + replayEndpoint = protectedEndpoint + } 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 + } + guard 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: replayEndpoint, + 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 } + guard let keyId = pending.keyId, + mergeHandle(handle, endpointEpoch: Self.endpointEpoch, keyId: keyId) + else { + return false + } + } + 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, + endpointEpoch: installation.endpointEpoch, + appAttestKeyId: installation.keyId + ) + } catch { + return false + } + 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 { + return false + } + } + return true + } + private func makeInstallationId() throws -> String { let bytes = try installationIdBytes() precondition( @@ -732,6 +1205,79 @@ 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 + } + } + 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 + } + private func delegate( challenge: Challenge, installationHandle: UUID, @@ -763,6 +1309,81 @@ public final class BuzzDevPushEnrollmentDriver { return response.endpointGrant } + private func revokeInstallation( + installationHandle: UUID, + endpointEpoch: Int64, + appAttestKeyId: String + ) 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( + keyId: appAttestKeyId, + 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 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" @@ -828,16 +1449,6 @@ public final class BuzzDevPushEnrollmentDriver { } } - private static func isHTTPOrigin(_ url: URL) -> Bool { - (url.scheme == "http" || url.scheme == "https") - && url.host != nil - && (url.path.isEmpty || url.path == "/") - && url.user == nil - && url.password == nil - && url.query == nil - && url.fragment == nil - } - private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) { guard url.scheme == "ws" || url.scheme == "wss", url.host != nil, @@ -889,6 +1500,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 } @@ -938,6 +1573,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 @@ -966,6 +1613,49 @@ 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 + 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 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/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/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift new file mode 100644 index 00000000000..48d664d4259 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushGatewayStateReset.swift @@ -0,0 +1,94 @@ +/// Performs the ordered state transition when the configured push gateway changes. +public enum BuzzPushGatewayStateReset { + /// 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 { + 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 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 pending.gatewayInstallationHandle.map(revocationPendingHandles.contains) != true + && !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() { + 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 || nextRecords.count != records.count { + try replaceRecords(nextRecords.filter { $0.gatewayOrigin == gatewayOrigin }) + } + if !stalePending.isEmpty || nextPending.count != pendingEnrollments.count { + try replacePendingEnrollments( + nextPending.filter { $0.gatewayOrigin == 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/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift new file mode 100644 index 00000000000..14a105b86c5 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLegacyRecovery.swift @@ -0,0 +1,104 @@ +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 let pendingEnrollments: [BuzzPushLegacyPendingRecovery] + + public init( + relayOrigins: [String], + endpointGrants: [String], + pendingEnrollments: [BuzzPushLegacyPendingRecovery] + ) { + self.relayOrigins = relayOrigins + self.endpointGrants = endpointGrants + self.pendingEnrollments = pendingEnrollments + } + + private struct LegacyGrant: Decodable { + let relayOrigin: String + let endpointGrant: 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. + /// 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([BuzzPushLegacyPendingRecovery].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(), + pendingEnrollments: legacyPending + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift index 402f90be30f..bb960048927 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -1,9 +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 @@ -14,10 +18,14 @@ 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, relayOrigin: String, relayPubkey: String, + endpoint: String? = nil, endpointHash: String, appProfile: String, expiresAt: Int64, @@ -27,10 +35,13 @@ 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 self.relayPubkey = relayPubkey + self.endpoint = endpoint self.endpointHash = endpointHash self.appProfile = appProfile self.expiresAt = expiresAt @@ -41,5 +52,46 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { self.keyId = keyId self.attestation = attestation self.delegationGeneration = delegationGeneration + self.delegationRevoked = delegationRevoked + } + + func withGatewayInstallationHandle(_ handle: String) -> Self { + Self( + gatewayOrigin: gatewayOrigin, + relayOrigin: relayOrigin, + relayPubkey: relayPubkey, + endpoint: endpoint, + endpointHash: endpointHash, + appProfile: appProfile, + expiresAt: expiresAt, + installationId: installationId, + gatewayInstallationHandle: handle, + challengeId: challengeId, + challenge: challenge, + keyId: keyId, + attestation: attestation, + 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/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift index fd0bc34d696..c33c5fad286 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -6,6 +6,8 @@ public enum BuzzPushTranscriptError: Error, Equatable { /// authority-bearing strings; rather than guess at UTF-8-vs-escaping /// behavior we fail closed. case nonASCIIInput(field: String) + /// The configured gateway was not an HTTP or HTTPS origin. + case invalidGatewayOrigin } /// Canonical NIP-PL App Attest transcript encoder. @@ -27,11 +29,9 @@ public enum BuzzPushTranscriptError: Error, Equatable { /// generated and asserted by the gateway's own encoder. The tests in this /// package replay those vectors byte-for-byte. /// -/// The `audience` member of each transcript is a **fixed protocol constant** -/// defined by NIP-PL (`https://push.buzz.xyz/v1/...`). It is a cross-route -/// domain-separation string, not a deployment URL: the gateway hardcodes it -/// regardless of where it is hosted, so clients must never derive it from a -/// discovered gateway base URL or relay host. +/// The `audience` member uses the fixed origin and route registered by NIP-PL +/// v1. A configurable gateway origin changes transport routing, not these +/// protocol bytes. public enum BuzzPushTranscript { // MARK: Domains @@ -41,22 +41,18 @@ public enum BuzzPushTranscript { public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1" public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1" - // MARK: Fixed audiences (protocol constants, see type docs) - - public static let enrollAudience = "https://push.buzz.xyz/v1/installations" - public static let delegateAudience = "https://push.buzz.xyz/v1/delegations" - public static let rotateEndpointAudience = "https://push.buzz.xyz/v1/installations/endpoint" - public static let revokeDelegationAudience = "https://push.buzz.xyz/v1/delegations/revoke" - public static let revokeInstallationAudience = "https://push.buzz.xyz/v1/installations/revoke" - /// Wire version pinned by NIP-PL. Every transcript carries `"v":1`. public static let wireVersion: Int64 = 1 + /// Origin registered by NIP-PL v1 for every App Attest transcript audience. + private static let registeredAudienceOrigin = "https://push.buzz.xyz" + // MARK: Transcripts /// `buzz.push.enroll.v1` — these exact bytes are the App Attest /// `clientData` supplied to attestation verification. public static func enroll( + gatewayOrigin: URL, challengeId: UUID, challenge: String, keyId: String, @@ -67,7 +63,7 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.enrollAudience) + try o.string("audience", audience(gatewayOrigin, route: "v1/installations")) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") try o.string("key_id", keyId, field: "key_id") @@ -81,6 +77,7 @@ public enum BuzzPushTranscript { /// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func delegate( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -92,7 +89,7 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.delegateAudience) + try o.string("audience", audience(gatewayOrigin, route: "v1/delegations")) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -107,6 +104,7 @@ public enum BuzzPushTranscript { /// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func rotateEndpoint( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -116,7 +114,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.rotateEndpointAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/installations/endpoint") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -129,6 +130,7 @@ public enum BuzzPushTranscript { /// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func revokeDelegation( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -137,7 +139,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.revokeDelegationAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/delegations/revoke") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -149,6 +154,7 @@ public enum BuzzPushTranscript { /// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func revokeInstallation( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -157,7 +163,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.revokeInstallationAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/installations/revoke") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -172,6 +181,34 @@ public enum BuzzPushTranscript { Data((domain + "\n" + object.encoded()).utf8) } + /// Validates and canonicalizes a configured gateway origin. + public static func canonicalGatewayOrigin(_ value: URL) throws -> (url: URL, text: String) { + guard let scheme = value.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = value.host?.lowercased(), + value.path.isEmpty || value.path == "/", + value.user == nil, + value.password == nil, + value.query == nil, + value.fragment == nil + else { + throw BuzzPushTranscriptError.invalidGatewayOrigin + } + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = value.port + guard let url = components.url, let text = components.string else { + throw BuzzPushTranscriptError.invalidGatewayOrigin + } + return (url, text) + } + + private static func audience(_ gatewayOrigin: URL, route: String) throws -> String { + _ = try canonicalGatewayOrigin(gatewayOrigin) + return "\(registeredAudienceOrigin)/\(route)" + } + /// Ordered compact JSON object writer. Emission order == call order; /// there is deliberately no sorting, no whitespace, and no `Encodable` /// round-trip anywhere near these bytes. diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 2345bc46c68..59c75a1fa97 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -11,6 +11,7 @@ import XCTest final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private static let gatewayURL = URL(string: "http://push.example/")! + private static let gatewayOrigin = "http://push.example" private static let relayURL = URL(string: "wss://relay.example/")! private static let relayPubkey = String(repeating: "a", count: 64) private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" @@ -121,12 +122,16 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.gatewayOrigin, Self.gatewayOrigin) XCTAssertEqual(record.relayOrigin, "wss://relay.example") try assertMatchesVector( "enroll", actual: appAttest.clientData[0], expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", - fixture: makeFixtureTranscript(name: "enroll", replacements: []) + fixture: makeFixtureTranscript( + name: "enroll", + replacements: [] + ) ) try assertMatchesVector( "delegate", @@ -142,10 +147,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual( record, BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "opaque-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -373,190 +380,1256 @@ 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) - - XCTAssertEqual(record.relayPubkey, Self.relayPubkey) - XCTAssertNil(record.relayMetadataPubkey) + XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } - func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { - let service = RecordingDCAppAttestService(isSupported: false) - let provider = BuzzDCAppAttestProvider( - service: service, - keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + 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 ) - do { - _ = try await provider.prepareAttestation() - XCTFail("Expected App Attest to be unavailable") - } catch { - XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported) - } - XCTAssertEqual(service.generateKeyCallCount, 0) + XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } - func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws { - let service = RecordingDCAppAttestService( - generatedKeyId: Self.keyId, - attestationObject: Data([0x01, 0x02, 0x03]) + 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), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt ) - let keyIdStore = MemoryAppAttestKeyIdStore() - let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) - let clientData = Data("enrollment transcript".utf8) + 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]) - let prepared = try await provider.prepareAttestation() - let attestation = try await provider.attestation(prepared, clientData: clientData) + _ = try makeDriver(store: store, appAttest: RecordingAppAttest()) - XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: "")) - XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId]) - XCTAssertEqual(attestation.keyId, Self.keyId) - XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString()) - XCTAssertEqual(service.attestedKeyIds, [Self.keyId]) + XCTAssertEqual(store.saved, []) + XCTAssertTrue(store.pending.isEmpty) XCTAssertEqual( - service.attestationClientDataHashes, - [Data(SHA256.hash(data: clientData))] + store.resetOperations, ["cleanup:https://old-gateway.example", "records", "pending"]) + XCTAssertEqual( + store.cleanup, + [ + BuzzPushGatewayCleanupState( + gatewayOrigin: "https://old-gateway.example", + grants: [record], + pendingEnrollments: [pending] + ) + ] ) } - func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws { - let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06])) - let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) - let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) - let clientData = Data("delegation transcript".utf8) + 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]) - let assertion = try await provider.assertion(clientData: clientData) + try store.reset(forGatewayOrigin: "https://gateway-b.example") + store.resetOperations.removeAll() + try store.reset(forGatewayOrigin: "https://gateway-a.example") - XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) - XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) - XCTAssertEqual( - service.assertionClientDataHashes, - [Data(SHA256.hash(data: clientData))] - ) - XCTAssertEqual(service.generateKeyCallCount, 0) + XCTAssertEqual(store.saved, [record]) + XCTAssertTrue(store.cleanup.isEmpty) + XCTAssertEqual(store.resetOperations, ["records", "cleanup-removed:https://gateway-a.example"]) } - func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws { - for invalidKeyId in [ - "not-a-key-id", - String(Self.keyId.dropLast(2)) + "p=", - Data(repeating: 0xAA, count: 31).base64EncodedString(), - Data(repeating: 0xAA, count: 33).base64EncodedString(), - ] { - let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId) - let keyIdStore = MemoryAppAttestKeyIdStore() - let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) - - do { - _ = try await provider.prepareAttestation() - XCTFail("Accepted invalid generated key ID: \(invalidKeyId)") - } catch { - XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + 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", + 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: [quarantined], + pendingEnrollments: [pending], + revocationPendingInstallationHandles: [quarantinedHandle] + ) + ] + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeRequests = 0 + var replayedOldEndpoint = false + var revokedHandles = Set() + 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 <= 2 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"): + 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")") + return Self.response(request, status: 500, json: [:]) } - XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty) } - } - - func testRealAppAttestRejectsMismatchedPreparedKey() async throws { - let service = RecordingDCAppAttestService() - let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) - let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) - let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() do { - _ = try await provider.attestation( - BuzzDevAttestation(keyId: otherKeyId, attestation: ""), - clientData: Data("enrollment transcript".utf8) - ) - XCTFail("Expected the prepared key ID to match persistent state") - } catch { - XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + _ = 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(service.attestedKeyIds.isEmpty) + + XCTAssertTrue(replayedOldEndpoint) + XCTAssertEqual(revokedHandles, [quarantinedHandle, Self.installationHandle]) + XCTAssertEqual(store.saved, []) + XCTAssertTrue(store.pending.isEmpty) + XCTAssertTrue(store.cleanup.isEmpty) } - func testRealAppAttestForwardsServiceErrors() async throws { - let expected = NSError(domain: "DeviceCheckTest", code: 41) - let service = RecordingDCAppAttestService(error: expected) - let provider = BuzzDCAppAttestProvider( - service: service, - keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + 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 provider.assertion(clientData: Data("delegation transcript".utf8)) - XCTFail("Expected the DeviceCheck error") - } catch { - XCTAssertEqual((error as NSError).domain, expected.domain) - XCTAssertEqual((error as NSError).code, expected.code) + _ = 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. } - } - - func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws { - var capturedQuery: [String: Any] = [:] - let store = BuzzAppAttestKeyIdKeychainStore( - accessGroup: "group.buzz", - copyMatching: { query, result in - capturedQuery = query as! [String: Any] - result?.pointee = Data(Self.keyId.utf8) as CFData - return errSecSuccess - } - ) - XCTAssertEqual(try store.keyId(), Self.keyId) + XCTAssertTrue(revoked) + XCTAssertTrue(store.saved.isEmpty) + XCTAssertTrue(store.pending.isEmpty) + XCTAssertTrue(store.cleanup.isEmpty) XCTAssertEqual( - capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String) - XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") - XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1") - XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz") - XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true) - XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String) - } - - func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws { - let missing = BuzzAppAttestKeyIdKeychainStore( - accessGroup: nil, - copyMatching: { _, _ in errSecItemNotFound } + store.replacementOrigins, + ["wss://relay.example", "wss://shared-relay.example"] ) - XCTAssertNil(try missing.keyId()) - - for invalidKeyId in [ - "bad", - String(Self.keyId.dropLast(2)) + "p=", - Data(repeating: 0xAA, count: 31).base64EncodedString(), - Data(repeating: 0xAA, count: 33).base64EncodedString(), - ] { - let invalid = BuzzAppAttestKeyIdKeychainStore( - accessGroup: nil, - copyMatching: { _, result in - result?.pointee = Data(invalidKeyId.utf8) as CFData - return errSecSuccess - } - ) - XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") { - XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) - } - } } - func testKeychainStoreUpdatesExistingKeyId() throws { - var updatedQuery: [String: Any] = [:] - var updatedValues: [String: Any] = [:] - var addCallCount = 0 - let store = BuzzAppAttestKeyIdKeychainStore( - accessGroup: nil, - update: { query, values in - updatedQuery = query as! [String: Any] - updatedValues = values as! [String: Any] - return errSecSuccess - }, - add: { _, _ in - addCallCount += 1 + 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( + 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 unrelatedHigherGeneration = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://other-relay.example", + relayPubkey: String(repeating: "c", count: 64), + relayMetadataPubkey: String(repeating: "c", count: 64), + 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], + pendingRemoveFailuresRemaining: 1 + ) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeRequests = 0 + var revokedGenerations: [Int] = [] + 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 <= 2 else { + return Self.response(request, status: 503, json: ["error": "injected"]) + } + return Self.response( + request, + status: 200, + json: [ + "challenge_id": + challengeRequests == 1 ? Self.firstChallengeId : 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) + 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")") + 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 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)), + relayURL: Self.relayURL + ) + XCTFail("Expected the injected replacement delegation challenge failure") + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations/challenges", expected: 200, actual: 503, _ + ) { + // The known committed generation was revoked before replacement began. + } + + XCTAssertEqual(revokedGenerations, [2, 1]) + XCTAssertEqual(store.saved, [unrelatedHigherGeneration]) + XCTAssertEqual(store.pending.count, 1) + XCTAssertEqual(store.pending.first?.relayPubkey, newRelayPubkey) + 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.replacementOrigins, ["wss://sibling.example"]) + 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( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, + 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 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, + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + 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/"): + 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) + 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")") + return Self.response(request, status: 500, json: [:]) + } + } + + try await driver.cleanRetiredGateways() + + XCTAssertEqual(store.saved, [current]) + XCTAssertTrue(store.cleanup.isEmpty) + 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 oldGatewayURL = URL(string: "http://old-gateway.example")! + let store = MemoryGrantStore() + let oldDriver = try makeDriver( + gatewayBaseURL: oldGatewayURL, + store: store, + appAttest: RecordingAppAttest() + ) + var challengeRequests = 0 + var installationRequests = 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://old-gateway.example/v1/installations/challenges"): + challengeRequests += 1 + return Self.response( + request, + status: 200, + json: [ + "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: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + 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) + } + + 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( + 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 appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + URLProtocolStub.handler = { request in + XCTAssertNotEqual(request.url?.absoluteString, "https://relay.example/") + return Self.response(request, status: 503, json: ["error": "unavailable"]) + } + + do { + try await driver.cleanRetiredGateways(deviceToken: Data((1...32).map(UInt8.init))) + XCTFail("Expected retired gateway cleanup to remain queued") + } catch { + XCTAssertEqual( + error as? BuzzDevPushEnrollmentError, + .retiredGatewayCleanupIncomplete + ) + } + + XCTAssertTrue(store.saved.isEmpty) + XCTAssertEqual(try XCTUnwrap(store.cleanup.first).grants, [stale]) + 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 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 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 { + 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.map(\.gatewayOrigin), [offlineOrigin]) + XCTAssertEqual(store.cleanup.first?.grants, offline.grants) + XCTAssertEqual( + store.cleanup.first?.revocationPendingInstallationHandles, + ["44444444-4444-4444-8444-444444444444"] + ) + } + + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { + let service = RecordingDCAppAttestService(isSupported: false) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Expected App Attest to be unavailable") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported) + } + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws { + let service = RecordingDCAppAttestService( + generatedKeyId: Self.keyId, + attestationObject: Data([0x01, 0x02, 0x03]) + ) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("enrollment transcript".utf8) + + let prepared = try await provider.prepareAttestation() + let attestation = try await provider.attestation(prepared, clientData: clientData) + + XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: "")) + XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId]) + XCTAssertEqual(attestation.keyId, Self.keyId) + XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString()) + XCTAssertEqual(service.attestedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.attestationClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + } + + func 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(keyId: Self.keyId, clientData: clientData) + + XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) + XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.assertionClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func 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", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Accepted invalid generated key ID: \(invalidKeyId)") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty) + } + } + + func testRealAppAttestRejectsMismatchedPreparedKey() async throws { + let service = RecordingDCAppAttestService() + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() + + do { + _ = try await provider.attestation( + BuzzDevAttestation(keyId: otherKeyId, attestation: ""), + clientData: Data("enrollment transcript".utf8) + ) + XCTFail("Expected the prepared key ID to match persistent state") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(service.attestedKeyIds.isEmpty) + } + + func testRealAppAttestForwardsServiceErrors() async throws { + let expected = NSError(domain: "DeviceCheckTest", code: 41) + let service = RecordingDCAppAttestService(error: expected) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.assertion( + keyId: Self.keyId, + clientData: Data("delegation transcript".utf8) + ) + XCTFail("Expected the DeviceCheck error") + } catch { + XCTAssertEqual((error as NSError).domain, expected.domain) + XCTAssertEqual((error as NSError).code, expected.code) + } + } + + func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws { + var capturedQuery: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + copyMatching: { query, result in + capturedQuery = query as! [String: Any] + result?.pointee = Data(Self.keyId.utf8) as CFData + return errSecSuccess + } + ) + + XCTAssertEqual(try store.keyId(), Self.keyId) + XCTAssertEqual( + capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String) + XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1") + XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz") + XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true) + XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String) + } + + func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws { + let missing = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecItemNotFound } + ) + XCTAssertNil(try missing.keyId()) + + for invalidKeyId in [ + "bad", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let invalid = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, result in + result?.pointee = Data(invalidKeyId.utf8) as CFData + return errSecSuccess + } + ) + XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") { + XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + } + } + + func testKeychainStoreUpdatesExistingKeyId() throws { + var updatedQuery: [String: Any] = [:] + var updatedValues: [String: Any] = [:] + var addCallCount = 0 + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { query, values in + updatedQuery = query as! [String: Any] + updatedValues = values as! [String: Any] + return errSecSuccess + }, + add: { _, _ in + addCallCount += 1 return errSecSuccess } ) @@ -598,52 +1671,212 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) } - let updateFailure = BuzzAppAttestKeyIdKeychainStore( - accessGroup: nil, - update: { _, _ in errSecInteractionNotAllowed } + let updateFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let addFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecItemNotFound }, + add: { _, _ in errSecDuplicateItem } + ) + XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem)) + } + } + + func testEnrollmentContinuesWhileRetiredGatewayCleanupRemainsQueued() async throws { + let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + 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 { + XCTFail("Persisted grant reuse must not call the gateway") + return Self.response(request, status: 500, json: [:]) + } + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record, existing) + XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(store.cleanup.map(\.gatewayOrigin), ["http://retired-gateway.example"]) + 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: 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://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 ) - XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) { - XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) - } - let addFailure = BuzzAppAttestKeyIdKeychainStore( - accessGroup: nil, - update: { _, _ in errSecItemNotFound }, - add: { _, _ in errSecDuplicateItem } - ) - XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) { - XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem)) - } + 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 testReusesPersistedUnexpiredGrant() async throws { - let existing = BuzzPushEndpointGrantRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - relayMetadataPubkey: Self.relayPubkey, - installationId: Self.installationId, - endpointGrant: "existing-grant", - endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-dogfood", - endpointEpoch: 1, - generation: 1, - expiresAt: Self.expiresAt - ) - let store = MemoryGrantStore(records: [existing]) + 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 - guard request.httpMethod == "GET" else { - XCTFail("Persisted grant reuse must not call the gateway") + 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: [:]) } - return Self.response( - request, - status: 200, - json: [ - "self": Self.relayPubkey, - "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], - ] - ) } let record = try await driver.enroll( @@ -651,17 +1884,87 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { relayURL: Self.relayURL ) - XCTAssertEqual(record, existing) - XCTAssertEqual(store.saved, [existing]) - XCTAssertEqual(URLProtocolStub.requests.count, 1) + XCTAssertEqual(installationAttempts, 2) + XCTAssertEqual(recoveryAttempts, 1) + 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, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -703,10 +2006,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws { let secondRelayPubkey = String(repeating: "b", count: 64) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "first-relay-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -771,12 +2076,150 @@ 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 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, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -878,9 +2321,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let oldMetadataPubkey = String(repeating: "c", count: 64) let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: pushPubkey, relayMetadataPubkey: oldMetadataPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -919,9 +2364,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -956,9 +2403,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -1024,6 +2473,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } private func makeDriver( + gatewayBaseURL: URL = BuzzDevPushEnrollmentDriverTests.gatewayURL, store: BuzzPushEndpointGrantStore, appAttest: BuzzDevAppAttesting, installationIdBytes: @escaping () throws -> Data = { @@ -1033,7 +2483,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, @@ -1147,13 +2597,52 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] + var cleanup: [BuzzPushGatewayCleanupState] = [] + var replacementOrigins: [String] = [] + var replacementGeneration: Int64 = 0 + var legacyEndpointGrants: [String] = [] + var legacyPendingEnrollments: [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] = [] + var resetOperations: [String] = [] var grantSaveFailuresRemaining: Int + var cleanupSaveFailureCalls: Set + var pendingRemoveFailuresRemaining: Int + private var cleanupSaveCallCount = 0 init( records: [BuzzPushEndpointGrantRecord] = [], - grantSaveFailuresRemaining: Int = 0 + pending: [BuzzPushPendingEnrollmentRecord] = [], + grantSaveFailuresRemaining: Int = 0, + 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( + gatewayOrigin: gatewayOrigin, + records: saved, + pendingEnrollments: pending, + cleanupStates: cleanup, + saveCleanupState: { state in + 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 + }, + replacePendingEnrollments: { + self.resetOperations.append("pending") + self.pending = $0 + } + ) } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { @@ -1162,39 +2651,118 @@ 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 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 + && $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, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? { pending.first { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } } func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { pending.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } pending.append(record) } - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws { + if pendingRemoveFailuresRemaining > 0 { + pendingRemoveFailuresRemaining -= 1 + throw NSError(domain: "MemoryGrantStore", code: 3) + } pending.removeAll { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile + } + } + 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) + } + func removeGatewayCleanupState(gatewayOrigin: String) throws { + cleanup.removeAll { $0.gatewayOrigin == gatewayOrigin } + } + func replacementQueueState() throws -> BuzzPushReplacementQueueState { + BuzzPushReplacementQueueState( + generation: replacementGeneration, + relayOrigins: replacementOrigins + ) } + func queueReplacementRelayOrigins(_ relayOrigins: [String]) throws { + replacementGeneration += 1 + replacementOrigins = Array(Set(replacementOrigins + relayOrigins)).sorted() + } + func checkpointReplacementRelayOrigins( + _ relayOrigins: [String], + expectedGeneration: Int64 + ) throws -> Bool { + guard replacementGeneration == expectedGeneration else { return false } + let completedOrigins = Set(relayOrigins) + replacementOrigins.removeAll { completedOrigins.contains($0) } + return true + } + func clearReplacementRelayOrigins() throws { + replacementGeneration += 1 + replacementOrigins = [] + } + func quarantinedLegacyEndpointGrants() throws -> [String] { legacyEndpointGrants } + func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] + { legacyPendingEnrollments } } private final class RecordingAppAttest: BuzzDevAppAttesting { var clientData: [Data] = [] + var preparedAttestations: [BuzzDevAttestation] = [] + var assertionKeyIds: [String] = [] func prepareAttestation() async throws -> BuzzDevAttestation { - BuzzDevAttestation( + let prepared = BuzzDevAttestation( keyId: BuzzDevPushEnrollmentDriverTests.keyId, attestation: BuzzDevPushEnrollmentDriverTests.attestation ) + preparedAttestations.append(prepared) + return prepared } func attestation( @@ -1205,8 +2773,9 @@ private final class RecordingAppAttest: BuzzDevAppAttesting { return prepared } - func assertion(clientData: Data) async throws -> String { + func assertion(keyId: String, clientData: Data) async throws -> String { self.clientData.append(clientData) + assertionKeyIds.append(keyId) return BuzzDevPushEnrollmentDriverTests.assertion } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/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/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift new file mode 100644 index 00000000000..211fb60eb1e --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLegacyRecoveryTests.swift @@ -0,0 +1,42 @@ +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","endpointHash":"abc","appProfile":"buzz-ios-dogfood","expiresAt":99}] + """.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"]) + #expect(inventory.pendingEnrollments.map(\.endpointHash) == ["abc"]) + } + + @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/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift index 3cb242bc4cf..c08fe9c82c5 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -42,6 +42,7 @@ final class BuzzPushTranscriptTests: XCTestCase { // Deterministic inputs mirroring the fixture's `inputs` block. static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + static let gatewayOrigin = URL(string: "https://push.buzz.xyz")! static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" @@ -70,6 +71,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testEnrollVector() throws { try assertMatchesVector("enroll", BuzzPushTranscript.enroll( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, keyId: Self.keyId, @@ -82,6 +84,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testDelegateVector() throws { try assertMatchesVector("delegate", BuzzPushTranscript.delegate( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -95,6 +98,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRotateEndpointVector() throws { try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -106,6 +110,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRevokeDelegationVector() throws { try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -116,6 +121,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRevokeInstallationVector() throws { try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -132,6 +138,41 @@ final class BuzzPushTranscriptTests: XCTestCase { ) } + func testConfiguredTransportOriginKeepsRegisteredTranscriptAudience() throws { + let bytes = try BuzzPushTranscript.delegate( + gatewayOrigin: URL(string: "https://push.example")!, + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + ) + + XCTAssertTrue( + String(decoding: bytes, as: UTF8.self) + .contains("\"audience\":\"https://push.buzz.xyz/v1/delegations\"") + ) + } + + func testInvalidGatewayOriginRejected() { + XCTAssertThrowsError(try BuzzPushTranscript.delegate( + gatewayOrigin: URL(string: "https://push.example/path")!, + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + )) { + XCTAssertEqual($0 as? BuzzPushTranscriptError, .invalidGatewayOrigin) + } + } + // MARK: Escaping edges (the exact JSONSerialization failure modes) func testSolidusIsNotEscaped() throws { diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 21946f40772..755923548e2 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -512,7 +512,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "set -e\n. \"$SRCROOT/../scripts/require-push-gateway-origin.sh\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 47c99eb9eab..9b6cf12c4f5 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 } @@ -344,8 +346,159 @@ 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 + } + Task { + do { + result(try initializePushGateway(gatewayURL)) + } catch { + result( + FlutterError( + code: "push_gateway_initialization_failed", + message: "Push gateway initialization failed.", + details: error.localizedDescription + ) + ) + } + } + case "completeGatewayMigration": + 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(try pushGatewayMigrationInventory()) + } 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 "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], + !relayOrigins.isEmpty, + relayOrigins.allSatisfy({ !$0.isEmpty }), + let generation = (arguments["generation"] as? NSNumber)?.int64Value, + let expectedDeviceToken = arguments["deviceToken"] as? String, + !expectedDeviceToken.isEmpty + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Gateway replacement checkpoint requires relayOrigins.", + details: nil + ) + ) + return + } + guard + apnsDeviceToken?.map({ String(format: "%02x", $0) }).joined() + == expectedDeviceToken + else { + result(false) + return + } + do { + result( + try endpointGrantStore.checkpointReplacementRelayOrigins( + relayOrigins, + expectedGeneration: generation + ) + ) + } catch { + result( + FlutterError( + code: "push_gateway_checkpoint_failed", + message: "Push gateway replacement checkpoint failed.", + details: error.localizedDescription + ) + ) + } case "startRegistration": - startPushRegistration(result: result) + guard let gatewayURL = gatewayURL(from: call) else { + result( + FlutterError( + code: "invalid_arguments", + message: "Push registration requires gatewayUrl.", + details: nil + ) + ) + return + } + do { + try configurePushGateway(gatewayURL) + 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": @@ -441,6 +594,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( @@ -476,6 +639,7 @@ import os.log ) return } + let forceDelegationRenewal = arguments["forceDelegationRenewal"] as? Bool ?? false do { let driver = try BuzzDevPushEnrollmentDriver( @@ -490,9 +654,15 @@ import os.log do { let record = try await driver.enroll( deviceToken: deviceToken, - relayURL: relayURL + relayURL: relayURL, + forceDelegationRenewal: forceDelegationRenewal ) - await MainActor.run { result(record.flutterArguments) } + try self?.endpointGrantStore.clearQuarantinedLegacyState() + var arguments = record.flutterArguments + if let inventory = try self?.pushGatewayMigrationInventory() { + arguments.merge(inventory) { _, latest in latest } + } + await MainActor.run { result(arguments) } } catch { await MainActor.run { result( @@ -516,6 +686,69 @@ 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 cleanupDeviceToken = apnsDeviceToken + let task = Task { [weak self] in + 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 + _ = await task.result + self?.gatewayCleanupTask = nil + } + return task + } + + 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 configurePushGateway(_ gatewayURL: URL) throws { + let gatewayOrigin = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayURL) + guard pushGatewayURL != gatewayOrigin.url else { return } + try endpointGrantStore.reset(forGatewayOrigin: gatewayOrigin.text) + pushGatewayURL = gatewayOrigin.url + } + + private func initializePushGateway(_ gatewayURL: URL) throws -> [String: Any] { + try configurePushGateway(gatewayURL) + return try pushGatewayMigrationInventory() + } + + private func pushGatewayMigrationInventory() throws -> [String: Any] { + let retiredRelayOrigins = Array( + Set( + try endpointGrantStore.gatewayCleanupStates() + .flatMap { $0.grants.map(\.relayOrigin) + $0.pendingEnrollments.map(\.relayOrigin) } + ) + ).sorted() + let replacementState = try endpointGrantStore.replacementQueueState() + return [ + "retiredRelayOrigins": retiredRelayOrigins, + "replacementRelayOrigins": replacementState.relayOrigins, + "replacementGeneration": replacementState.generation, + ] + } + private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index ffedbedd19a..8cdda51bcea 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -6,8 +6,12 @@ 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 static let cleanupAccount = "gateway-cleanup-v1" + private static let replacementRelaysAccount = "replacement-relays-v1" private let accessGroup: String? @@ -15,6 +19,47 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { self.accessGroup = accessGroup } + 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( + gatewayOrigin: gatewayOrigin, + records: allRecords, + pendingEnrollments: allPending, + cleanupStates: gatewayCleanupStates(), + saveCleanupState: saveGatewayCleanupState, + removeCleanupState: removeGatewayCleanupState, + replaceRecords: { try self.replace($0, account: Self.recordsAccount) }, + replacePendingEnrollments: { try self.replace($0, account: Self.pendingAccount) } + ) + } + + func quarantinedLegacyEndpointGrants() throws -> [String] { + try quarantinedLegacyInventory().endpointGrants + } + + func quarantinedLegacyPendingEnrollments() throws + -> [BuzzPushLegacyRecoveryInventory.BuzzPushLegacyPendingRecovery] + { + try quarantinedLegacyInventory().pendingEnrollments + } + + 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] { var query = baseQuery(account: Self.recordsAccount) query[kSecReturnData as String] = true @@ -39,38 +84,157 @@ 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 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 { + $0.gatewayOrigin == gatewayOrigin + && $0.gatewayInstallationHandle == installationHandle + } + 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, 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) } + 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) + } + + 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 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 checkpointReplacementRelayOrigins( + _ relayOrigins: [String], + expectedGeneration: Int64 + ) throws -> Bool { + var state = try replacementQueueState() + guard state.generation == expectedGeneration else { return false } + let completedOrigins = Set(relayOrigins) + state.relayOrigins.removeAll { completedOrigins.contains($0) } + try replaceValue(state, account: Self.replacementRelaysAccount) + return true + } + + func clearReplacementRelayOrigins() throws { + 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] { var query = baseQuery(account: Self.pendingAccount) query[kSecReturnData as String] = true @@ -92,8 +256,32 @@ 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 replace(_ values: [T], account: String) throws { - let data = try JSONEncoder().encode(values) + 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( baseQuery(account: account) as CFDictionary, [kSecValueData as String: data] as CFDictionary diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 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), diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 7788082745e..624d00c3d81 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -412,32 +412,41 @@ class CommunityListNotifier extends AsyncNotifier> { }); } - Future markPushLeaseAccepted( + Future markPushLeaseAccepted( String id, { required List subscriptions, required int generation, + String? gatewayOrigin, }) => _serializePushMutation(() async { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); final index = current.indexWhere((community) => community.id == id); - if (index < 0) return; + if (index < 0) return false; final community = current[index]; final acceptedGeneration = community.pushSubscriptionState.acceptedGeneration ?? 0; final generationCursor = community.pushSubscriptionState.generationCursor ?? 0; - if (generation < max(acceptedGeneration, generationCursor)) return; + if (generation < max(acceptedGeneration, generationCursor)) return false; + if (buzzPushSubscriptionsFingerprint( + community.pushSubscriptionState.desired, + ) != + buzzPushSubscriptionsFingerprint(subscriptions)) { + return false; + } final updated = community.copyWith( pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, generation: generation, + gatewayOrigin: gatewayOrigin, ), ); await storage.save(updated); 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 0e86745f92d..f0dba0dce42 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'; @@ -16,6 +17,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 { @@ -33,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; @@ -68,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, @@ -90,15 +125,256 @@ bool buzzPushLifecycleEnabled({ required BuzzPushLeaseDescriptor? descriptor, }) => community?.pushNotificationsEnabled == true && descriptor != null; +@visibleForTesting +List buzzPushCommunitiesRequiringGatewayMigration({ + required List communities, + required Set retiredRelayOrigins, + Set replacementRelayOrigins = const {}, + required String targetGatewayOrigin, +}) => communities + .where( + (community) => + community.pushNotificationsEnabled && + retiredRelayOrigins.contains( + buzzPushRelayWebSocketOrigin(community.relayUrl), + ) && + (replacementRelayOrigins.contains( + buzzPushRelayWebSocketOrigin(community.relayUrl), + ) || + community.pushSubscriptionState.acceptedGatewayOrigin != + targetGatewayOrigin), + ) + .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; + +@visibleForTesting +Future markBuzzPushGatewayMigrationAcceptedIfCurrent({ + required bool Function() attemptIsCurrent, + required Future Function() markAccepted, +}) { + if (!attemptIsCurrent()) return Future.value(false); + 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, + 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( + Iterable targets, +) { + final groups = >{}; + for (final target in targets) { + groups.putIfAbsent(target.descriptor.executorPubkey, () => []).add(target); + } + 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, + 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. +@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); + 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, required Future Function(int generation) publish, - required Future Function(int generation) markAccepted, + 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); - await markAccepted(generation); + if (!await markAccepted(generation)) { + throw StateError('A newer push lease superseded the published generation.'); + } return generation; } @@ -112,19 +388,46 @@ class BuzzPushBootstrap extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); - final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); + useListenable(retiredBuzzPushRelayOrigins); + useListenable(replacementBuzzPushRelayOrigins); + useListenable(replacementBuzzPushGeneration); + 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 gatewayMigrationFailures = useMemoized( + BuzzPushAttemptFailureBudget.new, + ); 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); final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + final token = apnsDeviceToken.value; + final retiredRelayOrigins = retiredBuzzPushRelayOrigins.value; + final replacementRelayOrigins = replacementBuzzPushRelayOrigins.value; + final replacementGeneration = replacementBuzzPushGeneration.value; + final migrationRelayOrigins = retiredRelayOrigins.union( + replacementRelayOrigins, + ); + final targetGatewayOrigin = buzzPushGatewayOrigin(Env.pushGatewayUrl); + final migrationCommunities = buzzPushCommunitiesRequiringGatewayMigration( + communities: communities, + retiredRelayOrigins: migrationRelayOrigins, + replacementRelayOrigins: replacementRelayOrigins, + targetGatewayOrigin: targetGatewayOrigin, + ); + final activeLifecycleReady = + _ready(session, config, community, memberPubkey) && + buzzPushLifecycleEnabled(community: community, descriptor: descriptor); useEffect(() { final listener = AppLifecycleListener( @@ -141,10 +444,47 @@ 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(); + gatewayInitializationFailures.value = 0; + gatewayInitializationAttempt.complete(attempt); + } catch (error, stack) { + 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 migration is deferred until the next app launch.', + ); + } + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, [gatewayInitializationRetry.value]); + useEffect( () => () { - registrationAttempt.dispose(); + gatewayInitializationAttempt.dispose(); publicationAttempt.dispose(); + gatewayMigrationAttempt.dispose(); tombstoneAttempt.dispose(); }, const [], @@ -209,49 +549,155 @@ class BuzzPushBootstrap extends HookConsumerWidget { ], ); + final activeCommunityAwaitingGatewayMigration = + community != null && + buzzPushCommunitiesRequiringGatewayMigration( + communities: [community], + retiredRelayOrigins: migrationRelayOrigins, + replacementRelayOrigins: replacementRelayOrigins, + targetGatewayOrigin: targetGatewayOrigin, + ).isNotEmpty; useEffect( () { - if (!_ready(session, config, community, memberPubkey) || - !buzzPushLifecycleEnabled( - community: community, - descriptor: descriptor, - )) { + if (token == null || + migrationRelayOrigins.isEmpty || + !communitiesAsync.hasValue) { return null; } - final activeCommunity = community!; - final activeDescriptor = descriptor!; - final attempt = '${activeCommunity.id}|${config.baseUrl}'; - if (!registrationAttempt.tryBegin(attempt)) return null; + final attempt = [ + token, + 'retired:${(retiredRelayOrigins.toList()..sort()).join(',')}', + 'replacement:${(replacementRelayOrigins.toList()..sort()).join(',')}', + 'replacement-generation:$replacementGeneration', + ].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 { - await startBuzzPushRegistrationIfCapable( - activeDescriptor, - startRegistration: startBuzzPushRegistration, + final candidates = buzzPushCommunitiesRequiringGatewayMigration( + communities: communities, + retiredRelayOrigins: migrationRelayOrigins, + replacementRelayOrigins: replacementRelayOrigins, + targetGatewayOrigin: targetGatewayOrigin, + ); + final resolution = await resolveBuzzPushGatewayMigrationTargets( + communities: candidates, + fetchDescriptor: fetchBuzzPushLeaseDescriptor, + ); + final authorityGroups = + buzzPushGroupGatewayMigrationsByDelegationAuthority( + resolution.targets, + ); + final stopped = await processBuzzPushGatewayMigrationGroups( + groups: authorityGroups.values, + 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) + .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, + attemptIsCurrent: attemptIsCurrent, + ); + } + if (queuedOrigins.isEmpty) return false; + if (!attemptIsCurrent()) return true; + await checkpointBuzzPushGatewayReplacements( + queuedOrigins, + replacementGeneration, + token, + ); + // 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 true; + }, ); + if (stopped) return; + if (!attemptIsCurrent()) return; + await completeBuzzPushGatewayMigration(); + gatewayMigrationFailures.clear(attempt); + gatewayMigrationAttempt.complete(attempt); } catch (error, stack) { - registrationAttempt.failed( + if (!attemptIsCurrent()) return; + final failureCount = gatewayMigrationFailures.recordFailure( attempt, - retry: () { - if (context.mounted) registrationRetry.value += 1; - }, ); - debugPrint('Push registration bootstrap failed: $error'); + final retryDelay = buzzPushGatewayInitializationRetryDelay( + failureCount, + ); + 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); } }()); return null; }, [ - session.status, - config.baseUrl, - community?.id, - memberPubkey, - descriptor, - registrationRetry.value, + token, + migrationRelayOrigins, + replacementRelayOrigins, + replacementGeneration, + communitiesAsync.hasValue, + communities, + gatewayMigrationRetry.value, ], ); - final token = apnsDeviceToken.value; useEffect( () { if (!_ready(session, config, community, memberPubkey) || @@ -259,7 +705,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { community: community, descriptor: descriptor, ) || - token == null) { + token == null || + activeCommunityAwaitingGatewayMigration) { return null; } final activeCommunity = community!; @@ -323,11 +770,20 @@ class BuzzPushBootstrap extends HookConsumerWidget { memberPubkey, descriptor, token, + activeCommunityAwaitingGatewayMigration, publicationRetry.value, ], ); - 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( @@ -380,7 +836,74 @@ class BuzzPushBootstrap extends HookConsumerWidget { community.id, subscriptions: desired, generation: leaseGeneration, + gatewayOrigin: buzzPushGatewayOrigin(Env.pushGatewayUrl), + ), + ); + return grant; + } + + static Future _publishCommunityReplacement( + WidgetRef ref, + Community community, + List communities, + String targetGatewayOrigin, { + required BuzzPushLeaseDescriptor descriptor, + bool forceDelegationRenewal = false, + required bool Function() attemptIsCurrent, + }) 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 grant = await runBuzzPushGatewayMigrationMutationIfCurrent( + attemptIsCurrent: attemptIsCurrent, + mutate: () => enrollBuzzPush( + config.wsUrl, + Env.pushGatewayUrl, + communitiesForSnapshotRefresh: communities, + forceDelegationRenewal: forceDelegationRenewal, + ), + ); + final notifier = ref.read(communityListProvider.notifier); + await publishBuzzPushLeaseRecoverably( + reserveGeneration: () => + notifier.reservePushLeaseGeneration(community.id), + operationIsCurrent: attemptIsCurrent, + 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) => + markBuzzPushGatewayMigrationAcceptedIfCurrent( + attemptIsCurrent: attemptIsCurrent, + markAccepted: () => notifier.markPushLeaseAccepted( + community.id, + subscriptions: community.pushSubscriptionState.desired, + generation: generation, + gatewayOrigin: targetGatewayOrigin, + ), + ), ); return grant; } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index a0c674f14f6..a7c14a4d771 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -94,6 +94,9 @@ final apnsRegistrationError = ValueNotifier(null); 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. /// @@ -136,13 +139,109 @@ Future syncPendingBuzzPushNotificationResponse() 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 inventory = await _channel.invokeMapMethod( + 'initializeGateway', + {'gatewayUrl': Env.pushGatewayUrl}, + ); + final retired = _relayOriginSet(inventory?['retiredRelayOrigins']); + 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. + 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 { + 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. + } +} + +/// 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( + Set relayOrigins, + int generation, + String deviceToken, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + 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, + }..removeAll(relayOrigins); + } 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. 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. } @@ -212,14 +311,24 @@ 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.'); } + retiredBuzzPushRelayOrigins.value = _relayOriginSet( + raw['retiredRelayOrigins'], + ); + replacementBuzzPushRelayOrigins.value = _relayOriginSet( + raw['replacementRelayOrigins'], + ); + replacementBuzzPushGeneration.value = + raw['replacementGeneration'] as int? ?? 0; final grant = BuzzPushEndpointGrant.fromMap(raw); await readBuzzPushEndpointGrants(); if (communitiesForSnapshotRefresh != null) { @@ -233,6 +342,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/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/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..0284db09fc3 --- /dev/null +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +gateway_origin= +has_dart_define=false +old_ifs=$IFS +IFS=',' +for encoded in ${DART_DEFINES:-}; do + decoded=$(printf '%s' "$encoded" | base64 --decode 2>/dev/null || printf '%s' "$encoded" | base64 -D 2>/dev/null || true) + case "$decoded" in + BUZZ_PUSH_GATEWAY_URL=*) + 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 as a Dart define or Xcode build setting for every mobile build." >&2 + exit 1 +fi + +if [ -n "${SRCROOT:-}" ]; then + script_dir=$SRCROOT/../scripts +else + script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fi +dart_bin=${FLUTTER_ROOT:+$FLUTTER_ROOT/bin/dart} +if [ -z "$dart_bin" ] || [ ! -x "$dart_bin" ]; then + dart_bin=$(command -v dart || true) +fi +if [ -z "$dart_bin" ]; then + echo "error: Dart is required to validate BUZZ_PUSH_GATEWAY_URL." >&2 + exit 1 +fi +case ${CONFIGURATION:-Release} in + Debug*) + "$dart_bin" "$script_dir/validate_push_gateway_origin.dart" "$gateway_origin" + ;; + *) + "$dart_bin" "$script_dir/validate_push_gateway_origin.dart" --require-https "$gateway_origin" + ;; +esac diff --git a/mobile/scripts/validate_push_gateway_origin.dart b/mobile/scripts/validate_push_gateway_origin.dart new file mode 100644 index 00000000000..97a8ed7aa4c --- /dev/null +++ b/mobile/scripts/validate_push_gateway_origin.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +bool isValidPushGatewayOrigin(String value, {bool requireHttps = false}) { + try { + final uri = Uri.parse(value); + if (requireHttps + ? uri.scheme != 'https' + : uri.scheme != 'http' && uri.scheme != 'https') { + return false; + } + if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty) { + return false; + } + if (uri.path.isNotEmpty && uri.path != '/') return false; + if (uri.hasQuery || uri.hasFragment) return false; + if (requireHttps && uri.hasPort) return false; + final port = uri.port; + return port >= 1 && port <= 65535; + } on FormatException { + return false; + } +} + +void main(List arguments) { + final requireHttps = + arguments.isNotEmpty && arguments.first == '--require-https'; + final values = requireHttps ? arguments.skip(1).toList() : arguments; + if (values.length == 1 && + isValidPushGatewayOrigin(values.single, requireHttps: requireHttps)) { + return; + } + final requirement = requireHttps + ? 'an HTTPS origin without an explicit port, credentials, path, query, or fragment' + : 'an HTTP(S) origin without credentials, path, query, or fragment'; + stderr.writeln('error: BUZZ_PUSH_GATEWAY_URL must be $requirement.'); + exitCode = 1; +} diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index 345226e84d1..cbbad6afaff 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; @@ -218,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); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 6380837bf6d..fcd5be1772a 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -2,9 +2,32 @@ 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() { + 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); @@ -29,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); }); @@ -116,6 +141,284 @@ 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', + }, + targetGatewayOrigin: 'https://push.example', + ).map((community) => community.name), + ['Active', 'Inactive'], + ); + }); + + 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( + 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('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('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( + '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('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', + 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', + ]); + + 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 { + 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( + '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')]), @@ -156,12 +459,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( @@ -184,6 +488,59 @@ 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, + ); + }); + + 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({ diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 4bff3a2c2c3..52e5a3d2121 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -21,6 +21,9 @@ void main() { apnsRegistrationError.value = null; pushEndpointGrants.value = const []; pushEndpointGrantError.value = null; + retiredBuzzPushRelayOrigins.value = const {}; + replacementBuzzPushRelayOrigins.value = const {}; + replacementBuzzPushGeneration.value = 0; pushCommunitySnapshotError.value = null; pendingPushNotificationLink.value = null; installBuzzPushMethodHandler(); @@ -46,6 +49,177 @@ 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 { + 'retiredRelayOrigins': ['wss://old-relay.example'], + 'replacementRelayOrigins': ['wss://rotated-relay.example'], + 'replacementGeneration': 4, + }; + }); + + 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', + }); + expect(replacementBuzzPushGeneration.value, 4); + }, + ); + + 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 { + 'retiredRelayOrigins': [], + 'replacementRelayOrigins': [], + 'replacementGeneration': 0, + }; + }); + + await completeBuzzPushGatewayMigration(); + + expect(retiredBuzzPushRelayOrigins.value, isEmpty); + expect(replacementBuzzPushRelayOrigins.value, isEmpty); + expect(replacementBuzzPushGeneration.value, 0); + }, + ); + + 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('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 = { + 'wss://done.example', + 'wss://pending.example', + }; + replacementBuzzPushGeneration.value = 7; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'checkpointGatewayReplacements'); + expect(call.arguments, { + 'relayOrigins': ['wss://also-done.example', 'wss://done.example'], + 'generation': 7, + 'deviceToken': 'device-token', + }); + return true; + }); + + replacementBuzzPushRelayOrigins.value = { + 'wss://also-done.example', + ...replacementBuzzPushRelayOrigins.value, + }; + await checkpointBuzzPushGatewayReplacements( + {'wss://done.example', 'wss://also-done.example'}, + 7, + 'device-token', + ); + + 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, 'checkpointGatewayReplacements'); + expect(call.arguments, { + 'relayOrigins': ['wss://pending.example'], + 'generation': 7, + 'deviceToken': 'stale-token', + }); + return false; + }); + + await expectLater( + checkpointBuzzPushGatewayReplacements( + {'wss://pending.example'}, + 7, + 'stale-token', + ), + throwsStateError, + ); + + expect(replacementBuzzPushRelayOrigins.value, {'wss://pending.example'}); + expect(replacementBuzzPushGeneration.value, 7); + }); + test( 'starts native permission and APNs registration without a result gate', () async { @@ -53,6 +227,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_channel, (call) async { expect(call.method, 'startRegistration'); + expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); return null; }); @@ -177,7 +352,12 @@ void main() { methods.add(call.method); if (call.method == 'enrollPush') { enrollmentArguments.add(call.arguments); - return _grantMap('new-grant'); + return { + ..._grantMap('new-grant'), + 'retiredRelayOrigins': ['wss://retired.example'], + 'replacementRelayOrigins': ['wss://sibling.example'], + 'replacementGeneration': 8, + }; } if (call.method == 'endpointGrants') { return [_grantMap('new-grant')]; @@ -196,6 +376,7 @@ void main() { final secondGrant = await enrollBuzzPush( 'wss://relay.example/', 'https://gateway-two.example/', + forceDelegationRenewal: true, communitiesForSnapshotRefresh: [ Community( id: 'community-id', @@ -210,14 +391,19 @@ void main() { expect(firstGrant.endpointGrant, 'new-grant'); 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/', 'gatewayUrl': 'https://gateway-one.example/', + 'forceDelegationRenewal': false, }, { 'relayUrl': 'wss://relay.example/', 'gatewayUrl': 'https://gateway-two.example/', + 'forceDelegationRenewal': true, }, ]); expect(methods, [ @@ -246,11 +432,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); }); 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..6379a98f1b6 --- /dev/null +++ b/mobile/test/shared/push/push_gateway_origin_validator_test.dart @@ -0,0 +1,45 @@ +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('requires HTTPS for release and profile builds', () { + expect( + isValidPushGatewayOrigin('https://push.example', requireHttps: true), + isTrue, + ); + expect( + isValidPushGatewayOrigin('http://localhost:8080', requireHttps: true), + isFalse, + ); + expect( + isValidPushGatewayOrigin('https://push.example:8443', requireHttps: true), + isFalse, + ); + }); + + test('rejects malformed or non-origin gateway URLs', () { + for (final value in [ + '', + 'push.example', + 'ftp://push.example', + 'https://push.example/path', + 'https://push.example?token=x', + 'https://push.example#fragment', + 'https://user@push.example', + 'https://push.example:70000', + ]) { + expect(isValidPushGatewayOrigin(value), isFalse, reason: value); + } + }); +} diff --git a/mobile/test/shared/push/push_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()); }); 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'); -